From 91c440ca021dd2f7378f29bb539db7f305555c4f Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:15:59 +0800 Subject: [PATCH] feat(chat): select the steward channel executor explicitly The steward channel now selects its executor and its model, and a configured operator credential re-points neither. The previous rule resolved the channel onto the managed host when DEEPSEEK_API_KEY was present while the endpoint stayed codex, because dsh has no interactive Chat transport -- so the executor and the model disagreed, and the swapped model was handed to the Codex adapter. - manager_channel_binding resolves the endpoint from explicit configuration only; `codex` is the shipped default and LOOPX_MANAGER_ENDPOINT re-points it. - The steward model follows the selected executor, so the shipped CLI endpoint keeps the vendor default and manager_model_config no longer reads the credential. - The managed Turn host fails closed as the typed managed_host_chat_transport_unsupported host-tool gate instead of an unknown endpoint ValueError, in the Chat service and Lark routing. - The three hardcoded endpoint fallbacks now resolve through the one function that owns the rule, and Chat capabilities carry the binding for frontend readback. Replaces the steward half of the stacked chain (#4417). Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../manager-evidence-and-continuity-v0.md | 39 +++- .../loopx-steward-channel-binding-smoke.py | 195 +++++++++++++++++ .../manager_runtime/machine_profile.py | 19 +- loopx/chat_agent.py | 29 +++ loopx/chat_lark_api.py | 22 +- loopx/chat_manager.py | 163 ++++++++++++-- loopx/chat_runtime.py | 4 +- loopx/chat_server.py | 6 +- loopx/control_plane/operator_credential.py | 52 +++++ loopx/extensions/lark/goal_topic_runtime.py | 4 +- tests/test_chat_manager_context.py | 15 +- tests/test_manager_channel_binding.py | 207 ++++++++++++++++++ 12 files changed, 710 insertions(+), 45 deletions(-) create mode 100644 examples/loopx-steward-channel-binding-smoke.py create mode 100644 loopx/control_plane/operator_credential.py create mode 100644 tests/test_manager_channel_binding.py 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"