diff --git a/docs/reference/protocols/manager-evidence-and-continuity-v0.md b/docs/reference/protocols/manager-evidence-and-continuity-v0.md index 98596f5270..f1047add76 100644 --- a/docs/reference/protocols/manager-evidence-and-continuity-v0.md +++ b/docs/reference/protocols/manager-evidence-and-continuity-v0.md @@ -81,10 +81,43 @@ in a `manager.context` event, and supplies it to the executor. Chat prose is never the inventory. Normal manager questions no longer silently use a limited frontend projection; explicitly choosing status-only still uses that projection. -For Codex, manager defaults are `gpt-6-astra` with `high` reasoning. Set +Manager defaults are `gpt-6-astra` with `high` reasoning. Set `LOOPX_MANAGER_MODEL` and `LOOPX_MANAGER_REASONING_EFFORT` on the Chat service to -override them. Thread start, resume and turn start explicitly carry the settings; -worker configuration is unchanged. Capabilities expose the manager defaults. +override them; an explicit override always wins. Thread start, resume and turn +start explicitly carry the settings; worker configuration is unchanged. +Capabilities expose the manager defaults and their source. + +### Steward channel host selection + +The steward channel **selects** its executor; nothing discovers it. `codex` is +the shipped endpoint because it is the only transport that can hold an +interactive steward session today, and `LOOPX_MANAGER_ENDPOINT` re-points that +default. A configured operator credential (`DEEPSEEK_API_KEY`, endpoint in +`DEEPSEEK_BASE_URL`) is never a selection signal: discovering a provider key does +not move the steward channel onto that provider's executor, and it does not move +the channel's model either. The model follows the endpoint the operator selected, +so `gpt-6-astra` stays the default while the channel runs on the CLI endpoint. The +credential is reported as a fact -- the variable name, never the value -- and only +for the endpoint that actually authenticates with it. + +The managed Turn host (`dsh`) runs one bounded work segment per request and has no +interactive Chat transport, so a session request that names it fails as the typed +`managed_host_chat_transport_unsupported` host-tool gate instead of an unknown +endpoint error, in both the Chat service and Lark routing. Promoting `dsh` to the +steward default is gated on that transport, not on a credential. The steward still +**drives** managed work on `dsh`: those bounded Turns are the managed execution +unit described by the LoopX Turn host selection contract, and they are separate +from the channel the steward answers on. + +The Chat capabilities payload carries this resolution in its `manager` block +(`channel_binding`): the resolved executor endpoint and its source, its executor +kind in the same vocabulary as the governed Turn surface (`individual` runs on +one person's CLI login, `managed` on an operator credential), the resolved model +and its source, whether an operator credential is configured, and +`available`/`unavailable_reason` when LoopX can prove the selected endpoint cannot +serve this channel. `available` is `null` when the projection makes no claim. A +frontend can show which executor and model the steward channel resolved, and why, +without re-deriving the rule. Legacy managed manager sessions retain their logical identity and bounded chat history but start a fresh executor thread in the same Codex home on first restore. This removes inherited project instructions without importing sessions diff --git a/examples/loopx-steward-channel-binding-smoke.py b/examples/loopx-steward-channel-binding-smoke.py new file mode 100644 index 0000000000..33d6d0718f --- /dev/null +++ b/examples/loopx-steward-channel-binding-smoke.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Prove the steward channel selects its executor explicitly and stays put.""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from loopx.chat_agent import ( # noqa: E402 + MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED, + CodexChatAgentError, +) +from loopx.chat_manager import ( # noqa: E402 + MANAGER_ENDPOINT_ENV_VAR, + MANAGER_ENDPOINT_SOURCE_EXPLICIT_CONFIG, + manager_channel_binding, + manager_executor_endpoint_default, + manager_model_config, + open_manager_session, +) +from loopx.chat_runtime import ChatRuntimeController # noqa: E402 +from loopx.chat_store import ChatSessionStore # noqa: E402 + + +CREDENTIAL_ENV = "DEEPSEEK_API_KEY" +CREDENTIAL_VALUE = "fixture-operator-credential" + + +def _assert(condition: bool, message: str) -> None: + if not condition: + raise SystemExit(f"steward channel binding smoke failed: {message}") + + +def _assert_credential_does_not_select() -> dict[str, object]: + """A configured provider key must not re-point the steward channel.""" + + without_credential = manager_channel_binding({}) + with_credential = manager_channel_binding({CREDENTIAL_ENV: CREDENTIAL_VALUE}) + _assert( + without_credential["executor_endpoint"] == "codex" + and with_credential["executor_endpoint"] == "codex", + "the steward channel must keep the shipped CLI endpoint either way", + ) + _assert( + without_credential["executor_endpoint_source"] + == with_credential["executor_endpoint_source"] + == "product_default", + "a credential must never become the endpoint source", + ) + _assert( + without_credential["model"] == with_credential["model"] == "gpt-6-astra" + and with_credential["model_source"] == "vendor_default", + "a credential for a provider this channel does not run on must not move the model", + ) + _assert( + with_credential["operator_credential_configured"] is True + and without_credential["operator_credential_configured"] is False, + "the credential must still be reported as a fact", + ) + _assert( + CREDENTIAL_VALUE not in json.dumps(with_credential) + and with_credential["credential_env_var"] == "", + "the binding must never echo a credential value, nor claim one for a CLI endpoint", + ) + _assert( + manager_model_config( + {CREDENTIAL_ENV: CREDENTIAL_VALUE, "LOOPX_MANAGER_MODEL": "fixture-model"} + ) + == {"model": "fixture-model", "reasoning_effort": "high"}, + "an explicit model override must win over the shipped default", + ) + return { + "without_credential": without_credential, + "with_credential": with_credential, + } + + +def _assert_explicit_selection_and_managed_host_gate() -> dict[str, object]: + """Explicit selection wins; the managed host fails closed as a typed gate.""" + + selected = manager_channel_binding({MANAGER_ENDPOINT_ENV_VAR: "dsh"}) + _assert( + selected["executor_endpoint"] == "dsh" + and selected["executor_endpoint_source"] + == MANAGER_ENDPOINT_SOURCE_EXPLICIT_CONFIG, + "an explicit endpoint selection must win over the shipped default", + ) + _assert( + selected["executor_kind"] == "managed" + and selected["available"] is False + and selected["unavailable_reason"] == MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED, + "the managed host must report its missing Chat transport as a typed reason", + ) + _assert( + manager_executor_endpoint_default( + {MANAGER_ENDPOINT_ENV_VAR: "dsh", CREDENTIAL_ENV: CREDENTIAL_VALUE} + ) + == "dsh", + "the selected endpoint must not depend on the credential", + ) + + with tempfile.TemporaryDirectory() as gate_root: + root = Path(gate_root) + runtime = ChatRuntimeController( + store=ChatSessionStore(root / "store"), codex_bin="fixture-codex" + ) + try: + try: + runtime.open_session( + goal_id="loopx-steward-binding-fixture", + agent_id="dsh", + work_dir=root, + objective="fixture", + mode="new", + ) + except CodexChatAgentError as exc: + _assert( + exc.error_code == MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED + and exc.gate.get("kind") == "host_tool_gate" + and "loopx turn" in exc.gate.get("next_action", ""), + "the managed host must fail closed as a typed host-tool gate", + ) + else: + raise SystemExit( + "steward channel binding smoke failed: dsh opened an interactive session" + ) + finally: + runtime.close() + return selected + + +def _assert_session_opens_the_selected_endpoint() -> str: + """The channel opens the endpoint the operator selected, not a credential.""" + + opened: list[dict[str, object]] = [] + + class _Controller: + def open_session(self, **kwargs): + opened.append(kwargs) + return {"session_id": "fixture-session"}, False + + controller = _Controller() + with tempfile.TemporaryDirectory() as work_dir: + ambient = os.environ.pop(CREDENTIAL_ENV, None) + ambient_endpoint = os.environ.pop(MANAGER_ENDPOINT_ENV_VAR, None) + try: + open_manager_session( + controller=controller, + goal_id="loopx-steward-binding-fixture", + work_dir=Path(work_dir), + ) + _assert( + opened[-1]["agent_id"] == "codex", + "without a selection the manager session must open the shipped endpoint", + ) + + os.environ[CREDENTIAL_ENV] = CREDENTIAL_VALUE + open_manager_session( + controller=controller, + goal_id="loopx-steward-binding-fixture", + work_dir=Path(work_dir), + ) + _assert( + opened[-1]["agent_id"] == "codex", + "a configured credential must not re-point the manager session", + ) + finally: + os.environ.pop(CREDENTIAL_ENV, None) + if ambient is not None: + os.environ[CREDENTIAL_ENV] = ambient + if ambient_endpoint is not None: + os.environ[MANAGER_ENDPOINT_ENV_VAR] = ambient_endpoint + return str(opened[-1]["agent_id"]) + + +def main() -> int: + payload = { + "ok": True, + "credential_selection_probe": _assert_credential_does_not_select(), + "explicit_selection": _assert_explicit_selection_and_managed_host_gate(), + "opened_endpoint": _assert_session_opens_the_selected_endpoint(), + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/capabilities/manager_runtime/machine_profile.py b/loopx/capabilities/manager_runtime/machine_profile.py index 0934c6c860..a4c93c5201 100644 --- a/loopx/capabilities/manager_runtime/machine_profile.py +++ b/loopx/capabilities/manager_runtime/machine_profile.py @@ -201,8 +201,16 @@ def manager_runtime_session_fields(profile: Mapping[str, Any]) -> dict[str, Any] def manager_runtime_capability_projection( runtime_controller: object, model_configuration: Mapping[str, Any], + *, + channel_binding: Mapping[str, Any] | None = None, ) -> dict[str, Any]: - """Build the manager section of the shared chat capabilities projection.""" + """Build the manager section of the shared chat capabilities projection. + + ``channel_binding`` is the resolved steward-channel executor and model + binding. The caller owns it, including its credential facts; this projection + only carries it into readback so a frontend can show which executor and model + the manager channel resolved and why, without re-deriving the rule. + """ resolver = getattr(runtime_controller, "manager_runtime_profile", None) runtime = ( @@ -210,4 +218,11 @@ def manager_runtime_capability_projection( if callable(resolver) else {**effective_manager_runtime_profile(None), "status": "ready"} ) - return {"scope": "owner_global", **dict(model_configuration), "runtime": runtime} + projection: dict[str, Any] = { + "scope": "owner_global", + **dict(model_configuration), + "runtime": runtime, + } + if channel_binding is not None: + projection["channel_binding"] = dict(channel_binding) + return projection diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 6cc1248073..49452df238 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -47,6 +47,35 @@ def _host_tool_gate(summary: str, next_action: str) -> dict[str, str]: } +# The managed Turn host runs one bounded work segment per request and has no +# interactive Chat transport, so a session request for it is a known outcome +# rather than an unknown endpoint. +MANAGED_TURN_HOST_IDS = frozenset({"dsh"}) +MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED = "managed_host_chat_transport_unsupported" + + +def agent_endpoint_error(agent_id: str) -> ValueError: + """Return the typed error for an Agent id this runtime cannot hold. + + A managed Turn host keeps a typed host-tool gate and an actionable next + step, so the steward channel never half-connects to a host it cannot hold. + Every other unknown id keeps the existing untyped fallback. + """ + + if agent_id in MANAGED_TURN_HOST_IDS: + return CodexChatAgentError( + f"The managed host '{agent_id}' runs bounded LoopX Turns and cannot " + "hold an interactive Chat session yet.", + error_code=MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED, + gate=_host_tool_gate( + f"'{agent_id}' has no LoopX Chat transport; it is a bounded Turn host.", + "Select a chat-capable Agent endpoint for this session, or run " + "the managed host through `loopx turn`.", + ), + ) + return ValueError(f"unknown Agent endpoint: {agent_id}") + + def _approval_gate(summary: str) -> dict[str, str]: return { "kind": "approval_gate", diff --git a/loopx/chat_lark_api.py b/loopx/chat_lark_api.py index 63102c3c46..da8a4ab635 100644 --- a/loopx/chat_lark_api.py +++ b/loopx/chat_lark_api.py @@ -30,7 +30,12 @@ CommandRunner, default_subprocess_runner, ) -from .chat_manager import manager_channel, open_manager_session +from .chat_agent import CodexChatAgentError +from .chat_manager import ( + manager_channel, + manager_executor_endpoint_default, + open_manager_session, +) from .extensions.lark.goal_channel_contracts import binding_for_goal, goal_from_registry from .extensions.lark.goal_channel_targets import goal_channel_target_for_name from .history import load_registry @@ -477,11 +482,11 @@ def _lark_connect(self) -> None: or stored_routing.get("conversation_kind") or "goal" ) - executor_endpoint_id = ( - _compact_text(body.get("executor_endpoint_id"), limit=100) - or stored_routing.get("executor_endpoint_id") - or "codex" - ) + executor_endpoint_id = _compact_text( + body.get("executor_endpoint_id"), limit=100 + ) or stored_routing.get("executor_endpoint_id") + if conversation_kind == "manager" and not executor_endpoint_id: + executor_endpoint_id = manager_executor_endpoint_default() session_id: str | None = None session_ids_by_agent: dict[str, str] = {} if conversation_kind == "manager": @@ -591,6 +596,11 @@ def _lark_connect(self) -> None: else None, session_id=session_id, ) + except CodexChatAgentError as exc: + self._send_error( + str(exc), status=400, gate=exc.gate, error_code=exc.error_code + ) + return except ValueError as exc: self._send_error(str(exc), status=400, error_code="invalid_lark_connection") return diff --git a/loopx/chat_manager.py b/loopx/chat_manager.py index 028f29e207..d113c517e6 100644 --- a/loopx/chat_manager.py +++ b/loopx/chat_manager.py @@ -3,10 +3,16 @@ from __future__ import annotations import hashlib -import os from pathlib import Path from typing import Any +from .control_plane.operator_credential import ( + configured_operator_credential, + env_text, + operator_credential_configured, +) +from .chat_agent import MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED, MANAGED_TURN_HOST_IDS + MANAGER_AGENT_GOAL_ID = "loopx-manager" MANAGER_AGENT_OBJECTIVE = ( "Serve as the user's global LoopX manager, independent of the currently selected Goal or project. Answer only the current user message in concise Chinese. " @@ -90,18 +96,141 @@ def is_manager_channel(value: Any) -> bool: return value == "manager" or str(value or "").startswith("manager.external.") +# The steward channel selects its executor and its model explicitly, and a +# discovered credential re-points neither one. The shipped endpoint is the +# interactive CLI host because it is the only transport that can hold a steward +# session today; the managed host (`dsh`) runs one bounded work segment per +# request, so the steward drives managed Turns through `loopx turn` while its +# own channel stays on the CLI. Promoting the managed host to this channel is +# gated on it gaining an interactive Chat transport, never on a credential +# appearing. +MANAGER_CHANNEL_BINDING_SCHEMA_VERSION = "manager_channel_binding_v0" +MANAGER_ENDPOINT_ENV_VAR = "LOOPX_MANAGER_ENDPOINT" +MANAGER_ENDPOINT_DEFAULT = "codex" +MANAGER_ENDPOINT_SOURCE_PRODUCT_DEFAULT = "product_default" +MANAGER_ENDPOINT_SOURCE_EXPLICIT_CONFIG = "explicit_config" +# Executor kinds name where this channel's model work is billed and bounded +# rather than which adapter is launched, and they use the same vocabulary as the +# governed Turn surface: an individual executor runs on one person's own CLI +# login, a managed executor on an operator-supplied credential. Which kind an +# endpoint is decides whether an operator credential belongs to it at all; the +# mere presence of a credential decides nothing. +MANAGER_EXECUTOR_KIND_INDIVIDUAL = "individual" +MANAGER_EXECUTOR_KIND_MANAGED = "managed" +MANAGER_ENDPOINT_KINDS = { + MANAGER_ENDPOINT_DEFAULT: MANAGER_EXECUTOR_KIND_INDIVIDUAL, + # The managed host is billed to the operator's own endpoint, not to one + # person's CLI login. + "dsh": MANAGER_EXECUTOR_KIND_MANAGED, +} +# The managed Turn hosts are exactly the endpoints the Chat runtime refuses to +# hold an interactive session on, so the channel reports them as unavailable. +MANAGER_ENDPOINTS_WITHOUT_CHAT_TRANSPORT = MANAGED_TURN_HOST_IDS + +MANAGER_MODEL_ENV_VAR = "LOOPX_MANAGER_MODEL" +MANAGER_MODEL_DEFAULT = "gpt-6-astra" +MANAGER_MODEL_SOURCE_ENV_OVERRIDE = "env_override" +MANAGER_MODEL_SOURCE_VENDOR_DEFAULT = "vendor_default" +MANAGER_REASONING_EFFORT_ENV_VAR = "LOOPX_MANAGER_REASONING_EFFORT" +MANAGER_REASONING_EFFORT_DEFAULT = "high" +MANAGER_REASONING_EFFORTS = ( + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +) + + +def selected_manager_executor_endpoint( + environ: dict[str, str] | None = None, +) -> tuple[str, str]: + """Return the selected steward executor endpoint and the source selecting it. + + Selection is environment-independent beyond one explicit override: the + shipped endpoint applies until the operator re-points it with + ``LOOPX_MANAGER_ENDPOINT``. A configured credential is never a selection + signal, so discovering a provider key cannot move the steward channel onto + an executor the operator did not choose. + """ + + explicit = env_text(MANAGER_ENDPOINT_ENV_VAR, environ) + if explicit: + return explicit, MANAGER_ENDPOINT_SOURCE_EXPLICIT_CONFIG + return MANAGER_ENDPOINT_DEFAULT, MANAGER_ENDPOINT_SOURCE_PRODUCT_DEFAULT + + +def manager_executor_endpoint_default(environ: dict[str, str] | None = None) -> str: + """Return the selected steward executor endpoint.""" + + return selected_manager_executor_endpoint(environ)[0] + + +def manager_channel_binding( + environ: dict[str, str] | None = None, +) -> dict[str, Any]: + """Project the steward channel's resolved executor, model, and their source. + + This is the channel's readback contract: which executor it resolved and why, + which provider authenticates that executor, which model follows it, and + whether the channel can actually run there. Credential facts are reported as + the variable name only -- never the value -- because a credential + authenticates the selected configuration instead of selecting it. + + ``available`` is ``False`` only when LoopX can prove the selected endpoint + cannot serve this channel, which is what a caller fails closed on, and + ``None`` when this projection makes no claim rather than an unproven ``True``. + """ + + endpoint, endpoint_source = selected_manager_executor_endpoint(environ) + executor_kind = MANAGER_ENDPOINT_KINDS.get(endpoint, "") + credential_env = "" + if executor_kind == MANAGER_EXECUTOR_KIND_MANAGED: + credential_env = configured_operator_credential(environ) or "" + if endpoint in MANAGER_ENDPOINTS_WITHOUT_CHAT_TRANSPORT: + available: bool | None = False + unavailable_reason: str | None = MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED + else: + available, unavailable_reason = None, None + model_override = env_text(MANAGER_MODEL_ENV_VAR, environ) + if model_override: + model, model_source = model_override, MANAGER_MODEL_SOURCE_ENV_OVERRIDE + else: + model, model_source = MANAGER_MODEL_DEFAULT, MANAGER_MODEL_SOURCE_VENDOR_DEFAULT + return { + "schema_version": MANAGER_CHANNEL_BINDING_SCHEMA_VERSION, + "executor_endpoint": endpoint, + "executor_endpoint_source": endpoint_source, + "executor_kind": executor_kind, + "credential_env_var": credential_env, + "operator_credential_configured": operator_credential_configured(environ), + "available": available, + "unavailable_reason": unavailable_reason, + "model": model, + "model_source": model_source, + } + + def open_manager_session( *, controller: Any, goal_id: str, work_dir: Path, - executor_endpoint_id: str = "codex", + executor_endpoint_id: str | None = None, provider: str = "", audience: str = "", ) -> tuple[dict[str, Any], bool]: + resolved_endpoint = ( + str(executor_endpoint_id).strip() + if executor_endpoint_id + else manager_executor_endpoint_default() + ) return controller.open_session( goal_id=goal_id, - agent_id=executor_endpoint_id, + agent_id=resolved_endpoint, work_dir=work_dir, objective=MANAGER_AGENT_OBJECTIVE, mode="resume_latest", @@ -117,23 +246,21 @@ def manager_skill_text() -> str: return (Path(__file__).parent / "capabilities/manager_context/skills/loopx-manager/SKILL.md").read_text(encoding="utf-8") -def manager_model_config() -> dict[str, str]: - model = ( - os.environ.get("LOOPX_MANAGER_MODEL", "gpt-6-astra").strip() or "gpt-6-astra" - ) +def manager_model_config(environ: dict[str, str] | None = None) -> dict[str, str]: + """Return the manager host arguments: model and reasoning effort. + + Both are explicit product defaults with exactly one environment override + each. A configured operator credential is not an input: the steward model + follows the executor the operator selected, so a credential for a provider + this channel is not running on cannot silently change it. + """ + + model = env_text(MANAGER_MODEL_ENV_VAR, environ) or MANAGER_MODEL_DEFAULT effort = ( - os.environ.get("LOOPX_MANAGER_REASONING_EFFORT", "high").strip() or "high" + env_text(MANAGER_REASONING_EFFORT_ENV_VAR, environ) + or MANAGER_REASONING_EFFORT_DEFAULT ) - if effort not in { - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", - "ultra", - }: + if effort not in MANAGER_REASONING_EFFORTS: raise ValueError("invalid manager reasoning effort") return {"model": model, "reasoning_effort": effort} diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index fa17ad829f..730dfc7278 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -20,7 +20,7 @@ load_effective_manager_runtime_profile, manager_runtime_session_fields, ) from .chat_acp import ACPStdioAdapter -from .chat_agent import CodexChatAgentError, CodexChatAgentSession, CodexChatTimeoutError +from .chat_agent import CodexChatAgentError, CodexChatAgentSession, CodexChatTimeoutError, agent_endpoint_error from .chat_endpoints import AgentEndpointRegistry from .kiro_cli_goal_mode import ( KIRO_CLI_BIN, @@ -524,7 +524,7 @@ def open_session( if latest is not None and latest.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: return latest, True if capability is None: - raise ValueError(f"unknown Agent endpoint: {agent_id}") + raise agent_endpoint_error(agent_id) if not capability["available"]: raise ValueError(f"Agent endpoint is unavailable: {agent_id}") if latest is not None: diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 28b6dc4c77..cfa13adf94 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -32,7 +32,7 @@ from .chat_runtime import ChatRuntimeController, TERMINAL_TURN_STATES from .chat_manager import ( MANAGER_AGENT_GOAL_ID, MANAGER_AGENT_OBJECTIVE, is_manager_channel, - manager_workspace, manager_model_config, + manager_channel_binding, manager_workspace, manager_model_config, ) from .chat_ssh_source_api import SshSourceRequestMixin from .chat_store import ChatSessionStore @@ -1269,8 +1269,8 @@ def do_GET(self) -> None: "ok": True, "schema_version": "loopx_chat_capabilities_v1", "manager": manager_runtime_capability_projection( - self.server.runtime_controller, manager_model_config() - ), + self.server.runtime_controller, manager_model_config(), + channel_binding=manager_channel_binding()), "runtime_identity": release_runtime_identity(), "agent_backend": "multi_adapter", "sandbox": "read-only", diff --git a/loopx/control_plane/operator_credential.py b/loopx/control_plane/operator_credential.py new file mode 100644 index 0000000000..0d47e889f6 --- /dev/null +++ b/loopx/control_plane/operator_credential.py @@ -0,0 +1,52 @@ +"""Operator-supplied model credential facts shared by LoopX host surfaces. + +This module reports credential *facts* and nothing else. It never selects a +host, an endpoint, or a model, and it never reads a credential value. + +Selection is a separate, explicit decision owned by the surface that runs the +work: the governed Turn host comes from +``turn_driver.host_binding.selected_turn_host`` and the steward channel endpoint +comes from ``chat_manager.manager_channel_binding``. Both report the credential +facts quoted from here so their readback cannot drift apart, and both treat the +credential as authentication for the configuration the operator selected -- +never as a reason to change it. Discovering that a credential exists may help +the operator set a surface up, but it must not silently re-point a surface that +is already configured. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping + +# Credential env vars the operator-supplied provider already reads. Only the +# variable name is ever reported back; values stay in the process environment. +OPERATOR_CREDENTIAL_ENV_VARS = ("DEEPSEEK_API_KEY",) +OPERATOR_ENDPOINT_ENV_VAR = "DEEPSEEK_BASE_URL" + + +def env_text(name: str, environ: Mapping[str, str] | None = None) -> str | None: + """Return a stripped env value, or ``None`` when it is unset or blank.""" + + source = os.environ if environ is None else environ + value = str(source.get(name, "") or "").strip() + return value or None + + +def configured_operator_credential( + environ: Mapping[str, str] | None = None, +) -> str | None: + """Return the configured operator credential env var name, else ``None``.""" + + for name in OPERATOR_CREDENTIAL_ENV_VARS: + if env_text(name, environ) is not None: + return name + return None + + +def operator_credential_configured( + environ: Mapping[str, str] | None = None, +) -> bool: + """Whether any operator model credential is configured for this process.""" + + return configured_operator_credential(environ) is not None diff --git a/loopx/extensions/lark/goal_topic_runtime.py b/loopx/extensions/lark/goal_topic_runtime.py index a0ba295152..a33302e8a9 100644 --- a/loopx/extensions/lark/goal_topic_runtime.py +++ b/loopx/extensions/lark/goal_topic_runtime.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any -from ...chat_manager import MANAGER_AGENT_OBJECTIVE +from ...chat_manager import MANAGER_AGENT_OBJECTIVE, manager_executor_endpoint_default from .manager_routing import ( has_manager_binding, invalid_manager_authority_result, @@ -793,7 +793,7 @@ def answer_lark_goal_topic( session_id = str(route.get("session_id") or "") manager = route.get("conversation_kind") == "manager" agent_id = ( - str(route.get("executor_endpoint_id") or "codex") + str(route.get("executor_endpoint_id") or manager_executor_endpoint_default()) if manager else str(route.get("agent_id") or "codex") ) diff --git a/tests/test_chat_manager_context.py b/tests/test_chat_manager_context.py index b5ef477749..ed5bc035f6 100644 --- a/tests/test_chat_manager_context.py +++ b/tests/test_chat_manager_context.py @@ -46,22 +46,19 @@ def _apply_manager_runtime_profile(runtime_root, profile): ) -def test_manager_defaults_are_independent_of_worker_configuration(monkeypatch): - monkeypatch.delenv("LOOPX_MANAGER_MODEL", raising=False) - monkeypatch.delenv("LOOPX_MANAGER_REASONING_EFFORT", raising=False) - assert manager_model_config() == { +def test_manager_defaults_are_independent_of_worker_configuration(): + assert manager_model_config({}) == { "model": "gpt-6-astra", "reasoning_effort": "high", } - monkeypatch.setenv("LOOPX_MANAGER_MODEL", "fixture-model") - monkeypatch.setenv("LOOPX_MANAGER_REASONING_EFFORT", "low") - assert manager_model_config() == { + assert manager_model_config( + {"LOOPX_MANAGER_MODEL": "fixture-model", "LOOPX_MANAGER_REASONING_EFFORT": "low"} + ) == { "model": "fixture-model", "reasoning_effort": "low", } - monkeypatch.setenv("LOOPX_MANAGER_REASONING_EFFORT", "typo") with pytest.raises(ValueError): - manager_model_config() + manager_model_config({"LOOPX_MANAGER_REASONING_EFFORT": "typo"}) def test_context_scopes_before_read_and_missing_registry_is_unknown( diff --git a/tests/test_manager_channel_binding.py b/tests/test_manager_channel_binding.py new file mode 100644 index 0000000000..b6ff43ebdd --- /dev/null +++ b/tests/test_manager_channel_binding.py @@ -0,0 +1,207 @@ +"""The steward channel selects its executor explicitly; a credential only authenticates.""" + +from __future__ import annotations + +import json + +import pytest + +from loopx.chat_agent import ( + MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED, + CodexChatAgentError, +) +from loopx.capabilities.manager_runtime import manager_runtime_capability_projection +from loopx.chat_manager import ( + MANAGER_ENDPOINT_SOURCE_EXPLICIT_CONFIG, + MANAGER_ENDPOINT_SOURCE_PRODUCT_DEFAULT, + MANAGER_MODEL_SOURCE_ENV_OVERRIDE, + MANAGER_MODEL_SOURCE_VENDOR_DEFAULT, + manager_channel_binding, + manager_executor_endpoint_default, + manager_model_config, + open_manager_session, + selected_manager_executor_endpoint, +) +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_store import ChatSessionStore + + +def test_the_shipped_steward_channel_defaults_to_the_cli_endpoint(): + binding = manager_channel_binding({}) + + assert ( + binding["executor_endpoint"] == manager_executor_endpoint_default({}) == "codex" + ) + assert ( + binding["executor_endpoint_source"] == MANAGER_ENDPOINT_SOURCE_PRODUCT_DEFAULT + ) + assert binding["executor_kind"] == "individual" + assert binding["credential_env_var"] == "" + assert binding["operator_credential_configured"] is False + assert binding["available"] is None + assert binding["unavailable_reason"] is None + assert binding["model"] == "gpt-6-astra" + assert binding["model_source"] == MANAGER_MODEL_SOURCE_VENDOR_DEFAULT + + +def test_a_configured_credential_never_re_points_the_steward_channel(): + """Discovering a provider key must not change the executor or the model.""" + + without = manager_channel_binding({}) + with_credential = manager_channel_binding({"DEEPSEEK_API_KEY": "fixture"}) + + assert with_credential["executor_endpoint"] == without["executor_endpoint"] + assert ( + with_credential["executor_endpoint_source"] + == without["executor_endpoint_source"] + == MANAGER_ENDPOINT_SOURCE_PRODUCT_DEFAULT + ) + assert with_credential["model"] == without["model"] == "gpt-6-astra" + assert with_credential["model_source"] == MANAGER_MODEL_SOURCE_VENDOR_DEFAULT + # The credential stays a reported fact, not a selection signal. + assert with_credential["operator_credential_configured"] is True + assert with_credential["credential_env_var"] == "" + assert "fixture" not in json.dumps(with_credential) + + +def test_an_explicit_endpoint_selection_wins_over_the_shipped_default(): + endpoint, source = selected_manager_executor_endpoint( + {"LOOPX_MANAGER_ENDPOINT": "fixture-endpoint"} + ) + + assert (endpoint, source) == ( + "fixture-endpoint", + MANAGER_ENDPOINT_SOURCE_EXPLICIT_CONFIG, + ) + assert manager_executor_endpoint_default({"LOOPX_MANAGER_ENDPOINT": " "}) == "codex" + + +def test_selecting_the_managed_host_reports_the_missing_chat_transport(): + """The managed host is a bounded Turn host, so the channel fails closed.""" + + binding = manager_channel_binding({"LOOPX_MANAGER_ENDPOINT": "dsh"}) + + assert binding["executor_endpoint"] == "dsh" + assert binding["executor_kind"] == "managed" + assert binding["available"] is False + assert binding["unavailable_reason"] == MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED + # An operator-billed endpoint names the credential it authenticates with. + assert binding["credential_env_var"] == "" + assert ( + manager_channel_binding( + {"LOOPX_MANAGER_ENDPOINT": "dsh", "DEEPSEEK_API_KEY": "fixture"} + )["credential_env_var"] + == "DEEPSEEK_API_KEY" + ) + + +def test_an_unknown_explicit_endpoint_makes_no_availability_claim(): + binding = manager_channel_binding({"LOOPX_MANAGER_ENDPOINT": "fixture-endpoint"}) + + assert binding["executor_kind"] == "" + assert binding["available"] is None + assert binding["model"] == "gpt-6-astra" + + +def test_explicit_model_override_wins_with_and_without_credential(): + overridden = manager_channel_binding( + {"DEEPSEEK_API_KEY": "fixture", "LOOPX_MANAGER_MODEL": "fixture-model"} + ) + assert overridden["model"] == "fixture-model" + assert overridden["model_source"] == MANAGER_MODEL_SOURCE_ENV_OVERRIDE + + assert manager_model_config( + {"DEEPSEEK_API_KEY": "fixture", "LOOPX_MANAGER_MODEL": "fixture-model"} + ) == {"model": "fixture-model", "reasoning_effort": "high"} + assert manager_model_config({"LOOPX_MANAGER_REASONING_EFFORT": "low"}) == { + "model": "gpt-6-astra", + "reasoning_effort": "low", + } + + +def test_manager_model_config_reads_the_process_environment(monkeypatch): + monkeypatch.delenv("LOOPX_MANAGER_MODEL", raising=False) + monkeypatch.setenv("DEEPSEEK_API_KEY", "fixture") + + assert manager_model_config()["model"] == "gpt-6-astra" + + +def test_open_manager_session_resolves_the_endpoint_only_when_unset(tmp_path): + calls: list[dict[str, object]] = [] + + class Controller: + def open_session(self, **kwargs): + calls.append(kwargs) + return {"session_id": "fixture"}, False + + controller = Controller() + open_manager_session(controller=controller, goal_id="g", work_dir=tmp_path) + assert calls[-1]["agent_id"] == manager_executor_endpoint_default() + + open_manager_session( + controller=controller, + goal_id="g", + work_dir=tmp_path, + executor_endpoint_id="claude-code", + ) + assert calls[-1]["agent_id"] == "claude-code" + + +def test_managed_host_without_a_chat_transport_raises_a_typed_gate(tmp_path): + runtime = ChatRuntimeController( + store=ChatSessionStore(tmp_path / "store"), codex_bin="fixture-codex" + ) + try: + with pytest.raises(CodexChatAgentError) as raised: + runtime.open_session( + goal_id="fixture-goal", + agent_id="dsh", + work_dir=tmp_path, + objective="fixture", + mode="new", + ) + finally: + runtime.close() + + assert raised.value.error_code == MANAGED_HOST_CHAT_TRANSPORT_UNSUPPORTED + assert raised.value.gate["kind"] == "host_tool_gate" + assert "loopx turn" in raised.value.gate["next_action"] + + +def test_unknown_endpoint_keeps_the_untyped_lookup_error(tmp_path): + runtime = ChatRuntimeController( + store=ChatSessionStore(tmp_path / "store"), codex_bin="fixture-codex" + ) + try: + with pytest.raises(ValueError, match="unknown Agent endpoint"): + runtime.open_session( + goal_id="fixture-goal", + agent_id="not-a-registered-endpoint", + work_dir=tmp_path, + objective="fixture", + mode="new", + ) + finally: + runtime.close() + + +def test_manager_capability_projection_carries_the_channel_binding(): + binding = manager_channel_binding({"DEEPSEEK_API_KEY": "fixture"}) + projection = manager_runtime_capability_projection( + object(), + {"model": "gpt-6-astra", "reasoning_effort": "high"}, + channel_binding=binding, + ) + + assert projection["scope"] == "owner_global" + assert projection["channel_binding"] == binding + assert "fixture" not in json.dumps(projection) + + +def test_manager_capability_projection_stays_unchanged_without_a_binding(): + projection = manager_runtime_capability_projection( + object(), {"model": "gpt-6-astra", "reasoning_effort": "high"} + ) + + assert "channel_binding" not in projection + assert projection["model"] == "gpt-6-astra"