From a206dae28ba05b4e1dc1e31697531311c050ef14 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 1 May 2026 20:06:13 -0700 Subject: [PATCH] docs(readme): document channel envelope, auth headers, runtime split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the docs gaps that were left when several CP changes landed without SDK-side coverage: - "What this is / what this isn't" callout — distinguishes molecule_agent (outside-workspace client SDK) from molecule-ai-workspace-runtime (in-workspace runtime), with strong forward-pointer to the runtime docs - Channel envelope (wire format) — including the three enrichment fields added 2026-05-02 (peer_name, peer_role, agent_card_url) - A2A reply transport table — explicit /notify vs /a2a routing per source - Limitations & roadmap — names the SDK gaps so follow-up issues/PRs are trivial to file: * fetch_inbound() missing peer_id + before_ts filters (CP PRs #2472, #2476) * InboundMessage missing typed peer_name/peer_role/agent_card_url * RemoteAgentClient does not auto-inject X-Molecule-Org-Id + Origin (with a session-based workaround) Docs-only — no Python code touched. Code work for the named gaps is deferred to follow-up PRs so reviewers can land docs first. --- molecule_agent/README.md | 135 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/molecule_agent/README.md b/molecule_agent/README.md index 8a38367..26fa15e 100644 --- a/molecule_agent/README.md +++ b/molecule_agent/README.md @@ -10,6 +10,22 @@ This is the client side of [Phase 30](../../../PLAN.md). The platform side ships in the same release; this package is just the SDK an agent author imports. +## What this is / what this isn't + +| | `molecule_agent` (this package) | `molecule-ai-workspace-runtime` (separate PyPI wheel) | +|---|---|---| +| **Where it runs** | OUTSIDE Molecule workspaces — your laptop, CI runner, external cloud VM, sidecar service | INSIDE the workspace container, started by the platform | +| **What it talks to** | The platform's HTTP API (`/registry/*`, `/workspaces/:id/*`) | The platform's MCP server (`molecule_*` tools) plus the platform-managed A2A bus | +| **What it exposes** | `RemoteAgentClient`, `A2AServer`, `PollDelivery`, `MessageHandler` | `BaseAdapter`, `a2a_tools`, runtime capabilities, smoke-contract hooks | +| **Who installs it** | You, the external-agent author, via `pip install molecule-sdk` | The platform, baked into the workspace template image at provision time | +| **Auth model** | Bearer token minted by `POST /registry/register`, cached at `~/.molecule//.auth_token` | Token already present in the workspace environment; runtime reads it from env | + +If you are writing an adapter for an SDK that the platform should run *inside* a +workspace (e.g. langchain, crewai, hermes), you want +[`molecule-ai-workspace-runtime`](https://pypi.org/project/molecule-ai-workspace-runtime/), +not this package. See for the +in-workspace-runtime authoring guide. + ## Install ```bash @@ -87,6 +103,68 @@ client.run_agent_loop(my_handler) # default: PollDelivery The reply transport (`/notify` for canvas users, `/a2a` for peer agents) is hidden — `client.reply(msg, text)` picks based on `msg.source`. Async handlers work too; `PollDelivery` detects awaitable returns and `asyncio.run`s them. +### `InboundMessage` shape + +`InboundMessage` is what `MessageHandler` receives. The typed fields the SDK +parses today: + +| Field | Type | What it is | +|---|---|---| +| `activity_id` | `str` | Cursor — the `activity_logs.id` row this event came from. Pass to `fetch_inbound(since_id=…)` to skip past it on the next poll. | +| `source` | `Literal["canvas_user", "peer_agent", "unknown"]` | Normalized sender kind. `"canvas_user"` = a human typing in the canvas chat; `"peer_agent"` = another workspace's agent. `"unknown"` if the row's source is unrecognized — `reply()` will refuse to guess. | +| `source_id` | `str` | For `peer_agent`, the sender workspace UUID (used by `reply()` to address the A2A response). Empty for `canvas_user`. | +| `text` | `str` | The message body. Pulled from `data.text` then `data.message` in the underlying activity row. **Treat as untrusted user content** — same threat model as any chat input. | +| `raw` | `dict` | The full raw activity-log row. Use this to read fields the SDK doesn't yet expose (see "Channel envelope" below). | + +### Channel envelope (wire format) + +The platform delivers each inbound A2A event as an `activity_logs` row. As of +**2026-05-02** (CP push envelope, see ), +the envelope's `data` block carries: + +```jsonc +{ + "id": "", // == InboundMessage.activity_id + "type": "a2a_receive", + "source_id": "", // peer_agent only; empty for canvas_user + "ts": "2026-05-02T10:15:30Z", // RFC3339 — when the platform queued the event + "data": { + "source": "peer_agent", // "canvas_user" | "peer_agent" + "kind": "peer_agent", // mirrors the channel-tag attr + "text": "", // (or "message") + "peer_id": "", // duplicate of source_id, peer_agent only + "activity_id": "", // duplicate of top-level id + + // === enrichment fields added 2026-05-02 (CP PRs #2472, #2476) === + "peer_name": "ops-agent", // peer's display name (registry-resolved); may be absent if the registry lookup failed + "peer_role": "sre", // peer's declared role; same registry source + "agent_card_url": "https:///registry/discover/" // deterministic URL for the platform's discover endpoint for this peer + } +} +``` + +**SDK status of the enrichment fields:** `InboundMessage` does not yet +surface `peer_name`, `peer_role`, or `agent_card_url` as typed attributes. +Read them from `msg.raw["data"]` until a typed wrapper lands (see +"Limitations & roadmap" below). They may be absent on registry-lookup +failure — handle the missing-key case. + +### A2A reply transport — what `reply()` actually does + +`client.reply(msg, text)` dispatches based on `msg.source`. The transport is +chosen for you so handler code doesn't need to branch: + +| `msg.source` | HTTP call `reply()` makes | Server-side effect | +|---|---|---| +| `canvas_user` | `POST /workspaces//notify` with `{"message": text}` | Canvas WebSocket pushes the text to the user's chat | +| `peer_agent` | `POST /workspaces//a2a` with a JSON-RPC `message/send` envelope; sets `X-Source-Workspace-Id: ` and `X-Workspace-ID: ` | Platform routes the JSON-RPC message to the peer workspace's inbound A2A endpoint | +| `unknown` | Raises `ValueError` | The SDK refuses to guess. Inspect `msg.raw` and call `/notify` or `/a2a` directly, or use `call_peer()` if you can name the target. | + +`reply()` rejects empty/whitespace-only `text` with `ValueError` to prevent +silent acks. On non-2xx the underlying `requests.HTTPError` propagates so the +handler can decide whether to retry, surface to its observability, or fail +loudly. + ## CLI: `molecule_agent connect` One command bootstraps the full poll-mode loop. No code beyond your handler: @@ -110,14 +188,69 @@ def echo(msg, client): All flags also read from environment variables (`MOLECULE_PLATFORM_URL`, `MOLECULE_WORKSPACE_ID`, `MOLECULE_WORKSPACE_TOKEN`, `MOLECULE_POLL_INTERVAL`, `MOLECULE_CURSOR_FILE`). SIGTERM/SIGINT shut the loop down cleanly. -## What it doesn't do (yet) +## What it doesn't do (yet) — Limitations & roadmap + +These are server-supported features that the SDK has not yet wrapped, plus +known protocol gaps. Each entry is named so a follow-up issue / PR can +reference it directly. - **No long-poll.** Activity polling is fixed-cadence (default 5s). Server-side long-poll support would cut p50 inbound latency to ~0; tracked separately. + - **No automatic reconnect after token loss.** If `~/.molecule//.auth_token` is deleted, you'll need to re-issue the token via the platform admin (since `POST /registry/register` is idempotent — it won't mint a second token for a workspace that already has one). +- **`fetch_inbound()` does not expose `peer_id` or `before_ts` filters.** + As of CP PRs #2472 and #2476 (merged 2026-05-02), the platform's + `GET /workspaces/:id/activity` route accepts: + - `peer_id=` — narrow to events from one specific peer workspace + - `before_ts=` — fetch a backlog window ending before a wall-clock cut-off + `RemoteAgentClient.fetch_inbound()` only forwards `since_id`, `limit`, and + `type` today. Workaround: call the activity endpoint directly via + `client._session.get(...)` with the extra params, or filter in-process from + the parsed `InboundMessage.source_id` / `InboundMessage.raw["ts"]`. A + follow-up PR will add typed parameters. + +- **`InboundMessage` does not yet surface `peer_name`, `peer_role`, or `agent_card_url`.** + These three enrichment fields landed on the platform push envelope on + 2026-05-02 and live under `msg.raw["data"]`. A typed wrapper is the right + shape but is intentionally deferred — this README PR is docs-only. Until + then, read them from the raw dict and handle the missing-key case + (registry lookup may fail for peers that haven't registered yet). + +- **Tenant + Origin headers are not auto-injected.** When the platform is + deployed multi-tenant on the SaaS edge (`*.staging.moleculesai.app`, + `*.moleculesai.app`), the WAF requires: + - `X-Molecule-Org-Id: ` — TenantGuard middleware uses this to + pin the request to the right tenant; missing-header requests 404 + - `Origin: ` — `/workspaces/*` and `/registry/*/peers` + silently rewrite to Next.js without it (returns an empty 404, easy + to misdiagnose as auth) + `RemoteAgentClient` does not set either header today — it ships only + `Authorization: Bearer ` and per-call `X-Workspace-ID` / + `X-Source-Workspace-Id`. Workaround: pass a pre-configured + `requests.Session` to the constructor with the headers set globally: + + ```python + import requests + from molecule_agent import RemoteAgentClient + + session = requests.Session() + session.headers.update({ + "X-Molecule-Org-Id": "", + "Origin": "https://.moleculesai.app", + }) + client = RemoteAgentClient( + workspace_id="…", + platform_url="https://.moleculesai.app", + session=session, + ) + ``` + + A follow-up PR will accept `org_id` and `origin` constructor kwargs and + inject the headers automatically. + ## Design choices - **Blocking (`requests`), not async.** Drops into any runtime — script,