diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f987a5..b753fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan engine's `PreToolUse` hook passes it, `PolicyToolAuthorizer` puts it in the policy input as `call_id`, and a call without one is denied. - A rewritten message carries the gateway's masked text (`[EMAIL]`). +- **BREAKING: the agent document is read from its gateway sections.** The + model is `llm.config.model` (the gateway agent's kong model; `null` → the + image's own model) and the MCP servers are `mcp.servers`, every one attached + to the engine. `guardrails` is not read. A document without these sections + resolves with no model and no MCP servers. For gitagent a model without a + `provider:` prefix always gets `openai:`. - **BREAKING: MCP servers are addressed by name.** Each bound server is `{mcp_url}/{tool_name}` (one percent-encoded segment) with no query; the server id path and the `?tools=` query are gone, since the gateway resolves @@ -76,6 +82,12 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan `SrsTurnGuard.resolve_turn` are deleted. The SDK no longer runs a separate content check on a tool call's string arguments; the tool call goes to the gateway as one `tool-input` check. +- **BREAKING: model-gateway and MCP-mode handling.** `ResolvedAgent.gateway_type`, + `gateway_base_url`, `gateway_completions_url`, `has_native_gateway` and + `mcp_mode`, `DEFAULT_MCP_MODE`, `build_mcp_servers(mcp_mode=)`, the generic + gateway refusal and the unresolved-binding refusal are gone; `model`, + `model_gateway`, `modelGatewayId`, `mcp_servers` and `mcp_mode` are no longer + read. - **BREAKING: the standalone SRS policy engine.** `SrsPolicyEngine`, `srs_authorizer_from_env` and `computeragent.policy.srs` are gone; nothing fetches `{srs}/rai/policies/{id}` any more. Use the built-in guardrails gate, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c308ef6..acf5e92 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -11,7 +11,7 @@ ComputerAgent(…) every setting: argument, else its environment vari │ constructor, synchronous — fails before anything runs ├─▶ settings.resolve_settings() → Settings (missing ones reported together) ├─▶ agentos_agent.resolve_agent() GET {base_url}/v1/agents/{id} x-api-key - │ └─ ResolvedAgent: harness, model, mcp_mode, mcp_bindings + │ └─ ResolvedAgent: harness, model, mcp_bindings ├─▶ gateway_envs.build_gateway_envs() → the engine's environment (llm_url) ├─▶ gateway_envs.build_options() → permission mode, model, caller options ├─▶ mcp.build_mcp_servers() → engine MCP servers on mcp_url diff --git a/docs/USING_THE_SDK.md b/docs/USING_THE_SDK.md index 7e4c1da..dcb1d18 100644 --- a/docs/USING_THE_SDK.md +++ b/docs/USING_THE_SDK.md @@ -161,7 +161,6 @@ After construction, `agent.agent` is the `ResolvedAgent`: agent.agent.name # str agent.agent.harness # "claude-agent-sdk" | "gitagent" agent.agent.model # what goes on the wire -agent.agent.mcp_mode # "engine" | "studio" agent.agent.mcp_bindings # tuple[McpBinding, ...] ``` @@ -288,11 +287,13 @@ You do **not** pass any of these — `GET {base_url}/v1/agents/{id}` answers the | From the document | Becomes | |---|---| -| `model_gateway.binding.externalModelId` → `.model` → top-level `model` | the model string put on the wire | -| `mcp_mode`, `mcp_servers` | the MCP servers attached to the engine (below) | +| `llm.config.model` | the model put on the wire (the gateway agent's kong model); `llm` null or no model → the image's own model | +| `mcp.servers` | the MCP servers attached to the engine (below) | -The document's `source` and gateway URL are **not** used: the agent is baked into the image, and -every model call goes to `llm_url`. +`guardrails` is **not** read: the agent gateway applies it (§9). The document's `source` is not used +(the agent is baked into the image), and every model call goes to `llm_url`. A document without the +`llm` and `mcp` sections resolves with no model and no MCP servers; the older fields (`model`, +`model_gateway`, `mcp_servers`, `mcp_mode`, `rai_policy_id`) are not read. ### Where the agent comes from @@ -328,12 +329,13 @@ variables stop the Claude CLI's own background traffic (telemetry, error reporti which would otherwise go straight to Anthropic and Datadog. `CLAUDE_CODE_USE_BEDROCK` is removed from your `envs=`, because the CLI would route through Bedrock and ignore `ANTHROPIC_BASE_URL`. -For a native gateway the model is the binding's `externalModelId` (the backing Lyzr Studio agent id), -not the catalog name — which is why overriding `options={"model": ...}` is usually a mistake. +The model is the gateway agent's kong model (`llm.config.model`), the only model the agent gateway +accepts for this agent — which is why overriding `options={"model": ...}` is usually a mistake. For +gitagent a model without a `provider:` prefix gets `openai:`; the Claude engine strips any prefix. ### MCP servers -With `mcp_mode` `"engine"` (the default), each binding becomes an engine MCP server: +Each binding in `mcp.servers` becomes an engine MCP server: ```python mcp_servers[tool_name] = { @@ -351,7 +353,6 @@ mcp_servers[tool_name] = { and a warning names it. - `allowed_tools` gets `mcp__` on Anthropic-shaped engines only; gitagent treats `allowed_tools` as a strict allowlist. -- With any other `mcp_mode`, Studio runs the tools and nothing is attached. They are added to the servers the identity loader resolved (a GAP agent's `gap_tools` server stays); an `mcp_servers` key in your own `options` still wins. @@ -574,10 +575,9 @@ All of these are raised **from the constructor**, before anything runs. | Shape | Message names | What to do | |---|---|---| | `harness` not in `{claude-agent-sdk, gitagent}` | `harness` | Pass your own `EngineDriver` as `engine`. | -| `model_gateway.type != "native"` | `model_gateway.type` | Bind the agent to a native gateway, or none. | -| an `mcp_servers` binding with no `server_id` or `tool_name` | `mcp_servers[i]` | Fix the binding in AgentOS. | +| `mcp` not an object, or `mcp.servers` not a list | `mcp` / `mcp.servers` | Fix the agent in AgentOS. | +| an `mcp.servers` binding with no `server_id` or `tool_name` | `mcp.servers[i]` | Fix the binding in AgentOS. | | `orchestration` set | `orchestration` | Sub-agent fan-out is not supported. | -| `modelGatewayId` set but `model_gateway` absent | `did not resolve` | The binding did not resolve for this caller; check the agent's `ownerGroup`. | | GAP mode with `mcp_servers` in `agent.yaml` | the server names | Bind those servers to the agent in AgentOS. | --- @@ -591,7 +591,7 @@ All of these are raised **from the constructor**, before anything runs. | `AgentResolveError: no agent … (404)` | `agent_id` is not the 24-hex Mongo `_id`. | Take the id from the agent's URL in AgentOS. | | Every turn ends with `[Content blocked: RAI policy check failed: guardrails …]` | The guardrails check reached no verdict. | Check the `guardrails_url` route on the egress proxy; the status and error code follow `guardrails`. | | Every tool call is denied with `guardrails 4xx` / `guardrails unreachable` | The `guardrails_url` route or the key's permission; a `400` is a body the SDK got wrong. | Check the route; report a `400`. | -| A tool you expected is missing | A sign-in MCP binding the caller has not connected (logged as withheld), or `mcp_mode` is not `"engine"`. | Connect the account in Studio, or check `agent.agent.mcp_mode`. | +| A tool you expected is missing | A sign-in MCP binding the caller has not connected (logged as withheld), or the agent document has no `mcp` section. | Connect the account in Studio, or check `agent.agent.mcp_bindings`. | | `400 context_management: Extra inputs are not permitted` | The Claude CLI injected experimental betas. | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` is set automatically; check nothing in `envs=` is lost. | | `UnknownEngineError` / `UnknownSessionStoreError` | Typo, or a plugin you meant to register. | The message lists what *is* registered. | @@ -606,7 +606,6 @@ turn, tool calls, the reply — so a failure localises itself. ## 12. Known limitations - **Sub-agent orchestration is not supported**; an agent with `orchestration` set is refused. -- **Only `native` gateways (or none).** Generic gateways are refused. - **Only two harnesses run in-process:** `claude-agent-sdk` and `gitagent`. - **Model calls inside a turn are not checked by the SDK**; see §9. - **Concurrent `chat()`s on one instance are refused.** Run concurrent work as separate instances. diff --git a/examples/e2e/live_agent_run.py b/examples/e2e/live_agent_run.py index 3fd82a4..3fbe9f6 100644 --- a/examples/e2e/live_agent_run.py +++ b/examples/e2e/live_agent_run.py @@ -101,7 +101,7 @@ async def main(args: argparse.Namespace) -> int: resolved = agent.agent settings = agent._settings # noqa: SLF001 — diagnostics, not API ok(f"name={resolved.name!r} harness={resolved.harness} model={resolved.model}") - ok(f"MCP mode={resolved.mcp_mode} bindings={[b.tool_name for b in resolved.mcp_bindings]}") + ok(f"MCP bindings={[b.tool_name for b in resolved.mcp_bindings]}") stage(2, "The egress routes") routes = { diff --git a/src/computeragent/harness/__init__.py b/src/computeragent/harness/__init__.py index 84eede1..504dabe 100644 --- a/src/computeragent/harness/__init__.py +++ b/src/computeragent/harness/__init__.py @@ -393,7 +393,6 @@ def __init__( # 5. MCP servers bound to the agent, all through mcp_url. mcp_servers, mcp_allowed_tools = build_mcp_servers( self.agent.mcp_bindings, - mcp_mode=self.agent.mcp_mode, mcp_url=self._settings.mcp_url, api_key=self._settings.api_key(), anthropic_shaped=engine_name in ANTHROPIC_SHAPED, diff --git a/src/computeragent/harness/agentos_agent.py b/src/computeragent/harness/agentos_agent.py index bd9c4c1..ff1a80d 100644 --- a/src/computeragent/harness/agentos_agent.py +++ b/src/computeragent/harness/agentos_agent.py @@ -2,9 +2,13 @@ The single entry point for the SDK's one run mode: give it an AgentOS API base, an agent id, and a Lyzr workspace key, and it returns everything needed to run -that agent locally — harness, the effective model, and the model gateway it is -bound to. The agent itself is baked into the image, so the document's source is -not read. +that agent locally: the harness, the model and the MCP servers. The agent itself +is baked into the image, so the document's source is not read. + +Fields read: ``id``, ``name``, ``harness``, ``type``, ``orchestration``, and +the gateway sections ``llm`` (``llm.config.model``) and ``mcp`` +(``mcp.servers``). ``guardrails`` is not read: the agent gateway applies it. +A document without these sections resolves with no model and no MCP servers. GET {base_url}/v1/agents/{agent_id} header: x-api-key: @@ -36,7 +40,7 @@ from typing import Any from urllib.parse import urlsplit -from .mcp import DEFAULT_MCP_MODE, McpBinding, McpBindingError, parse_bindings +from .mcp import McpBinding, McpBindingError, parse_bindings logger = logging.getLogger("computeragent.agentos_agent") @@ -68,26 +72,14 @@ class ResolvedAgent: agent_id: str name: str harness: str - #: The model string to put on the wire. For a native gateway this is the - #: backing Lyzr Studio agent id, NOT the catalog name — see `model_of`. + #: The model string to put on the wire: the gateway agent's kong model + #: (``llm.config.model``), or ``None`` to use the image's own model. model: str | None - #: ``"native"``, or ``None`` when the agent has no effective gateway. - gateway_type: str | None - gateway_base_url: str | None - #: The server's own ``{baseUrl}{LYZR_UPSTREAM_PATH}``. Preferred over - #: reconstructing the path client-side. - gateway_completions_url: str | None agent_type: str | None - #: Who runs the MCP tool loop: ``"engine"`` attaches the bound servers to - #: the engine, anything else leaves the tools to Studio. - mcp_mode: str = DEFAULT_MCP_MODE - #: MCP servers bound to the agent, as AgentOS resolved them for the caller. + #: MCP servers bound to the agent (``mcp.servers``), as AgentOS resolved + #: them for the caller. mcp_bindings: tuple[McpBinding, ...] = () - @property - def has_native_gateway(self) -> bool: - return self.gateway_type == "native" - def api_base(base_url: str) -> str: """Normalise whatever the caller passed into the ``/v1`` API root. @@ -130,23 +122,16 @@ def api_base(base_url: str) -> str: def model_of(doc: dict[str, Any]) -> str | None: - """The model string AgentOS would have dispatched. - - Mirrors ``modelForDispatch`` (agent-defs.ts): the gateway binding's - ``externalModelId`` wins, else its ``model``, else the doc's top-level - ``model``. For a **native** gateway ``externalModelId`` is the backing Lyzr - Studio agent id (a Mongo ObjectId) — that is what goes upstream as the - OpenAI ``model``. The catalog name (``"Anthropic:anthropic/claude-…"``) only - ever configured the Studio agent; sending it to the gateway is a 500. + """The model the agent gateway accepts for this agent: ``llm.config.model``. - ``None`` means "nothing bound" — let the GAP repo's ``agent.yaml`` decide. + ``None`` when ``llm`` or its ``config`` is absent, null or not an object, or + the model is blank: the image's own model is used (the GAP ``agent.yaml`` + ``model.preferred``). ``llm.provider`` is not read. """ - gateway = doc.get("model_gateway") or {} - binding = gateway.get("binding") or {} if isinstance(gateway, dict) else {} - for candidate in (binding.get("externalModelId"), binding.get("model"), doc.get("model")): - if isinstance(candidate, str) and candidate.strip(): - return candidate - return None + llm = doc.get("llm") + config = llm.get("config") if isinstance(llm, dict) else None + model = config.get("model") if isinstance(config, dict) else None + return model if isinstance(model, str) and model.strip() else None def require_runnable_harness(agent: ResolvedAgent, *, engine_given: bool) -> None: @@ -169,21 +154,7 @@ def require_runnable_harness(agent: ResolvedAgent, *, engine_given: bool) -> Non def _require_supported(doc: dict[str, Any], agent_id: str) -> None: - """Refuse agent shapes this run mode does not implement, naming the field. - - A *null* gateway is fine — that is a direct-Anthropic run, exactly what the - TS ``defaultEnvsFor`` produces when nothing resolves. Only a **generic** - gateway is refused, because its upstream contract differs. - """ - gateway = doc.get("model_gateway") - if isinstance(gateway, dict): - gateway_type = gateway.get("type") - if gateway_type != "native": - raise UnsupportedAgentError( - f"agent {agent_id}: model_gateway.type={gateway_type!r} is not supported — this " - "run mode implements native gateways (and no gateway at all), not generic ones." - ) - + """Refuse agent shapes this run mode does not implement, naming the field.""" if doc.get("orchestration") is not None: raise UnsupportedAgentError( f"agent {agent_id}: orchestration is set and sub-agent fan-out is not supported in this " @@ -191,25 +162,6 @@ def _require_supported(doc: dict[str, Any], agent_id: str) -> None: ) -def _require_consistent_gateway(doc: dict[str, Any], agent_id: str) -> None: - """Port of ``requireConsistentGatewayBinding`` (agent-defs.ts:220). - - A binding that names an ``externalModelId`` while its gateway failed to - resolve is the one case where the *catalog* name would reach the gateway - and 500 it. Server-side that is a 503 before dispatch; here it is a refusal - before boot. - """ - gateway = doc.get("model_gateway") - if isinstance(gateway, dict): - return # gateway resolved — nothing to reconcile - if doc.get("modelGatewayId"): - raise UnsupportedAgentError( - f"agent {agent_id}: bound to modelGatewayId={doc['modelGatewayId']!r} but the server " - "returned no model_gateway — the binding did not resolve for this caller. Check that " - "the agent's ownerGroup matches a group with a configured gateway." - ) - - def resolve_agent( agent_id: str, *, @@ -266,33 +218,25 @@ def resolve_agent( ) _require_supported(doc, agent_id) - _require_consistent_gateway(doc, agent_id) try: - mcp_bindings = parse_bindings(doc.get("mcp_servers")) + mcp_bindings = parse_bindings(doc.get("mcp")) except McpBindingError as exc: raise UnsupportedAgentError(f"agent {agent_id}: {exc}") from None - mcp_mode = doc.get("mcp_mode") - gateway = doc.get("model_gateway") if isinstance(doc.get("model_gateway"), dict) else None resolved = ResolvedAgent( agent_id=str(doc.get("id") or agent_id), name=str(doc.get("name") or agent_id), harness=str(doc.get("harness") or "claude-agent-sdk"), model=model_of(doc), - gateway_type=(gateway or {}).get("type"), - gateway_base_url=(gateway or {}).get("baseUrl"), - gateway_completions_url=(gateway or {}).get("completionsUrl"), agent_type=doc.get("type"), - mcp_mode=mcp_mode if isinstance(mcp_mode, str) and mcp_mode else DEFAULT_MCP_MODE, mcp_bindings=mcp_bindings, ) logger.debug( - "resolved agent %s name=%r harness=%s gateway=%s model=%s", + "resolved agent %s name=%r harness=%s model=%s", agent_id, resolved.name, resolved.harness, - resolved.gateway_type, resolved.model, ) return resolved diff --git a/src/computeragent/harness/gateway_envs.py b/src/computeragent/harness/gateway_envs.py index 8169100..f6012e1 100644 --- a/src/computeragent/harness/gateway_envs.py +++ b/src/computeragent/harness/gateway_envs.py @@ -43,16 +43,18 @@ def strip_provider_prefix(model: str) -> str: def dispatch_model(agent: ResolvedAgent, *, engine_name: str) -> str | None: """The model string to hand the engine, per ``modelForDispatch``. - gitclaw resolves its provider from the model prefix, so a gateway model with - no prefix gets ``openai:`` — that is how an OpenAI-shaped gateway model - finds ``OPENAI_API_KEY`` instead of failing with "No API key for provider". - The Claude engine wants the opposite: any prefix stripped. + gitclaw resolves its provider from the model prefix, and every model call + goes to ``llm_url`` (the agent gateway, OpenAI-shaped for gitagent), so a + model with no prefix gets ``openai:`` — that is how it finds + ``OPENAI_API_KEY`` instead of failing with "No API key for provider". A + prefixed model is unchanged. The Claude engine wants the opposite: any + prefix stripped. """ model = agent.model if model is None: return None if engine_name == "gitagent": - if agent.has_native_gateway and ":" not in model: + if ":" not in model: return f"openai:{model}" return model return strip_provider_prefix(model) diff --git a/src/computeragent/harness/mcp.py b/src/computeragent/harness/mcp.py index d504f53..6d8079c 100644 --- a/src/computeragent/harness/mcp.py +++ b/src/computeragent/harness/mcp.py @@ -1,13 +1,12 @@ """MCP servers bound to the agent in AgentOS, served through ``mcp_url``. -The bindings come from the agent document (``GET {base_url}/v1/agents/{id}``, -``mcp_servers`` + ``mcp_mode``), which AgentOS already resolves for the caller: -a "require user sign-in" binding (``persist_auth: false``) carries the caller's -own server as ``your_server_id``. This mirrors CAS ``mcpDescriptorsFor`` -(agentos-server ``agent-defs.ts``) and ``applyMcpServers`` -(computeragent-server ``index.ts``): - -* ``mcp_mode`` other than ``"engine"`` attaches nothing — Studio runs the tools. +The bindings come from the agent document's ``mcp.servers`` +(``GET {base_url}/v1/agents/{id}``), which AgentOS already resolves for the +caller: a "require user sign-in" binding (``persist_auth: false``) carries the +caller's own server as ``your_server_id``. Every binding is attached to the +engine. This mirrors CAS ``applyMcpServers`` (computeragent-server +``index.ts``): + * The server name, and so the tool namespace ``mcp____``, is the binding's ``tool_name``, whoever runs the agent. * Each server is ``{mcp_url}/{name}``, ``{name}`` the binding's ``tool_name`` @@ -30,9 +29,6 @@ logger = logging.getLogger("computeragent.harness.mcp") -#: CAS ``DEFAULT_MCP_MODE`` (agentos-server ``mcp-binding.ts``). -DEFAULT_MCP_MODE = "engine" - class McpBindingError(ValueError): """A binding in the agent document cannot be turned into a server.""" @@ -40,7 +36,7 @@ class McpBindingError(ValueError): @dataclass(frozen=True) class McpBinding: - """One ``mcp_servers`` entry of the agent document.""" + """One ``mcp.servers`` entry of the agent document.""" server_id: str tool_name: str @@ -51,24 +47,33 @@ class McpBinding: your_server_id: str | None = None -def parse_bindings(raw: Any) -> tuple[McpBinding, ...]: - """Parse the document's ``mcp_servers`` list. A malformed entry raises - naming its index rather than being dropped: a silently missing tool looks - like the agent misbehaving.""" +def parse_bindings(section: Any) -> tuple[McpBinding, ...]: + """Parse the document's ``mcp`` section (``{provider, servers: [...]}``). + + A null or absent section, or null ``servers``, means no bindings. A + malformed entry raises naming its index rather than being dropped: a + silently missing tool looks like the agent misbehaving. ``provider``, + ``needs_your_connection`` and ``standard_server_id`` are not read. + """ + if section is None: + return () + if not isinstance(section, dict): + raise McpBindingError("mcp must be an object") + raw = section.get("servers") if raw is None: return () if not isinstance(raw, list): - raise McpBindingError(f"mcp_servers must be a list, got {type(raw).__name__}") + raise McpBindingError("mcp.servers must be a list") bindings: list[McpBinding] = [] for index, entry in enumerate(raw): if not isinstance(entry, dict): - raise McpBindingError(f"mcp_servers[{index}] must be an object") + raise McpBindingError(f"mcp.servers[{index}] must be an object") server_id = entry.get("server_id") tool_name = entry.get("tool_name") if not (isinstance(server_id, str) and server_id.strip()): - raise McpBindingError(f"mcp_servers[{index}] has no server_id") + raise McpBindingError(f"mcp.servers[{index}] has no server_id") if not (isinstance(tool_name, str) and tool_name.strip()): - raise McpBindingError(f"mcp_servers[{index}] ({server_id}) has no tool_name") + raise McpBindingError(f"mcp.servers[{index}] ({server_id}) has no tool_name") actions = entry.get("action_names") or [] your_server_id = entry.get("your_server_id") bindings.append( @@ -86,14 +91,11 @@ def parse_bindings(raw: Any) -> tuple[McpBinding, ...]: def build_mcp_servers( bindings: tuple[McpBinding, ...], *, - mcp_mode: str, mcp_url: str, api_key: str, anthropic_shaped: bool, ) -> tuple[dict[str, dict[str, Any]], list[str]]: """Engine ``mcp_servers`` entries and the ``allowed_tools`` grants for them.""" - if mcp_mode != DEFAULT_MCP_MODE: - return {}, [] base = mcp_url.rstrip("/") servers: dict[str, dict[str, Any]] = {} allowed: list[str] = [] @@ -120,7 +122,6 @@ def build_mcp_servers( __all__ = [ - "DEFAULT_MCP_MODE", "McpBinding", "McpBindingError", "build_mcp_servers", diff --git a/tests/fixtures/agent_doc.json b/tests/fixtures/agent_doc.json index b6489b8..40bf27d 100644 --- a/tests/fixtures/agent_doc.json +++ b/tests/fixtures/agent_doc.json @@ -5,36 +5,24 @@ "harness": "claude-agent-sdk", "source": "https://github.com/vrajp-lyzr/general-agent-private", "sourceUrl": "https://github.com/vrajp-lyzr/general-agent-private", - "model": "Anthropic:anthropic/claude-sonnet-4-6", - "modelGatewayId": "6a9a6c9893432debd1668743", - "model_gateway": { - "id": "6a9a6c9893432debd1668743", - "slug": "lyzr-studio", - "label": "Lyzr Studio", - "type": "native", - "baseUrl": "https://excellent-flavor-glance-myspace.trycloudflare.com", - "completionsUrl": "https://excellent-flavor-glance-myspace.trycloudflare.com/v4/chat/completions", - "models": [ - "OpenAI:gpt-4o", - "Anthropic:anthropic/claude-haiku-4-5", - "Anthropic:anthropic/claude-sonnet-4-6", - "Google:gemini/gemini-2.5-pro", - "Aws-Bedrock:bedrock/us.anthropic.claude-sonnet-4-6" - ], - "isDefault": true, - "ownerGroup": "ac9b6d5f-fbbc-41b6-b832-3e5e47e31c83", - "hasSecret": false, - "binding": { - "model": "Anthropic:anthropic/claude-sonnet-4-6", - "externalModelId": "6aa93f1a75ce74c4e6772b20", - "raiPolicyId": null + "credential_id": "fe5e3df8-bbf5-4aba-ada7-e312a1da44a7", + "llm": { + "provider": "kong", + "config": { + "model": "claude-sonnet-4-5" } }, - "credential_id": "fe5e3df8-bbf5-4aba-ada7-e312a1da44a7", + "guardrails": { + "provider": "lyzr-srs", + "config": { + "id": "pol_1" + } + }, + "mcp": { + "provider": "lyzr-mcp", + "servers": [] + }, "type": "client", - "rai_policy_id": null, - "mcp_servers": [], - "mcp_mode": "engine", "runtime": null, "orchestration": null, "origin": "registry", diff --git a/tests/test_agentos_agent_resolve.py b/tests/test_agentos_agent_resolve.py index 55cdc91..83df243 100644 --- a/tests/test_agentos_agent_resolve.py +++ b/tests/test_agentos_agent_resolve.py @@ -69,12 +69,11 @@ def test_resolves_every_field(agent_doc): assert agent.agent_id == AGENT_ID assert agent.name == "private git test with client" assert agent.harness == "claude-agent-sdk" - assert agent.model == "6aa93f1a75ce74c4e6772b20" - assert agent.gateway_type == "native" - assert agent.has_native_gateway is True - assert agent.gateway_base_url == "https://excellent-flavor-glance-myspace.trycloudflare.com" - assert agent.gateway_completions_url.endswith("/v4/chat/completions") + assert agent.model == "claude-sonnet-4-5" assert agent.agent_type == "client" + assert agent.mcp_bindings == () + for gone in ("gateway_type", "gateway_base_url", "gateway_completions_url", "mcp_mode"): + assert not hasattr(agent, gone) @respx.mock @@ -127,48 +126,66 @@ def test_egress_base_builds_single_prefix(agent_doc, base): assert request.headers["x-api-key"] == KEY -# ── model precedence ───────────────────────────────────────────────────────── +# ── the model ──────────────────────────────────────────────────────────────── -def test_external_model_id_wins(agent_doc): - assert model_of(agent_doc) == "6aa93f1a75ce74c4e6772b20" +def test_model_from_llm_config(agent_doc): + assert model_of(agent_doc) == "claude-sonnet-4-5" -def test_falls_back_to_binding_model(agent_doc): - agent_doc["model_gateway"]["binding"]["externalModelId"] = None - assert model_of(agent_doc) == "Anthropic:anthropic/claude-sonnet-4-6" +@pytest.mark.parametrize( + "llm", + [None, "absent", {}, {"config": None}, {"config": {"model": " "}}, {"config": "x"}, "kong"], +) +def test_null_llm_uses_the_image_model(agent_doc, llm): + from computeragent.harness.gateway_envs import build_options + if llm == "absent": + del agent_doc["llm"] + else: + agent_doc["llm"] = llm + assert model_of(agent_doc) is None -def test_falls_back_to_top_level_model(agent_doc): - agent_doc["model_gateway"]["binding"] = {"model": None, "externalModelId": None} - assert model_of(agent_doc) == "Anthropic:anthropic/claude-sonnet-4-6" + with respx.mock: + respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) + agent = _resolve() + assert "model" not in build_options(agent, engine_name="claude-agent-sdk") -def test_no_model_anywhere_is_none(agent_doc): - agent_doc["model_gateway"] = None - agent_doc["model"] = None - assert model_of(agent_doc) is None +# ── accepted shapes ────────────────────────────────────────────────────────── -def test_blank_strings_are_not_a_model(agent_doc): - agent_doc["model_gateway"]["binding"]["externalModelId"] = " " - assert model_of(agent_doc) == "Anthropic:anthropic/claude-sonnet-4-6" +@respx.mock +def test_legacy_fields_are_ignored(agent_doc): + """A pre-#164 document resolves with no model and no MCP servers.""" + agent_doc.update( + { + "llm": None, + "mcp": None, + "model": "Anthropic:anthropic/claude-sonnet-4-6", + "model_gateway": {"type": "generic", "binding": {"externalModelId": "x"}}, + "modelGatewayId": "6a9a6c9893432debd1668743", + "mcp_servers": [{"server_id": "srv_1", "tool_name": "jira"}], + "mcp_mode": "studio", + } + ) + respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) + agent = _resolve() -# ── accepted shapes ────────────────────────────────────────────────────────── + assert agent.model is None + assert agent.mcp_bindings == () @respx.mock -def test_null_gateway_is_allowed(agent_doc): - """No gateway is a direct-Anthropic run, not an error.""" - agent_doc["model_gateway"] = None - agent_doc["modelGatewayId"] = None - respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) +def test_guardrails_section_ignored(agent_doc): + agent_doc["guardrails"] = {"provider": "lyzr-srs", "config": {"id": "pol_1"}} + route = respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) agent = _resolve() - assert agent.gateway_type is None - assert agent.has_native_gateway is False + assert route.call_count == 1 + assert not any("guardrail" in f or "polic" in f for f in vars(agent)) @respx.mock @@ -182,36 +199,36 @@ def test_gitagent_harness_is_allowed(agent_doc): # ── refusals ───────────────────────────────────────────────────────────────── -@respx.mock -def test_refuses_generic_gateway(agent_doc): - agent_doc["model_gateway"]["type"] = "generic" - respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) - - with pytest.raises(UnsupportedAgentError, match="model_gateway.type"): - _resolve() - - @respx.mock def test_bound_mcp_servers_are_resolved(agent_doc): - agent_doc["mcp_servers"] = [ - {"server_id": "srv_1", "tool_name": "jira", "action_names": ["search"]} - ] + agent_doc["mcp"] = { + "provider": "lyzr-mcp", + "servers": [ + { + "server_id": "srv_1", + "tool_name": "jira", + "action_names": ["search"], + "persist_auth": True, + "needs_your_connection": False, + "your_server_id": None, + } + ], + } respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) agent = _resolve() - assert agent.mcp_mode == "engine" assert [(b.server_id, b.tool_name, b.action_names) for b in agent.mcp_bindings] == [ ("srv_1", "jira", ("search",)) ] @respx.mock -def test_a_binding_without_a_tool_name_is_refused_by_index(agent_doc): - agent_doc["mcp_servers"] = [{"server_id": "srv_1", "action_names": ["x"]}] +def test_a_binding_without_a_name_refuses_the_agent(agent_doc): + agent_doc["mcp"] = {"servers": [{"server_id": "s"}]} respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) - with pytest.raises(UnsupportedAgentError, match=r"mcp_servers\[0\].*tool_name"): + with pytest.raises(UnsupportedAgentError, match=r"mcp\.servers\[0\].*tool_name"): _resolve() @@ -242,9 +259,6 @@ def test_unknown_harness_is_refused_when_no_engine_is_given(agent_doc): name="a", harness="deepagents", model=None, - gateway_type=None, - gateway_base_url=None, - gateway_completions_url=None, agent_type=None, ) @@ -261,27 +275,12 @@ def test_bringing_your_own_engine_rescues_an_unknown_harness(agent_doc): name="a", harness="deepagents", model=None, - gateway_type=None, - gateway_base_url=None, - gateway_completions_url=None, agent_type=None, ) require_runnable_harness(agent, engine_given=True) -@respx.mock -def test_refuses_a_binding_whose_gateway_did_not_resolve(agent_doc): - """`requireConsistentGatewayBinding`: the one case where the catalog name - would reach the gateway and 500 it.""" - agent_doc["model_gateway"] = None # gateway failed to resolve for this caller - # modelGatewayId stays set — the agent still believes it is bound. - respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) - - with pytest.raises(UnsupportedAgentError, match="did not resolve"): - _resolve() - - # ── HTTP errors ────────────────────────────────────────────────────────────── diff --git a/tests/test_computeragent_init.py b/tests/test_computeragent_init.py index a424964..beb72d8 100644 --- a/tests/test_computeragent_init.py +++ b/tests/test_computeragent_init.py @@ -82,8 +82,8 @@ def test_exposes_the_resolved_agent(agent_doc): agent = build(agent_doc) assert agent.agent.agent_id == AGENT_ID - assert agent.agent.model == "6aa93f1a75ce74c4e6772b20" - assert agent.agent.has_native_gateway + assert agent.agent.model == "claude-sonnet-4-5" + assert not hasattr(agent.agent, "has_native_gateway") @respx.mock @@ -105,15 +105,6 @@ def test_missing_llm_url_is_refused_at_construction(): ComputerAgent(agent_id=AGENT_ID, base_url=BASE, api_key=KEY, engine=FakeEngine()) -@respx.mock -def test_the_agent_documents_gateway_url_is_not_used(agent_doc): - """Model calls go to llm_url only, never to the document's gateway.""" - agent = build(agent_doc) - envs = agent._coordinator._envs # type: ignore[attr-defined] - - assert agent_doc["model_gateway"]["baseUrl"] not in "".join(envs.values()) - - # ── engine selection ───────────────────────────────────────────────────────── @@ -277,7 +268,7 @@ def test_options_overlay_carries_dispatch_defaults_and_caller_options(agent_doc) assert overlay["permission_mode"] == "bypassPermissions" assert overlay["setting_sources"] == ["project"] - assert overlay["model"] == "6aa93f1a75ce74c4e6772b20" + assert overlay["model"] == "claude-sonnet-4-5" assert overlay["system_prompt"] == "You are terse." @@ -383,4 +374,4 @@ async def test_the_resolved_model_reaches_the_engine_context(agent_doc, tmp_path async with build(agent_doc, engine=engine) as agent: await agent.chat("hello") - assert engine.contexts[0].manifest.options["model"] == "6aa93f1a75ce74c4e6772b20" + assert engine.contexts[0].manifest.options["model"] == "claude-sonnet-4-5" diff --git a/tests/test_gateway_envs.py b/tests/test_gateway_envs.py index ec97d9d..aec099a 100644 --- a/tests/test_gateway_envs.py +++ b/tests/test_gateway_envs.py @@ -21,8 +21,7 @@ KEY = "ags_testkey" LLM_URL = "http://egress.internal/llm" -GATEWAY = "https://excellent-flavor-glance-myspace.trycloudflare.com" -STUDIO_ID = "6aa93f1a75ce74c4e6772b20" +KONG_MODEL = "claude-sonnet-4-5" def agent(**overrides) -> ResolvedAgent: @@ -30,10 +29,7 @@ def agent(**overrides) -> ResolvedAgent: agent_id="6aa93f1b3817a13d3103d2c1", name="private git test with client", harness="claude-agent-sdk", - model=STUDIO_ID, - gateway_type="native", - gateway_base_url=GATEWAY, - gateway_completions_url=f"{GATEWAY}/v4/chat/completions", + model=KONG_MODEL, agent_type="client", ) defaults.update(overrides) @@ -93,8 +89,8 @@ def test_a_malformed_llm_url_is_refused(bad): # ── model ──────────────────────────────────────────────────────────────────── -def test_studio_agent_id_reaches_the_claude_engine_untouched(): - assert dispatch_model(agent(), engine_name="claude-agent-sdk") == STUDIO_ID +def test_kong_model_reaches_the_claude_engine_untouched(): + assert dispatch_model(agent(), engine_name="claude-agent-sdk") == KONG_MODEL @pytest.mark.parametrize( @@ -103,7 +99,7 @@ def test_studio_agent_id_reaches_the_claude_engine_untouched(): ("Anthropic:anthropic/claude-sonnet-4-6", "anthropic/claude-sonnet-4-6"), ("openai:gpt-4o", "gpt-4o"), ("bedrock/us.anthropic.claude-sonnet-4-6", "bedrock/us.anthropic.claude-sonnet-4-6"), - (STUDIO_ID, STUDIO_ID), + (KONG_MODEL, KONG_MODEL), ], ) def test_provider_prefix_strip_matches_the_ts_engine(given, expected): @@ -114,7 +110,7 @@ def test_gitagent_gets_an_openai_prefix_so_it_finds_the_right_key(): """gitclaw resolves its provider from the prefix; without one an OpenAI-shaped gateway model fails with 'No API key for provider'.""" assert ( - dispatch_model(agent(harness="gitagent"), engine_name="gitagent") == f"openai:{STUDIO_ID}" + dispatch_model(agent(harness="gitagent"), engine_name="gitagent") == f"openai:{KONG_MODEL}" ) @@ -123,11 +119,6 @@ def test_gitagent_does_not_double_prefix_a_model_that_has_one(): assert dispatch_model(a, engine_name="gitagent") == "anthropic:claude-sonnet-4-6" -def test_gitagent_without_a_gateway_leaves_the_model_alone(): - a = agent(harness="gitagent", gateway_type=None, gateway_base_url=None, model="gpt-4o") - assert dispatch_model(a, engine_name="gitagent") == "gpt-4o" - - def test_no_model_stays_none(): assert dispatch_model(agent(model=None), engine_name="claude-agent-sdk") is None @@ -174,7 +165,7 @@ def test_options_carry_what_agentos_dispatches(): assert options["permission_mode"] == "bypassPermissions" assert options["setting_sources"] == ["project"] - assert options["model"] == STUDIO_ID + assert options["model"] == KONG_MODEL def test_caller_options_win(): @@ -186,7 +177,7 @@ def test_caller_options_win(): assert options["permission_mode"] == "default" assert options["system_prompt"] == "You are terse." - assert options["model"] == STUDIO_ID + assert options["model"] == KONG_MODEL def test_caller_can_override_the_resolved_model(): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index e41c415..63050d5 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -35,10 +35,9 @@ CHECK_URL = f"{GUARDRAILS}/v1/check" -def build_servers(bindings, *, mcp_mode="engine", anthropic_shaped=True): +def build_servers(bindings, *, anthropic_shaped=True): return build_mcp_servers( tuple(bindings), - mcp_mode=mcp_mode, mcp_url=MCP_URL, api_key=KEY, anthropic_shaped=anthropic_shaped, @@ -50,16 +49,20 @@ def build_servers(bindings, *, mcp_mode="engine", anthropic_shaped=True): def test_parses_every_field(): (binding,) = parse_bindings( - [ - { - "server_id": "srv_creator", - "tool_name": "jira", - "action_names": ["search", "create"], - "persist_auth": False, - "your_server_id": "srv_mine", - "needs_your_connection": False, - } - ] + { + "provider": "lyzr-mcp", + "servers": [ + { + "server_id": "srv_creator", + "tool_name": "jira", + "action_names": ["search", "create"], + "persist_auth": False, + "your_server_id": "srv_mine", + "needs_your_connection": False, + "standard_server_id": "std_1", + } + ], + } ) assert binding == McpBinding( @@ -71,24 +74,28 @@ def test_parses_every_field(): ) -def test_no_bindings(): +def test_parses_mcp_section(): assert parse_bindings(None) == () - assert parse_bindings([]) == () + assert parse_bindings({"servers": []}) == () + assert parse_bindings({"servers": None}) == () + assert parse_bindings({"provider": "lyzr-mcp"}) == () def test_persist_auth_defaults_to_shared(): - (binding,) = parse_bindings([{"server_id": "s", "tool_name": "t"}]) + (binding,) = parse_bindings({"servers": [{"server_id": "s", "tool_name": "t"}]}) assert binding.persist_auth is True @pytest.mark.parametrize( ("raw", "match"), [ - ("nope", "must be a list"), - (["nope"], r"mcp_servers\[0\] must be an object"), - ([{"tool_name": "t"}], r"mcp_servers\[0\] has no server_id"), - ([{"server_id": "s"}], r"mcp_servers\[0\] \(s\) has no tool_name"), - ([{"server_id": " ", "tool_name": "t"}], "no server_id"), + ("nope", "mcp must be an object"), + ([], "mcp must be an object"), + ({"servers": "nope"}, "mcp.servers must be a list"), + ({"servers": ["nope"]}, r"mcp\.servers\[0\] must be an object"), + ({"servers": [{"tool_name": "t"}]}, r"mcp\.servers\[0\] has no server_id"), + ({"servers": [{"server_id": "s"}]}, r"mcp\.servers\[0\] \(s\) has no tool_name"), + ({"servers": [{"server_id": " ", "tool_name": "t"}]}, "no server_id"), ], ) def test_a_malformed_binding_is_refused(raw, match): @@ -165,10 +172,6 @@ def test_a_sign_in_binding_without_a_connection_is_withheld(caplog): assert any("jira" in r.getMessage() for r in caplog.records) -def test_studio_mode_attaches_nothing(): - assert build_servers([McpBinding("srv_1", "jira")], mcp_mode="studio") == ({}, []) - - def test_gitagent_gets_servers_but_no_allowed_tools_entries(): """gitclaw treats allowedTools as a strict allowlist; an mcp__ entry would filter out every tool it has.""" @@ -181,7 +184,6 @@ def test_gitagent_gets_servers_but_no_allowed_tools_entries(): def test_a_trailing_slash_on_mcp_url_does_not_double_up(): servers, _ = build_mcp_servers( (McpBinding("srv_1", "jira"),), - mcp_mode="engine", mcp_url=MCP_URL + "/", api_key=KEY, anthropic_shaped=True, @@ -242,7 +244,10 @@ def test_mcp_url_is_required(): @pytest.mark.asyncio @respx.mock async def test_bound_servers_reach_the_engine(agent_doc): - agent_doc["mcp_servers"] = [{"server_id": "srv_1", "tool_name": "jira"}] + agent_doc["mcp"] = { + "provider": "lyzr-mcp", + "servers": [{"server_id": "srv_1", "tool_name": "jira"}], + } engine = FakeEngine() async with build(agent_doc, engine=engine) as agent: @@ -263,7 +268,10 @@ async def test_bound_servers_reach_the_engine(agent_doc): async def test_gap_tools_and_bound_servers_are_both_kept(agent_doc, tmp_path): """The bound servers are added to the loader's own, not swapped for them.""" (tmp_path / "agent.yaml").write_text("name: fixture\ntools: [Read]\n", encoding="utf-8") - agent_doc["mcp_servers"] = [{"server_id": "srv_1", "tool_name": "jira"}] + agent_doc["mcp"] = { + "provider": "lyzr-mcp", + "servers": [{"server_id": "srv_1", "tool_name": "jira"}], + } engine = FakeEngine() async with build(agent_doc, engine=engine, gap_dir=tmp_path) as agent: @@ -277,7 +285,10 @@ async def test_gap_tools_and_bound_servers_are_both_kept(agent_doc, tmp_path): @pytest.mark.asyncio @respx.mock async def test_a_caller_mcp_servers_option_still_wins(agent_doc): - agent_doc["mcp_servers"] = [{"server_id": "srv_1", "tool_name": "jira"}] + agent_doc["mcp"] = { + "provider": "lyzr-mcp", + "servers": [{"server_id": "srv_1", "tool_name": "jira"}], + } engine = FakeEngine() async with build(agent_doc, engine=engine, options={"mcp_servers": {}}) as agent: