Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 13 additions & 14 deletions docs/USING_THE_SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]
```

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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] = {
Expand All @@ -351,7 +353,6 @@ mcp_servers[tool_name] = {
and a warning names it.
- `allowed_tools` gets `mcp__<tool_name>` 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.
Expand Down Expand Up @@ -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. |

---
Expand All @@ -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. |

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion examples/e2e/live_agent_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 0 additions & 1 deletion src/computeragent/harness/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
102 changes: 23 additions & 79 deletions src/computeragent/harness/agentos_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <lyzr key>

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -169,47 +154,14 @@ 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 "
"run mode. The agent would run as a plain single agent, which is not what it declares."
)


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,
*,
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions src/computeragent/harness/gateway_envs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading