From 58d7b4922d6d6eb88f5eb8ce0c00edddbab31d2d Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Tue, 27 Oct 2026 16:59:10 +0530 Subject: [PATCH 01/14] feat(harness)!: verbose COMPUTER_AGENT_* settings; the app server takes the agent and token per session The settings' environment variables are renamed so each says what it is: COMPUTER_AGENT_ID, COMPUTER_AGENT_ENGINE, COMPUTER_AGENT_EGRESS_PROXY_SECRET (was LYZR_API_KEY) and COMPUTER_AGENT_EGRESS_{API,LLM,MCP,GUARDRAILS, TELEMETRY,SESSION_STORE}_URL, plus COMPUTER_AGENT_GAP_DIR. Constructor arguments are unchanged; no old name is read. Old CAS now passes COMPUTER_AGENT_EGRESS_PROXY_SECRET and COMPUTER_AGENT_ID in POST /v1/sessions envs: the app server seeds the token from it and builds the session's agent from the id, falling back to the image's own. --- examples/e2e/live_agent_run.py | 20 +++---- src/computeragent/harness/coordinator.py | 6 +- src/computeragent/harness/egress_token.py | 4 +- src/computeragent/harness/gateway_envs.py | 2 +- src/computeragent/harness/settings.py | 20 +++---- src/computeragent/server/app.py | 33 +++++++---- src/computeragent/session_stores/http.py | 12 ++-- tests/server_helpers.py | 6 +- tests/test_computeragent_init.py | 2 +- tests/test_egress_token.py | 16 +++--- tests/test_gateway_envs.py | 6 +- tests/test_server_app.py | 24 ++++++-- tests/test_session_store_http.py | 12 ++-- tests/test_settings.py | 67 ++++++++++++----------- tests/test_turn_key.py | 20 +++---- 15 files changed, 143 insertions(+), 107 deletions(-) diff --git a/examples/e2e/live_agent_run.py b/examples/e2e/live_agent_run.py index 3fbe9f6..88287d6 100644 --- a/examples/e2e/live_agent_run.py +++ b/examples/e2e/live_agent_run.py @@ -7,14 +7,14 @@ ComputerAgent reads every setting from its environment variable, so the run is configured the way a sandbox is: - export COMPUTERAGENT_AGENT_ID=6aa93f1b3817a13d3103d2c1 - export COMPUTERAGENT_ENGINE=claude-agent-sdk - export COMPUTERAGENT_BASE_URL=http://egress/agentos/api - export COMPUTERAGENT_GUARDRAILS_URL=http://egress/guardrails - export COMPUTERAGENT_LLM_URL=http://egress/llm - export COMPUTERAGENT_MCP_URL=http://egress/mcp/v1 - export LYZR_API_KEY=... - # optional: COMPUTERAGENT_TELEMETRY_URL, COMPUTERAGENT_GAP_DIR + export COMPUTER_AGENT_ID=6aa93f1b3817a13d3103d2c1 + export COMPUTER_AGENT_ENGINE=claude-agent-sdk + export COMPUTER_AGENT_EGRESS_API_URL=http://egress/agentos/api + export COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=http://egress/guardrails + export COMPUTER_AGENT_EGRESS_LLM_URL=http://egress/llm + export COMPUTER_AGENT_EGRESS_MCP_URL=http://egress/mcp/v1 + export COMPUTER_AGENT_EGRESS_PROXY_SECRET=... + # optional: COMPUTER_AGENT_EGRESS_TELEMETRY_URL, COMPUTER_AGENT_GAP_DIR uv run python examples/e2e/live_agent_run.py @@ -216,13 +216,13 @@ def parse_args() -> argparse.Namespace: p.add_argument( "--engine", default=None, - help="'claude-agent-sdk' or 'gitagent' (else COMPUTERAGENT_ENGINE)", + help="'claude-agent-sdk' or 'gitagent' (else COMPUTER_AGENT_ENGINE)", ) p.add_argument( "--gap-dir", default=None, type=lambda v: Path(v).resolve(), - help="run in GAP mode from this directory (else COMPUTERAGENT_GAP_DIR)", + help="run in GAP mode from this directory (else COMPUTER_AGENT_GAP_DIR)", ) return p.parse_args() diff --git a/src/computeragent/harness/coordinator.py b/src/computeragent/harness/coordinator.py index 216f23f..60e9c75 100644 --- a/src/computeragent/harness/coordinator.py +++ b/src/computeragent/harness/coordinator.py @@ -135,7 +135,7 @@ def __init__( # the newest sandbox token and their answers are observed. forward_cli: bool = False, # Read at every turn (and at boot): the engine's ANTHROPIC_API_KEY / - # OPENAI_API_KEY / LYZR_API_KEY and the bound MCP servers' x-api-key. + # OPENAI_API_KEY / COMPUTER_AGENT_EGRESS_PROXY_SECRET and the bound MCP servers' x-api-key. api_key: Callable[[], str] | None = None, ) -> None: self._engine = engine @@ -365,7 +365,9 @@ async def _engine_envs_and_manifest(self) -> tuple[dict[str, str], Any]: from dataclasses import replace envs = self._turn_envs() - options = self._turn_options(envs.get("LYZR_API_KEY") if self._api_key else None) + options = self._turn_options( + envs.get("COMPUTER_AGENT_EGRESS_PROXY_SECRET") if self._api_key else None + ) if self._forward_cli: from .egress_forwarder import EgressForwarder diff --git a/src/computeragent/harness/egress_token.py b/src/computeragent/harness/egress_token.py index a4416da..11bcf8f 100644 --- a/src/computeragent/harness/egress_token.py +++ b/src/computeragent/harness/egress_token.py @@ -16,7 +16,7 @@ no chain yet (which becomes the root of a new chain); * the expiry is later than the chain's newest. -A chain whose newest expiry has passed is dropped. When ``LYZR_API_KEY`` +A chain whose newest expiry has passed is dropped. When ``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` holds a token of the chain it is set to the successor, so processes spawned later inherit it. Tokens and header values are never logged. @@ -44,7 +44,7 @@ TOKEN_PREFIX = "sbt_" MAX_TOKEN_BYTES = 4096 -_ENV_KEY = "LYZR_API_KEY" +_ENV_KEY = "COMPUTER_AGENT_EGRESS_PROXY_SECRET" _BEARER = "bearer " diff --git a/src/computeragent/harness/gateway_envs.py b/src/computeragent/harness/gateway_envs.py index d778b83..86f806c 100644 --- a/src/computeragent/harness/gateway_envs.py +++ b/src/computeragent/harness/gateway_envs.py @@ -67,7 +67,7 @@ def key_envs(api_key: str) -> dict[str, str]: return { "ANTHROPIC_API_KEY": api_key, "OPENAI_API_KEY": api_key, - "LYZR_API_KEY": api_key, + "COMPUTER_AGENT_EGRESS_PROXY_SECRET": api_key, } diff --git a/src/computeragent/harness/settings.py b/src/computeragent/harness/settings.py index 28eb68f..b31aa80 100644 --- a/src/computeragent/harness/settings.py +++ b/src/computeragent/harness/settings.py @@ -25,16 +25,16 @@ #: Argument name → environment variable. ENV_VARS: dict[str, str] = { - "agent_id": "COMPUTERAGENT_AGENT_ID", - "engine": "COMPUTERAGENT_ENGINE", - "base_url": "COMPUTERAGENT_BASE_URL", - "api_key": "LYZR_API_KEY", - "llm_url": "COMPUTERAGENT_LLM_URL", - "mcp_url": "COMPUTERAGENT_MCP_URL", - "guardrails_url": "COMPUTERAGENT_GUARDRAILS_URL", - "telemetry_url": "COMPUTERAGENT_TELEMETRY_URL", - "session_store_url": "COMPUTERAGENT_SESSION_STORE_URL", - "gap_dir": "COMPUTERAGENT_GAP_DIR", + "agent_id": "COMPUTER_AGENT_ID", + "engine": "COMPUTER_AGENT_ENGINE", + "base_url": "COMPUTER_AGENT_EGRESS_API_URL", + "api_key": "COMPUTER_AGENT_EGRESS_PROXY_SECRET", + "llm_url": "COMPUTER_AGENT_EGRESS_LLM_URL", + "mcp_url": "COMPUTER_AGENT_EGRESS_MCP_URL", + "guardrails_url": "COMPUTER_AGENT_EGRESS_GUARDRAILS_URL", + "telemetry_url": "COMPUTER_AGENT_EGRESS_TELEMETRY_URL", + "session_store_url": "COMPUTER_AGENT_EGRESS_SESSION_STORE_URL", + "gap_dir": "COMPUTER_AGENT_GAP_DIR", } #: Egress routes: required, and each must be an http(s) URL. diff --git a/src/computeragent/server/app.py b/src/computeragent/server/app.py index 0faa2b9..33b2ac5 100644 --- a/src/computeragent/server/app.py +++ b/src/computeragent/server/app.py @@ -17,12 +17,14 @@ GET /ping, POST /invocations AgentCore: the same routes in relay envelope v1 (relay.py) One ``ComputerAgent`` per session, built from the image's settings -(``COMPUTERAGENT_*``) with no ``api_key=``: the session's -``envs.LYZR_API_KEY`` seeds ``os.environ["LYZR_API_KEY"]`` (every other -``envs`` key is ignored) and each message's ``credential`` replaces it before -its turn. The key is process-wide, so turns run one at a time per process. -``identity`` is not read (the agent is baked into the image); ``engine`` is -checked against the registered engines and the image's own engine runs. +(``COMPUTER_AGENT_*``) with no ``api_key=``. Old CAS passes two of them in the +session's ``envs``: ``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` (the sandbox token) +seeds the process environment, and ``COMPUTER_AGENT_ID`` is the session's +agent (else the image's own ``COMPUTER_AGENT_ID``); every other ``envs`` key is +ignored. Each message's ``credential`` replaces the token before its turn. The +token is process-wide, so turns run one at a time per process. +``engine`` is checked against the registered engines and the image's own +engine runs; ``identity`` is not read. ``messages`` are the session's first turns and ``streamingInput: false`` closes its input after them, as in the reference. Resume across sandboxes is the session store's (``sessionStore``, keyed by ``sessionId``). @@ -71,6 +73,11 @@ HOOK_PREFIX = "/aws/lambda-microvms/runtime/v1/" ERROR_SOURCE = "COMPUTERAGENT_SERVER" +#: The ``envs`` keys old CAS passes at ``POST /v1/sessions``; the token is +#: also the variable the SDK reads it from. +TOKEN_ENV = "COMPUTER_AGENT_EGRESS_PROXY_SECRET" +AGENT_ID_ENV = "COMPUTER_AGENT_ID" + #: Builds a session's agent: (session_id, options, session_store, on_tool_call) → ComputerAgent. AgentFactory = Callable[..., Any] @@ -103,7 +110,7 @@ def default_agent_factory(**kwargs: Any) -> Any: on_tool_call = kwargs.pop("on_tool_call") # The Claude engine never asks for approvals (it runs under a # prompt-skipping permission mode); passing a callback only logs a warning. - if os.environ.get("COMPUTERAGENT_ENGINE") != "claude-agent-sdk": + if os.environ.get("COMPUTER_AGENT_ENGINE") != "claude-agent-sdk": kwargs["on_tool_call"] = on_tool_call return ComputerAgent(**kwargs) @@ -220,7 +227,7 @@ async def _run(self) -> None: message, credential = item async with self.turn_lock: if credential: - os.environ["LYZR_API_KEY"] = credential + os.environ[TOKEN_ENV] = credential if not await self._turn(message): return await self.end("complete") @@ -419,8 +426,11 @@ async def create_session(request: Request) -> Response: "SESSION_ID_INVALID", "sessionId must match [A-Za-z0-9._-]{1,128} and cannot be '.' or '..'", ) - if envs.get("LYZR_API_KEY"): - os.environ["LYZR_API_KEY"] = envs["LYZR_API_KEY"] + if envs.get(TOKEN_ENV): + os.environ[TOKEN_ENV] = envs[TOKEN_ENV] + agent_kwargs: dict[str, Any] = {} + if envs.get(AGENT_ID_ENV, "").strip(): + agent_kwargs["agent_id"] = envs[AGENT_ID_ENV].strip() old = sessions.pop(sid, None) if old is not None: await old.cancel() @@ -438,6 +448,7 @@ async def on_tool_call(req: Any) -> dict[str, Any]: options=options, session_store=body.get("sessionStore"), on_tool_call=on_tool_call, + **agent_kwargs, ) except Exception as exc: from ..harness import AgentResolveError @@ -468,7 +479,7 @@ async def on_tool_call(req: Any) -> dict[str, Any]: } ) session.start() - initial_credential = envs.get("LYZR_API_KEY") + initial_credential = envs.get(TOKEN_ENV) for message in messages: await session.queue.put((message, initial_credential)) if not body.get("streamingInput"): diff --git a/src/computeragent/session_stores/http.py b/src/computeragent/session_stores/http.py index c8777a5..4b4c55b 100644 --- a/src/computeragent/session_stores/http.py +++ b/src/computeragent/session_stores/http.py @@ -206,10 +206,10 @@ class SessionStoreAppendError(RuntimeError): def _env_api_key() -> str | None: - """``LYZR_API_KEY`` as it stands now, as the newest token of its chain.""" + """``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` as it stands now, as the newest token of its chain.""" from ..harness.egress_token import CREDENTIAL - value = os.environ.get("LYZR_API_KEY", "").strip() + value = os.environ.get("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "").strip() return CREDENTIAL.resolve(value) if value else None @@ -217,7 +217,7 @@ def http_session_store_builder(**defaults: Any) -> Callable[[Any], HttpSessionSt """Registry builder. Takes no connection defaults: ``base_url`` comes per session (from - ``COMPUTERAGENT_SESSION_STORE_URL`` through :func:`with_base_url`, or the + ``COMPUTER_AGENT_EGRESS_SESSION_STORE_URL`` through :func:`with_base_url`, or the descriptor). A ``token`` option, which older dispatchers still send, is dropped: the store sends only ``x-api-key``. """ @@ -230,7 +230,7 @@ def _build(options: Any = None) -> HttpSessionStore: if not merged.get("base_url"): raise ValueError( "http session store: options.base_url is required — set " - "COMPUTERAGENT_SESSION_STORE_URL or send it in the descriptor" + "COMPUTER_AGENT_EGRESS_SESSION_STORE_URL or send it in the descriptor" ) return HttpSessionStore(**merged) @@ -238,7 +238,7 @@ def _build(options: Any = None) -> HttpSessionStore: def with_base_url(spec: Any, url: str | None) -> Any: - """Apply ``COMPUTERAGENT_SESSION_STORE_URL`` to a session-store spec. + """Apply ``COMPUTER_AGENT_EGRESS_SESSION_STORE_URL`` to a session-store spec. A store instance, or a kind other than ``http``, is returned unchanged. For kind ``http`` with ``url`` set, ``options.base_url`` becomes ``url`` @@ -260,7 +260,7 @@ def with_base_url(spec: Any, url: str | None) -> Any: else: logger.debug("session store URL setting ignored for this kind") return spec - logger.info("session store base URL taken from COMPUTERAGENT_SESSION_STORE_URL") + logger.info("session store base URL taken from COMPUTER_AGENT_EGRESS_SESSION_STORE_URL") return {"kind": "http", "options": {**options, "base_url": url}} diff --git a/tests/server_helpers.py b/tests/server_helpers.py index c43331f..0fea5e1 100644 --- a/tests/server_helpers.py +++ b/tests/server_helpers.py @@ -123,8 +123,8 @@ async def no_opinion(*_a: Any) -> None: # lets approvals reach the client def factory(**kwargs: Any) -> ComputerAgent: if calls is not None: calls.append(dict(kwargs)) + kwargs.setdefault("agent_id", AGENT_ID) return ComputerAgent( - agent_id=AGENT_ID, base_url=BASE, engine=engine, llm_url="http://egress.internal/llm", @@ -145,7 +145,7 @@ def session_body(**overrides: Any) -> dict[str, Any]: body: dict[str, Any] = { "engine": "claude-agent-sdk", "identity": {"loader": "gitagentprotocol", "source": {"type": "local", "path": "/agent"}}, - "envs": {"LYZR_API_KEY": "sbt_1", "OTHER": "x"}, + "envs": {"COMPUTER_AGENT_EGRESS_PROXY_SECRET": "sbt_1", "OTHER": "x"}, "sessionId": "s1", "streamingInput": True, } @@ -172,4 +172,4 @@ def frames(text: str) -> list[tuple[int | None, str, dict[str, Any]]]: def env_key() -> str | None: - return os.environ.get("LYZR_API_KEY") + return os.environ.get("COMPUTER_AGENT_EGRESS_PROXY_SECRET") diff --git a/tests/test_computeragent_init.py b/tests/test_computeragent_init.py index 77bc5e8..991ff45 100644 --- a/tests/test_computeragent_init.py +++ b/tests/test_computeragent_init.py @@ -249,7 +249,7 @@ def test_gateway_envs_reach_the_coordinator(agent_doc): coordinator = agent._coordinator # type: ignore[attr-defined] envs = coordinator._envs - for name in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "LYZR_API_KEY"): + for name in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "COMPUTER_AGENT_EGRESS_PROXY_SECRET"): assert name not in envs # added at every turn assert coordinator._api_key() == KEY assert envs["CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"] == "1" diff --git a/tests/test_egress_token.py b/tests/test_egress_token.py index c888b03..32d1ee1 100644 --- a/tests/test_egress_token.py +++ b/tests/test_egress_token.py @@ -37,16 +37,16 @@ def cred(env=None) -> EgressCredential: def test_adopts_later_expiry(): - env = {"LYZR_API_KEY": "sbt_a"} + env = {"COMPUTER_AGENT_EGRESS_PROXY_SECRET": "sbt_a"} c = cred(env) assert c.adopt("sbt_a", "sbt_b", later(4)) is True assert c.resolve("sbt_a") == c.resolve("sbt_b") == "sbt_b" - assert env["LYZR_API_KEY"] == "sbt_b" + assert env["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] == "sbt_b" assert c.adopt("sbt_b", "sbt_c", later(5)) is True assert {c.resolve(t) for t in ("sbt_a", "sbt_b", "sbt_c")} == {"sbt_c"} - assert env["LYZR_API_KEY"] == "sbt_c" + assert env["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] == "sbt_c" @pytest.mark.parametrize( @@ -96,10 +96,10 @@ def test_a_chain_whose_newest_token_expired_is_dropped(): def test_new_root_is_not_rolled_back(monkeypatch): """The sandbox's next turn brings a fresh key; a late reissue of the old chain only extends the old chain.""" - monkeypatch.setenv("LYZR_API_KEY", "sbt_1") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_1") CREDENTIAL.configure([f"{EGRESS}/llm"]) CREDENTIAL.adopt("sbt_1", "sbt_1b", later(1)) - monkeypatch.setenv("LYZR_API_KEY", "sbt_2") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_2") CREDENTIAL.adopt("sbt_1b", "sbt_1c", later(2)) @@ -107,7 +107,7 @@ def test_new_root_is_not_rolled_back(monkeypatch): assert settings.api_key() == "sbt_2" import os - assert os.environ["LYZR_API_KEY"] == "sbt_2" + assert os.environ["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] == "sbt_2" def settings_from_env(**kw): @@ -129,7 +129,7 @@ def settings_from_env(**kw): def test_explicit_key_follows_its_chain(monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_env") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_env") CREDENTIAL.configure([f"{EGRESS}/llm"]) settings = settings_from_env(api_key="sbt_k") @@ -138,7 +138,7 @@ def test_explicit_key_follows_its_chain(monkeypatch): assert settings.api_key() == "sbt_k2" import os - assert os.environ["LYZR_API_KEY"] == "sbt_env" + assert os.environ["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] == "sbt_env" def test_concurrent_adopt(): diff --git a/tests/test_gateway_envs.py b/tests/test_gateway_envs.py index fd3dfef..fb31c0c 100644 --- a/tests/test_gateway_envs.py +++ b/tests/test_gateway_envs.py @@ -125,7 +125,11 @@ def test_no_model_stays_none(): def test_key_envs(): - assert key_envs("k") == {"ANTHROPIC_API_KEY": "k", "OPENAI_API_KEY": "k", "LYZR_API_KEY": "k"} + assert key_envs("k") == { + "ANTHROPIC_API_KEY": "k", + "OPENAI_API_KEY": "k", + "COMPUTER_AGENT_EGRESS_PROXY_SECRET": "k", + } assert not set(key_envs("k")) & set(build_gateway_envs(llm_url=LLM_URL)) diff --git a/tests/test_server_app.py b/tests/test_server_app.py index d95dad1..40ba7c3 100644 --- a/tests/test_server_app.py +++ b/tests/test_server_app.py @@ -9,6 +9,7 @@ from computeragent.server.app import default_agent_factory from server_helpers import ( + AGENT_ID, FakeEngine, client_for, env_key, @@ -21,7 +22,7 @@ @pytest.fixture(autouse=True) def _no_key(monkeypatch): - monkeypatch.delenv("LYZR_API_KEY", raising=False) + monkeypatch.delenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", raising=False) monkeypatch.delenv("OTHER", raising=False) @@ -62,6 +63,20 @@ async def test_create_session_seeds_the_key(agent_doc): assert "OTHER" not in os.environ assert "api_key" not in calls[0] assert calls[0]["session_id"] == "s1" + assert "agent_id" not in calls[0] # no COMPUTER_AGENT_ID: the image's own agent + + +@respx.mock +async def test_the_session_agent_comes_from_envs(agent_doc): + mock_backends(agent_doc) + calls: list = [] + envs = {"COMPUTER_AGENT_EGRESS_PROXY_SECRET": "sbt_1", "COMPUTER_AGENT_ID": f" {AGENT_ID} "} + async with client_for(make_app(FakeEngine(), calls)) as client: + r = await client.post("/v1/sessions", json=session_body(envs=envs)) + + assert r.status_code == 201 + assert calls[0]["agent_id"] == AGENT_ID + assert env_key() == "sbt_1" def test_the_default_factory_passes_no_api_key(monkeypatch): @@ -72,7 +87,7 @@ def __init__(self, **kwargs): seen.update(kwargs) monkeypatch.setattr("computeragent.harness.ComputerAgent", Recorder) - monkeypatch.setenv("COMPUTERAGENT_ENGINE", "gitagent") + monkeypatch.setenv("COMPUTER_AGENT_ENGINE", "gitagent") default_agent_factory(session_id="s", options=None, session_store=None, on_tool_call=print) @@ -115,7 +130,7 @@ async def test_message_credential_is_the_turn_key(agent_doc): await client.post("/v1/sessions/s1/end-input") await client.get("/v1/sessions/s1/events") - keys = [ctx.envs["LYZR_API_KEY"] for ctx in engine.contexts] + keys = [ctx.envs["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] for ctx in engine.contexts] assert keys == ["sbt_2", "sbt_2"] # without a credential the previous key stays @@ -176,7 +191,8 @@ async def test_never_logs_secrets(agent_doc, caplog): with caplog.at_level(logging.DEBUG): async with client_for(make_app(FakeEngine())) as client: await client.post( - "/v1/sessions", json=session_body(envs={"LYZR_API_KEY": "sbt_secret_1"}) + "/v1/sessions", + json=session_body(envs={"COMPUTER_AGENT_EGRESS_PROXY_SECRET": "sbt_secret_1"}), ) await client.post( "/v1/sessions/s1/messages", diff --git a/tests/test_session_store_http.py b/tests/test_session_store_http.py index ac66c42..8accd52 100644 --- a/tests/test_session_store_http.py +++ b/tests/test_session_store_http.py @@ -215,7 +215,7 @@ def later(hours: float = 4) -> str: @respx.mock async def test_sends_only_the_api_key(monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_a") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_a") CREDENTIAL.configure([BASE]) load = respx.get(ENTRIES_URL).mock(return_value=httpx.Response(200, json={"entries": []})) append = respx.post(ENTRIES_URL).mock(return_value=httpx.Response(200, json={})) @@ -239,7 +239,7 @@ async def test_sends_only_the_api_key(monkeypatch): @respx.mock async def test_no_key_means_no_credential(monkeypatch): - monkeypatch.delenv("LYZR_API_KEY", raising=False) + monkeypatch.delenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", raising=False) route = respx.get(ENTRIES_URL).mock(return_value=httpx.Response(200, json={"entries": []})) await store().load(KEY) @@ -249,7 +249,7 @@ async def test_no_key_means_no_credential(monkeypatch): @respx.mock async def test_proxy_401_is_not_a_store_error(caplog, monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_a") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_a") respx.get(ENTRIES_URL).mock( return_value=httpx.Response( 401, json={"error": {"code": "TOKEN_INVALID", "message": "leak"}} @@ -270,14 +270,14 @@ async def test_close_is_idempotent(): await s.close() -# ── COMPUTERAGENT_SESSION_STORE_URL ────────────────────────────────────────── +# ── COMPUTER_AGENT_EGRESS_SESSION_STORE_URL ────────────────────────────────────────── EGRESS_STORE = "http://egress/computer-agent-api" @respx.mock async def test_session_store_url_precedence(caplog, monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_a") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_a") route = respx.get(f"{EGRESS_STORE}/session-store/entries").mock( return_value=httpx.Response(200, json={"entries": []}) ) @@ -290,7 +290,7 @@ async def test_session_store_url_precedence(caplog, monkeypatch): await resolve_session_store(spec).load(KEY) assert route.called (info,) = [r for r in caplog.records if r.levelname == "INFO"] - assert "http" not in info.getMessage().replace("COMPUTERAGENT_SESSION_STORE_URL", "") + assert "http" not in info.getMessage().replace("COMPUTER_AGENT_EGRESS_SESSION_STORE_URL", "") spec = with_base_url({"kind": "http", "options": {"base_url": "http://other"}}, EGRESS_STORE) assert spec["options"]["base_url"] == EGRESS_STORE diff --git a/tests/test_settings.py b/tests/test_settings.py index ba2d7f4..86abede 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -22,13 +22,13 @@ LLM_URL = "http://egress.internal/llm" FULL_ENV = { - "COMPUTERAGENT_AGENT_ID": AGENT_ID, - "COMPUTERAGENT_ENGINE": "claude-agent-sdk", - "COMPUTERAGENT_BASE_URL": BASE, - "LYZR_API_KEY": "env-key", - "COMPUTERAGENT_LLM_URL": LLM_URL, - "COMPUTERAGENT_MCP_URL": "http://egress.internal/mcp", - "COMPUTERAGENT_GUARDRAILS_URL": "http://egress.internal/guardrails", + "COMPUTER_AGENT_ID": AGENT_ID, + "COMPUTER_AGENT_ENGINE": "claude-agent-sdk", + "COMPUTER_AGENT_EGRESS_API_URL": BASE, + "COMPUTER_AGENT_EGRESS_PROXY_SECRET": "env-key", + "COMPUTER_AGENT_EGRESS_LLM_URL": LLM_URL, + "COMPUTER_AGENT_EGRESS_MCP_URL": "http://egress.internal/mcp", + "COMPUTER_AGENT_EGRESS_GUARDRAILS_URL": "http://egress.internal/guardrails", } @@ -50,16 +50,16 @@ def resolve(env, **kw): def test_every_setting_has_an_env_var(): assert ENV_VARS == { - "agent_id": "COMPUTERAGENT_AGENT_ID", - "engine": "COMPUTERAGENT_ENGINE", - "base_url": "COMPUTERAGENT_BASE_URL", - "api_key": "LYZR_API_KEY", - "llm_url": "COMPUTERAGENT_LLM_URL", - "mcp_url": "COMPUTERAGENT_MCP_URL", - "guardrails_url": "COMPUTERAGENT_GUARDRAILS_URL", - "telemetry_url": "COMPUTERAGENT_TELEMETRY_URL", - "session_store_url": "COMPUTERAGENT_SESSION_STORE_URL", - "gap_dir": "COMPUTERAGENT_GAP_DIR", + "agent_id": "COMPUTER_AGENT_ID", + "engine": "COMPUTER_AGENT_ENGINE", + "base_url": "COMPUTER_AGENT_EGRESS_API_URL", + "api_key": "COMPUTER_AGENT_EGRESS_PROXY_SECRET", + "llm_url": "COMPUTER_AGENT_EGRESS_LLM_URL", + "mcp_url": "COMPUTER_AGENT_EGRESS_MCP_URL", + "guardrails_url": "COMPUTER_AGENT_EGRESS_GUARDRAILS_URL", + "telemetry_url": "COMPUTER_AGENT_EGRESS_TELEMETRY_URL", + "session_store_url": "COMPUTER_AGENT_EGRESS_SESSION_STORE_URL", + "gap_dir": "COMPUTER_AGENT_GAP_DIR", } @@ -78,7 +78,7 @@ def test_env_vars_alone_are_enough(): def test_the_old_srs_and_agent_api_env_vars_are_not_read(): env = { - **{k: v for k, v in FULL_ENV.items() if k != "COMPUTERAGENT_GUARDRAILS_URL"}, + **{k: v for k, v in FULL_ENV.items() if k != "COMPUTER_AGENT_EGRESS_GUARDRAILS_URL"}, "COMPUTERAGENT_SRS_URL": "http://egress.internal/srs", "AGENT_API_URL": "http://egress.internal/agent-api", "COMPUTERAGENT_AGENT_API_URL": "http://egress.internal/agent-api", @@ -106,24 +106,24 @@ def test_an_engine_instance_is_kept_as_is(): def test_gap_dir_comes_from_its_env_var(): - settings = resolve({**FULL_ENV, "COMPUTERAGENT_GAP_DIR": "/opt/agent"}) + settings = resolve({**FULL_ENV, "COMPUTER_AGENT_GAP_DIR": "/opt/agent"}) assert settings.gap_dir == "/opt/agent" def test_gap_dir_argument_wins(tmp_path): - settings = resolve({**FULL_ENV, "COMPUTERAGENT_GAP_DIR": "/opt/agent"}, gap_dir=tmp_path) + settings = resolve({**FULL_ENV, "COMPUTER_AGENT_GAP_DIR": "/opt/agent"}, gap_dir=tmp_path) assert settings.gap_dir == tmp_path @pytest.mark.parametrize("blank", ["", " "]) def test_a_blank_value_counts_as_unset(blank): - settings = resolve({**FULL_ENV, "COMPUTERAGENT_BASE_URL": blank}, base_url="http://arg") + settings = resolve({**FULL_ENV, "COMPUTER_AGENT_EGRESS_API_URL": blank}, base_url="http://arg") assert settings.base_url == "http://arg" with pytest.raises(SettingsError, match="base_url"): - resolve({**FULL_ENV, "COMPUTERAGENT_BASE_URL": blank}) + resolve({**FULL_ENV, "COMPUTER_AGENT_EGRESS_API_URL": blank}) def test_every_missing_setting_is_reported_at_once(): @@ -143,7 +143,7 @@ def test_an_env_api_key_is_read_on_every_use(): settings = resolve(env) assert settings.api_key() == "env-key" - env["LYZR_API_KEY"] = "rotated-key" + env["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] = "rotated-key" assert settings.api_key() == "rotated-key" @@ -152,7 +152,7 @@ def test_an_explicit_api_key_ignores_the_env_var(): env = dict(FULL_ENV) settings = resolve(env, api_key="arg-key") - env["LYZR_API_KEY"] = "rotated-key" + env["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] = "rotated-key" assert settings.api_key() == "arg-key" @@ -160,9 +160,9 @@ def test_an_explicit_api_key_ignores_the_env_var(): def test_an_api_key_removed_from_the_env_fails_when_used(): env = dict(FULL_ENV) settings = resolve(env) - del env["LYZR_API_KEY"] + del env["COMPUTER_AGENT_EGRESS_PROXY_SECRET"] - with pytest.raises(SettingsError, match="LYZR_API_KEY"): + with pytest.raises(SettingsError, match="COMPUTER_AGENT_EGRESS_PROXY_SECRET"): settings.api_key() @@ -191,7 +191,7 @@ def test_computeragent_gap_dir_from_env(agent_doc, monkeypatch, tmp_path): (tmp_path / "agent.yaml").write_text("name: fixture\n", encoding="utf-8") for name, value in FULL_ENV.items(): monkeypatch.setenv(name, value) - monkeypatch.setenv("COMPUTERAGENT_GAP_DIR", str(tmp_path)) + monkeypatch.setenv("COMPUTER_AGENT_GAP_DIR", str(tmp_path)) respx.get(URL).mock(return_value=httpx.Response(200, json=agent_doc)) agent = ComputerAgent() @@ -213,7 +213,7 @@ def test_llm_url_argument_wins_and_loses_its_trailing_slash(): @pytest.mark.parametrize("bad", ["egress/llm", "ftp://egress/llm"]) def test_a_url_setting_must_be_http(bad): with pytest.raises(SettingsError, match="http\\(s\\) URLs: llm_url"): - resolve({**FULL_ENV, "COMPUTERAGENT_LLM_URL": bad}) + resolve({**FULL_ENV, "COMPUTER_AGENT_EGRESS_LLM_URL": bad}) def test_the_telemetry_url_is_optional(): @@ -221,14 +221,14 @@ def test_the_telemetry_url_is_optional(): def test_the_telemetry_url_comes_from_its_env_var(): - settings = resolve({**FULL_ENV, "COMPUTERAGENT_TELEMETRY_URL": "http://egress/otlp/"}) + settings = resolve({**FULL_ENV, "COMPUTER_AGENT_EGRESS_TELEMETRY_URL": "http://egress/otlp/"}) assert settings.telemetry_url == "http://egress/otlp" def test_an_optional_url_must_still_be_http_when_set(): with pytest.raises(SettingsError, match="telemetry_url"): - resolve({**FULL_ENV, "COMPUTERAGENT_TELEMETRY_URL": "egress/otlp"}) + resolve({**FULL_ENV, "COMPUTER_AGENT_EGRESS_TELEMETRY_URL": "egress/otlp"}) def test_events_url_is_unknown(): @@ -247,7 +247,7 @@ def test_api_key_resolves_the_chain(explicit): from computeragent.harness.egress_token import CREDENTIAL - env = {**FULL_ENV, "LYZR_API_KEY": "sbt_root"} + env = {**FULL_ENV, "COMPUTER_AGENT_EGRESS_PROXY_SECRET": "sbt_root"} settings = resolve(env, api_key=explicit) expiry = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() @@ -260,7 +260,10 @@ def test_session_store_url(): assert resolve(FULL_ENV).session_store_url is None assert ( resolve( - {**FULL_ENV, "COMPUTERAGENT_SESSION_STORE_URL": "http://egress/computer-agent-api/"} + { + **FULL_ENV, + "COMPUTER_AGENT_EGRESS_SESSION_STORE_URL": "http://egress/computer-agent-api/", + } ).session_store_url == "http://egress/computer-agent-api" ) diff --git a/tests/test_turn_key.py b/tests/test_turn_key.py index f50f768..2c58a51 100644 --- a/tests/test_turn_key.py +++ b/tests/test_turn_key.py @@ -21,7 +21,7 @@ LLM_URL = "http://egress.internal/llm" MCP_URL = "http://egress.internal/mcp/v1" GUARDRAILS = "http://egress.internal/guardrails" -KEY_NAMES = ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "LYZR_API_KEY") +KEY_NAMES = ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "COMPUTER_AGENT_EGRESS_PROXY_SECRET") class RecordingEngine: @@ -69,12 +69,12 @@ def keys_of(ctx: EngineContext) -> set[str]: @respx.mock async def test_engine_env_follows_the_key(agent_doc, monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_1") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_1") engine = RecordingEngine() async with build(agent_doc, engine) as agent: await agent.chat("one") - monkeypatch.setenv("LYZR_API_KEY", "sbt_2") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_2") await agent.chat("two") first, second = engine.contexts @@ -86,7 +86,7 @@ async def test_engine_env_follows_the_key(agent_doc, monkeypatch): @respx.mock async def test_mcp_headers_follow_the_key(agent_doc, monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_1") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_1") engine = RecordingEngine() mine = {"type": "http", "url": "http://elsewhere/mcp", "headers": {"x-api-key": "own"}} @@ -96,7 +96,7 @@ async def test_mcp_headers_follow_the_key(agent_doc, monkeypatch): "mine": mine, } await agent.chat("one") - monkeypatch.setenv("LYZR_API_KEY", "sbt_2") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_2") await agent.chat("two") servers = engine.contexts[1].manifest.options["mcp_servers"] @@ -110,12 +110,12 @@ async def test_mcp_headers_follow_the_key(agent_doc, monkeypatch): @respx.mock async def test_explicit_api_key_follows_its_reissue(agent_doc, monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_env") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_env") engine = RecordingEngine() async with build(agent_doc, engine, api_key="sbt_k") as agent: await agent.chat("one") - monkeypatch.setenv("LYZR_API_KEY", "sbt_env_2") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_env_2") await agent.chat("two") expiry = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() assert CREDENTIAL.adopt("sbt_k", "sbt_k2", expiry) @@ -126,13 +126,13 @@ async def test_explicit_api_key_follows_its_reissue(agent_doc, monkeypatch): @respx.mock async def test_missing_key_fails_the_turn(agent_doc, monkeypatch): - monkeypatch.setenv("LYZR_API_KEY", "sbt_1") + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET", "sbt_1") engine = RecordingEngine() async with build(agent_doc, engine) as agent: await agent.chat("one") - monkeypatch.delenv("LYZR_API_KEY") - with pytest.raises(SettingsError, match="LYZR_API_KEY") as exc: + monkeypatch.delenv("COMPUTER_AGENT_EGRESS_PROXY_SECRET") + with pytest.raises(SettingsError, match="COMPUTER_AGENT_EGRESS_PROXY_SECRET") as exc: await agent.chat("two") assert len(engine.contexts) == 1 From a0b8b0a3a8bb017c29191ea936d55e45ace397eb Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Tue, 27 Oct 2026 16:59:30 +0530 Subject: [PATCH 02/14] fix(server): keep the SSE stream open across keepalives events() bounded each read with asyncio.wait_for(frames.__anext__(), 20 s). On a timeout, wait_for cancelled the EventLog generator inside its wait for new events; a cancelled async generator is finished, so the next read raised StopAsyncIteration and the response ended right after the first keepalive. On Lambda MicroVM CAS reported STREAM_UPSTREAM_LOST on any 20 s quiet gap. EventLog.iterate now takes the keepalive and yields None when it expires, so no cancellation crosses the generator. --- src/computeragent/server/app.py | 12 ++---------- src/computeragent/server/events.py | 24 ++++++++++++++++++++---- tests/test_server_app.py | 24 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/computeragent/server/app.py b/src/computeragent/server/app.py index 33b2ac5..4b0c5a6 100644 --- a/src/computeragent/server/app.py +++ b/src/computeragent/server/app.py @@ -546,16 +546,8 @@ async def events(request: Request) -> Response: since = -1 async def stream() -> AsyncIterator[str]: - frames = s.log.iterate(since=since).__aiter__() - while True: - try: - event_id, event = await asyncio.wait_for(frames.__anext__(), KEEPALIVE_S) - except asyncio.TimeoutError: - yield ":keepalive\n\n" - continue - except StopAsyncIteration: - return - yield encode_sse(event_id, event) + async for item in s.log.iterate(since=since, keepalive=KEEPALIVE_S): + yield ":keepalive\n\n" if item is None else encode_sse(*item) return StreamingResponse( stream(), media_type="text/event-stream", headers={"cache-control": "no-cache"} diff --git a/src/computeragent/server/events.py b/src/computeragent/server/events.py index 532beca..e246ba8 100644 --- a/src/computeragent/server/events.py +++ b/src/computeragent/server/events.py @@ -297,17 +297,33 @@ async def close(self) -> None: self._closed = True self._changed.notify_all() - async def iterate(self, *, since: int) -> AsyncIterator[tuple[int, dict[str, Any]]]: - """Events with an id above ``since``, then live ones until the log closes.""" + async def iterate( + self, *, since: int, keepalive: float | None = None + ) -> AsyncIterator[tuple[int, dict[str, Any]] | None]: + """Events with an id above ``since``, then live ones until the log closes. + + With ``keepalive``, ``None`` is yielded after that many seconds without + an event. The wait is bounded here, inside the generator: a timeout + applied to ``__anext__()`` from outside would cancel the generator and + end the iteration for good. + """ cursor = since + 1 while True: + idle = False async with self._changed: pending = [e for e in self._events if e[0] >= cursor] if not pending: if self._closed: return - await self._changed.wait() - continue + try: + await asyncio.wait_for(self._changed.wait(), keepalive) + except asyncio.TimeoutError: + idle = True + else: + continue + if idle: + yield None + continue for event_id, event in pending: cursor = event_id + 1 yield event_id, event diff --git a/tests/test_server_app.py b/tests/test_server_app.py index 40ba7c3..d80a907 100644 --- a/tests/test_server_app.py +++ b/tests/test_server_app.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import pytest @@ -134,6 +135,29 @@ async def test_message_credential_is_the_turn_key(agent_doc): assert keys == ["sbt_2", "sbt_2"] # without a credential the previous key stays +@respx.mock +async def test_a_quiet_gap_keeps_the_stream_open(agent_doc, monkeypatch): + monkeypatch.setattr("computeragent.server.app.KEEPALIVE_S", 0.05) + mock_backends(agent_doc) + async with client_for(make_app(FakeEngine())) as client: + await client.post("/v1/sessions", json=session_body()) + + async def later_turn(): + await asyncio.sleep(0.3) # several keepalive intervals without an event + await client.post( + "/v1/sessions/s1/messages", json={"message": {"role": "user", "content": "hi"}} + ) + await client.post("/v1/sessions/s1/end-input") + + turn = asyncio.create_task(later_turn()) + text = (await client.get("/v1/sessions/s1/events")).text + await turn + + assert text.count(":keepalive") >= 2 + assert text.index(":keepalive") < text.index("event: sdk_message") + assert frames(text)[-1][1] == "ca_session_ended" + + @respx.mock async def test_session_routes(agent_doc): mock_backends(agent_doc) From a64e2a3532745585c87c1449a07f20a786a61898 Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Tue, 27 Oct 2026 16:59:46 +0530 Subject: [PATCH 03/14] feat(harness): key the session store by session_id The Claude CLI keys transcripts by its own per-process UUID, so a new sandbox for the same conversation looked under a key nobody wrote and started blank, and agentos had no stable id to file the store under (old-CAS plan 10). With ComputerAgent(session_id=..., session_store=...), PinnedSessionStore puts that id on every append and load; project_key and subpath are kept. The Claude engine runs the CLI under uuid5(session_id): on the first turn of a process it loads the store once and starts a new conversation under that UUID, or resumes the stored one. Every process serving a conversation then uses the same CLI UUID, so the stored entries' ids always match. The app server already passes session_id = the CAS sessionId. Without session_id nothing changes. --- src/computeragent/engines/claude_agent.py | 28 +++++ src/computeragent/harness/__init__.py | 7 +- src/computeragent/session_stores/__init__.py | 25 ++++- tests/test_session_store_pinned.py | 111 +++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 tests/test_session_store_pinned.py diff --git a/src/computeragent/engines/claude_agent.py b/src/computeragent/engines/claude_agent.py index de6effa..03a104b 100644 --- a/src/computeragent/engines/claude_agent.py +++ b/src/computeragent/engines/claude_agent.py @@ -12,6 +12,7 @@ from __future__ import annotations import logging +import uuid from collections.abc import AsyncIterator from contextlib import suppress from typing import Any @@ -22,6 +23,7 @@ EngineCapabilities, EngineContext, EngineEvent, + SessionKey, ) logger = logging.getLogger("computeragent.engines.claude_agent") @@ -45,6 +47,7 @@ class ClaudeAgentEngine: async def start_session(self, ctx: EngineContext) -> AsyncIterator[EngineEvent]: options = _build_options(ctx) + await _pin_engine_session(options, ctx) # claude-agent-sdk wants prompts as an AsyncIterable of # ``{"type": "user", "message": {...}}`` dicts when ``can_use_tool`` @@ -183,6 +186,31 @@ def _build_options(ctx: EngineContext) -> Any: return options +async def _pin_engine_session(options: Any, ctx: EngineContext) -> None: + """Run the CLI under one UUID per conversation when the store is pinned. + + The CLI session id must be a UUID, so it is derived from the pinned + ``session_id``: a new conversation starts with ``session_id = uuid5(...)``, + and a conversation the store already holds resumes it (``resume``), which + makes the SDK load the transcript first. Every process serving the + conversation then uses the same UUID, so the stored entries' ids match. + Later turns in one process already resume from ``ctx.resume_session_id``. + """ + store = ctx.session_store + pinned = getattr(store, "pinned_session_id", None) + if store is None or not pinned or options.resume or options.session_id: + return + cli_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"computeragent:session:{pinned}")) + key: SessionKey = { + "project_key": claude_agent_sdk.project_key_for_directory(options.cwd), + "session_id": cli_id, + } + if await store.load(key): + options.resume = cli_id + else: + options.session_id = cli_id + + def _policy_pretooluse_matcher(decider: Any) -> Any: """Wrap the bound ``can_use_tool`` decider as a PreToolUse ``HookMatcher``. diff --git a/src/computeragent/harness/__init__.py b/src/computeragent/harness/__init__.py index 6794e1f..6815ea7 100644 --- a/src/computeragent/harness/__init__.py +++ b/src/computeragent/harness/__init__.py @@ -48,7 +48,7 @@ PolicyDecider, SessionStore, ) -from ..session_stores import is_session_store, resolve_session_store +from ..session_stores import PinnedSessionStore, is_session_store, resolve_session_store from ..session_stores.http import with_base_url from ..substrates import LocalSubstrate from ..telemetry.pipeline import Pipeline @@ -377,6 +377,11 @@ def __init__( ) ) + # A conversation id keys the store, so any process (sandbox) serving + # this conversation finds its transcript (see PinnedSessionStore). + if resolved_store is not None and session_id: + resolved_store = cast("SessionStore", PinnedSessionStore(resolved_store, session_id)) + # `on_tool_call` is only consulted by engines that emit permission # requests. On claude-agent-sdk gating runs through `policy_decider`, # so a caller who passes only this believes they have a tool gate and diff --git a/src/computeragent/session_stores/__init__.py b/src/computeragent/session_stores/__init__.py index ea5ce3a..e57ced0 100644 --- a/src/computeragent/session_stores/__init__.py +++ b/src/computeragent/session_stores/__init__.py @@ -17,7 +17,7 @@ from typing import Any, cast from ..harness.errors import UnknownSessionStoreError -from ..protocol_types import SessionStore +from ..protocol_types import SessionKey, SessionStore, SessionStoreEntry from .http import with_base_url from .memory import InMemorySessionStore @@ -43,6 +43,28 @@ def is_session_store(candidate: Any) -> bool: ) +class PinnedSessionStore: + """A store whose every key carries one ``session_id``. + + The Claude CLI keys transcripts by its own per-process UUID, so a new + sandbox for the same conversation would look under a key nobody wrote. + Pinning ``session_id`` to the conversation's id (the CAS session behind the + egress proxy) makes every process read and write the same transcript; + ``project_key`` and ``subpath`` pass through. Only ``append`` and ``load`` + are forwarded, the two verbs every store implements. + """ + + def __init__(self, inner: SessionStore, session_id: str) -> None: + self.inner = inner + self.pinned_session_id = session_id + + async def append(self, key: SessionKey, entries: list[SessionStoreEntry]) -> None: + await self.inner.append({**key, "session_id": self.pinned_session_id}, entries) + + async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: + return await self.inner.load({**key, "session_id": self.pinned_session_id}) + + def register_session_store(name: str, store: SessionStore | SessionStoreBuilder) -> None: """Register a store kind, as either a builder or a ready instance.""" if is_session_store(store): @@ -122,6 +144,7 @@ def _http_builder(options: Any = None) -> SessionStore: __all__ = [ "InMemorySessionStore", + "PinnedSessionStore", "is_session_store", "SessionStoreBuilder", "register_session_store", diff --git a/tests/test_session_store_pinned.py b/tests/test_session_store_pinned.py new file mode 100644 index 0000000..40fb8ba --- /dev/null +++ b/tests/test_session_store_pinned.py @@ -0,0 +1,111 @@ +"""A ``session_id`` keys the session store, so every process (sandbox) serving +a conversation reads and writes one transcript.""" + +from __future__ import annotations + +import json +import uuid +from types import SimpleNamespace + +import httpx +import pytest +import respx + +from computeragent.engines.claude_agent import _pin_engine_session +from computeragent.session_stores import InMemorySessionStore, PinnedSessionStore +from computeragent.session_stores.http import HttpSessionStore +from test_session_store_wiring import OptionsCapturingEngine, build + +BASE = "https://egress.example/computer-agent-api" +ENTRIES_URL = f"{BASE}/session-store/entries" +CAS = "agentos-6ae05f784be1b7463255fde4-1a2b3c4d5e6f" +CLI_UUID = "af60bf57-0000-4000-8000-000000000001" + + +@respx.mock +async def test_the_cli_uuid_never_reaches_the_http_store(): + load = respx.get(ENTRIES_URL).mock(return_value=httpx.Response(200, json={"entries": []})) + post = respx.post(ENTRIES_URL).mock(return_value=httpx.Response(200, json={"appended": 1})) + store = PinnedSessionStore(HttpSessionStore(base_url=BASE), CAS) + + await store.load({"project_key": "-workspace", "session_id": CLI_UUID}) + await store.append({"project_key": "-workspace", "session_id": CLI_UUID}, [{"uuid": "e1"}]) + sub = {"project_key": "-workspace", "session_id": CLI_UUID, "subpath": "subagents/agent-a"} + await store.append(sub, [{"uuid": "e2"}]) + + assert dict(load.calls[0].request.url.params) == {"sessionId": CAS, "projectKey": "-workspace"} + bodies = [json.loads(c.request.content) for c in post.calls] + assert [(b["sessionId"], b["projectKey"], b.get("subpath")) for b in bodies] == [ + (CAS, "-workspace", None), + (CAS, "-workspace", "subagents/agent-a"), + ] + wire = str(load.calls[0].request.url) + "".join(c.request.content.decode() for c in post.calls) + assert CLI_UUID not in wire + + +@pytest.mark.asyncio +@respx.mock +async def test_a_session_id_pins_the_store(agent_doc): + engine = OptionsCapturingEngine() + store = InMemorySessionStore() + + async with build(agent_doc, engine=engine, session_store=store, session_id=CAS) as agent: + await agent.chat("hi") + + pinned = engine.options[0].session_store + assert isinstance(pinned, PinnedSessionStore) + assert (pinned.inner, pinned.pinned_session_id) == (store, CAS) + + +class CountingStore(InMemorySessionStore): + def __init__(self) -> None: + super().__init__() + self.loads = 0 + + async def load(self, key): + self.loads += 1 + return await super().load(key) + + +def _engine_args(store, session_id=CAS, **options): + opts = SimpleNamespace(**{"cwd": "/workspace", "resume": None, "session_id": None, **options}) + pinned = PinnedSessionStore(store, session_id) if session_id else store + return opts, SimpleNamespace(session_store=pinned) + + +async def test_a_new_conversation_starts_under_its_derived_uuid(): + opts, ctx = _engine_args(InMemorySessionStore()) + + await _pin_engine_session(opts, ctx) + + assert opts.resume is None + assert uuid.UUID(opts.session_id).version == 5 + + +async def test_a_stored_conversation_is_resumed_under_the_same_uuid(): + store = InMemorySessionStore() + first, ctx = _engine_args(store) + await _pin_engine_session(first, ctx) # sandbox 1: a new conversation + await ctx.session_store.append( + {"project_key": "-workspace", "session_id": first.session_id}, [{"uuid": "e1"}] + ) + + second, ctx2 = _engine_args(store) # sandbox 2, same conversation + await _pin_engine_session(second, ctx2) + + assert (second.resume, second.session_id) == (first.session_id, None) + other, ctx3 = _engine_args(store, session_id="agentos-other-1") + await _pin_engine_session(other, ctx3) + assert other.session_id not in (first.session_id, None) + + +async def test_a_known_resume_or_an_unpinned_store_is_left_alone(): + store = CountingStore() + opts, ctx = _engine_args(store, resume="11111111-1111-4111-8111-111111111111") + await _pin_engine_session(opts, ctx) + unpinned, ctx2 = _engine_args(store, session_id=None) + await _pin_engine_session(unpinned, ctx2) + + assert store.loads == 0 + assert opts.resume == "11111111-1111-4111-8111-111111111111" and opts.session_id is None + assert (unpinned.resume, unpinned.session_id) == (None, None) From 550a02f479218328d62c09f79422e21b4ef5489a Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Tue, 27 Oct 2026 16:59:47 +0530 Subject: [PATCH 04/14] docs(examples): AgentCore and Lambda MicroVM sandbox images, and the deploy guide --- CHANGELOG.md | 46 +- README.md | 24 +- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 447 ++++++++++++++++++ docs/USING_THE_SDK.md | 80 ++-- examples/agentcore_runtime/.env.example | 23 + examples/agentcore_runtime/Dockerfile | 36 ++ .../agentcore_runtime/Dockerfile.dockerignore | 8 + examples/agentcore_runtime/app.py | 48 ++ examples/lambda_microvm/.env.example | 23 + examples/lambda_microvm/Dockerfile | 36 ++ .../lambda_microvm/Dockerfile.dockerignore | 8 + examples/lambda_microvm/app.py | 47 ++ 12 files changed, 775 insertions(+), 51 deletions(-) create mode 100644 docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md create mode 100644 examples/agentcore_runtime/.env.example create mode 100644 examples/agentcore_runtime/Dockerfile create mode 100644 examples/agentcore_runtime/Dockerfile.dockerignore create mode 100644 examples/agentcore_runtime/app.py create mode 100644 examples/lambda_microvm/.env.example create mode 100644 examples/lambda_microvm/Dockerfile create mode 100644 examples/lambda_microvm/Dockerfile.dockerignore create mode 100644 examples/lambda_microvm/app.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f97d8..f00cfc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan expiry, on a request sent with the newest token) makes every later egress request carry the new token: the agent-document read, the guardrails checks, gitagent's Anthropic/OpenAI clients and injected httpx clients. - `Settings.api_key()` returns the newest token and `LYZR_API_KEY` is updated + `Settings.api_key()` returns the newest token and `COMPUTER_AGENT_EGRESS_PROXY_SECRET` is updated (`computeragent.harness.egress_token`). - **A loopback forwarder for the Claude CLI.** On the `claude-agent-sdk` engine the CLI's `ANTHROPIC_BASE_URL` and MCP server URLs on an egress origin point at @@ -46,7 +46,7 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan Span and log processors export 128 records at a time. The OTLP exporters set `x-api-key` on every request through a stamping `requests` session, so a reissued sandbox token applies to telemetry too. -- **`COMPUTERAGENT_SESSION_STORE_URL` / `session_store_url`.** The base URL of +- **`COMPUTER_AGENT_EGRESS_SESSION_STORE_URL` / `session_store_url`.** The base URL of the `http` session store behind the egress proxy (`http://egress/computer-agent-api`); it overrides a descriptor's `base_url` for kind `http` and is ignored for other kinds and store instances @@ -54,11 +54,43 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan ### Changed +- **`session_id` keys the session store.** With `ComputerAgent(session_id=…, + session_store=…)`, every `append` and `load` carries that id instead of the + Claude CLI's per-process UUID (`project_key` and `subpath` are kept), and + the CLI runs under a UUID derived from it. A new process or sandbox with the + same `session_id` resumes the conversation on its first turn. The app server + builds each session with `session_id = sessionId`, so the `http` store is + keyed by the CAS session, which agentos expects (old-CAS plan 10). Without + `session_id` nothing changes. + +- **BREAKING: the settings' environment variables are renamed**, so the names + say what each one is; the constructor arguments are unchanged. No old name is + read any more: + + | Argument | Was | Now | + |---|---|---| + | `agent_id` | `COMPUTERAGENT_AGENT_ID` | `COMPUTER_AGENT_ID` | + | `engine` | `COMPUTERAGENT_ENGINE` | `COMPUTER_AGENT_ENGINE` | + | `api_key` | `LYZR_API_KEY` | `COMPUTER_AGENT_EGRESS_PROXY_SECRET` | + | `base_url` | `COMPUTERAGENT_BASE_URL` | `COMPUTER_AGENT_EGRESS_API_URL` | + | `llm_url` | `COMPUTERAGENT_LLM_URL` | `COMPUTER_AGENT_EGRESS_LLM_URL` | + | `mcp_url` | `COMPUTERAGENT_MCP_URL` | `COMPUTER_AGENT_EGRESS_MCP_URL` | + | `guardrails_url` | `COMPUTERAGENT_GUARDRAILS_URL` | `COMPUTER_AGENT_EGRESS_GUARDRAILS_URL` | + | `telemetry_url` | `COMPUTERAGENT_TELEMETRY_URL` | `COMPUTER_AGENT_EGRESS_TELEMETRY_URL` | + | `session_store_url` | `COMPUTERAGENT_SESSION_STORE_URL` | `COMPUTER_AGENT_EGRESS_SESSION_STORE_URL` | + | `gap_dir` | `COMPUTERAGENT_GAP_DIR` | `COMPUTER_AGENT_GAP_DIR` | + + Old CAS passes the same names in `POST /v1/sessions` `envs`: the app server + seeds `COMPUTER_AGENT_EGRESS_PROXY_SECRET` from it (the `LYZR_API_KEY` field + is no longer read) and builds the session's agent from `COMPUTER_AGENT_ID`, + falling back to the image's own. The engine's per-turn environment carries + the token as `COMPUTER_AGENT_EGRESS_PROXY_SECRET`. + - **BREAKING: guardrails go through the egress proxy.** The user's message and every tool call are checked with `POST {guardrails_url}/v1/check` (ops `input` and `tool-input`); the agent gateway applies the org's and the agent's RAI policies and owns fail-open. `guardrails_url` / - `COMPUTERAGENT_GUARDRAILS_URL` is required and replaces `srs_url` / + `COMPUTER_AGENT_EGRESS_GUARDRAILS_URL` is required and replaces `srs_url` / `COMPUTERAGENT_SRS_URL` and `agent_api_url` / `COMPUTERAGENT_AGENT_API_URL`, which are no longer read. - A check that reaches no verdict (unreachable, 5 s timeout, non-2xx, a @@ -69,9 +101,9 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan policy input as `call_id`, and a call without one is denied. - A rewritten message carries the gateway's masked text (`[EMAIL]`). - **The Lyzr key is read at every turn.** The engine's `ANTHROPIC_API_KEY`, - `OPENAI_API_KEY` and `LYZR_API_KEY` and the bound MCP servers' `x-api-key` + `OPENAI_API_KEY` and `COMPUTER_AGENT_EGRESS_PROXY_SECRET` and the bound MCP servers' `x-api-key` are set from `Settings.api_key()` when each turn starts (and at boot), so a - key replaced in `LYZR_API_KEY` between turns, or a reissued token, is used + key replaced in `COMPUTER_AGENT_EGRESS_PROXY_SECRET` between turns, or a reissued token, is used by the next turn. A missing key fails the turn with `SettingsError` before the engine starts. `build_gateway_envs` no longer takes `api_key`; `key_envs(api_key)` returns the three variables. @@ -84,12 +116,12 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan - **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 - both from its registry. Behind the egress proxy `COMPUTERAGENT_MCP_URL` is + both from its registry. Behind the egress proxy `COMPUTER_AGENT_EGRESS_MCP_URL` is `http://egress/mcp/v1`. - **BREAKING: the `http` session store sends only `x-api-key`.** The store token (`x-ca-session-store-token`), its refresh against `/v1/auth/token`, the `token` / `auto_refresh` arguments, `TOKEN_HEADER` and `expiry_of` are gone; - a `token` option is ignored. The key is `LYZR_API_KEY` at each request, as + a `token` option is ignored. The key is `COMPUTER_AGENT_EGRESS_PROXY_SECRET` at each request, as the newest sandbox token. - A session-store descriptor without a `kind` is reported by its keys, never its values. diff --git a/README.md b/README.md index 092954c..2a99204 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ permission mode — except the engine runs here. and telemetry are each reached at a URL you configure, with no direct path to any of them. ```bash -export COMPUTERAGENT_AGENT_ID=6aa93f1b3817a13d3103d2c1 -export COMPUTERAGENT_ENGINE=claude-agent-sdk -export COMPUTERAGENT_BASE_URL=http://egress/agentos/api # agent config, MCP bindings -export COMPUTERAGENT_GUARDRAILS_URL=http://egress/guardrails # guardrail checks -export COMPUTERAGENT_LLM_URL=http://egress/llm # every model call -export COMPUTERAGENT_MCP_URL=http://egress/mcp/v1 # MCP servers bound in AgentOS -export LYZR_API_KEY=... # sent as x-api-key; swapped by the proxy -# optional: COMPUTERAGENT_TELEMETRY_URL=http://egress/otlp (OTLP), -# COMPUTERAGENT_SESSION_STORE_URL=http://egress/computer-agent-api (http session store), -# COMPUTERAGENT_GAP_DIR (GAP mode) +export COMPUTER_AGENT_ID=6aa93f1b3817a13d3103d2c1 +export COMPUTER_AGENT_ENGINE=claude-agent-sdk +export COMPUTER_AGENT_EGRESS_API_URL=http://egress/computer-agent-api/agentos/api # agent config, MCP bindings +export COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=http://egress/guardrails # guardrail checks +export COMPUTER_AGENT_EGRESS_LLM_URL=http://egress/llm # every model call +export COMPUTER_AGENT_EGRESS_MCP_URL=http://egress/mcp/v1 # MCP servers bound in AgentOS +export COMPUTER_AGENT_EGRESS_PROXY_SECRET=... # the sandbox token: sent as x-api-key, swapped by the proxy +# optional: COMPUTER_AGENT_EGRESS_TELEMETRY_URL=http://egress/otlp (OTLP), +# COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=http://egress/computer-agent-api (http session store), +# COMPUTER_AGENT_GAP_DIR (GAP mode) ``` ```python @@ -67,6 +67,10 @@ telemetry, `[gap]` for GAP mode, and `[server]` for the app server a stage image **[`docs/USING_THE_SDK.md`](docs/USING_THE_SDK.md)** is the developer reference: every setting and the requests made from it, the guardrail flow, MCP, session stores, errors and troubleshooting. +**[`docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md`](docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md)** walks +through integrating the SDK into an existing service, building a sandbox image with the app server, +and registering it as a Lambda MicroVM image or an AgentCore runtime for an AgentOS agent. + [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) covers how the pieces fit together, for anyone changing the SDK rather than using it. diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md new file mode 100644 index 0000000..547d008 --- /dev/null +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -0,0 +1,447 @@ +# Integrating the CAS SDK, and deploying it on AgentCore and Lambda MicroVM + +This guide is for engineers who are adding the CAS SDK (`computer-agent-py`) to a system they +already run. It covers: + +1. [Pick an integration mode](#1-pick-an-integration-mode): the SDK as a library inside your service, + or the SDK's app server as a sandbox image that CAS drives. +2. [Settings](#2-settings): what goes into the image and what arrives per session. +3. [Use the SDK as a library](#3-use-the-sdk-as-a-library-in-your-own-service). +4. [Build a sandbox image](#4-build-a-sandbox-image) with the SDK's app server. +5. [Push it to ECR](#5-push-the-image-to-ecr). +6. [Register it as a Lambda MicroVM image](#6-register-a-lambda-microvm-image). +7. [Register it as an AgentCore runtime](#7-register-an-agentcore-runtime). +8. [Point an AgentOS agent at it](#8-point-an-agentos-agent-at-the-image). +9. [Troubleshooting](#9-troubleshooting). + +Working examples of everything below are in `examples/lambda_microvm/` and +`examples/agentcore_runtime/`. The settings reference is [`USING_THE_SDK.md`](USING_THE_SDK.md) §3. + +## How the pieces fit + +```mermaid +sequenceDiagram + participant U as AgentOS / your caller + participant C as CAS (computer-agent-service) + participant S as Sandbox: your image (SDK app server) + participant P as CAS egress proxy + participant G as Agent gateway + U->>C: run agent (image.ref = MicroVM image or AgentCore runtime) + C->>S: boot sandbox (RunMicrovm / InvokeAgentRuntime) + C->>S: POST /v1/sessions (envs: COMPUTER_AGENT_ID, COMPUTER_AGENT_EGRESS_PROXY_SECRET) + C->>S: POST /v1/sessions/{id}/messages (credential = fresh token) + S->>P: agent document, guardrails, model, MCP, telemetry (token as x-api-key) + P->>G: forwards with the real Lyzr key + S-->>C: GET /v1/sessions/{id}/events (SSE) +``` + +The sandbox never holds a provider key, a Lyzr key or a git token. Its only secret is the +short-lived sandbox token (`sbt_…`), and every call it makes goes to the egress proxy, which swaps +the token for the real credentials. The SDK takes care of the token, including the reissued +successor the proxy sends before expiry (`USING_THE_SDK.md` §8a). + +## 1. Pick an integration mode + +| | A. Library in your service | B. Sandbox image driven by CAS | +|---|---|---| +| Who calls the agent | your code: `ComputerAgent(...).chat(...)` | CAS, over the harness REST API | +| What you deploy | your existing service, with the SDK as a dependency | a container image running the SDK's app server, registered as a Lambda MicroVM image or an AgentCore runtime | +| Where the token comes from | you supply it (`COMPUTER_AGENT_EGRESS_PROXY_SECRET` or `api_key=`) | CAS, per session and per message | +| Read | §2, §3 | §2, §4–§8 | + +Mode B is what CAS needs for an agent's `image.ref`. Your own code does not go into the app server. +It goes into the agent itself (GAP directory, tools, MCP servers bound in AgentOS), or behind the +egress proxy. + +## 2. Settings + +The SDK reads every setting from a constructor argument or, when that is unset, from an environment +variable. Every missing required setting is reported in one `SettingsError`. + +| Variable | Argument | Required | In a sandbox image it comes from | +|---|---|---|---| +| `COMPUTER_AGENT_ID` | `agent_id` | yes | CAS, per session (`POST /v1/sessions` `envs`) | +| `COMPUTER_AGENT_EGRESS_PROXY_SECRET` | `api_key` | yes | CAS, per session and per message. **Never bake it into an image.** | +| `COMPUTER_AGENT_ENGINE` | `engine` | yes | the image (`claude-agent-sdk` or `gitagent`) | +| `COMPUTER_AGENT_EGRESS_API_URL` | `base_url` | yes | the image: `/computer-agent-api/agentos/api` | +| `COMPUTER_AGENT_EGRESS_LLM_URL` | `llm_url` | yes | the image: `/llm` | +| `COMPUTER_AGENT_EGRESS_MCP_URL` | `mcp_url` | yes | the image: `/mcp/v1` | +| `COMPUTER_AGENT_EGRESS_GUARDRAILS_URL` | `guardrails_url` | yes | the image: `/guardrails` | +| `COMPUTER_AGENT_EGRESS_TELEMETRY_URL` | `telemetry_url` | no | the image: `/otlp` (needs the `otel` extra) | +| `COMPUTER_AGENT_EGRESS_SESSION_STORE_URL` | `session_store_url` | no | the image: `/computer-agent-api` | +| `COMPUTER_AGENT_GAP_DIR` | `gap_dir` | no | the image, when the agent is a GAP directory baked into it | + +`` is the egress proxy's base URL, e.g. `https://egress.example.com`. The examples keep the +image's settings in a `.env` file that the Dockerfile copies next to `app.py`. Start from +`examples/*/.env.example`. + +## 3. Use the SDK as a library in your own service + +Install: + +```bash +pip install 'computer-agent-py[agentos]' # add [gitagent] for the gitagent engine, [otel] for telemetry +``` + +Run one turn: + +```python +import asyncio +from computeragent import ComputerAgent, AgentResolveError + +async def answer(prompt: str) -> str: + # Construction fetches the agent document with a blocking call: keep it off the event loop. + agent = await asyncio.to_thread(ComputerAgent) # settings from COMPUTER_AGENT_* (§2) + async with agent: + result = await asyncio.wait_for(agent.chat(prompt), timeout=300) + for message in reversed(result.messages): # the assistant's final text + text = getattr(message, "result", None) + if isinstance(text, str) and text: + return text + return "" +``` + +To pass settings from your own config instead of the environment, use the arguments, which win over +the variables: + +```python +agent = ComputerAgent( + agent_id=cfg.agent_id, + engine="claude-agent-sdk", + base_url=f"{cfg.egress}/computer-agent-api/agentos/api", + llm_url=f"{cfg.egress}/llm", + mcp_url=f"{cfg.egress}/mcp/v1", + guardrails_url=f"{cfg.egress}/guardrails", + api_key=sandbox_token, # or leave unset and keep it in COMPUTER_AGENT_EGRESS_PROXY_SECRET +) +``` + +Things to get right in a long-running service: + +- **One `ComputerAgent` is one session.** Do not run two `chat()` calls on the same instance at + once. Build one instance per conversation. +- **A turn has no timeout and nothing retries.** Wrap `chat()` in `asyncio.wait_for`. +- **The token is re-read every turn** when it comes from `COMPUTER_AGENT_EGRESS_PROXY_SECRET`. If + your system mints a fresh sandbox token per turn, write it there before the turn. An explicit + `api_key=` stays the key of that instance, replaced only by the proxy's own reissued successor. +- **Errors at construction** (`SettingsError`, `AgentResolveError`, `UnsupportedAgentError`) name + the setting or field, never its value, so they are safe to log and return. + +`examples/e2e/live_agent_run.py` runs one turn and prints a pass/fail line per stage. Use it to check +a new environment. + +## 4. Build a sandbox image + +The image runs the SDK's app server: `computeragent.server.create_app()` (or +`python -m computeragent.server`). The same process serves both runtimes: + +| Runtime | Routes the platform and CAS call | +|---|---| +| Lambda MicroVM | `POST /aws/lambda-microvms/runtime/v1/{ready,validate,run,suspend,resume,terminate}`, then the harness API: `GET /v1/health`, `POST /v1/sessions`, `POST /v1/sessions/{id}/messages`, `GET /v1/sessions/{id}/events`, … | +| AgentCore | `GET /ping`, and `POST /invocations` carrying the harness API in relay envelope v1 | + +The full route list is in `USING_THE_SDK.md` §14. + +### 4.1 Start from an example + +```bash +cp examples/lambda_microvm/.env.example examples/lambda_microvm/.env # set the proxy URLs and the engine +docker buildx build --platform linux/arm64 \ + -f examples/lambda_microvm/Dockerfile -t my-agent-sandbox . # from the repository root +``` + +`examples/agentcore_runtime/` is the same app with AgentCore wording; one image works on both +runtimes. + +### 4.2 Or add it to your own Dockerfile + +```dockerfile +FROM python:3.12-slim +RUN pip install 'computer-agent-py[agentos,server,gitagent]' 'python-dotenv>=1,<2' +COPY app.py .env /app/ + +# The Claude CLI refuses to skip permission prompts as root; the engine runs in the working directory. +RUN useradd --create-home --uid 10001 agent && mkdir /workspace && chown agent:agent /workspace +USER agent +WORKDIR /workspace +EXPOSE 8080 +CMD ["python", "/app/app.py"] +``` + +with `app.py`: + +```python +from pathlib import Path +import uvicorn +from dotenv import load_dotenv +from computeragent.server import create_app + +load_dotenv(Path(__file__).with_name(".env"), override=False) # the image's settings (§2) +app = create_app() + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8080, log_level="warning", access_log=False) +``` + +Rules every image must follow: + +- **`linux/arm64`.** Both runtimes run on Graviton. +- **Port 8080**, the only port the platforms talk to. +- **A non-root user.** +- **No secrets and no per-session values.** The image's settings are the egress routes and the + engine. `COMPUTER_AGENT_ID` and `COMPUTER_AGENT_EGRESS_PROXY_SECRET` arrive per session. The app + server must boot without them: a MicroVM image build runs the container to take its snapshot, + with no session. +- **A single-platform image with Docker v2 media types.** The MicroVM build service cannot resolve + an OCI image index (buildx's default, because of the provenance attestation): + + ```bash + docker buildx build --platform linux/arm64 --provenance=false --sbom=false \ + --output type=image,oci-mediatypes=false,push=true \ + -f Dockerfile -t .dkr.ecr..amazonaws.com/: . + ``` + +### 4.3 Check it locally before you push + +```bash +docker run --rm -p 8080:8080 my-agent-sandbox +curl -s -X POST localhost:8080/aws/lambda-microvms/runtime/v1/ready -o /dev/null -w '%{http_code}\n' # 200 +curl -s localhost:8080/v1/health # {"ok":true,...} +curl -s localhost:8080/ping # {"status":"Healthy",...} +curl -s -X POST localhost:8080/v1/sessions -H 'content-type: application/json' -d '{ + "engine": "claude-agent-sdk", + "identity": {"loader": "gitagentprotocol", "source": {"type": "local", "path": "/agent"}}, + "envs": {"COMPUTER_AGENT_ID": "", "COMPUTER_AGENT_EGRESS_PROXY_SECRET": ""} +}' +``` + +With a real token, the session answers `201`. With a fake one, the answer is a `502 +AGENT_BUILD_FAILED` whose detail names the proxy URL it tried, e.g. +`…/computer-agent-api/agentos/api/v1/agents/` and `401 TOKEN_INVALID`. That error proves +the routes and the per-session values are wired. + +## 5. Push the image to ECR + +Use an ECR repository in the **same AWS account** as the runtime. The MicroVM build service pulls +only from public ECR or same-account ECR. + +```bash +aws ecr create-repository --repository-name /-sandbox \ + --image-tag-mutability MUTABLE --encryption-configuration encryptionType=KMS +aws ecr get-login-password | docker login --username AWS --password-stdin .dkr.ecr..amazonaws.com +# build and push as in §4.2, then read the digest back: +aws ecr describe-images --repository-name /-sandbox --image-ids imageTag= \ + --query 'imageDetails[0].imageDigest' --output text +``` + +Always register by digest (`@sha256:`), not by tag. A tag can move. + +The role that pulls the image (the MicroVM build role, or the AgentCore execution role) needs pull +permission on your repository. Grant it in a repository policy scoped to that one role, rather than +widening the role: + +```bash +aws ecr set-repository-policy --repository-name /-sandbox --policy-text '{ + "Version": "2012-10-17", + "Statement": [{ + "Sid": "PullForRuntime", + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam:::role/"}, + "Action": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer", "ecr:BatchCheckLayerAvailability"] + }] +}' +``` + +AgentCore needs only `ecr:BatchGetImage` and `ecr:GetDownloadUrlForLayer`. Lambda MicroVM's build +role also needs `ecr:BatchCheckLayerAvailability`. + +## 6. Register a Lambda MicroVM image + +### 6.1 Prerequisites + +| What | Requirement | +|---|---| +| Build role | trusted by `lambda.amazonaws.com`. It can read your code artifact (`s3:GetObject`) and pull your image (§5). | +| Your principal | `lambda:CreateMicrovmImage` / `lambda:UpdateMicrovmImage`, and `iam:PassRole` on the build role. Without `iam:PassRole` the call fails with `AccessDeniedException … iam:PassRole`. | +| S3 bucket | one the build role can read, for the code artifact | +| Egress | the MicroVM must reach the egress proxy. `INTERNET_EGRESS` for a public proxy; a VPC egress connector for a private one. | + +### 6.2 The code artifact + +The MicroVM build takes a zip with a `Dockerfile` at its root and builds it on an AWS base image. +Make it one line that names your pushed image by digest: + +```bash +printf 'FROM .dkr.ecr..amazonaws.com//-sandbox@sha256:\n' > Dockerfile +zip artifact.zip Dockerfile +aws s3 cp artifact.zip s3:///-sandbox/artifact.zip +``` + +### 6.3 Create the image + +```bash +aws lambda-microvms create-microvm-image \ + --name -sandbox \ + --base-image-arn arn:aws:lambda::aws:microvm-image:al2023-1 \ + --build-role-arn arn:aws:iam:::role/ \ + --code-artifact uri=s3:///-sandbox/artifact.zip \ + --egress-network-connectors arn:aws:lambda::aws:network-connector:aws-network-connector:INTERNET_EGRESS \ + --cpu-configurations architecture=ARM_64 \ + --resources minimumMemoryInMiB=2048 \ + --hooks 'port=8080,microvmHooks={run=ENABLED,runTimeoutInSeconds=60,resume=ENABLED,resumeTimeoutInSeconds=60,suspend=ENABLED,suspendTimeoutInSeconds=60,terminate=ENABLED,terminateTimeoutInSeconds=60},microvmImageHooks={ready=ENABLED,readyTimeoutInSeconds=600,validate=ENABLED,validateTimeoutInSeconds=600}' \ + --tags Owner=,Service=-sandbox +``` + +| Flag | Why | +|---|---| +| `--hooks` | Enables all six lifecycle hooks on port 8080. The paths are fixed by the platform; the app server answers each with `200`. CAS sends no traffic until `run` answers. | +| `--cpu-configurations architecture=ARM_64` | The only architecture MicroVMs support | +| `--resources minimumMemoryInMiB=2048` | Baseline memory; vCPU scales with it. Raise it for heavy agents. | +| no `--environment-variables` | They are captured in the snapshot and shared by every session. Keep per-session values and secrets out. | + +The call returns the image ARN and version `1.0` in state `CREATING`. Wait for the build. Pass the +**ARN**, not the name; the name gives `ValidationException: Invalid ARN format`: + +```bash +aws lambda-microvms get-microvm-image-version \ + --image-identifier arn:aws:lambda:::microvm-image:-sandbox \ + --image-version 1.0 --query '[state,status,stateReason]' --output text +# SUCCESSFUL ACTIVE None (FAILED carries the reason) +``` + +### 6.4 Ship a new version + +Re-pushing an ECR tag does **not** change a MicroVM image: the build took a snapshot. For every new +image, push it, point the artifact at the new digest, and build a new version: + +```bash +aws lambda-microvms update-microvm-image \ + --image-identifier arn:aws:lambda:::microvm-image:-sandbox \ + --code-artifact uri=s3:///-sandbox/artifact.zip \ + --base-image-arn arn:aws:lambda::aws:microvm-image:al2023-1 \ + --build-role-arn arn:aws:iam:::role/ +``` + +`--base-image-arn` and `--build-role-arn` are required on every update. Poll the returned version as +in §6.3. Agents pinned to an older version keep it until you move the pin (§8). + +### 6.5 Limits to plan for + +- A MicroVM lives at most 8 hours. +- AWS base images move from `AVAILABLE` to `DEPRECATED`, then `EXPIRING` and `EXPIRED`; an expired + base can neither build nor run. Rebuild on a current base before then. +- Quotas: 100 images per account and region, 50 versions per image. + +### 6.6 Remove it + +```bash +aws lambda-microvms delete-microvm-image \ + --image-identifier arn:aws:lambda:::microvm-image:-sandbox +aws s3 rm s3:///-sandbox/artifact.zip +``` + +## 7. Register an AgentCore runtime + +### 7.1 Prerequisites + +| What | Requirement | +|---|---| +| Execution role | trusted by `bedrock-agentcore.amazonaws.com`. It can pull your image (§5) and write logs to `/aws/bedrock-agentcore/runtimes/*`. | +| Your principal | `bedrock-agentcore:CreateAgentRuntime` / `UpdateAgentRuntime`, and `iam:PassRole` on the execution role | +| Network | `PUBLIC` reaches a public egress proxy. For a private proxy use `VPC` with your subnets and security groups. | + +### 7.2 Create the runtime + +```bash +aws bedrock-agentcore-control create-agent-runtime \ + --agent-runtime-name _sandbox \ + --agent-runtime-artifact 'containerConfiguration={containerUri=.dkr.ecr..amazonaws.com//-sandbox@sha256:}' \ + --role-arn arn:aws:iam:::role/ \ + --network-configuration networkMode=PUBLIC \ + --protocol-configuration serverProtocol=HTTP \ + --tags Owner=,Service=-sandbox +``` + +The name allows letters, digits and `_` only, starting with a letter. The answer carries the runtime +ARN, `arn:aws:bedrock-agentcore:::runtime/_sandbox-<10 chars>`. Wait for +`READY`: + +```bash +aws bedrock-agentcore-control get-agent-runtime --agent-runtime-id _sandbox-<10 chars> \ + --query '[status,failureReason,agentRuntimeVersion]' --output text +``` + +Leave `--environment-variables` unset, for the same reason as on MicroVM: the values are shared by +every session. + +### 7.3 Ship a new version + +`update-agent-runtime` with the new digest (and the same role, network and protocol) creates a new +immutable runtime version. The `DEFAULT` endpoint follows the latest version. Create a named +endpoint (`create-agent-runtime-endpoint`) if you want to move agents explicitly. + +### 7.4 Remove it + +```bash +aws bedrock-agentcore-control delete-agent-runtime --agent-runtime-id _sandbox-<10 chars> +``` + +## 8. Point an AgentOS agent at the image + +An agent's `image` tells CAS what to boot for it. `ref` must be a MicroVM image ARN +(`arn:aws:lambda:…:microvm-image:…`) or an AgentCore runtime ARN +(`arn:aws:bedrock-agentcore:…:runtime/…`). An ECR image URI is refused. + +```bash +# Lambda MicroVM: version pins an image version; omit it for the latest active one. +curl -s -X PATCH "/agentos/api/v1/agents/" \ + -H "x-api-key: " -H 'content-type: application/json' \ + -d '{"image": {"runtime": "lambda-microvm", "ref": "arn:aws:lambda:::microvm-image:-sandbox", "version": "1.0"}}' + +# AgentCore: version is the endpoint (qualifier); omit it for CAS's default (DEFAULT). +curl -s -X PATCH "/agentos/api/v1/agents/" \ + -H "x-api-key: " -H 'content-type: application/json' \ + -d '{"image": {"runtime": "agentcore", "ref": "arn:aws:bedrock-agentcore:::runtime/_sandbox-<10 chars>"}}' +``` + +`"image": null` clears it. The change applies to the next new sandbox. The agent's own definition +(model, MCP servers, guardrails) stays in AgentOS; the image only decides where it runs. + +CAS itself must be allowed to use the resource: `lambda:RunMicrovm` on the MicroVM image, or +`bedrock-agentcore:InvokeAgentRuntime` on the runtime. This is part of CAS's own IAM. An +`AccessDenied` at boot surfaces as `MICROVM_AGENT_IMAGE_*` or `AGENTCORE_AGENT_RUNTIME_ACCESS_DENIED`. + +### What CAS sends your image + +| When | Request | What the app server does | +|---|---|---| +| Boot (MicroVM) | lifecycle hooks | answers `200` | +| Session start | `POST /v1/sessions` with `envs.COMPUTER_AGENT_ID` and `envs.COMPUTER_AGENT_EGRESS_PROXY_SECRET` | builds the session's `ComputerAgent` for that agent, with that token; every other `envs` key is ignored | +| Each turn | `POST /v1/sessions/{id}/messages` with `credential` | uses the fresh token for the turn | +| Streaming | `GET /v1/sessions/{id}/events` | the turn's events; one `ca_session_ended` when the session ends | +| AgentCore | the same requests inside `POST /invocations` (relay envelope v1) | answers HTTP 200 always; the real status is in the envelope | + +## 9. Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| MicroVM version `FAILED`: "An unknown error occurred" | The pushed image is an OCI index or uses OCI media types | Push with `--provenance=false --sbom=false --output type=image,oci-mediatypes=false,push=true` (§4.2) | +| MicroVM version `FAILED` on the pull | The build role cannot pull from your repository | Repository policy for the build role (§5) | +| `AccessDeniedException … iam:PassRole` on create | Your principal cannot pass the build or execution role | Grant `iam:PassRole` on that role only | +| `ValidationException: Invalid ARN format` on `get-microvm-image-version` | The image name was passed | Pass the image ARN | +| MicroVM build hangs at `ready`, or the container exits at start | The app requires a per-session value at boot | Read `COMPUTER_AGENT_ID` and the token per session; the app server does | +| Agent registration: `IMAGE_REF_INVALID` / `AGENTCORE_RUNTIME_ARN_INVALID` | `ref` is an ECR URI or a malformed ARN | Register the MicroVM image ARN or the AgentCore runtime ARN (§8) | +| Session start: `502 AGENT_BUILD_FAILED … 401 TOKEN_INVALID` | The token is not a valid sandbox token for this proxy, or it expired | CAS mints it; for a manual test, mint one at the proxy | +| Session start: `502 AGENT_BUILD_FAILED … SettingsError: missing required settings: …` | The image lacks an egress route or the engine | Set them in the image's `.env` (§2) | +| A pushed fix does not show up on MicroVM | MicroVM images are snapshots | `update-microvm-image` (§6.4) and move the agent's `version` pin | + +## Appendix: the example resources in Lyzr UAT + +Built from `examples/` on branch `feat/p1-sdk-12-agentcore-relay`, account `425642439121`, +`us-east-1`. They point at an egress proxy on a development tunnel (`ngrok-free.dev`), so treat them +as a worked example, not as production resources. + +| Runtime | Resource | Image | +|---|---|---| +| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:python-sdk-example-lambda-microvm`, version `3.0` | `computer-agent/python-sdk-example-lambda-microvm@sha256:b965ca4599b5f230adb17be86a75a2bfc4478fe2e210d022fb87b748d8549a26` | +| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/python_sdk_example_agentcore_runtime-aICRZZ5e7i`, version `3` (endpoint `DEFAULT`) | `computer-agent/python-sdk-example-agentcore-runtime@sha256:888648f290baa1efec9891ba82a3866794bab824b4aa08229f98d6e02ce8c592` | diff --git a/docs/USING_THE_SDK.md b/docs/USING_THE_SDK.md index 0b0ff21..6f5bcb9 100644 --- a/docs/USING_THE_SDK.md +++ b/docs/USING_THE_SDK.md @@ -47,13 +47,13 @@ The `claude-agent-sdk` engine spawns a Claude Code CLI subprocess, which **ships In a sandbox every setting comes from the environment, so construction takes no arguments: ```bash -export COMPUTERAGENT_AGENT_ID=6aa93f1b3817a13d3103d2c1 -export COMPUTERAGENT_ENGINE=claude-agent-sdk -export COMPUTERAGENT_BASE_URL=http://egress/agentos/api -export COMPUTERAGENT_GUARDRAILS_URL=http://egress/guardrails -export COMPUTERAGENT_LLM_URL=http://egress/llm -export COMPUTERAGENT_MCP_URL=http://egress/mcp/v1 -export LYZR_API_KEY=... # injected when the sandbox is created +export COMPUTER_AGENT_ID=6aa93f1b3817a13d3103d2c1 +export COMPUTER_AGENT_ENGINE=claude-agent-sdk +export COMPUTER_AGENT_EGRESS_API_URL=http://egress/agentos/api +export COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=http://egress/guardrails +export COMPUTER_AGENT_EGRESS_LLM_URL=http://egress/llm +export COMPUTER_AGENT_EGRESS_MCP_URL=http://egress/mcp/v1 +export COMPUTER_AGENT_EGRESS_PROXY_SECRET=... # injected when the sandbox is created ``` ```python @@ -79,7 +79,7 @@ async with ComputerAgent( guardrails_url="http://egress/guardrails", llm_url="http://egress/llm", mcp_url="http://egress/mcp/v1", - api_key=os.environ["LYZR_API_KEY"], + api_key=os.environ["COMPUTER_AGENT_EGRESS_PROXY_SECRET"], options={"system_prompt": "You are terse.", "disallowed_tools": ["Write", "Edit"]}, session_store="memory", budget_max_usd=0.50, @@ -103,16 +103,16 @@ request is made. A URL setting must be an `http(s)` URL. | Argument | Environment variable | Required | What it is | |---|---|---|---| -| `agent_id` | `COMPUTERAGENT_AGENT_ID` | **yes** | The agent's **24-hex Mongo `_id`** in AgentOS (the id in the agent's URL). Not the stable `agentId` field. | -| `engine` | `COMPUTERAGENT_ENGINE` | **yes** | `"claude-agent-sdk"` or `"gitagent"`; an `EngineDriver` instance only as an argument. | -| `api_key` | `LYZR_API_KEY` | **yes** | The key the SDK presents on every call as `x-api-key`. In a sandbox it is injected at creation and the egress proxy swaps it for the real one. Read **at every turn** (and at each other use): the engine's key variables and the bound MCP servers' `x-api-key` follow it, so the app server can set it from each message's `credential`. An explicit `api_key=` is kept and replaced only by its own reissued successors (§8a). | -| `base_url` | `COMPUTERAGENT_BASE_URL` | **yes** | Route to the AgentOS agent config: the model and its MCP bindings. | -| `guardrails_url` | `COMPUTERAGENT_GUARDRAILS_URL` | **yes** | Route to the guardrails check, for the user's message and every tool call. | -| `llm_url` | `COMPUTERAGENT_LLM_URL` | **yes** | Route for every model call the engine makes. | -| `mcp_url` | `COMPUTERAGENT_MCP_URL` | **yes** | Route for MCP: each server bound to the agent becomes `{mcp_url}/{name}`; `{name}` is the binding's `tool_name` (AgentOS keeps it to 1–128 printable ASCII characters); the gateway takes the server id and the allowed tools from its registry. | -| `telemetry_url` | `COMPUTERAGENT_TELEMETRY_URL` | no | Route for OTLP traces, metrics and logs. Unset: no OTLP export. | -| `session_store_url` | `COMPUTERAGENT_SESSION_STORE_URL` | no | Base URL for the `http` session store (§7), e.g. `http://egress/computer-agent-api`; no query or fragment. | -| `gap_dir` | `COMPUTERAGENT_GAP_DIR` | no | Turns on GAP mode (§6). | +| `agent_id` | `COMPUTER_AGENT_ID` | **yes** | The agent's **24-hex Mongo `_id`** in AgentOS (the id in the agent's URL). Not the stable `agentId` field. | +| `engine` | `COMPUTER_AGENT_ENGINE` | **yes** | `"claude-agent-sdk"` or `"gitagent"`; an `EngineDriver` instance only as an argument. | +| `api_key` | `COMPUTER_AGENT_EGRESS_PROXY_SECRET` | **yes** | The key the SDK presents on every call as `x-api-key`. In a sandbox it is injected at creation and the egress proxy swaps it for the real one. Read **at every turn** (and at each other use): the engine's key variables and the bound MCP servers' `x-api-key` follow it, so the app server can set it from each message's `credential`. An explicit `api_key=` is kept and replaced only by its own reissued successors (§8a). | +| `base_url` | `COMPUTER_AGENT_EGRESS_API_URL` | **yes** | Route to the AgentOS agent config: the model and its MCP bindings. | +| `guardrails_url` | `COMPUTER_AGENT_EGRESS_GUARDRAILS_URL` | **yes** | Route to the guardrails check, for the user's message and every tool call. | +| `llm_url` | `COMPUTER_AGENT_EGRESS_LLM_URL` | **yes** | Route for every model call the engine makes. | +| `mcp_url` | `COMPUTER_AGENT_EGRESS_MCP_URL` | **yes** | Route for MCP: each server bound to the agent becomes `{mcp_url}/{name}`; `{name}` is the binding's `tool_name` (AgentOS keeps it to 1–128 printable ASCII characters); the gateway takes the server id and the allowed tools from its registry. | +| `telemetry_url` | `COMPUTER_AGENT_EGRESS_TELEMETRY_URL` | no | Route for OTLP traces, metrics and logs. Unset: no OTLP export. | +| `session_store_url` | `COMPUTER_AGENT_EGRESS_SESSION_STORE_URL` | no | Base URL for the `http` session store (§7), e.g. `http://egress/computer-agent-api`; no query or fragment. | +| `gap_dir` | `COMPUTER_AGENT_GAP_DIR` | no | Turns on GAP mode (§6). | The requests made from each route, all with `x-api-key: `: @@ -318,7 +318,7 @@ Every engine gets the same environment: ANTHROPIC_BASE_URL = llm_url GITCLAW_MODEL_BASE_URL = llm_url GITAGENT_MODEL_BASE_URL = llm_url -ANTHROPIC_API_KEY = OPENAI_API_KEY = LYZR_API_KEY = api_key # read at every turn +ANTHROPIC_API_KEY = OPENAI_API_KEY = COMPUTER_AGENT_EGRESS_PROXY_SECRET = api_key # read at every turn CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = 1 DISABLE_AUTOUPDATER = 1 @@ -385,7 +385,7 @@ Two more kinds ship with the SDK: | `mongo` | `url` **(required)**, `database`, `collection`, `owner_user`, `owner_group` | One document per transcript, uuid-deduped. Warns loudly when `database` is unset — the fallback is *not* the database AgentOS reads. Needs the `[mongo]` extra. | | `http` | `base_url` **(required unless `session_store_url` is set)**, `append_timeout`, `load_timeout` | Two verbs (`GET`/`POST {base_url}/session-store/entries`) against the session-store service, which does the write — for a harness that cannot hold a database credential. `load` never raises: a store outage costs a session its resume, not its life. | -**The `http` kind behind the egress proxy.** Set `COMPUTERAGENT_SESSION_STORE_URL=http://egress/computer-agent-api` +**The `http` kind behind the egress proxy.** Set `COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=http://egress/computer-agent-api` (or `session_store_url=`), so requests go to `http://egress/computer-agent-api/session-store/entries`: - with the setting, kind `http` (a string or a descriptor) takes its `base_url` from it, and any @@ -393,9 +393,9 @@ Two more kinds ship with the SDK: - without it, the descriptor's `options.base_url` is required (`baseUrl` is not read); - a store instance or any other kind ignores the setting. -The store sends only `x-api-key`: `LYZR_API_KEY` read at each request, as the newest sandbox token +The store sends only `x-api-key`: `COMPUTER_AGENT_EGRESS_PROXY_SECRET` read at each request, as the newest sandbox token (§8a). There is no store token and no refresh; a `token` option is ignored. The proxy swaps the key -for the Lyzr key and names the session the service scopes the entries to. Without `LYZR_API_KEY` no +for the Lyzr key and names the session the service scopes the entries to. Without `COMPUTER_AGENT_EGRESS_PROXY_SECRET` no credential is sent (a local, unauthenticated store). An unknown kind raises `UnknownSessionStoreError` naming the registered set, and a known kind @@ -407,10 +407,16 @@ would double the transcript. **Multi-turn works within one `ComputerAgent`.** Each turn after the first resumes the previous turn's engine session automatically, so the agent remembers earlier turns whether or not a store is configured. -To resume a conversation in a *later process*, pass a durable store plus the engine session id from the -earlier run — read it off any message (`result.messages[-1].session_id`) and pass it back as -`options={"resume": ""}`. Use `resume`, not `session_id`: the CLI refuses an id that already -exists. + +**With `session_id=`, the store is keyed by it.** Every `append` and `load` carries that id instead of +the Claude CLI's per-process UUID (`project_key` and `subpath` are kept), and the CLI runs under a UUID +derived from it. So a later process, or another sandbox, built with the same `session_id` and the same +durable store resumes the conversation on its first turn: the SDK loads the store once to decide between +starting it and resuming it. + +Without `session_id`, the store is keyed by the CLI's UUID. To resume in a later process, pass that id +from the earlier run (`result.messages[-1].session_id`) back as `options={"resume": ""}`. Use +`resume`, not `session_id` in `options`: the CLI refuses an id that already exists. --- @@ -478,12 +484,12 @@ From then on **every later egress request from every SDK client** carries the ne clients (httpx hooks; an httpx client you inject gets them too); - the Claude CLI's model and MCP calls, which go through a loopback forwarder on `127.0.0.1` (below). -`Settings.api_key()` returns the newest token, and `LYZR_API_KEY` is updated so processes spawned +`Settings.api_key()` returns the newest token, and `COMPUTER_AGENT_EGRESS_PROXY_SECRET` is updated so processes spawned later inherit it. A key passed as `api_key=` follows its own chain the same way. A late reissue of an older token never replaces a newer key. Tokens are never logged; one INFO line records each reissue with its expiry. -Not covered: programs the agent's own tools start (e.g. a Bash `curl` with `$LYZR_API_KEY`) carry +Not covered: programs the agent's own tools start (e.g. a Bash `curl` with `$COMPUTER_AGENT_EGRESS_PROXY_SECRET`) carry the environment they were spawned with, and the SDK never sees their traffic. **The Claude CLI's forwarder.** The CLI's env and MCP headers are fixed when it is spawned, so on @@ -693,18 +699,24 @@ pip install 'computer-agent-py[server]' python -m computeragent.server # 0.0.0.0:8080; PORT overrides the port ``` -The image bakes in the agent's settings (`COMPUTERAGENT_*`, §3). CAS creates one session per sandbox +Building the image and registering it as a Lambda MicroVM image or an AgentCore runtime is covered +step by step in [`DEPLOYING_ON_AGENTCORE_AND_MICROVM.md`](DEPLOYING_ON_AGENTCORE_AND_MICROVM.md). + +The image bakes in the agent's settings (`COMPUTER_AGENT_*`, §3). CAS creates one session per sandbox and the server builds one `ComputerAgent` for it, with no `api_key=`: -- `envs.LYZR_API_KEY` in `POST /v1/sessions` (the sandbox token) seeds `LYZR_API_KEY`; every other - `envs` key is ignored. +- `envs` in `POST /v1/sessions` carries the sandbox token as `COMPUTER_AGENT_EGRESS_PROXY_SECRET`, + which seeds that variable, and the session's agent as `COMPUTER_AGENT_ID` (else the image's own + `COMPUTER_AGENT_ID`). Every other `envs` key is ignored. - Each `POST /v1/sessions/:id/messages` may carry the turn's `credential`; it replaces - `LYZR_API_KEY` before the turn. The key is process-wide, so turns run one at a time per process. + `COMPUTER_AGENT_EGRESS_PROXY_SECRET` before the turn. The key is process-wide, so turns run one + at a time per process. - `identity` is not read (the agent is baked into the image). `engine` must be a registered engine; the image's own engine runs. - `messages` are the session's first turns, and `streamingInput: false` closes its input after - them. Resume across sandboxes comes from the `http` session store (`sessionStore`, §7), keyed by - `sessionId`. + them. The session's agent is built with `session_id = sessionId`, so the `http` session store + (`sessionStore`, §7) is keyed by the CAS session: any sandbox serving that session resumes its + transcript. | Route | Answer | |---|---| diff --git a/examples/agentcore_runtime/.env.example b/examples/agentcore_runtime/.env.example new file mode 100644 index 0000000..cbe897a --- /dev/null +++ b/examples/agentcore_runtime/.env.example @@ -0,0 +1,23 @@ +# Settings of the AgentCore example app (app.py), read by the CAS SDK. Copy to +# .env and adjust: +# cp .env.example .env +# The Dockerfile copies .env into the image next to app.py; variables set at +# run time (docker run -e / --env-file) override it. + +# The engine: claude-agent-sdk or gitagent. +COMPUTER_AGENT_ENGINE=claude-agent-sdk + +# The CAS egress proxy's routes; every SDK call goes through them. +COMPUTER_AGENT_EGRESS_API_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api/agentos/api +COMPUTER_AGENT_EGRESS_LLM_URL=https://borrower-reroute-cornball.ngrok-free.dev/llm +COMPUTER_AGENT_EGRESS_MCP_URL=https://borrower-reroute-cornball.ngrok-free.dev/mcp/v1 +COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=https://borrower-reroute-cornball.ngrok-free.dev/guardrails +# The http session store (old CAS sends an http sessionStore descriptor per session). +COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api +# Optional: OTLP telemetry (needs the SDK's [otel] extra). +# COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp + +# Not here: old CAS passes these per session in the POST /v1/sessions envs it +# relays (this file is baked into the image): +# COMPUTER_AGENT_ID the AgentOS agent the session runs +# COMPUTER_AGENT_EGRESS_PROXY_SECRET the sandbox token (a secret) diff --git a/examples/agentcore_runtime/Dockerfile b/examples/agentcore_runtime/Dockerfile new file mode 100644 index 0000000..2fd46ac --- /dev/null +++ b/examples/agentcore_runtime/Dockerfile @@ -0,0 +1,36 @@ +# AgentCore Runtime example image: the CAS SDK's app server (app.py), installed +# from this checkout. Old CAS drives it through /invocations (relay envelope v1). +# +# Build from the repository root (AgentCore runs linux/arm64 images), after +# creating the app's settings file (the egress routes and the engine): +# cp examples/agentcore_runtime/.env.example examples/agentcore_runtime/.env +# docker buildx build --platform linux/arm64 \ +# -f examples/agentcore_runtime/Dockerfile -t cas-agentcore-example . +# +# The agent and the sandbox token are never baked in: old CAS passes +# COMPUTER_AGENT_ID and COMPUTER_AGENT_EGRESS_PROXY_SECRET per session in the +# POST /v1/sessions envs it relays. +FROM --platform=linux/arm64 python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# The SDK, from this checkout. With a published release, replace these three +# lines with: RUN pip install 'computer-agent-py[agentos,server,gitagent]' +WORKDIR /opt/computer-agent-py +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN pip install ".[agentos,server,gitagent]" "python-dotenv>=1,<2" + +# The app and its settings; app.py loads .env, and docker run -e overrides it. +COPY examples/agentcore_runtime/app.py examples/agentcore_runtime/.env /app/ + +# The Claude CLI refuses to skip permission prompts as root; the engine runs +# in the working directory. +RUN useradd --create-home --uid 10001 agent && mkdir /workspace && chown agent:agent /workspace +USER agent +WORKDIR /workspace + +EXPOSE 8080 +CMD ["python", "/app/app.py"] diff --git a/examples/agentcore_runtime/Dockerfile.dockerignore b/examples/agentcore_runtime/Dockerfile.dockerignore new file mode 100644 index 0000000..b06e72d --- /dev/null +++ b/examples/agentcore_runtime/Dockerfile.dockerignore @@ -0,0 +1,8 @@ +* +!pyproject.toml +!README.md +!LICENSE +!src/ +!examples/agentcore_runtime/app.py +!examples/agentcore_runtime/.env +**/__pycache__ diff --git a/examples/agentcore_runtime/app.py b/examples/agentcore_runtime/app.py new file mode 100644 index 0000000..021558d --- /dev/null +++ b/examples/agentcore_runtime/app.py @@ -0,0 +1,48 @@ +"""An Amazon Bedrock AgentCore Runtime app on the CAS SDK: the SDK's app server, +which old CAS drives. + +On ``0.0.0.0:8080`` it serves AgentCore's contract + + GET /ping {"status": "Healthy" | "HealthyBusy", "time_of_last_update": } + POST /invocations relay envelope v1, dispatched to the CAS harness API + +(``docs/USING_THE_SDK.md`` §14). The egress routes and the engine come from +``.env`` next to this file (baked into the image). The agent and the sandbox +token come per session: old CAS passes ``COMPUTER_AGENT_ID`` and +``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` in the ``POST /v1/sessions`` ``envs`` it +relays. + +Run locally: + + pip install 'computer-agent-py[agentos,server,gitagent]' python-dotenv + cp .env.example .env + python app.py +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import uvicorn +from dotenv import load_dotenv + +from computeragent.server import create_app + +# The app's .env (copied next to app.py in the image); variables already in the +# environment win over it. +load_dotenv(Path(__file__).with_name(".env"), override=False) + +app = create_app() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") + uvicorn.run( + app, + host="0.0.0.0", + port=int(os.environ.get("PORT", "8080")), + log_level="warning", + access_log=False, + ) diff --git a/examples/lambda_microvm/.env.example b/examples/lambda_microvm/.env.example new file mode 100644 index 0000000..91d6f08 --- /dev/null +++ b/examples/lambda_microvm/.env.example @@ -0,0 +1,23 @@ +# Settings of the Lambda MicroVM example app (app.py), read by the CAS SDK. +# Copy to .env and adjust: +# cp .env.example .env +# The Dockerfile copies .env into the image next to app.py; variables set at +# run time (docker run -e / --env-file) override it. + +# The engine: claude-agent-sdk or gitagent. +COMPUTER_AGENT_ENGINE=claude-agent-sdk + +# The CAS egress proxy's routes; every SDK call goes through them. +COMPUTER_AGENT_EGRESS_API_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api/agentos/api +COMPUTER_AGENT_EGRESS_LLM_URL=https://borrower-reroute-cornball.ngrok-free.dev/llm +COMPUTER_AGENT_EGRESS_MCP_URL=https://borrower-reroute-cornball.ngrok-free.dev/mcp/v1 +COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=https://borrower-reroute-cornball.ngrok-free.dev/guardrails +# The http session store (old CAS sends an http sessionStore descriptor per session). +COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api +# Optional: OTLP telemetry (needs the SDK's [otel] extra). +# COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp + +# Not here: old CAS passes these per session in POST /v1/sessions envs +# (this file is baked into the image): +# COMPUTER_AGENT_ID the AgentOS agent the session runs +# COMPUTER_AGENT_EGRESS_PROXY_SECRET the sandbox token (a secret) diff --git a/examples/lambda_microvm/Dockerfile b/examples/lambda_microvm/Dockerfile new file mode 100644 index 0000000..1246651 --- /dev/null +++ b/examples/lambda_microvm/Dockerfile @@ -0,0 +1,36 @@ +# Lambda MicroVM example image: the CAS SDK's app server (app.py), installed +# from this checkout. Old CAS drives it through the harness API. +# +# Build from the repository root (MicroVMs are arm64), after creating the +# app's settings file (the egress routes and the engine): +# cp examples/lambda_microvm/.env.example examples/lambda_microvm/.env +# docker buildx build --platform linux/arm64 \ +# -f examples/lambda_microvm/Dockerfile -t cas-lambda-microvm-example . +# +# The agent and the sandbox token are never baked in: old CAS passes +# COMPUTER_AGENT_ID and COMPUTER_AGENT_EGRESS_PROXY_SECRET per session in +# POST /v1/sessions envs. +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# The SDK, from this checkout. With a published release, replace these three +# lines with: RUN pip install 'computer-agent-py[agentos,server,gitagent]' +WORKDIR /opt/computer-agent-py +COPY pyproject.toml README.md LICENSE ./ +COPY src ./src +RUN pip install ".[agentos,server,gitagent]" "python-dotenv>=1,<2" + +# The app and its settings; app.py loads .env, and docker run -e overrides it. +COPY examples/lambda_microvm/app.py examples/lambda_microvm/.env /app/ + +# The Claude CLI refuses to skip permission prompts as root; the engine runs +# in the working directory. +RUN useradd --create-home --uid 10001 agent && mkdir /workspace && chown agent:agent /workspace +USER agent +WORKDIR /workspace + +EXPOSE 8080 +CMD ["python", "/app/app.py"] diff --git a/examples/lambda_microvm/Dockerfile.dockerignore b/examples/lambda_microvm/Dockerfile.dockerignore new file mode 100644 index 0000000..1cdc93b --- /dev/null +++ b/examples/lambda_microvm/Dockerfile.dockerignore @@ -0,0 +1,8 @@ +* +!pyproject.toml +!README.md +!LICENSE +!src/ +!examples/lambda_microvm/app.py +!examples/lambda_microvm/.env +**/__pycache__ diff --git a/examples/lambda_microvm/app.py b/examples/lambda_microvm/app.py new file mode 100644 index 0000000..1970591 --- /dev/null +++ b/examples/lambda_microvm/app.py @@ -0,0 +1,47 @@ +"""A Lambda MicroVM app on the CAS SDK: the SDK's app server, which old CAS drives. + +On ``0.0.0.0:8080`` it serves + + POST /aws/lambda-microvms/runtime/v1/{ready,validate,run,suspend,resume,terminate} -> 200 + GET /v1/health + POST /v1/sessions, POST /v1/sessions/{id}/messages, GET /v1/sessions/{id}/events, ... + +(the CAS harness API; ``docs/USING_THE_SDK.md`` §14). The egress routes and the +engine come from ``.env`` next to this file (baked into the image). The agent +and the sandbox token come per session: old CAS passes ``COMPUTER_AGENT_ID`` +and ``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` in ``POST /v1/sessions`` ``envs``. + +Run locally: + + pip install 'computer-agent-py[agentos,server,gitagent]' python-dotenv + cp .env.example .env + python app.py +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import uvicorn +from dotenv import load_dotenv + +from computeragent.server import create_app + +# The app's .env (copied next to app.py in the image); variables already in the +# environment win over it. +load_dotenv(Path(__file__).with_name(".env"), override=False) + +app = create_app() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") + uvicorn.run( + app, + host="0.0.0.0", + port=int(os.environ.get("PORT", "8080")), + log_level="warning", + access_log=False, + ) From cfdcc081e26e18993452ac4f0e21614cd2a37460 Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Tue, 27 Oct 2026 17:29:42 +0530 Subject: [PATCH 05/14] docs: point the guide's UAT appendix at the deployed versions --- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md index 547d008..2d319f1 100644 --- a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -443,5 +443,5 @@ as a worked example, not as production resources. | Runtime | Resource | Image | |---|---|---| -| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:python-sdk-example-lambda-microvm`, version `3.0` | `computer-agent/python-sdk-example-lambda-microvm@sha256:b965ca4599b5f230adb17be86a75a2bfc4478fe2e210d022fb87b748d8549a26` | -| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/python_sdk_example_agentcore_runtime-aICRZZ5e7i`, version `3` (endpoint `DEFAULT`) | `computer-agent/python-sdk-example-agentcore-runtime@sha256:888648f290baa1efec9891ba82a3866794bab824b4aa08229f98d6e02ce8c592` | +| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:python-sdk-example-lambda-microvm`, version `4.0` | `computer-agent/python-sdk-example-lambda-microvm@sha256:075a6ef4fd3eebf80f6bd8de56c83f8787b1710d837d93140a1b0fb206fdd819` | +| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/python_sdk_example_agentcore_runtime-aICRZZ5e7i`, version `4` (endpoint `DEFAULT`) | `computer-agent/python-sdk-example-agentcore-runtime@sha256:a02cd61a384502f81f7bfa435244010bdeb7b332fc22b3b312e3fcb7192324a0` | From bc16451fee28376df07d361d9660cad759ab2981 Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Tue, 27 Oct 2026 18:18:19 +0530 Subject: [PATCH 06/14] feat(examples): send OTLP telemetry through the egress proxy Both example images install the otel extra and set COMPUTER_AGENT_EGRESS_TELEMETRY_URL=/otlp. --- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 2 +- examples/agentcore_runtime/.env.example | 4 ++-- examples/agentcore_runtime/Dockerfile | 4 ++-- examples/agentcore_runtime/app.py | 2 +- examples/lambda_microvm/.env.example | 4 ++-- examples/lambda_microvm/Dockerfile | 4 ++-- examples/lambda_microvm/app.py | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md index 2d319f1..6d956ab 100644 --- a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -157,7 +157,7 @@ runtimes. ```dockerfile FROM python:3.12-slim -RUN pip install 'computer-agent-py[agentos,server,gitagent]' 'python-dotenv>=1,<2' +RUN pip install 'computer-agent-py[agentos,server,gitagent,otel]' 'python-dotenv>=1,<2' COPY app.py .env /app/ # The Claude CLI refuses to skip permission prompts as root; the engine runs in the working directory. diff --git a/examples/agentcore_runtime/.env.example b/examples/agentcore_runtime/.env.example index cbe897a..e0ddbeb 100644 --- a/examples/agentcore_runtime/.env.example +++ b/examples/agentcore_runtime/.env.example @@ -14,8 +14,8 @@ COMPUTER_AGENT_EGRESS_MCP_URL=https://borrower-reroute-cornball.ngrok-free.dev/m COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=https://borrower-reroute-cornball.ngrok-free.dev/guardrails # The http session store (old CAS sends an http sessionStore descriptor per session). COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api -# Optional: OTLP telemetry (needs the SDK's [otel] extra). -# COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp +# OTLP telemetry (traces, metrics, logs) through the proxy; the Dockerfile installs the [otel] extra. +COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp # Not here: old CAS passes these per session in the POST /v1/sessions envs it # relays (this file is baked into the image): diff --git a/examples/agentcore_runtime/Dockerfile b/examples/agentcore_runtime/Dockerfile index 2fd46ac..97cdebb 100644 --- a/examples/agentcore_runtime/Dockerfile +++ b/examples/agentcore_runtime/Dockerfile @@ -17,11 +17,11 @@ ENV PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 # The SDK, from this checkout. With a published release, replace these three -# lines with: RUN pip install 'computer-agent-py[agentos,server,gitagent]' +# lines with: RUN pip install 'computer-agent-py[agentos,server,gitagent,otel]' WORKDIR /opt/computer-agent-py COPY pyproject.toml README.md LICENSE ./ COPY src ./src -RUN pip install ".[agentos,server,gitagent]" "python-dotenv>=1,<2" +RUN pip install ".[agentos,server,gitagent,otel]" "python-dotenv>=1,<2" # The app and its settings; app.py loads .env, and docker run -e overrides it. COPY examples/agentcore_runtime/app.py examples/agentcore_runtime/.env /app/ diff --git a/examples/agentcore_runtime/app.py b/examples/agentcore_runtime/app.py index 021558d..1b8cdec 100644 --- a/examples/agentcore_runtime/app.py +++ b/examples/agentcore_runtime/app.py @@ -14,7 +14,7 @@ Run locally: - pip install 'computer-agent-py[agentos,server,gitagent]' python-dotenv + pip install 'computer-agent-py[agentos,server,gitagent,otel]' python-dotenv cp .env.example .env python app.py """ diff --git a/examples/lambda_microvm/.env.example b/examples/lambda_microvm/.env.example index 91d6f08..940fa1e 100644 --- a/examples/lambda_microvm/.env.example +++ b/examples/lambda_microvm/.env.example @@ -14,8 +14,8 @@ COMPUTER_AGENT_EGRESS_MCP_URL=https://borrower-reroute-cornball.ngrok-free.dev/m COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=https://borrower-reroute-cornball.ngrok-free.dev/guardrails # The http session store (old CAS sends an http sessionStore descriptor per session). COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api -# Optional: OTLP telemetry (needs the SDK's [otel] extra). -# COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp +# OTLP telemetry (traces, metrics, logs) through the proxy; the Dockerfile installs the [otel] extra. +COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp # Not here: old CAS passes these per session in POST /v1/sessions envs # (this file is baked into the image): diff --git a/examples/lambda_microvm/Dockerfile b/examples/lambda_microvm/Dockerfile index 1246651..688e269 100644 --- a/examples/lambda_microvm/Dockerfile +++ b/examples/lambda_microvm/Dockerfile @@ -17,11 +17,11 @@ ENV PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 # The SDK, from this checkout. With a published release, replace these three -# lines with: RUN pip install 'computer-agent-py[agentos,server,gitagent]' +# lines with: RUN pip install 'computer-agent-py[agentos,server,gitagent,otel]' WORKDIR /opt/computer-agent-py COPY pyproject.toml README.md LICENSE ./ COPY src ./src -RUN pip install ".[agentos,server,gitagent]" "python-dotenv>=1,<2" +RUN pip install ".[agentos,server,gitagent,otel]" "python-dotenv>=1,<2" # The app and its settings; app.py loads .env, and docker run -e overrides it. COPY examples/lambda_microvm/app.py examples/lambda_microvm/.env /app/ diff --git a/examples/lambda_microvm/app.py b/examples/lambda_microvm/app.py index 1970591..8d9e980 100644 --- a/examples/lambda_microvm/app.py +++ b/examples/lambda_microvm/app.py @@ -13,7 +13,7 @@ Run locally: - pip install 'computer-agent-py[agentos,server,gitagent]' python-dotenv + pip install 'computer-agent-py[agentos,server,gitagent,otel]' python-dotenv cp .env.example .env python app.py """ From fc77552674b44afff82aecf571889cb4d3ade29a Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Tue, 27 Oct 2026 19:06:52 +0530 Subject: [PATCH 07/14] docs: point the guide's UAT appendix at version 5 --- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md index 6d956ab..ded063d 100644 --- a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -443,5 +443,5 @@ as a worked example, not as production resources. | Runtime | Resource | Image | |---|---|---| -| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:python-sdk-example-lambda-microvm`, version `4.0` | `computer-agent/python-sdk-example-lambda-microvm@sha256:075a6ef4fd3eebf80f6bd8de56c83f8787b1710d837d93140a1b0fb206fdd819` | -| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/python_sdk_example_agentcore_runtime-aICRZZ5e7i`, version `4` (endpoint `DEFAULT`) | `computer-agent/python-sdk-example-agentcore-runtime@sha256:a02cd61a384502f81f7bfa435244010bdeb7b332fc22b3b312e3fcb7192324a0` | +| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:python-sdk-example-lambda-microvm`, version `5.0` | `computer-agent/python-sdk-example-lambda-microvm@sha256:00dd5c580b1baa7e2c136a168b38e144ba9078634f38d9a899074cf30ec4b86a` | +| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/python_sdk_example_agentcore_runtime-aICRZZ5e7i`, version `5` (endpoint `DEFAULT`) | `computer-agent/python-sdk-example-agentcore-runtime@sha256:12186d9e0654bb1a864fb042d4826ac9f513032d7e80d52c8d10adcf77795e97` | From ac69256fb1ff8ebc222a4991758ad742b84cb6af Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Wed, 28 Oct 2026 12:42:34 +0530 Subject: [PATCH 08/14] refactor(agentos)!: remove the dead policy-config helpers policy_config_url and fetch_policy_binding had no caller since 371c815 (0.4.0, one run mode). AgentOS has dropped the ingest/policy-config/{name} route they read (computeragent-enterprise a8843c9) because the agent gateway applies org and agent policies, so a direct caller got a 404 that read as "no policy". bind_policy stays: PUT /agents/{id}/policy is still served. --- CHANGELOG.md | 5 ++++ src/computeragent/agentos.py | 55 ------------------------------------ 2 files changed, 5 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f00cfc1..b2ca6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,11 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan ### Removed +- **`computeragent.agentos.policy_config_url` and `fetch_policy_binding`.** Nothing + in the SDK called them since 0.4.0, and AgentOS has dropped the + `ingest/policy-config/{name}` route they read (the agent gateway applies org + and agent policies), so a direct caller got a 404 that looked like "no policy". + - **BREAKING: the SDK's own policy resolution.** No org RAI policies are read (`/v3/org/rai-policy`), the agent document's `rai_policy_id` is ignored and `ResolvedAgent.rai_policy_id` is gone, and no SRS route diff --git a/src/computeragent/agentos.py b/src/computeragent/agentos.py index 7904b8f..177b293 100644 --- a/src/computeragent/agentos.py +++ b/src/computeragent/agentos.py @@ -124,61 +124,6 @@ def otel_url() -> str | None: return _resolve("otel", "AGENTOS_OTEL_URL") -def policy_config_url(name: str) -> str | None: - """AgentOS ingest ``policy-config/{name}`` URL, derived from the ingest - endpoint (``…/ingest/events`` → ``…/ingest/policy-config/{name}``). - - ``None`` when no ingest endpoint is configured.""" - base = ingest_url() - if not base: - return None - from urllib.parse import quote - - root = base[: -len("/events")] if base.endswith("/events") else base - return f"{root}/policy-config/{quote(name, safe='')}" - - -def fetch_policy_binding( - name: str, *, token: str | None = None, timeout: float = 5.0 -) -> dict[str, str] | None: - """Fetch the SRS policy *binding* for an agent name from AgentOS. - - Returns ``{"kind": "srs", "policyId": …, "principalId": …}`` when the agent - has a bound policy, else ``None``. Never returns the SRS endpoint/key — those - stay server-side; the CAS fills them from its own env. Best-effort: any - network/config error logs and returns ``None`` (no policy rather than a crash). - """ - url = policy_config_url(name) - if not url: - return None - try: - import httpx - except ImportError: # pragma: no cover - httpx ships with [remote]/[agentos] - logger.debug("httpx not installed; cannot fetch AgentOS policy-config") - return None - - headers = {"accept": "application/json"} - key = token or cak() - if key: - headers["authorization"] = f"Bearer {key}" - try: - resp = httpx.get(url, headers=headers, timeout=timeout) - resp.raise_for_status() - doc = resp.json() - except Exception as exc: # network / DNS / TLS / bad JSON — never fatal - logger.warning("AgentOS policy-config fetch from %s failed (%s)", url, exc) - return None - - policy = doc.get("policy") if isinstance(doc, dict) else None - if not isinstance(policy, dict) or not policy.get("policyId"): - return None - return { - "kind": "srs", - "policyId": str(policy["policyId"]), - "principalId": str(policy.get("principalId") or name), - } - - def bind_policy( agent_id: str, policy_id: str | None, *, token: str | None = None, timeout: float = 10.0 ) -> dict[str, str] | None: From a1b083f3885534744c09d4b0b72f296e5828dec0 Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Wed, 28 Oct 2026 12:47:22 +0530 Subject: [PATCH 09/14] feat(server): take the egress routes from the session's envs CAS now passes the six egress routes per session, beside the token and the agent id: COMPUTER_AGENT_EGRESS_{API,LLM,MCP,GUARDRAILS,SESSION_STORE, TELEMETRY}_URL in POST /v1/sessions envs. Each is used only when the image's own environment does not set it, and an agent factory's arguments win over both, so one image runs against whatever proxy CAS names while an image or its code can still pin a route. --- src/computeragent/server/app.py | 28 +++++++++++++++++++++++----- tests/server_helpers.py | 18 +++++++++--------- tests/test_server_app.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/computeragent/server/app.py b/src/computeragent/server/app.py index 4b0c5a6..e923cb0 100644 --- a/src/computeragent/server/app.py +++ b/src/computeragent/server/app.py @@ -17,12 +17,14 @@ GET /ping, POST /invocations AgentCore: the same routes in relay envelope v1 (relay.py) One ``ComputerAgent`` per session, built from the image's settings -(``COMPUTER_AGENT_*``) with no ``api_key=``. Old CAS passes two of them in the +(``COMPUTER_AGENT_*``) with no ``api_key=``. Old CAS passes settings in the session's ``envs``: ``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` (the sandbox token) -seeds the process environment, and ``COMPUTER_AGENT_ID`` is the session's -agent (else the image's own ``COMPUTER_AGENT_ID``); every other ``envs`` key is -ignored. Each message's ``credential`` replaces the token before its turn. The -token is process-wide, so turns run one at a time per process. +seeds the process environment; ``COMPUTER_AGENT_ID`` is the session's agent +(else the image's own); and each ``COMPUTER_AGENT_EGRESS_*_URL`` is used only +when the image's environment does not set it (an agent factory's own arguments +win over both). Every other ``envs`` key is ignored. Each message's +``credential`` replaces the token before its turn. The token is process-wide, +so turns run one at a time per process. ``engine`` is checked against the registered engines and the image's own engine runs; ``identity`` is not read. ``messages`` are the session's first turns and ``streamingInput: false`` closes @@ -77,6 +79,16 @@ #: also the variable the SDK reads it from. TOKEN_ENV = "COMPUTER_AGENT_EGRESS_PROXY_SECRET" AGENT_ID_ENV = "COMPUTER_AGENT_ID" +#: Egress routes CAS may pass in ``envs`` (``ComputerAgent`` argument names). +#: The image's own variables and the agent factory's arguments win over them. +SESSION_URL_SETTINGS = ( + "base_url", + "llm_url", + "mcp_url", + "guardrails_url", + "telemetry_url", + "session_store_url", +) #: Builds a session's agent: (session_id, options, session_store, on_tool_call) → ComputerAgent. AgentFactory = Callable[..., Any] @@ -431,6 +443,12 @@ async def create_session(request: Request) -> Response: agent_kwargs: dict[str, Any] = {} if envs.get(AGENT_ID_ENV, "").strip(): agent_kwargs["agent_id"] = envs[AGENT_ID_ENV].strip() + from ..harness.settings import ENV_VARS + + for name in SESSION_URL_SETTINGS: + value = envs.get(ENV_VARS[name], "").strip() + if value and not os.environ.get(ENV_VARS[name], "").strip(): + agent_kwargs[name] = value old = sessions.pop(sid, None) if old is not None: await old.cancel() diff --git a/tests/server_helpers.py b/tests/server_helpers.py index 0fea5e1..fc5626a 100644 --- a/tests/server_helpers.py +++ b/tests/server_helpers.py @@ -124,15 +124,15 @@ def factory(**kwargs: Any) -> ComputerAgent: if calls is not None: calls.append(dict(kwargs)) kwargs.setdefault("agent_id", AGENT_ID) - return ComputerAgent( - base_url=BASE, - engine=engine, - llm_url="http://egress.internal/llm", - mcp_url="http://egress.internal/mcp/v1", - guardrails_url=GUARDRAILS, - policy_decider=no_opinion if engine.ask_permission else None, - **kwargs, - ) + fixed = { # the factory's own arguments win over the session's (as in an image's code) + "base_url": BASE, + "engine": engine, + "llm_url": "http://egress.internal/llm", + "mcp_url": "http://egress.internal/mcp/v1", + "guardrails_url": GUARDRAILS, + "policy_decider": no_opinion if engine.ask_permission else None, + } + return ComputerAgent(**{**kwargs, **fixed}) return create_app(agent_factory=factory) diff --git a/tests/test_server_app.py b/tests/test_server_app.py index d80a907..625ee49 100644 --- a/tests/test_server_app.py +++ b/tests/test_server_app.py @@ -80,6 +80,36 @@ async def test_the_session_agent_comes_from_envs(agent_doc): assert env_key() == "sbt_1" +@respx.mock +async def test_session_egress_urls_yield_to_the_image(agent_doc, monkeypatch): + egress = "https://egress.uat.example" + session_urls = { + "COMPUTER_AGENT_EGRESS_API_URL": f"{egress}/computer-agent-api/agentos/api", + "COMPUTER_AGENT_EGRESS_LLM_URL": f"{egress}/llm", + "COMPUTER_AGENT_EGRESS_MCP_URL": f"{egress}/mcp/v1", + "COMPUTER_AGENT_EGRESS_GUARDRAILS_URL": f"{egress}/guardrails", + "COMPUTER_AGENT_EGRESS_SESSION_STORE_URL": f"{egress}/computer-agent-api", + "COMPUTER_AGENT_EGRESS_TELEMETRY_URL": f"{egress}/otlp", + } + for var in session_urls: + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("COMPUTER_AGENT_EGRESS_LLM_URL", "https://image.own/llm") # the image's env + mock_backends(agent_doc) + calls: list = [] + envs = {"COMPUTER_AGENT_EGRESS_PROXY_SECRET": "sbt_1", **session_urls} + async with client_for(make_app(FakeEngine(), calls)) as client: + r = await client.post("/v1/sessions", json=session_body(envs=envs)) + + assert r.status_code == 201 + assert {k: v for k, v in calls[0].items() if k.endswith("_url")} == { + "base_url": f"{egress}/computer-agent-api/agentos/api", + "mcp_url": f"{egress}/mcp/v1", + "guardrails_url": f"{egress}/guardrails", + "session_store_url": f"{egress}/computer-agent-api", + "telemetry_url": f"{egress}/otlp", + } # llm_url: the image's own variable wins + + def test_the_default_factory_passes_no_api_key(monkeypatch): seen = {} From e9fdc8f329a8637847d910822556a3b577bc382c Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Wed, 28 Oct 2026 12:47:47 +0530 Subject: [PATCH 10/14] docs: CAS passes the egress routes per session --- CHANGELOG.md | 8 +++++--- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 18 ++++++++++-------- docs/USING_THE_SDK.md | 6 +++++- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ca6c6..897f0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,9 +82,11 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan Old CAS passes the same names in `POST /v1/sessions` `envs`: the app server seeds `COMPUTER_AGENT_EGRESS_PROXY_SECRET` from it (the `LYZR_API_KEY` field - is no longer read) and builds the session's agent from `COMPUTER_AGENT_ID`, - falling back to the image's own. The engine's per-turn environment carries - the token as `COMPUTER_AGENT_EGRESS_PROXY_SECRET`. + is no longer read), builds the session's agent from `COMPUTER_AGENT_ID` + (falling back to the image's own), and takes each + `COMPUTER_AGENT_EGRESS_*_URL` route the image's own environment or the agent + factory does not set. The engine's per-turn environment carries the token as + `COMPUTER_AGENT_EGRESS_PROXY_SECRET`. - **BREAKING: guardrails go through the egress proxy.** The user's message and every tool call are checked with `POST {guardrails_url}/v1/check` (ops diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md index ded063d..6f9cdff 100644 --- a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -63,15 +63,17 @@ variable. Every missing required setting is reported in one `SettingsError`. | `COMPUTER_AGENT_ID` | `agent_id` | yes | CAS, per session (`POST /v1/sessions` `envs`) | | `COMPUTER_AGENT_EGRESS_PROXY_SECRET` | `api_key` | yes | CAS, per session and per message. **Never bake it into an image.** | | `COMPUTER_AGENT_ENGINE` | `engine` | yes | the image (`claude-agent-sdk` or `gitagent`) | -| `COMPUTER_AGENT_EGRESS_API_URL` | `base_url` | yes | the image: `/computer-agent-api/agentos/api` | -| `COMPUTER_AGENT_EGRESS_LLM_URL` | `llm_url` | yes | the image: `/llm` | -| `COMPUTER_AGENT_EGRESS_MCP_URL` | `mcp_url` | yes | the image: `/mcp/v1` | -| `COMPUTER_AGENT_EGRESS_GUARDRAILS_URL` | `guardrails_url` | yes | the image: `/guardrails` | -| `COMPUTER_AGENT_EGRESS_TELEMETRY_URL` | `telemetry_url` | no | the image: `/otlp` (needs the `otel` extra) | -| `COMPUTER_AGENT_EGRESS_SESSION_STORE_URL` | `session_store_url` | no | the image: `/computer-agent-api` | +| `COMPUTER_AGENT_EGRESS_API_URL` | `base_url` | yes | CAS per session, or the image: `/computer-agent-api/agentos/api` | +| `COMPUTER_AGENT_EGRESS_LLM_URL` | `llm_url` | yes | CAS per session, or the image: `/llm` | +| `COMPUTER_AGENT_EGRESS_MCP_URL` | `mcp_url` | yes | CAS per session, or the image: `/mcp/v1` | +| `COMPUTER_AGENT_EGRESS_GUARDRAILS_URL` | `guardrails_url` | yes | CAS per session, or the image: `/guardrails` | +| `COMPUTER_AGENT_EGRESS_TELEMETRY_URL` | `telemetry_url` | no | CAS per session, or the image: `/otlp` (needs the `otel` extra) | +| `COMPUTER_AGENT_EGRESS_SESSION_STORE_URL` | `session_store_url` | no | CAS per session, or the image: `/computer-agent-api` | | `COMPUTER_AGENT_GAP_DIR` | `gap_dir` | no | the image, when the agent is a GAP directory baked into it | -`` is the egress proxy's base URL, e.g. `https://egress.example.com`. The examples keep the +`` is the egress proxy's base URL, e.g. `https://egress.example.com`. CAS passes the six egress +routes per session; an image that sets one in its own environment keeps it (and an agent factory's +arguments win over both), so one image works in every environment CAS runs in. The examples keep the image's settings in a `.env` file that the Dockerfile copies next to `app.py`. Start from `examples/*/.env.example`. @@ -416,7 +418,7 @@ CAS itself must be allowed to use the resource: `lambda:RunMicrovm` on the Micro | When | Request | What the app server does | |---|---|---| | Boot (MicroVM) | lifecycle hooks | answers `200` | -| Session start | `POST /v1/sessions` with `envs.COMPUTER_AGENT_ID` and `envs.COMPUTER_AGENT_EGRESS_PROXY_SECRET` | builds the session's `ComputerAgent` for that agent, with that token; every other `envs` key is ignored | +| Session start | `POST /v1/sessions` with `envs.COMPUTER_AGENT_ID`, `envs.COMPUTER_AGENT_EGRESS_PROXY_SECRET` and the six `envs.COMPUTER_AGENT_EGRESS_*_URL` | builds the session's `ComputerAgent` for that agent, with that token, on those routes (an egress route the image sets itself wins); every other `envs` key is ignored | | Each turn | `POST /v1/sessions/{id}/messages` with `credential` | uses the fresh token for the turn | | Streaming | `GET /v1/sessions/{id}/events` | the turn's events; one `ca_session_ended` when the session ends | | AgentCore | the same requests inside `POST /invocations` (relay envelope v1) | answers HTTP 200 always; the real status is in the envelope | diff --git a/docs/USING_THE_SDK.md b/docs/USING_THE_SDK.md index 6f5bcb9..8ece568 100644 --- a/docs/USING_THE_SDK.md +++ b/docs/USING_THE_SDK.md @@ -707,7 +707,11 @@ and the server builds one `ComputerAgent` for it, with no `api_key=`: - `envs` in `POST /v1/sessions` carries the sandbox token as `COMPUTER_AGENT_EGRESS_PROXY_SECRET`, which seeds that variable, and the session's agent as `COMPUTER_AGENT_ID` (else the image's own - `COMPUTER_AGENT_ID`). Every other `envs` key is ignored. + `COMPUTER_AGENT_ID`). +- `envs` may also carry the six egress routes, `COMPUTER_AGENT_EGRESS_{API,LLM,MCP,GUARDRAILS, + SESSION_STORE,TELEMETRY}_URL`. Each is used only when the image's own environment does not set + it, so an image can pin a route; an agent factory's arguments (`create_app(agent_factory=…)`) win + over both. Every other `envs` key is ignored. - Each `POST /v1/sessions/:id/messages` may carry the turn's `credential`; it replaces `COMPUTER_AGENT_EGRESS_PROXY_SECRET` before the turn. The key is process-wide, so turns run one at a time per process. From a19fac013d3bc3546f981a36296f509e9463c3f6 Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Wed, 28 Oct 2026 13:00:41 +0530 Subject: [PATCH 11/14] feat(examples): the UAT default sandbox images Rename the examples to examples/sdk_default_lambda_microvm and examples/sdk_default_agentcore: they build the images UAT CAS boots when an agent names no MicroVM image or AgentCore runtime of its own. Their .env now bakes only the engine; CAS passes the agent id, the sandbox token and the six egress routes per session, so one image runs against whichever egress proxy CAS names. --- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 13 ++++++----- examples/agentcore_runtime/.env.example | 23 ------------------- .../agentcore_runtime/Dockerfile.dockerignore | 8 ------- examples/lambda_microvm/.env.example | 23 ------------------- .../lambda_microvm/Dockerfile.dockerignore | 8 ------- examples/sdk_default_agentcore/.env.example | 19 +++++++++++++++ .../Dockerfile | 18 +++++++-------- .../Dockerfile.dockerignore | 8 +++++++ .../app.py | 13 +++++------ .../sdk_default_lambda_microvm/.env.example | 19 +++++++++++++++ .../Dockerfile | 17 +++++++------- .../Dockerfile.dockerignore | 8 +++++++ .../app.py | 11 +++++---- 13 files changed, 90 insertions(+), 98 deletions(-) delete mode 100644 examples/agentcore_runtime/.env.example delete mode 100644 examples/agentcore_runtime/Dockerfile.dockerignore delete mode 100644 examples/lambda_microvm/.env.example delete mode 100644 examples/lambda_microvm/Dockerfile.dockerignore create mode 100644 examples/sdk_default_agentcore/.env.example rename examples/{agentcore_runtime => sdk_default_agentcore}/Dockerfile (59%) create mode 100644 examples/sdk_default_agentcore/Dockerfile.dockerignore rename examples/{agentcore_runtime => sdk_default_agentcore}/app.py (71%) create mode 100644 examples/sdk_default_lambda_microvm/.env.example rename examples/{lambda_microvm => sdk_default_lambda_microvm}/Dockerfile (60%) create mode 100644 examples/sdk_default_lambda_microvm/Dockerfile.dockerignore rename examples/{lambda_microvm => sdk_default_lambda_microvm}/app.py (71%) diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md index 6f9cdff..9314f83 100644 --- a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -14,8 +14,9 @@ already run. It covers: 8. [Point an AgentOS agent at it](#8-point-an-agentos-agent-at-the-image). 9. [Troubleshooting](#9-troubleshooting). -Working examples of everything below are in `examples/lambda_microvm/` and -`examples/agentcore_runtime/`. The settings reference is [`USING_THE_SDK.md`](USING_THE_SDK.md) §3. +Working examples of everything below are in `examples/sdk_default_lambda_microvm/` and +`examples/sdk_default_agentcore/`, the sources of the UAT default sandbox images (appendix). The +settings reference is [`USING_THE_SDK.md`](USING_THE_SDK.md) §3. ## How the pieces fit @@ -147,13 +148,13 @@ The full route list is in `USING_THE_SDK.md` §14. ### 4.1 Start from an example ```bash -cp examples/lambda_microvm/.env.example examples/lambda_microvm/.env # set the proxy URLs and the engine +cp examples/sdk_default_lambda_microvm/.env.example examples/sdk_default_lambda_microvm/.env # the engine docker buildx build --platform linux/arm64 \ - -f examples/lambda_microvm/Dockerfile -t my-agent-sandbox . # from the repository root + -f examples/sdk_default_lambda_microvm/Dockerfile -t my-agent-sandbox . # from the repository root ``` -`examples/agentcore_runtime/` is the same app with AgentCore wording; one image works on both -runtimes. +`examples/sdk_default_agentcore/` is the same app with AgentCore wording; one image works on both +runtimes. Neither bakes an egress route: CAS passes them per session (§2). ### 4.2 Or add it to your own Dockerfile diff --git a/examples/agentcore_runtime/.env.example b/examples/agentcore_runtime/.env.example deleted file mode 100644 index e0ddbeb..0000000 --- a/examples/agentcore_runtime/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -# Settings of the AgentCore example app (app.py), read by the CAS SDK. Copy to -# .env and adjust: -# cp .env.example .env -# The Dockerfile copies .env into the image next to app.py; variables set at -# run time (docker run -e / --env-file) override it. - -# The engine: claude-agent-sdk or gitagent. -COMPUTER_AGENT_ENGINE=claude-agent-sdk - -# The CAS egress proxy's routes; every SDK call goes through them. -COMPUTER_AGENT_EGRESS_API_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api/agentos/api -COMPUTER_AGENT_EGRESS_LLM_URL=https://borrower-reroute-cornball.ngrok-free.dev/llm -COMPUTER_AGENT_EGRESS_MCP_URL=https://borrower-reroute-cornball.ngrok-free.dev/mcp/v1 -COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=https://borrower-reroute-cornball.ngrok-free.dev/guardrails -# The http session store (old CAS sends an http sessionStore descriptor per session). -COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api -# OTLP telemetry (traces, metrics, logs) through the proxy; the Dockerfile installs the [otel] extra. -COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp - -# Not here: old CAS passes these per session in the POST /v1/sessions envs it -# relays (this file is baked into the image): -# COMPUTER_AGENT_ID the AgentOS agent the session runs -# COMPUTER_AGENT_EGRESS_PROXY_SECRET the sandbox token (a secret) diff --git a/examples/agentcore_runtime/Dockerfile.dockerignore b/examples/agentcore_runtime/Dockerfile.dockerignore deleted file mode 100644 index b06e72d..0000000 --- a/examples/agentcore_runtime/Dockerfile.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -* -!pyproject.toml -!README.md -!LICENSE -!src/ -!examples/agentcore_runtime/app.py -!examples/agentcore_runtime/.env -**/__pycache__ diff --git a/examples/lambda_microvm/.env.example b/examples/lambda_microvm/.env.example deleted file mode 100644 index 940fa1e..0000000 --- a/examples/lambda_microvm/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -# Settings of the Lambda MicroVM example app (app.py), read by the CAS SDK. -# Copy to .env and adjust: -# cp .env.example .env -# The Dockerfile copies .env into the image next to app.py; variables set at -# run time (docker run -e / --env-file) override it. - -# The engine: claude-agent-sdk or gitagent. -COMPUTER_AGENT_ENGINE=claude-agent-sdk - -# The CAS egress proxy's routes; every SDK call goes through them. -COMPUTER_AGENT_EGRESS_API_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api/agentos/api -COMPUTER_AGENT_EGRESS_LLM_URL=https://borrower-reroute-cornball.ngrok-free.dev/llm -COMPUTER_AGENT_EGRESS_MCP_URL=https://borrower-reroute-cornball.ngrok-free.dev/mcp/v1 -COMPUTER_AGENT_EGRESS_GUARDRAILS_URL=https://borrower-reroute-cornball.ngrok-free.dev/guardrails -# The http session store (old CAS sends an http sessionStore descriptor per session). -COMPUTER_AGENT_EGRESS_SESSION_STORE_URL=https://borrower-reroute-cornball.ngrok-free.dev/computer-agent-api -# OTLP telemetry (traces, metrics, logs) through the proxy; the Dockerfile installs the [otel] extra. -COMPUTER_AGENT_EGRESS_TELEMETRY_URL=https://borrower-reroute-cornball.ngrok-free.dev/otlp - -# Not here: old CAS passes these per session in POST /v1/sessions envs -# (this file is baked into the image): -# COMPUTER_AGENT_ID the AgentOS agent the session runs -# COMPUTER_AGENT_EGRESS_PROXY_SECRET the sandbox token (a secret) diff --git a/examples/lambda_microvm/Dockerfile.dockerignore b/examples/lambda_microvm/Dockerfile.dockerignore deleted file mode 100644 index 1cdc93b..0000000 --- a/examples/lambda_microvm/Dockerfile.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -* -!pyproject.toml -!README.md -!LICENSE -!src/ -!examples/lambda_microvm/app.py -!examples/lambda_microvm/.env -**/__pycache__ diff --git a/examples/sdk_default_agentcore/.env.example b/examples/sdk_default_agentcore/.env.example new file mode 100644 index 0000000..a5790b9 --- /dev/null +++ b/examples/sdk_default_agentcore/.env.example @@ -0,0 +1,19 @@ +# Settings baked into the UAT default AgentCore sandbox image (app.py), read by +# the CAS SDK. Copy to .env before building: +# cp .env.example .env +# The Dockerfile copies .env into the image next to app.py. + +# The engine: claude-agent-sdk or gitagent. +COMPUTER_AGENT_ENGINE=claude-agent-sdk + +# Not baked: CAS passes these per session in the POST /v1/sessions envs it +# relays, so one image runs against whichever egress proxy CAS names: +# COMPUTER_AGENT_ID the AgentOS agent the session runs +# COMPUTER_AGENT_EGRESS_PROXY_SECRET the sandbox token (a secret) +# COMPUTER_AGENT_EGRESS_API_URL /computer-agent-api/agentos/api +# COMPUTER_AGENT_EGRESS_LLM_URL /llm +# COMPUTER_AGENT_EGRESS_MCP_URL /mcp/v1 +# COMPUTER_AGENT_EGRESS_GUARDRAILS_URL /guardrails +# COMPUTER_AGENT_EGRESS_SESSION_STORE_URL /computer-agent-api +# COMPUTER_AGENT_EGRESS_TELEMETRY_URL /otlp +# Setting a route here pins it: the image's own value wins over CAS's. diff --git a/examples/agentcore_runtime/Dockerfile b/examples/sdk_default_agentcore/Dockerfile similarity index 59% rename from examples/agentcore_runtime/Dockerfile rename to examples/sdk_default_agentcore/Dockerfile index 97cdebb..18c184e 100644 --- a/examples/agentcore_runtime/Dockerfile +++ b/examples/sdk_default_agentcore/Dockerfile @@ -1,15 +1,15 @@ -# AgentCore Runtime example image: the CAS SDK's app server (app.py), installed -# from this checkout. Old CAS drives it through /invocations (relay envelope v1). +# UAT default AgentCore sandbox image: the CAS SDK's app server (app.py), +# installed from this checkout. Old CAS drives it through /invocations (relay +# envelope v1). # # Build from the repository root (AgentCore runs linux/arm64 images), after -# creating the app's settings file (the egress routes and the engine): -# cp examples/agentcore_runtime/.env.example examples/agentcore_runtime/.env +# creating the app's settings file (the engine): +# cp examples/sdk_default_agentcore/.env.example examples/sdk_default_agentcore/.env # docker buildx build --platform linux/arm64 \ -# -f examples/agentcore_runtime/Dockerfile -t cas-agentcore-example . +# -f examples/sdk_default_agentcore/Dockerfile -t sdk-default-agentcore . # -# The agent and the sandbox token are never baked in: old CAS passes -# COMPUTER_AGENT_ID and COMPUTER_AGENT_EGRESS_PROXY_SECRET per session in the -# POST /v1/sessions envs it relays. +# Nothing per session is baked in: old CAS passes the agent id, the sandbox +# token and the egress routes in the POST /v1/sessions envs it relays. FROM --platform=linux/arm64 python:3.12-slim ENV PYTHONUNBUFFERED=1 \ @@ -24,7 +24,7 @@ COPY src ./src RUN pip install ".[agentos,server,gitagent,otel]" "python-dotenv>=1,<2" # The app and its settings; app.py loads .env, and docker run -e overrides it. -COPY examples/agentcore_runtime/app.py examples/agentcore_runtime/.env /app/ +COPY examples/sdk_default_agentcore/app.py examples/sdk_default_agentcore/.env /app/ # The Claude CLI refuses to skip permission prompts as root; the engine runs # in the working directory. diff --git a/examples/sdk_default_agentcore/Dockerfile.dockerignore b/examples/sdk_default_agentcore/Dockerfile.dockerignore new file mode 100644 index 0000000..1af1181 --- /dev/null +++ b/examples/sdk_default_agentcore/Dockerfile.dockerignore @@ -0,0 +1,8 @@ +* +!pyproject.toml +!README.md +!LICENSE +!src/ +!examples/sdk_default_agentcore/app.py +!examples/sdk_default_agentcore/.env +**/__pycache__ diff --git a/examples/agentcore_runtime/app.py b/examples/sdk_default_agentcore/app.py similarity index 71% rename from examples/agentcore_runtime/app.py rename to examples/sdk_default_agentcore/app.py index 1b8cdec..7724717 100644 --- a/examples/agentcore_runtime/app.py +++ b/examples/sdk_default_agentcore/app.py @@ -1,16 +1,15 @@ -"""An Amazon Bedrock AgentCore Runtime app on the CAS SDK: the SDK's app server, -which old CAS drives. +"""The UAT default AgentCore sandbox: the CAS SDK's app server, which old CAS +drives when an agent names no AgentCore runtime of its own. On ``0.0.0.0:8080`` it serves AgentCore's contract GET /ping {"status": "Healthy" | "HealthyBusy", "time_of_last_update": } POST /invocations relay envelope v1, dispatched to the CAS harness API -(``docs/USING_THE_SDK.md`` §14). The egress routes and the engine come from -``.env`` next to this file (baked into the image). The agent and the sandbox -token come per session: old CAS passes ``COMPUTER_AGENT_ID`` and -``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` in the ``POST /v1/sessions`` ``envs`` it -relays. +(``docs/USING_THE_SDK.md`` §14). Only the engine is baked into ``.env``. Old +CAS passes the rest per session in the ``POST /v1/sessions`` ``envs`` it relays: +``COMPUTER_AGENT_ID``, ``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` and the six +``COMPUTER_AGENT_EGRESS_*_URL`` routes. Run locally: diff --git a/examples/sdk_default_lambda_microvm/.env.example b/examples/sdk_default_lambda_microvm/.env.example new file mode 100644 index 0000000..374f717 --- /dev/null +++ b/examples/sdk_default_lambda_microvm/.env.example @@ -0,0 +1,19 @@ +# Settings baked into the UAT default Lambda MicroVM sandbox image (app.py), +# read by the CAS SDK. Copy to .env before building: +# cp .env.example .env +# The Dockerfile copies .env into the image next to app.py. + +# The engine: claude-agent-sdk or gitagent. +COMPUTER_AGENT_ENGINE=claude-agent-sdk + +# Not baked: CAS passes these per session in POST /v1/sessions envs, so one +# image runs against whichever egress proxy CAS names: +# COMPUTER_AGENT_ID the AgentOS agent the session runs +# COMPUTER_AGENT_EGRESS_PROXY_SECRET the sandbox token (a secret) +# COMPUTER_AGENT_EGRESS_API_URL /computer-agent-api/agentos/api +# COMPUTER_AGENT_EGRESS_LLM_URL /llm +# COMPUTER_AGENT_EGRESS_MCP_URL /mcp/v1 +# COMPUTER_AGENT_EGRESS_GUARDRAILS_URL /guardrails +# COMPUTER_AGENT_EGRESS_SESSION_STORE_URL /computer-agent-api +# COMPUTER_AGENT_EGRESS_TELEMETRY_URL /otlp +# Setting a route here pins it: the image's own value wins over CAS's. diff --git a/examples/lambda_microvm/Dockerfile b/examples/sdk_default_lambda_microvm/Dockerfile similarity index 60% rename from examples/lambda_microvm/Dockerfile rename to examples/sdk_default_lambda_microvm/Dockerfile index 688e269..9e2bd6c 100644 --- a/examples/lambda_microvm/Dockerfile +++ b/examples/sdk_default_lambda_microvm/Dockerfile @@ -1,15 +1,14 @@ -# Lambda MicroVM example image: the CAS SDK's app server (app.py), installed -# from this checkout. Old CAS drives it through the harness API. +# UAT default Lambda MicroVM sandbox image: the CAS SDK's app server (app.py), +# installed from this checkout. Old CAS drives it through the harness API. # # Build from the repository root (MicroVMs are arm64), after creating the -# app's settings file (the egress routes and the engine): -# cp examples/lambda_microvm/.env.example examples/lambda_microvm/.env +# app's settings file (the engine): +# cp examples/sdk_default_lambda_microvm/.env.example examples/sdk_default_lambda_microvm/.env # docker buildx build --platform linux/arm64 \ -# -f examples/lambda_microvm/Dockerfile -t cas-lambda-microvm-example . +# -f examples/sdk_default_lambda_microvm/Dockerfile -t sdk-default-lambda-microvm . # -# The agent and the sandbox token are never baked in: old CAS passes -# COMPUTER_AGENT_ID and COMPUTER_AGENT_EGRESS_PROXY_SECRET per session in -# POST /v1/sessions envs. +# Nothing per session is baked in: old CAS passes the agent id, the sandbox +# token and the egress routes in POST /v1/sessions envs. FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ @@ -24,7 +23,7 @@ COPY src ./src RUN pip install ".[agentos,server,gitagent,otel]" "python-dotenv>=1,<2" # The app and its settings; app.py loads .env, and docker run -e overrides it. -COPY examples/lambda_microvm/app.py examples/lambda_microvm/.env /app/ +COPY examples/sdk_default_lambda_microvm/app.py examples/sdk_default_lambda_microvm/.env /app/ # The Claude CLI refuses to skip permission prompts as root; the engine runs # in the working directory. diff --git a/examples/sdk_default_lambda_microvm/Dockerfile.dockerignore b/examples/sdk_default_lambda_microvm/Dockerfile.dockerignore new file mode 100644 index 0000000..8d1e9da --- /dev/null +++ b/examples/sdk_default_lambda_microvm/Dockerfile.dockerignore @@ -0,0 +1,8 @@ +* +!pyproject.toml +!README.md +!LICENSE +!src/ +!examples/sdk_default_lambda_microvm/app.py +!examples/sdk_default_lambda_microvm/.env +**/__pycache__ diff --git a/examples/lambda_microvm/app.py b/examples/sdk_default_lambda_microvm/app.py similarity index 71% rename from examples/lambda_microvm/app.py rename to examples/sdk_default_lambda_microvm/app.py index 8d9e980..2007460 100644 --- a/examples/lambda_microvm/app.py +++ b/examples/sdk_default_lambda_microvm/app.py @@ -1,4 +1,5 @@ -"""A Lambda MicroVM app on the CAS SDK: the SDK's app server, which old CAS drives. +"""The UAT default Lambda MicroVM sandbox: the CAS SDK's app server, which old CAS +drives when an agent names no MicroVM image of its own. On ``0.0.0.0:8080`` it serves @@ -6,10 +7,10 @@ GET /v1/health POST /v1/sessions, POST /v1/sessions/{id}/messages, GET /v1/sessions/{id}/events, ... -(the CAS harness API; ``docs/USING_THE_SDK.md`` §14). The egress routes and the -engine come from ``.env`` next to this file (baked into the image). The agent -and the sandbox token come per session: old CAS passes ``COMPUTER_AGENT_ID`` -and ``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` in ``POST /v1/sessions`` ``envs``. +(the CAS harness API; ``docs/USING_THE_SDK.md`` §14). Only the engine is baked +into ``.env``. Old CAS passes the rest per session in ``POST /v1/sessions`` +``envs``: ``COMPUTER_AGENT_ID``, ``COMPUTER_AGENT_EGRESS_PROXY_SECRET`` and the +six ``COMPUTER_AGENT_EGRESS_*_URL`` routes. Run locally: From d700074854bb1e3495f600a70c6466836a7b9eed Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Wed, 28 Oct 2026 13:10:18 +0530 Subject: [PATCH 12/14] docs: the guide's appendix lists the UAT default sandboxes --- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md index 9314f83..d8822f1 100644 --- a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -438,13 +438,16 @@ CAS itself must be allowed to use the resource: `lambda:RunMicrovm` on the Micro | Session start: `502 AGENT_BUILD_FAILED … SettingsError: missing required settings: …` | The image lacks an egress route or the engine | Set them in the image's `.env` (§2) | | A pushed fix does not show up on MicroVM | MicroVM images are snapshots | `update-microvm-image` (§6.4) and move the agent's `version` pin | -## Appendix: the example resources in Lyzr UAT +## Appendix: the UAT default sandboxes -Built from `examples/` on branch `feat/p1-sdk-12-agentcore-relay`, account `425642439121`, -`us-east-1`. They point at an egress proxy on a development tunnel (`ngrok-free.dev`), so treat them -as a worked example, not as production resources. +UAT CAS boots these when an agent names no image of its own (`LAMBDA_MICROVM_IMAGE_ARN` and +`AGENTCORE_RUNTIME_ARN` on `computeragent-server`). They are built from +`examples/sdk_default_lambda_microvm/` and `examples/sdk_default_agentcore/`, bake no egress route +(CAS passes them per session), and are managed by Terraform in `lyzr-studio-infra` +(`infrastructure/serverless/lambda-microvm/sdk-default.tf`, `infrastructure/ecr`). Account +`425642439121`, `us-east-1`. | Runtime | Resource | Image | |---|---|---| -| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:python-sdk-example-lambda-microvm`, version `5.0` | `computer-agent/python-sdk-example-lambda-microvm@sha256:00dd5c580b1baa7e2c136a168b38e144ba9078634f38d9a899074cf30ec4b86a` | -| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/python_sdk_example_agentcore_runtime-aICRZZ5e7i`, version `5` (endpoint `DEFAULT`) | `computer-agent/python-sdk-example-agentcore-runtime@sha256:12186d9e0654bb1a864fb042d4826ac9f513032d7e80d52c8d10adcf77795e97` | +| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:lyzr-studio-uat-computeragent-sdk-default`, version `1.0` | `computer-agent/sdk-default-lambda-microvm@sha256:a8a1f7288d87609f46040d18202e6a886a71142b88094f996f1d4301dc023cf4` | +| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/lyzr_studio_uat_computeragent_sdk_default-wzY4Av5AO0`, version `1` (endpoint `DEFAULT`) | `computer-agent/sdk-default-agentcore@sha256:36366184d84e2f3bd36de74549e7e60c110e958e72352feb9dce23febb994aff` | From 85739e7b75b96e86b570ac4098a04dd375fea46f Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Wed, 28 Oct 2026 13:21:33 +0530 Subject: [PATCH 13/14] feat(server): close abandoned relay streams, cap AgentCore answers, log the instance Matches CAS's AgentCore relay (computeragent-enterprise #158). - PY1: AgentCore never forwards a caller's disconnect, so CAS names every envelope with x-ca-relay-stream: and sends DELETE /_relay/streams/ when its caller goes away or before it re-attaches. The relay strips the header before dispatch, keeps a map id -> open call, and answers the DELETE itself: it closes that call (the relayed /events stream ends and its subscriber detaches) -> relayed 204; an unknown or non-UUID id -> relayed 404. Logs relay.stream.opened / relay.stream.closed. - PY2: AgentCore cuts any answer at 104,857,600 bytes. A JSON envelope over that is replaced by a relayed 502 AGENTCORE_RESPONSE_TOO_LARGE (source CAS_RUNTIME, retry false, meta.maxBytes) instead of a cut body. Event streams are not capped. - PY3: the per-microVM instance id is logged once, as relay.instance, when it is minted on the first invocation. --- CHANGELOG.md | 5 + docs/USING_THE_SDK.md | 10 ++ src/computeragent/server/relay.py | 104 ++++++++++++++++-- tests/test_server_relay.py | 170 +++++++++++++++++++++++++++++- 4 files changed, 277 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897f0bf..03184a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Every call the SDK makes now matches a route the CAS egress proxy serves (plan (`Healthy` / `HealthyBusy`) and `POST /invocations` (relay envelope v1, dispatched in-process; streamed answers keep the `: ca-relay` head frame). One image covers Lambda MicroVM and AgentCore (`computeragent.server.relay`). + CAS can close an abandoned relayed stream: the `x-ca-relay-stream` envelope + header (a UUID v4) is stripped before dispatch and `DELETE /_relay/streams/` + closes that answer (relayed 204, unknown id 404). A JSON envelope over + AgentCore's 100 MiB cap becomes a relayed 502 `AGENTCORE_RESPONSE_TOO_LARGE`. + The relay logs `relay.instance id=…` once, and stream open/close with the id only. - **The app server for stage images** (`computeragent.server`, extra `server`). `python -m computeragent.server` serves CAS's harness REST API on `0.0.0.0:8080`: - the Lambda MicroVM lifecycle hooks, health, sessions, messages, events, diff --git a/docs/USING_THE_SDK.md b/docs/USING_THE_SDK.md index 8ece568..79f3f06 100644 --- a/docs/USING_THE_SDK.md +++ b/docs/USING_THE_SDK.md @@ -771,3 +771,13 @@ it `linux/arm64` for AgentCore): - any other answer is `{relay: 1, instance, status, headers, body | bodyBase64}`; - a malformed envelope is that JSON with `status: 400` and code `AGENTCORE_RELAY_REQUEST_INVALID`. - `instance` is minted on the first invocation, so CAS can tell when AgentCore replaced the microVM. + The relay logs it once at INFO as `relay.instance id=`. +- Streams can be closed: AgentCore never forwards a caller's disconnect, so CAS names each answer in + the envelope header `x-ca-relay-stream` (a UUID v4, any case; stripped before the routes see the + request). `DELETE /_relay/streams/` is answered by the relay itself: it closes that open + answer as a dropped connection would (the relayed stream ends, `/events` detaches) → relayed + `204`; an unknown id or one that is not a UUID v4 → relayed `404`. The relay logs + `relay.stream.opened` / `relay.stream.closed` with the id only. +- A JSON answer whose envelope exceeds AgentCore's 100 MiB payload cap (roughly 75 MiB of binary + body, after base64) is replaced by a relayed `502` with code `AGENTCORE_RESPONSE_TOO_LARGE` + (`meta.maxBytes`); event streams are not capped. diff --git a/src/computeragent/server/relay.py b/src/computeragent/server/relay.py index bfdca0b..329bd51 100644 --- a/src/computeragent/server/relay.py +++ b/src/computeragent/server/relay.py @@ -16,11 +16,20 @@ as they are produced; - anything else is ``{relay: 1, instance, status, headers, body | bodyBase64}``; - a malformed envelope is that JSON with ``status: 400`` and code - ``AGENTCORE_RELAY_REQUEST_INVALID``. + ``AGENTCORE_RELAY_REQUEST_INVALID``; + - a JSON answer larger than ``RESPONSE_MAX_BYTES`` (AgentCore's payload cap) + is replaced by that JSON with ``status: 502`` and code + ``AGENTCORE_RESPONSE_TOO_LARGE``; event streams are not capped. ``instance`` is a random id minted on the first invocation, not at process start (AgentCore may restore every microVM from one snapshot), so CAS reads a changed ``instance`` as "AgentCore replaced the microVM". + +Stream close (additive to v1). AgentCore never forwards a caller's disconnect, +so an envelope names its answer in ``headers["x-ca-relay-stream"]`` (a UUID v4, +stripped before dispatch). ``DELETE /_relay/streams/`` is answered by the +relay itself: it closes that open answer as a dropped connection would +(relayed 204); an unknown id is a relayed 404. """ from __future__ import annotations @@ -30,6 +39,7 @@ import binascii import contextlib import json +import logging import re import uuid from typing import TYPE_CHECKING, Any @@ -42,8 +52,15 @@ from starlette.requests import Request from starlette.types import ASGIApp, Message +logger = logging.getLogger("computeragent.server") + RELAY_VERSION = 1 SSE_HEAD_PREFIX = ": ca-relay " +#: The envelope header naming a relayed answer, so CAS can close it later. +RELAY_STREAM_HEADER = "x-ca-relay-stream" +RELAY_STREAM_CLOSE_PREFIX = "/_relay/streams/" +#: AgentCore's response payload cap (100 MiB). +RESPONSE_MAX_BYTES = 104_857_600 SKIP_RESPONSE_HEADERS = { "content-length", "content-encoding", @@ -52,6 +69,9 @@ "keep-alive", } _METHOD = re.compile(r"^[A-Z]+$") +_UUID_V4 = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.IGNORECASE +) #: The relay's own routes are never relayed. _NOT_RELAYED = ("/invocations", "/ping") @@ -165,27 +185,58 @@ async def close(self) -> None: self.task.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await self.task + if not self.started.done(): # closed before the app answered + self.started.set_exception(ConnectionAbortedError("closed by the relay")) def relay_routes( - get_app: Callable[[], ASGIApp], busy: Any + get_app: Callable[[], ASGIApp], busy: Any, *, max_response_bytes: int = RESPONSE_MAX_BYTES ) -> tuple[Callable[..., Any], Callable[..., Any]]: """The ``/ping`` and ``/invocations`` endpoints, relaying into ``get_app()``.""" instance: dict[str, str] = {} + #: Open calls by relay stream id (lower-case UUID v4). + streams: dict[str, _Call] = {} def nonce() -> str: - return instance.setdefault("id", str(uuid.uuid4())) - - def envelope(status: int, headers: dict[str, str], body: bytes) -> JSONResponse: - return JSONResponse( + if "id" not in instance: + instance["id"] = str(uuid.uuid4()) + logger.info("relay.instance id=%s", instance["id"]) + return instance["id"] + + def forget(sid: str | None, call: _Call, reason: str) -> None: + if sid is not None and streams.get(sid) is call: + del streams[sid] + logger.info("relay.stream.closed stream=%s reason=%s", sid, reason) + + def render(status: int, headers: dict[str, str], body: bytes) -> bytes: + # Serialized once, as JSONResponse would, so its size can be checked. + return json.dumps( { "relay": RELAY_VERSION, "instance": nonce(), "status": status, "headers": headers, **encode_body(body), + }, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + + def envelope(status: int, headers: dict[str, str], body: bytes) -> Response: + content = render(status, headers, body) + if len(content) > max_response_bytes: + # AgentCore would cut it mid-body; refuse it whole instead. + error = { + "detail": "The response is larger than AgentCore can carry.", + "code": "AGENTCORE_RESPONSE_TOO_LARGE", + "retry": False, + "severity": "error", + "source": "CAS_RUNTIME", + "meta": {"maxBytes": max_response_bytes}, } - ) + content = render(502, {"content-type": "application/json"}, json.dumps(error).encode()) + return Response(content, media_type="application/json") async def ping(_: Request) -> Response: return JSONResponse( @@ -211,12 +262,40 @@ async def invocations(request: Request) -> Response: "meta": {}, } return envelope(400, {"content-type": "application/json"}, json.dumps(error).encode()) - method, path, headers, body = parsed + method, path, envelope_headers, body = parsed + # The stream id is the relay's, never the app's. + headers: dict[str, str] = {} + sid: str | None = None + for name, text in envelope_headers.items(): + if name.lower() != RELAY_STREAM_HEADER: + headers[name] = text + elif _UUID_V4.match(text): + sid = text.lower() + route = path.partition("?")[0] + if method == "DELETE" and route.startswith(RELAY_STREAM_CLOSE_PREFIX): + target = route[len(RELAY_STREAM_CLOSE_PREFIX) :].lower() + closing = streams.get(target) if _UUID_V4.match(target) else None + if closing is None: + return envelope(404, {}, b"") + forget(target, closing, "deleted") + await closing.close() + return envelope(204, {}, b"") call = _Call(get_app(), method, path, headers, body) + if sid is not None and sid not in streams: + streams[sid] = call + logger.info("relay.stream.opened stream=%s", sid) + call.task.add_done_callback(lambda _: forget(sid, call, "ended")) + else: + sid = None + + async def finish() -> None: + await call.close() + forget(sid, call, "ended") + try: status, raw_headers = await call.started except Exception: - await call.close() + await finish() return envelope(500, {}, b"") out_headers: dict[str, str] = {} for key, value in raw_headers: @@ -228,25 +307,28 @@ async def invocations(request: Request) -> Response: if 200 <= status < 300 and "text/event-stream" in out_headers.get("content-type", ""): async def relayed() -> AsyncIterator[bytes]: + # A closed call (DELETE) queues its end marker, so this returns cleanly. try: yield encode_sse_head(nonce(), status).encode() async for chunk in call.stream(): yield chunk finally: - await call.close() + await finish() return StreamingResponse( relayed(), media_type="text/event-stream", headers={"cache-control": "no-cache"} ) data = b"".join([chunk async for chunk in call.stream()]) - await call.close() + await finish() return envelope(status, out_headers, data) return ping, invocations __all__ = [ + "RELAY_STREAM_HEADER", "RELAY_VERSION", + "RESPONSE_MAX_BYTES", "SSE_HEAD_PREFIX", "encode_body", "encode_sse_head", diff --git a/tests/test_server_relay.py b/tests/test_server_relay.py index e8f8371..3d720ad 100644 --- a/tests/test_server_relay.py +++ b/tests/test_server_relay.py @@ -8,11 +8,22 @@ import asyncio import base64 import json +import logging +import uuid +from types import SimpleNamespace import pytest import respx +from starlette.applications import Starlette +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route -from computeragent.server.relay import encode_body, parse_relay_request +from computeragent.server.relay import ( + RELAY_STREAM_HEADER, + encode_body, + parse_relay_request, + relay_routes, +) from server_helpers import FakeEngine, client_for, frames, make_app, mock_backends, session_body @@ -173,3 +184,160 @@ def test_parse_relay_request_and_bodies(): "bodyBase64": base64.b64encode(bytes([0xFF, 0x00])).decode() } assert encode_body(b"") == {} + + +# ── stream close, size cap, logging (a minimal app behind relay_routes) ───── + + +def relay_app(**options): + """A tiny app behind the relay: /headers echoes request headers, /events + streams until closed, /blob?n=N answers N bytes.""" + state = {"seen": [], "opened": asyncio.Event(), "open": False} + + async def headers(request): + state["seen"].append(dict(request.headers)) + return JSONResponse({"ok": True}) + + async def events(_): + async def gen(): + state["open"] = True + state["opened"].set() + try: + yield b"id: 1\nevent: hello\ndata: {}\n\n" + await asyncio.Event().wait() + finally: + state["open"] = False + + return StreamingResponse(gen(), media_type="text/event-stream") + + async def blob(request): + return Response(b"a" * int(request.query_params["n"]), media_type="text/plain") + + app = None + ping, invocations = relay_routes( + lambda: app, SimpleNamespace(running=0, last_update=0), **options + ) + app = Starlette( + routes=[ + Route("/headers", headers, methods=["GET", "POST"]), + Route("/events", events), + Route("/blob", blob), + Route("/ping", ping), + Route("/invocations", invocations, methods=["POST"]), + ] + ) + return app, state + + +def close_env(sid): + return envelope("DELETE", f"/_relay/streams/{sid}") + + +async def test_delete_closes_an_open_stream(caplog): + caplog.set_level(logging.INFO, logger="computeragent.server") + app, state = relay_app() + sid = str(uuid.uuid4()) + async with client_for(app) as client: + sse = asyncio.create_task( + invoke(client, envelope("GET", "/events", headers={"X-CA-Relay-Stream": sid})) + ) + await asyncio.wait_for(state["opened"].wait(), 5) + closed = (await invoke(client, close_env(sid))).json() + streamed = await asyncio.wait_for(sse, 5) + again = (await invoke(client, close_env(sid))).json() + + assert closed["status"] == 204 and closed["instance"] and "body" not in closed + assert state["open"] is False + head, rest = streamed.text.split("\n\n", 1) + assert json.loads(head[len(": ca-relay ") :])["status"] == 200 + assert frames(rest) == [(1, "hello", {})] + assert again["status"] == 404 and "body" not in again + logged = [r.getMessage() for r in caplog.records] + assert logged.count(f"relay.stream.opened stream={sid}") == 1 + assert logged.count(f"relay.stream.closed stream={sid} reason=deleted") == 1 + assert not [m for m in logged if "reason=ended" in m] + + +@pytest.mark.parametrize("target", [str(uuid.uuid4()), "not-a-uuid", "../x"]) +async def test_close_of_an_unknown_stream_is_a_404(target): + app, state = relay_app() + async with client_for(app) as client: + got = (await invoke(client, close_env(target))).json() + + assert got["status"] == 404 and "body" not in got + + +async def test_other_methods_on_the_close_path_reach_the_app(): + app, _ = relay_app() + async with client_for(app) as client: + got = (await invoke(client, envelope("GET", f"/_relay/streams/{uuid.uuid4()}"))).json() + + assert got["status"] == 404 # the app's own answer: it has no such route + + +@pytest.mark.parametrize("name", ["x-ca-relay-stream", "X-CA-Relay-Stream", "X-CA-RELAY-STREAM"]) +@pytest.mark.parametrize("value", [str(uuid.uuid4()), "not-a-uuid"]) +async def test_the_app_never_sees_the_stream_header(name, value): + app, state = relay_app() + async with client_for(app) as client: + got = (await invoke(client, envelope("GET", "/headers", headers={name: value}))).json() + + assert got["status"] == 200 + (seen,) = state["seen"] + assert "x-ca-relay-stream" not in seen + assert value not in seen.values() + assert seen["content-type"] == "application/json" + + +async def test_an_envelope_over_the_cap_is_a_502(): + blob = envelope("GET", "/blob?n=1000") + app, _ = relay_app() + async with client_for(app) as client: + size = len((await invoke(client, blob)).content) + app, _ = relay_app(max_response_bytes=size) + async with client_for(app) as client: + at_cap = await invoke(client, blob) + app, _ = relay_app(max_response_bytes=size - 1) + async with client_for(app) as client: + over = (await invoke(client, blob)).json() + + assert len(at_cap.content) == size + assert at_cap.json()["status"] == 200 and at_cap.json()["body"] == "a" * 1000 + assert over["relay"] == 1 and over["instance"] + assert over["status"] == 502 + assert over["headers"] == {"content-type": "application/json"} + assert json.loads(over["body"]) == { + "detail": "The response is larger than AgentCore can carry.", + "code": "AGENTCORE_RESPONSE_TOO_LARGE", + "retry": False, + "severity": "error", + "source": "CAS_RUNTIME", + "meta": {"maxBytes": size - 1}, + } + + +async def test_relay_logs_ids_only(caplog): + caplog.set_level(logging.INFO, logger="computeragent.server") + app, _ = relay_app() + sids = [str(uuid.uuid4()), str(uuid.uuid4())] + secret = {"authorization": "Bearer sbt_secret", "x-other": "hdr-secret"} + async with client_for(app) as client: + instance = (await invoke(client, envelope("GET", "/headers"))).json()["instance"] + for sid in sids: + env = envelope( + "POST", + "/headers", + {"q": "body-secret"}, + headers={**secret, RELAY_STREAM_HEADER: sid}, + ) + await invoke(client, env) + + logged = [r.getMessage() for r in caplog.records] + assert logged.count(f"relay.instance id={instance}") == 1 + assert len([m for m in logged if m.startswith("relay.instance")]) == 1 + for sid in sids: + assert logged.count(f"relay.stream.opened stream={sid}") == 1 + assert logged.count(f"relay.stream.closed stream={sid} reason=ended") == 1 + text = " ".join(logged) + for leak in ("sbt_secret", "hdr-secret", "body-secret", '"ok"'): + assert leak not in text From e5f414eb183da688d10853b86d33d4cc238d9ebe Mon Sep 17 00:00:00 2001 From: Vraj Parikh Date: Wed, 28 Oct 2026 13:29:00 +0530 Subject: [PATCH 14/14] docs: the UAT default sandboxes are at version 2 --- docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md index d8822f1..b3d826d 100644 --- a/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md +++ b/docs/DEPLOYING_ON_AGENTCORE_AND_MICROVM.md @@ -449,5 +449,5 @@ UAT CAS boots these when an agent names no image of its own (`LAMBDA_MICROVM_IMA | Runtime | Resource | Image | |---|---|---| -| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:lyzr-studio-uat-computeragent-sdk-default`, version `1.0` | `computer-agent/sdk-default-lambda-microvm@sha256:a8a1f7288d87609f46040d18202e6a886a71142b88094f996f1d4301dc023cf4` | -| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/lyzr_studio_uat_computeragent_sdk_default-wzY4Av5AO0`, version `1` (endpoint `DEFAULT`) | `computer-agent/sdk-default-agentcore@sha256:36366184d84e2f3bd36de74549e7e60c110e958e72352feb9dce23febb994aff` | +| Lambda MicroVM | `arn:aws:lambda:us-east-1:425642439121:microvm-image:lyzr-studio-uat-computeragent-sdk-default`, version `2.0` | `computer-agent/sdk-default-lambda-microvm@sha256:1984285ebff6ea2c7cf816c5ebe39ded7f829963b121c4774dd2899d9868128c` | +| AgentCore | `arn:aws:bedrock-agentcore:us-east-1:425642439121:runtime/lyzr_studio_uat_computeragent_sdk_default-wzY4Av5AO0`, version `2` (endpoint `DEFAULT`) | `computer-agent/sdk-default-agentcore@sha256:e24c8cdd7dd4277642a9c1b82e2622d6e5fb05f3c060cfa504f3afb14d655838` |