feat: poll-mode inbound delivery + molecule connect CLI (Phase 30.8c)

External agents that can't expose a public HTTP endpoint (laptops behind
NAT, ephemeral CI runners, hermes self-hosted, codex et al) had to reverse-
engineer the activity-poll loop from molecule-mcp-claude-channel/server.ts
because the SDK only shipped the push-mode `A2AServer` (Phase 30.8b).

This adds the complementary path:

- `RemoteAgentClient.fetch_inbound(since_id=…)` — one-shot GET against
  `/workspaces/:id/activity?type=a2a_receive&since_id=…`. Cursor-loss (410)
  surfaces as `CursorLostError`; caller resets and re-polls.
- `RemoteAgentClient.reply(msg, text)` — smart-routes to `/notify` for
  canvas users, `/a2a` (JSON-RPC envelope + X-Source-Workspace-Id) for peer
  agents. Hides the reply-path bifurcation from connector authors.
- `PollDelivery` / `PushDelivery` / `InboundDelivery` protocol — same
  `MessageHandler` callback works for both transports.
- `RemoteAgentClient.run_agent_loop(handler, delivery=None)` — combined
  heartbeat + state-poll + inbound dispatch. Defaults to `PollDelivery`.
  Async handlers detected and `asyncio.run`'d (matches A2AServer pattern).
  Sleep cadence = min(heartbeat_interval, delivery.interval).
- `python -m molecule_agent connect` CLI — one-line bootstrap. Loads a
  user's `module:function` via importlib, registers, runs the loop until
  pause/delete or SIGTERM. All flags also read from environment variables.

Tests: 50 new (test_inbound.py, test_cli_connect.py) covering every prod
branch — source normalization, cursor advancement, 410 reset, async/sync
handler dispatch, handler exception → log+continue+advance, smart-reply
routing for canvas vs peer vs unknown sources, run_agent_loop terminal
states, sleep-interval selection, CLI handler resolution failures.

Resolves #17.
This commit is contained in:
Hongming Wang 2026-04-30 13:03:44 -07:00
parent c4c9dcfe06
commit 70d66cd814
8 changed files with 1695 additions and 7 deletions

View File

@ -139,6 +139,8 @@ unless noted):
| `POST` | `/workspaces/:id/delegate` | 30.6 | bearer + X-Workspace-ID, 300s timeout | | `POST` | `/workspaces/:id/delegate` | 30.6 | bearer + X-Workspace-ID, 300s timeout |
| `GET` | `/workspaces/:id/plugins/:name/download` | 30.3 | bearer | | `GET` | `/workspaces/:id/plugins/:name/download` | 30.3 | bearer |
| `POST` | `/workspaces/:id/plugins` | 30.3 | bearer | | `POST` | `/workspaces/:id/plugins` | 30.3 | bearer |
| `GET` | `/workspaces/:id/activity?type=a2a_receive&since_id=…` | 30.8c | bearer (poll-mode inbound) |
| `POST` | `/workspaces/:id/notify` | 30.8c | bearer (canvas-user reply) |
**Token** is cached at `~/.molecule/<workspace_id>/.auth_token` with `0600` **Token** is cached at `~/.molecule/<workspace_id>/.auth_token` with `0600`
permissions. On restart the client reuses the cached token — the platform permissions. On restart the client reuses the cached token — the platform
@ -201,9 +203,20 @@ python -m molecule_agent verify-sha256 ./my-plugin-dir
first**. Do not patch silently — the SDK is consumed across multiple first**. Do not patch silently — the SDK is consumed across multiple
runtime environments and silent patches can cause subtle breakage elsewhere. runtime environments and silent patches can cause subtle breakage elsewhere.
- `molecule_agent` does not yet bundle an inbound A2A server helper. - `molecule_agent` ships two inbound delivery paths: **push** (Phase 30.8b,
Platform-initiated calls to a remote agent without a publicly reachable `A2AServer` — for agents with a publicly reachable URL) and **poll** (Phase
endpoint will not succeed. See Phase 30.8b in the platform's `PLAN.md`. 30.8c, `PollDelivery` — for agents behind NAT or without a public endpoint,
the typical case for hermes-self-hosted, codex, and similar OSS runtimes).
Both feed the same `MessageHandler` callback through
`RemoteAgentClient.run_agent_loop(handler)`. The reply transport
(`/notify` for canvas users vs `/a2a` for peer agents) is hidden behind
`client.reply(msg, text)`.
- One-line bootstrap for poll-mode agents:
`python -m molecule_agent connect --platform-url … --workspace-id … --token … --handler my_module:fn`.
Picks `PollDelivery` automatically when `--reported-url` is empty; SIGTERM/SIGINT
shut the loop down cleanly. Cursor optionally persisted to `--cursor-file` so
restarts resume from the last-seen activity row.
--- ---

View File

@ -57,13 +57,62 @@ A runnable demo with full setup walkthrough lives at
| `heartbeat(...)` | 30.1 | Single bearer-authed heartbeat | | `heartbeat(...)` | 30.1 | Single bearer-authed heartbeat |
| `get_peers()` / `discover_peer()` | 30.6 | Sibling URL discovery with TTL cache | | `get_peers()` / `discover_peer()` | 30.6 | Sibling URL discovery with TTL cache |
| `call_peer(target, message)` | 30.6 | Direct A2A with proxy fallback | | `call_peer(target, message)` | 30.6 | Direct A2A with proxy fallback |
| `fetch_inbound(since_id=…)` | 30.8c | One-shot poll of `/workspaces/:id/activity` for inbound A2A |
| `reply(msg, text)` | 30.8c | Smart-routes reply to `/notify` (canvas user) or `/a2a` (peer) |
| `run_heartbeat_loop()` | combo | Drives heartbeat + state-poll on a timer; exits on pause/delete | | `run_heartbeat_loop()` | combo | Drives heartbeat + state-poll on a timer; exits on pause/delete |
| `run_agent_loop(handler)` | combo | Heartbeat + state + **inbound dispatch**; exits on pause/delete |
## Inbound delivery — push vs poll
Two ways an external agent can receive A2A messages:
| Path | When to use | Class |
|---|---|---|
| **Push** | Your agent has a publicly reachable URL (cloud VM, ngrok tunnel) | `A2AServer` (Phase 30.8b) |
| **Poll** | Your agent is behind NAT, on a laptop, or in a CI runner with no public URL | `PollDelivery` (Phase 30.8c) |
Both dispatch to the same `MessageHandler` callback through `run_agent_loop`:
```python
from molecule_agent import RemoteAgentClient, InboundMessage
def my_handler(msg: InboundMessage, client: RemoteAgentClient) -> str | None:
print(f"← {msg.source}: {msg.text}")
return f"echo: {msg.text}" # auto-routed via /notify or /a2a
client = RemoteAgentClient(workspace_id="…", platform_url="…")
client.register()
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.
## CLI: `molecule_agent connect`
One command bootstraps the full poll-mode loop. No code beyond your handler:
```bash
python -m molecule_agent connect \
--platform-url https://your-tenant.moleculesai.app \
--workspace-id 550e8400-… \
--token your-workspace-token \
--handler my_handlers:echo \
--poll-interval 5 \
--cursor-file ~/.molecule/cursor
```
Where `my_handlers.py` is anywhere on `PYTHONPATH`:
```python
def echo(msg, client):
return f"echo: {msg.text}"
```
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)
- **No inbound A2A server.** Other agents can't initiate calls to your remote - **No long-poll.** Activity polling is fixed-cadence (default 5s). Server-side long-poll support would cut p50 inbound latency to ~0; tracked separately.
agent unless you host an HTTP endpoint yourself. Future `start_a2a_server()`
helper will close this gap.
- **No automatic reconnect after token loss.** If `~/.molecule/<id>/.auth_token` - **No automatic reconnect after token loss.** If `~/.molecule/<id>/.auth_token`
is deleted, you'll need to re-issue the token via the platform admin (since 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 `POST /registry/register` is idempotent — it won't mint a second token for

View File

@ -41,6 +41,16 @@ from .client import (
WorkspaceState, WorkspaceState,
verify_plugin_sha256, verify_plugin_sha256,
) )
from .inbound import (
CursorLostError,
DEFAULT_POLL_INTERVAL,
InboundDelivery,
InboundMessage,
InboundSource,
MessageHandler,
PollDelivery,
PushDelivery,
)
# compute_plugin_sha256 lives in __main__ (the CLI entry point). # compute_plugin_sha256 lives in __main__ (the CLI entry point).
# Import it here so `from molecule_agent import compute_plugin_sha256` works. # Import it here so `from molecule_agent import compute_plugin_sha256` works.
@ -51,6 +61,14 @@ __all__ = [
"RemoteAgentClient", "RemoteAgentClient",
"WorkspaceState", "WorkspaceState",
"PeerInfo", "PeerInfo",
"InboundMessage",
"InboundSource",
"InboundDelivery",
"PollDelivery",
"PushDelivery",
"MessageHandler",
"CursorLostError",
"DEFAULT_POLL_INTERVAL",
"compute_plugin_sha256", "compute_plugin_sha256",
"verify_plugin_sha256", "verify_plugin_sha256",
"__version__", "__version__",

View File

@ -6,12 +6,24 @@ Commands:
plugin.yaml (self-referential). Output the plugin.yaml (self-referential). Output the
hash so you can paste it into plugin.yaml hash so you can paste it into plugin.yaml
under the sha256 field. under the sha256 field.
connect Register and run a remote agent against a
Molecule platform heartbeat + state-poll
+ inbound message poll, all in one process.
Loads a user-supplied handler module:func
and dispatches every inbound A2A message.
Designed for hermes / codex / any third-party
runtime that can't expose a reachable URL.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import importlib
import json import json
import logging
import os
import signal
import sys import sys
from pathlib import Path from pathlib import Path
@ -52,6 +64,119 @@ def compute_plugin_sha256(plugin_dir: Path) -> str:
return hashlib.sha256(manifest_bytes).hexdigest() return hashlib.sha256(manifest_bytes).hexdigest()
def _resolve_handler(spec: str):
"""Resolve a ``module.path:function`` spec into the callable.
Mirrors the convention used by gunicorn / uvicorn / celery for app
references a single string the user can put in a config file or env
var. Raises ``SystemExit`` with a readable message on any failure
(import, attribute lookup, non-callable result) so the CLI's exit
surface is clean.
"""
if ":" not in spec:
raise SystemExit(
f"error: handler spec {spec!r} must be of the form 'module.path:function'"
)
mod_path, func_name = spec.split(":", 1)
if not mod_path or not func_name:
raise SystemExit(f"error: handler spec {spec!r} is malformed")
try:
# Importing the user's module pulls in their code — we run it from
# the current working directory by default so 'my_handler:fn' works
# without setting PYTHONPATH first.
if "" not in sys.path:
sys.path.insert(0, "")
module = importlib.import_module(mod_path)
except Exception as exc:
raise SystemExit(f"error: could not import {mod_path}: {exc}")
try:
func = getattr(module, func_name)
except AttributeError:
raise SystemExit(f"error: {mod_path} has no attribute {func_name!r}")
if not callable(func):
raise SystemExit(f"error: {spec} is not callable")
return func
def _connect_command(args: argparse.Namespace) -> int:
"""Run the register + heartbeat + inbound-poll loop.
Returns the process exit code. 0 on graceful exit (paused/removed/SIGTERM),
non-zero on registration / handler-import failures.
"""
# Lazy import — the connect path pulls in requests + the full client,
# while verify-sha256 should stay light.
from .client import RemoteAgentClient
from .inbound import PollDelivery
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="[molecule] %(message)s",
)
handler = _resolve_handler(args.handler)
client = RemoteAgentClient(
workspace_id=args.workspace_id,
platform_url=args.platform_url,
agent_card={"name": args.agent_name or f"remote-{args.workspace_id[:8]}"},
reported_url=args.reported_url or "",
)
if args.token:
# User passed a token explicitly — persist it so register() can be
# skipped on a known-tokened workspace. The platform's register
# endpoint refuses to issue a second token when one is on file.
client.save_token(args.token)
# If we don't have a token yet (and one wasn't provided), call register
# so the platform mints one. On a known-tokened workspace this still
# succeeds and just returns the cached token.
if client.load_token() is None:
try:
client.register()
except Exception as exc:
print(f"[molecule] register failed: {exc}", file=sys.stderr)
return 2
print(
f"[molecule] connected as {args.workspace_id} "
f"(platform={args.platform_url}, delivery=poll, interval={args.poll_interval}s)"
)
cursor_file = None
if args.cursor_file:
cursor_file = Path(args.cursor_file).expanduser()
delivery = PollDelivery(
client,
interval=args.poll_interval,
cursor_file=cursor_file,
)
# Graceful shutdown on SIGINT / SIGTERM. The loop's built-in stop
# condition is platform-driven (paused / deleted), so we install a
# signal handler that sets max_iterations to the loop counter +1
# by raising KeyboardInterrupt — caught below.
def _on_signal(_sig, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGINT, _on_signal)
signal.signal(signal.SIGTERM, _on_signal)
try:
terminal = client.run_agent_loop(handler, delivery=delivery)
print(f"[molecule] platform reports workspace {terminal} — exiting")
return 0
except KeyboardInterrupt:
print("[molecule] received signal — shutting down cleanly")
try:
delivery.stop()
except Exception:
pass
return 0
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="molecule_agent", prog="molecule_agent",
@ -69,6 +194,74 @@ def main() -> None:
help="Path to the plugin directory (must contain plugin.yaml)", help="Path to the plugin directory (must contain plugin.yaml)",
) )
cn = sub.add_parser(
"connect",
help=(
"Register and run a remote agent against a Molecule platform — "
"heartbeat + state-poll + inbound A2A message dispatch."
),
)
cn.add_argument(
"--platform-url",
required=True,
default=os.environ.get("MOLECULE_PLATFORM_URL"),
help="Base URL of the Molecule platform (env: MOLECULE_PLATFORM_URL)",
)
cn.add_argument(
"--workspace-id",
required=True,
default=os.environ.get("MOLECULE_WORKSPACE_ID"),
help="UUID of the workspace this agent claims (env: MOLECULE_WORKSPACE_ID)",
)
cn.add_argument(
"--token",
default=os.environ.get("MOLECULE_WORKSPACE_TOKEN"),
help=(
"Pre-issued workspace bearer token (env: MOLECULE_WORKSPACE_TOKEN). "
"If omitted, the CLI calls /registry/register and caches the issued token."
),
)
cn.add_argument(
"--handler",
required=True,
help=(
"Handler spec in 'module.path:function' form. The function receives "
"(InboundMessage, RemoteAgentClient) and returns a reply string or None."
),
)
cn.add_argument(
"--agent-name",
default=os.environ.get("MOLECULE_AGENT_NAME"),
help="Name in the agent_card (env: MOLECULE_AGENT_NAME). Defaults to remote-<id8>.",
)
cn.add_argument(
"--reported-url",
default=os.environ.get("MOLECULE_REPORTED_URL", ""),
help=(
"Externally-reachable URL siblings can call. Empty = poll-only mode "
"(env: MOLECULE_REPORTED_URL)."
),
)
cn.add_argument(
"--poll-interval",
type=float,
default=float(os.environ.get("MOLECULE_POLL_INTERVAL", "5.0")),
help="Seconds between activity polls (env: MOLECULE_POLL_INTERVAL).",
)
cn.add_argument(
"--cursor-file",
default=os.environ.get("MOLECULE_CURSOR_FILE"),
help=(
"Path to persist the activity cursor across restarts (env: "
"MOLECULE_CURSOR_FILE). Default: in-process only."
),
)
cn.add_argument(
"--verbose",
action="store_true",
help="Enable INFO-level logging.",
)
args = parser.parse_args() args = parser.parse_args()
if args.command == "verify-sha256": if args.command == "verify-sha256":
@ -80,6 +273,8 @@ def main() -> None:
print(f"Computed SHA256: {h}") print(f"Computed SHA256: {h}")
except Exception as exc: except Exception as exc:
sys.exit(f"error: {exc}") sys.exit(f"error: {exc}")
elif args.command == "connect":
sys.exit(_connect_command(args))
else: else:
parser.print_help() parser.print_help()

View File

@ -31,10 +31,13 @@ import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import TYPE_CHECKING, Any
import requests import requests
if TYPE_CHECKING:
from .inbound import InboundDelivery, InboundMessage, MessageHandler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Polling cadence defaults. Chosen to align with the platform's 60-second # Polling cadence defaults. Chosen to align with the platform's 60-second
@ -687,6 +690,266 @@ class RemoteAgentClient:
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
# ------------------------------------------------------------------
# Inbound delivery (poll mode) — Phase 30.8c
# ------------------------------------------------------------------
def fetch_inbound(
self,
since_id: str | None = None,
limit: int = 100,
type: str = "a2a_receive",
) -> list["InboundMessage"]:
"""Fetch one batch of inbound A2A activity rows.
Hits ``GET /workspaces/:id/activity?type=&since_id=&limit=``.
Returns the rows newer than ``since_id`` in oldest-first order,
parsed into :class:`~molecule_agent.inbound.InboundMessage`.
Used by :class:`~molecule_agent.inbound.PollDelivery`; most callers
should drive this through :py:meth:`run_agent_loop` rather than
polling manually.
Args:
since_id: Activity-id cursor only rows newer than this are
returned. Pass ``None`` for the initial fetch.
limit: Max rows per batch. Default 100. Server-side cap may
lower this.
type: Activity-row type filter. Default ``"a2a_receive"``;
pass another type to consume different streams (e.g.
``"workspace_state_changed"``).
Returns:
List of :class:`InboundMessage`, oldest first. May be empty.
Raises:
:class:`~molecule_agent.inbound.CursorLostError`: if the server
returns 410 Gone (cursor's row has been rotated out of the
activity window). Caller should reset the cursor and retry.
``requests.HTTPError``: on other non-2xx responses (401, 5xx, ).
"""
# Local import to avoid a circular dependency at module load — the
# inbound module references RemoteAgentClient via TYPE_CHECKING.
from .inbound import CursorLostError, _parse_activity_row
params: dict[str, str] = {"type": type, "limit": str(int(limit))}
if since_id:
params["since_id"] = since_id
url = f"{self.platform_url}/workspaces/{self.workspace_id}/activity"
resp = self._session.get(
url,
headers=self._auth_headers(),
params=params,
timeout=15.0,
)
if resp.status_code == 410:
raise CursorLostError(
f"cursor {since_id!r} no longer valid (410 Gone); reset and re-poll"
)
# 429 retry: rebuild the URL with encoded query string and route
# through _get_with_retry, which honours Retry-After + jittered
# backoff. We only retry on 429 — every other status falls through
# to raise_for_status below.
if resp.status_code == 429:
from urllib.parse import urlencode
resp = self._get_with_retry(
url + "?" + urlencode(params),
headers=self._auth_headers(),
)
resp.raise_for_status()
rows = resp.json() or []
if not isinstance(rows, list):
# Defensive: if the server ever wraps in {"items": […]} we
# accept that shape too rather than silently dropping data.
rows = rows.get("items", []) if isinstance(rows, dict) else []
out: list["InboundMessage"] = []
for row in rows:
if not isinstance(row, dict):
continue
msg = _parse_activity_row(row)
if msg is not None:
out.append(msg)
return out
def reply(self, message: "InboundMessage", text: str) -> None:
"""Reply to an inbound message.
The reply transport is picked from ``message.source``:
* ``canvas_user`` ``POST /workspaces/:id/notify`` with
``{"message": text}``. The canvas surfaces the text to the user.
* ``peer_agent`` ``POST /workspaces/:peer_id/a2a`` with a JSON-RPC
``message/send`` envelope and ``X-Source-Workspace-Id`` header.
* ``unknown`` raises ``ValueError``. The SDK refuses to guess the
transport; the caller should inspect ``message.raw`` and use
:py:meth:`call_peer` or a direct HTTP call as appropriate.
Args:
message: The :class:`InboundMessage` being replied to. Determines
the transport.
text: Reply text. Empty / whitespace-only strings raise
``ValueError`` to prevent accidental silent acks.
Raises:
``ValueError``: on empty text or unknown source.
``requests.HTTPError``: on non-2xx server response.
"""
if not text or not text.strip():
raise ValueError("reply text must be non-empty")
if message.source == "canvas_user":
resp = self._session.post(
f"{self.platform_url}/workspaces/{self.workspace_id}/notify",
headers={
**self._auth_headers(),
"Content-Type": "application/json",
},
json={"message": text},
timeout=15.0,
)
resp.raise_for_status()
return
if message.source == "peer_agent":
target = message.source_id or ""
if not target:
raise ValueError(
"peer_agent inbound message has no source_id — cannot route reply"
)
body = {
"jsonrpc": "2.0",
"id": str(uuid.uuid4()),
"method": "message/send",
"params": {
"message": {
"role": "agent",
"messageId": str(uuid.uuid4()),
"parts": [{"kind": "text", "text": text}],
}
},
}
resp = self._session.post(
f"{self.platform_url}/workspaces/{target}/a2a",
headers={
**self._auth_headers(),
"X-Source-Workspace-Id": self.workspace_id,
"X-Workspace-ID": self.workspace_id,
"Content-Type": "application/json",
},
json=body,
timeout=15.0,
)
resp.raise_for_status()
return
raise ValueError(
f"cannot auto-route reply for source={message.source!r}; "
"inspect message.raw and call /notify or /a2a directly"
)
def run_agent_loop(
self,
handler: "MessageHandler",
delivery: "InboundDelivery | None" = None,
max_iterations: int | None = None,
task_supplier: "callable | None" = None,
) -> str:
"""Combined heartbeat + state-poll + inbound-delivery loop.
Generalization of :py:meth:`run_heartbeat_loop` that also drains
inbound messages on every tick. This is the recommended entry
point for an external agent author registers, heartbeats,
state-polls, and dispatches inbound, all in one sync call.
Args:
handler: ``Callable[[InboundMessage, RemoteAgentClient],
str | None | Awaitable[str | None]]``. Invoked once per
inbound message. Returning a non-empty string sends an
automatic reply via :py:meth:`reply`. ``None`` skips the
reply (useful for fire-and-forget consumers).
delivery: An :class:`InboundDelivery` implementation. Defaults
to :class:`PollDelivery` (the right choice when the agent
can't expose an inbound URL — i.e. ``reported_url`` is
empty or starts with ``remote://``). Pass an explicit
:class:`PushDelivery` (constructed around an
:class:`A2AServer`) for push-mode agents.
max_iterations: Stop after N iterations. ``None`` = run until
the platform reports the workspace paused or deleted.
task_supplier: Optional zero-arg callable returning a dict
``{"current_task": str, "active_tasks": int}`` reported on
each heartbeat (same contract as :py:meth:`run_heartbeat_loop`).
Returns:
The terminal status: ``"paused"``, ``"removed"``, or
``"max_iterations"``.
Errors from the activity poll, heartbeat, or state poll are
logged and the loop continues a transient platform hiccup
should not take a remote agent offline. Handler exceptions are
caught at the delivery layer (see :class:`PollDelivery`).
"""
from .inbound import PollDelivery
if delivery is None:
delivery = PollDelivery(self)
i = 0
try:
while True:
if max_iterations is not None and i >= max_iterations:
return "max_iterations"
i += 1
report: dict[str, Any] = {}
if task_supplier is not None:
try:
report = task_supplier() or {}
except Exception as exc:
logger.warning("task_supplier raised: %s", exc)
try:
self.heartbeat(
current_task=str(report.get("current_task", "")),
active_tasks=int(report.get("active_tasks", 0)),
)
except Exception as exc:
logger.warning("heartbeat failed: %s — continuing", exc)
try:
delivery.run_once(handler)
except Exception as exc:
logger.warning("inbound delivery.run_once raised: %s — continuing", exc)
try:
state = self.poll_state()
except Exception as exc:
logger.warning("state poll failed: %s — continuing", exc)
state = None
if state is not None and state.should_stop:
logger.info(
"platform reports workspace %s (paused=%s deleted=%s) — exiting",
state.status, state.paused, state.deleted,
)
return state.status
# Sleep cadence: take the smaller of heartbeat_interval and
# the delivery's poll interval (when present) so inbound
# latency is bounded by the delivery's setting, not by the
# heartbeat cadence.
interval = self.heartbeat_interval
poll_interval = getattr(delivery, "interval", None)
if isinstance(poll_interval, (int, float)) and poll_interval > 0:
interval = min(interval, float(poll_interval))
time.sleep(interval)
finally:
try:
delivery.stop()
except Exception as exc:
logger.warning("delivery.stop raised on loop exit: %s", exc)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Delegation — KI-002 idempotency guard # Delegation — KI-002 idempotency guard
# ------------------------------------------------------------------ # ------------------------------------------------------------------

370
molecule_agent/inbound.py Normal file
View File

@ -0,0 +1,370 @@
"""Poll-mode inbound delivery for remote agents that can't expose an HTTP endpoint.
The :class:`A2AServer` companion (Phase 30.8b) covers the case where an agent
can host a publicly reachable HTTP endpoint and the platform pushes work to it.
Many real adopters can't — laptops behind NAT, ephemeral CI runners, hermes
self-hosted on a developer machine. For those, the platform queues inbound
A2A messages on the workspace's ``activity_logs`` and the agent polls.
This module provides:
* :class:`InboundMessage` typed view over an ``activity_logs`` row that
carries an ``a2a_receive`` event. Source is normalized to ``canvas_user``
vs ``peer_agent`` so the SDK can route replies without the caller having
to know which envelope to use.
* :class:`CursorLostError` raised when the activity endpoint returns
410 Gone (the cursor's row was rotated out). Caller resets and re-polls.
* :class:`InboundDelivery` protocol that ``run_agent_loop`` accepts; both
:class:`PollDelivery` and :class:`PushDelivery` satisfy it.
* :class:`PollDelivery` the new poll-mode implementation.
* :class:`PushDelivery` thin wrapper over :class:`A2AServer` so the same
``run_agent_loop`` works for push-mode agents that expose an inbound URL.
Big-tech prior art: Slack Socket Mode, Telegram getUpdates, AWS SQS long
polling, Stripe ``stripe listen``. Same shape cursor-based poll, SDK-owned
loop, single handler callback, smart-reply hidden behind the SDK.
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import (
Any,
Awaitable,
Callable,
Literal,
Protocol,
TYPE_CHECKING,
runtime_checkable,
)
if TYPE_CHECKING:
from .client import RemoteAgentClient
logger = logging.getLogger(__name__)
InboundSource = Literal["canvas_user", "peer_agent", "unknown"]
@dataclass
class InboundMessage:
"""One inbound A2A event the agent must handle.
The ``activity_id`` is the cursor pass it as ``since_id`` on the next
fetch to avoid re-receiving this message.
``source`` is normalized so the SDK can pick the reply transport:
* ``canvas_user`` a user typing in the canvas chat. Reply via
``POST /workspaces/:id/notify``.
* ``peer_agent`` another workspace's agent. Reply via
``POST /workspaces/:peer_id/a2a`` with a JSON-RPC envelope and
``X-Source-Workspace-Id`` header.
* ``unknown`` the activity row didn't carry a recognizable source.
:py:meth:`RemoteAgentClient.reply` raises ``ValueError`` rather than
guess.
"""
activity_id: str
source: InboundSource
source_id: str
text: str
raw: dict[str, Any] = field(default_factory=dict)
class CursorLostError(Exception):
"""Raised when ``GET /workspaces/:id/activity`` returns 410 Gone.
The platform retires old activity rows on a fixed window (see
workspace-server's activity_logs retention policy). If the agent's
cursor points at a row that has been rotated out, the server replies
410. Callers should reset the cursor (``since_id=None``) and re-poll;
they will catch up on whatever's still in the window.
"""
# ---------------------------------------------------------------------------
# Activity row → InboundMessage parsing
# ---------------------------------------------------------------------------
def _parse_activity_row(row: dict[str, Any]) -> InboundMessage | None:
"""Convert one ``activity_logs`` row into an :class:`InboundMessage`.
Returns ``None`` if the row is malformed or doesn't carry text we can
deliver preferable to raising and aborting the whole poll batch.
Activity row shape (per workspace-server's handlers/activity.go):
``{"id": ..., "type": "a2a_receive", "source_id": ..., "data": {...}, ...}``
"""
aid = str(row.get("id") or "")
if not aid:
return None
data = row.get("data") if isinstance(row.get("data"), dict) else {}
source_kind = str(data.get("source") or row.get("source") or "")
source_id = str(row.get("source_id") or data.get("source_id") or "")
# Normalize source. The platform uses "canvas_user" / "peer_agent" /
# sometimes "user" (legacy). Anything else falls into "unknown" so we
# don't accidentally route a reply down the wrong transport.
source: InboundSource
if source_kind in ("canvas_user", "user"):
source = "canvas_user"
elif source_kind == "peer_agent":
source = "peer_agent"
elif source_id and source_id != "user":
# Heuristic: a non-empty source_id that isn't the "user" sentinel
# is almost certainly a peer workspace.
source = "peer_agent"
elif source_id == "user":
source = "canvas_user"
else:
source = "unknown"
text = str(data.get("text") or data.get("message") or "")
return InboundMessage(
activity_id=aid,
source=source,
source_id=source_id,
text=text,
raw=row,
)
# ---------------------------------------------------------------------------
# Handler + delivery protocol
# ---------------------------------------------------------------------------
# A handler receives the inbound message + the client (so it can reply, fetch
# secrets, call peers, etc.) and returns either a reply string or None.
# Sync OR async — :class:`PollDelivery` detects ``Awaitable`` results and
# awaits them, mirroring the pattern in :class:`A2AServer`.
MessageHandler = Callable[
["InboundMessage", "RemoteAgentClient"],
"str | None | Awaitable[str | None]",
]
@runtime_checkable
class InboundDelivery(Protocol):
"""The contract :py:meth:`RemoteAgentClient.run_agent_loop` calls into.
Two implementations ship with the SDK:
* :class:`PollDelivery` for agents without a reachable URL.
* :class:`PushDelivery` for agents that host an A2AServer.
Third parties can supply their own (e.g. WebSocket, gRPC streaming)
by satisfying this protocol.
"""
def run_once(self, handler: MessageHandler) -> int:
"""Drain one batch of inbound messages and dispatch to handler.
Returns the count of messages dispatched. The caller's outer loop
decides cadence / sleep.
"""
...
def stop(self) -> None:
"""Release any resources (close sockets, stop background threads)."""
...
# ---------------------------------------------------------------------------
# PollDelivery — the new path
# ---------------------------------------------------------------------------
# Default poll cadence. 5s gives <5s p50 latency for canvas-user messages
# while keeping load on workspace-server modest (one GET per agent per 5s).
# Slack Socket Mode runs at ~1s, Telegram getUpdates with timeout=30 is the
# canonical long-poll. We don't have long-poll support server-side yet, so
# fixed 5s is the conservative choice. Tunable via constructor.
DEFAULT_POLL_INTERVAL = 5.0
class PollDelivery:
"""Poll ``GET /workspaces/:id/activity?type=a2a_receive&since_id=…``.
The cursor is process-memory by default; a restart re-polls from
scratch, which is harmless because handlers should be idempotent
(the platform makes no exactly-once guarantees on activity poll
the same SDK-level convention as Slack Events API).
Pass ``cursor_file`` to persist the cursor across restarts:
PollDelivery(client, cursor_file=Path("~/.molecule/cursor"))
Cursor-loss (HTTP 410) is handled transparently the cursor is
reset to ``None`` and the next poll starts fresh with whatever's in
the activity window.
"""
def __init__(
self,
client: "RemoteAgentClient",
interval: float = DEFAULT_POLL_INTERVAL,
type: str = "a2a_receive",
limit: int = 100,
cursor_file: Path | None = None,
) -> None:
self._client = client
self.interval = interval
self.type = type
self.limit = limit
self._cursor_file = cursor_file
self._cursor: str | None = self._load_cursor()
self._stopped = False
def _load_cursor(self) -> str | None:
if self._cursor_file is None or not self._cursor_file.exists():
return None
try:
cur = self._cursor_file.read_text().strip()
return cur or None
except OSError as exc:
logger.warning("could not read cursor file %s: %s", self._cursor_file, exc)
return None
def _save_cursor(self) -> None:
if self._cursor_file is None or self._cursor is None:
return
try:
self._cursor_file.parent.mkdir(parents=True, exist_ok=True)
self._cursor_file.write_text(self._cursor)
except OSError as exc:
logger.warning("could not write cursor file %s: %s", self._cursor_file, exc)
@property
def cursor(self) -> str | None:
"""Current cursor (``activity_id`` of the most recently dispatched
message). Useful for tests and observability."""
return self._cursor
def run_once(self, handler: MessageHandler) -> int:
"""Fetch one batch and dispatch each message to ``handler``.
Returns the number of messages dispatched. A handler exception is
logged but does not abort the batch at-least-once semantics, the
same row may be re-delivered on the next iteration if its cursor
wasn't advanced.
"""
if self._stopped:
return 0
try:
batch = self._client.fetch_inbound(
since_id=self._cursor,
limit=self.limit,
type=self.type,
)
except CursorLostError:
logger.info("cursor %s lost (410 Gone) — resetting", self._cursor)
self._cursor = None
return 0
dispatched = 0
for msg in batch:
try:
self._dispatch(handler, msg)
except Exception as exc:
# Log + continue. We DO advance the cursor past this message
# so a poison-pill input doesn't block the queue forever —
# this matches how Slack Events delivers and how SQS DLQs
# work. The handler is expected to surface its own errors
# via logging or its own observability.
logger.exception("handler raised on activity %s: %s", msg.activity_id, exc)
self._cursor = msg.activity_id
dispatched += 1
if dispatched:
self._save_cursor()
return dispatched
def _dispatch(self, handler: MessageHandler, msg: "InboundMessage") -> None:
"""Invoke handler, await if async, send the reply if returned."""
result = handler(msg, self._client)
if inspect.isawaitable(result):
# Detect a running loop without using the deprecated
# asyncio.get_event_loop() (Py3.12+). If a loop is running we
# refuse — the caller is async and should await the handler
# themselves; we can't synchronously block on an awaitable
# without deadlocking the running loop.
try:
asyncio.get_running_loop()
except RuntimeError:
# No running loop — safe to spin up a fresh one. Mirrors
# A2AServer's pattern: build, run, close. asyncio.run is
# the modern equivalent of new_loop+run_until_complete+close
# and handles the close even on exception.
result = asyncio.run(result) # type: ignore[arg-type]
else:
raise RuntimeError(
"PollDelivery.run_once was called from inside a running "
"event loop with an async handler. Use a sync handler "
"here, or schedule run_once on a worker thread via "
"asyncio.to_thread()."
)
reply_text = result if isinstance(result, str) else None
if reply_text:
try:
self._client.reply(msg, reply_text)
except Exception as exc:
logger.warning("reply send failed for activity %s: %s", msg.activity_id, exc)
def stop(self) -> None:
self._stopped = True
# ---------------------------------------------------------------------------
# PushDelivery — wraps the existing A2AServer
# ---------------------------------------------------------------------------
class PushDelivery:
"""Adapt :class:`A2AServer` to the :class:`InboundDelivery` protocol.
Use this when the agent CAN expose a reachable HTTP endpoint. The
A2AServer runs in its own thread and dispatches to ``handler`` as
HTTP requests arrive ``run_once`` is a no-op (the loop driver in
:py:meth:`RemoteAgentClient.run_agent_loop` simply sleeps and
keeps the heartbeat alive).
"""
def __init__(self, client: "RemoteAgentClient", server: Any) -> None:
# ``server`` typed Any to avoid a circular import; it's an A2AServer.
self._client = client
self._server = server
def run_once(self, handler: MessageHandler) -> int: # noqa: ARG002 — handler unused
# A2AServer dispatches synchronously on its own thread; nothing
# for the outer loop to do per-tick.
return 0
def stop(self) -> None:
try:
self._server.stop()
except Exception as exc:
logger.warning("PushDelivery stop: A2AServer.stop raised: %s", exc)
__all__ = [
"CursorLostError",
"DEFAULT_POLL_INTERVAL",
"InboundDelivery",
"InboundMessage",
"InboundSource",
"MessageHandler",
"PollDelivery",
"PushDelivery",
"_parse_activity_row",
]

167
tests/test_cli_connect.py Normal file
View File

@ -0,0 +1,167 @@
"""Tests for `python -m molecule_agent connect` CLI handler resolution.
Run-loop integration is covered by tests/test_inbound.py these tests only
exercise the CLI's argument parsing, handler resolution, and the
register-on-missing-token behavior. We do not start the full loop because
that's already covered, and starting it from a CLI test runs into signal
+ event-loop interactions that aren't worth reproducing here.
"""
from __future__ import annotations
import sys
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from molecule_agent.__main__ import _resolve_handler
# ---------------------------------------------------------------------------
# _resolve_handler
# ---------------------------------------------------------------------------
def _write_handler_module(tmp_path: Path, name: str, body: str) -> None:
"""Drop a handler module into tmp_path and prepend tmp_path to sys.path."""
p = tmp_path / f"{name}.py"
p.write_text(textwrap.dedent(body))
if str(tmp_path) not in sys.path:
sys.path.insert(0, str(tmp_path))
def test_resolve_handler_happy_path(tmp_path: Path):
_write_handler_module(
tmp_path,
"ok_handler_mod",
"""
def echo(msg, client):
return msg.text
""",
)
fn = _resolve_handler("ok_handler_mod:echo")
assert callable(fn)
# Sanity-check the resolved callable's name.
assert fn.__name__ == "echo"
def test_resolve_handler_missing_colon_exits(tmp_path: Path):
with pytest.raises(SystemExit, match="must be of the form"):
_resolve_handler("not_a_spec_no_colon")
def test_resolve_handler_empty_module_exits():
with pytest.raises(SystemExit, match="malformed"):
_resolve_handler(":fn")
def test_resolve_handler_empty_function_exits():
with pytest.raises(SystemExit, match="malformed"):
_resolve_handler("mod:")
def test_resolve_handler_import_error_exits():
with pytest.raises(SystemExit, match="could not import"):
_resolve_handler("definitely_not_a_real_module_xyzzy:fn")
def test_resolve_handler_attribute_error_exits(tmp_path: Path):
_write_handler_module(
tmp_path,
"no_func_mod",
"""
OTHER = 1
""",
)
with pytest.raises(SystemExit, match="no attribute"):
_resolve_handler("no_func_mod:not_there")
def test_resolve_handler_not_callable_exits(tmp_path: Path):
_write_handler_module(
tmp_path,
"not_callable_mod",
"""
IT_IS_AN_INT = 42
""",
)
with pytest.raises(SystemExit, match="not callable"):
_resolve_handler("not_callable_mod:IT_IS_AN_INT")
# ---------------------------------------------------------------------------
# _connect_command — registration / token-loading branches
# ---------------------------------------------------------------------------
def test_connect_command_register_failure_returns_2(tmp_path: Path, monkeypatch):
_write_handler_module(
tmp_path,
"rcfail_mod",
"""
def fn(msg, client):
return None
""",
)
from molecule_agent import __main__ as cli_mod
args = MagicMock()
args.handler = "rcfail_mod:fn"
args.platform_url = "http://platform.test"
args.workspace_id = "ws-zzz"
args.token = None
args.agent_name = None
args.reported_url = ""
args.poll_interval = 1.0
args.cursor_file = None
args.verbose = False
fake_client = MagicMock()
fake_client.load_token.return_value = None # no cached token
fake_client.register.side_effect = RuntimeError("network sad")
with patch("molecule_agent.client.RemoteAgentClient", return_value=fake_client):
rc = cli_mod._connect_command(args)
assert rc == 2
def test_connect_command_uses_provided_token_skips_register(tmp_path: Path, monkeypatch):
_write_handler_module(
tmp_path,
"tokset_mod",
"""
def fn(msg, client):
return None
""",
)
from molecule_agent import __main__ as cli_mod
args = MagicMock()
args.handler = "tokset_mod:fn"
args.platform_url = "http://platform.test"
args.workspace_id = "ws-zzz"
args.token = "explicit-token"
args.agent_name = None
args.reported_url = ""
args.poll_interval = 1.0
args.cursor_file = None
args.verbose = False
fake_client = MagicMock()
# Once save_token has been called, load_token should return the token,
# so register is NOT called.
fake_client.load_token.return_value = "explicit-token"
# run_agent_loop returns a terminal status — paused — so the function
# exits 0 cleanly without us having to signal-break the loop.
fake_client.run_agent_loop.return_value = "paused"
with patch("molecule_agent.client.RemoteAgentClient", return_value=fake_client):
rc = cli_mod._connect_command(args)
assert rc == 0
fake_client.save_token.assert_called_once_with("explicit-token")
fake_client.register.assert_not_called()
fake_client.run_agent_loop.assert_called_once()

613
tests/test_inbound.py Normal file
View File

@ -0,0 +1,613 @@
"""Tests for poll-mode inbound delivery (Phase 30.8c).
Covers:
* :func:`_parse_activity_row` source normalization and edge cases.
* :py:meth:`RemoteAgentClient.fetch_inbound` happy path, cursor, 410, shapes.
* :py:meth:`RemoteAgentClient.reply` smart-routing (canvas vs peer).
* :class:`PollDelivery` cursor advancement, async/sync handler dispatch,
error handling, 410 reset, cursor-file persistence, stop().
* :py:meth:`RemoteAgentClient.run_agent_loop` heartbeat + state + delivery
composition, default-delivery selection, terminal-status handling, sleep
cadence selection.
Mocking style matches ``tests/test_remote_agent.py``: a ``FakeResponse`` /
``MagicMock`` session, no third-party HTTP mock library.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, call
import pytest
import requests
from molecule_agent import (
CursorLostError,
InboundMessage,
PollDelivery,
PushDelivery,
RemoteAgentClient,
WorkspaceState,
)
from molecule_agent.inbound import _parse_activity_row
# ---------------------------------------------------------------------------
# FakeResponse — same shape as the existing test_remote_agent helper
# ---------------------------------------------------------------------------
class FakeResponse:
def __init__(self, status_code: int = 200, json_body: Any = None, text: str = ""):
self.status_code = status_code
self._json = json_body
self.text = text
self.headers: dict[str, str] = {}
def json(self) -> Any:
return self._json
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise requests.HTTPError(f"HTTP {self.status_code}")
@pytest.fixture
def tmp_token_dir(tmp_path: Path) -> Path:
return tmp_path / "molecule-token-cache"
@pytest.fixture
def client(tmp_token_dir: Path) -> RemoteAgentClient:
session = MagicMock()
c = RemoteAgentClient(
workspace_id="ws-abc-123",
platform_url="http://platform.test",
agent_card={"name": "test-agent"},
token_dir=tmp_token_dir,
session=session,
)
# Pre-seed the cached token so _auth_headers returns one and we don't
# have to mock /registry/register on every test.
c.save_token("test-token-secret")
return c
# ---------------------------------------------------------------------------
# _parse_activity_row
# ---------------------------------------------------------------------------
def test_parse_activity_row_canvas_user_explicit():
row = {
"id": "act-1",
"type": "a2a_receive",
"source_id": "user",
"data": {"source": "canvas_user", "text": "hi"},
}
msg = _parse_activity_row(row)
assert msg is not None
assert msg.activity_id == "act-1"
assert msg.source == "canvas_user"
assert msg.source_id == "user"
assert msg.text == "hi"
def test_parse_activity_row_legacy_user_normalizes_to_canvas():
# Older platform versions used 'user' instead of 'canvas_user'.
row = {"id": "act-2", "data": {"source": "user", "text": "hello"}}
msg = _parse_activity_row(row)
assert msg is not None
assert msg.source == "canvas_user"
def test_parse_activity_row_peer_agent_explicit():
row = {
"id": "act-3",
"source_id": "peer-ws-77",
"data": {"source": "peer_agent", "text": "ping"},
}
msg = _parse_activity_row(row)
assert msg is not None
assert msg.source == "peer_agent"
assert msg.source_id == "peer-ws-77"
def test_parse_activity_row_inferred_peer_from_source_id():
# No explicit source field but a non-'user' source_id present → infer peer_agent.
# This protects us from server-side variants that omit 'source' in data.
row = {"id": "act-4", "source_id": "peer-ws-88", "data": {"text": "ping"}}
msg = _parse_activity_row(row)
assert msg is not None
assert msg.source == "peer_agent"
def test_parse_activity_row_inferred_canvas_from_user_source_id():
row = {"id": "act-5", "source_id": "user", "data": {"text": "hi"}}
msg = _parse_activity_row(row)
assert msg is not None
assert msg.source == "canvas_user"
def test_parse_activity_row_unknown_source_falls_through():
# No source_id, no source → unknown. Reply path will refuse to guess.
row = {"id": "act-6", "data": {"text": "??"}}
msg = _parse_activity_row(row)
assert msg is not None
assert msg.source == "unknown"
def test_parse_activity_row_no_id_returns_none():
row = {"data": {"source": "canvas_user", "text": "no id"}}
assert _parse_activity_row(row) is None
def test_parse_activity_row_text_alt_key():
# Some server paths use 'message' instead of 'text'. Accept both.
row = {"id": "act-7", "data": {"source": "canvas_user", "message": "alt"}}
msg = _parse_activity_row(row)
assert msg is not None
assert msg.text == "alt"
# ---------------------------------------------------------------------------
# fetch_inbound
# ---------------------------------------------------------------------------
def test_fetch_inbound_happy_path(client: RemoteAgentClient):
rows = [
{"id": "act-1", "data": {"source": "canvas_user", "text": "hi"}},
{"id": "act-2", "source_id": "peer-77", "data": {"source": "peer_agent", "text": "ping"}},
]
client._session.get.return_value = FakeResponse(200, rows)
out = client.fetch_inbound()
assert len(out) == 2
assert out[0].source == "canvas_user"
assert out[1].source == "peer_agent"
# Verify the GET shape.
call_args = client._session.get.call_args
assert call_args.args[0] == "http://platform.test/workspaces/ws-abc-123/activity"
assert call_args.kwargs["params"]["type"] == "a2a_receive"
assert call_args.kwargs["params"]["limit"] == "100"
assert "since_id" not in call_args.kwargs["params"]
def test_fetch_inbound_with_since_id_passes_cursor(client: RemoteAgentClient):
client._session.get.return_value = FakeResponse(200, [])
client.fetch_inbound(since_id="act-prev")
params = client._session.get.call_args.kwargs["params"]
assert params["since_id"] == "act-prev"
def test_fetch_inbound_410_raises_cursor_lost(client: RemoteAgentClient):
client._session.get.return_value = FakeResponse(410, {"error": "cursor lost"})
with pytest.raises(CursorLostError):
client.fetch_inbound(since_id="act-stale")
def test_fetch_inbound_accepts_dict_items_wrapper(client: RemoteAgentClient):
# If a future server version wraps in {"items": [...]}, we still parse.
body = {"items": [{"id": "act-1", "data": {"source": "canvas_user", "text": "hi"}}]}
client._session.get.return_value = FakeResponse(200, body)
out = client.fetch_inbound()
assert len(out) == 1
assert out[0].activity_id == "act-1"
def test_fetch_inbound_skips_malformed_rows(client: RemoteAgentClient):
rows = [
{"id": "act-1", "data": {"source": "canvas_user", "text": "ok"}},
"not a dict",
{"data": {"text": "no id"}}, # missing id → skipped
]
client._session.get.return_value = FakeResponse(200, rows)
out = client.fetch_inbound()
assert len(out) == 1
assert out[0].activity_id == "act-1"
def test_fetch_inbound_401_raises_http_error(client: RemoteAgentClient):
client._session.get.return_value = FakeResponse(401)
with pytest.raises(requests.HTTPError):
client.fetch_inbound()
def test_fetch_inbound_empty_returns_empty(client: RemoteAgentClient):
client._session.get.return_value = FakeResponse(200, [])
assert client.fetch_inbound() == []
# ---------------------------------------------------------------------------
# reply()
# ---------------------------------------------------------------------------
def test_reply_canvas_user_hits_notify(client: RemoteAgentClient):
msg = InboundMessage(
activity_id="act-1", source="canvas_user", source_id="user", text="hi"
)
client._session.post.return_value = FakeResponse(200, {"status": "sent"})
client.reply(msg, "hello")
call_args = client._session.post.call_args
assert call_args.args[0] == "http://platform.test/workspaces/ws-abc-123/notify"
assert call_args.kwargs["json"] == {"message": "hello"}
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test-token-secret"
def test_reply_peer_agent_hits_a2a(client: RemoteAgentClient):
msg = InboundMessage(
activity_id="act-2", source="peer_agent", source_id="peer-ws-77", text="ping"
)
client._session.post.return_value = FakeResponse(200, {"jsonrpc": "2.0", "result": {}})
client.reply(msg, "pong")
call_args = client._session.post.call_args
assert call_args.args[0] == "http://platform.test/workspaces/peer-ws-77/a2a"
body = call_args.kwargs["json"]
assert body["jsonrpc"] == "2.0"
assert body["method"] == "message/send"
assert body["params"]["message"]["parts"][0]["text"] == "pong"
headers = call_args.kwargs["headers"]
assert headers["X-Source-Workspace-Id"] == "ws-abc-123"
assert headers["X-Workspace-ID"] == "ws-abc-123"
def test_reply_unknown_source_raises_value_error(client: RemoteAgentClient):
msg = InboundMessage(activity_id="act-3", source="unknown", source_id="", text="?")
with pytest.raises(ValueError, match="cannot auto-route"):
client.reply(msg, "won't send")
client._session.post.assert_not_called()
def test_reply_empty_text_raises_value_error(client: RemoteAgentClient):
msg = InboundMessage(activity_id="act-4", source="canvas_user", source_id="user", text="hi")
with pytest.raises(ValueError, match="non-empty"):
client.reply(msg, "")
with pytest.raises(ValueError, match="non-empty"):
client.reply(msg, " ")
client._session.post.assert_not_called()
def test_reply_peer_agent_missing_source_id_raises(client: RemoteAgentClient):
msg = InboundMessage(activity_id="act-5", source="peer_agent", source_id="", text="?")
with pytest.raises(ValueError, match="no source_id"):
client.reply(msg, "won't send")
def test_reply_propagates_http_error(client: RemoteAgentClient):
msg = InboundMessage(activity_id="act-6", source="canvas_user", source_id="user", text="hi")
client._session.post.return_value = FakeResponse(500)
with pytest.raises(requests.HTTPError):
client.reply(msg, "boom")
# ---------------------------------------------------------------------------
# PollDelivery
# ---------------------------------------------------------------------------
def test_poll_delivery_run_once_advances_cursor(client: RemoteAgentClient):
rows = [
{"id": "act-1", "data": {"source": "canvas_user", "text": "a"}},
{"id": "act-2", "data": {"source": "canvas_user", "text": "b"}},
]
client._session.get.return_value = FakeResponse(200, rows)
delivery = PollDelivery(client, interval=0.0)
received: list[str] = []
def handler(msg: InboundMessage, _client: RemoteAgentClient):
received.append(msg.text)
return None # no reply
n = delivery.run_once(handler)
assert n == 2
assert received == ["a", "b"]
assert delivery.cursor == "act-2"
def test_poll_delivery_handler_exception_advances_and_continues(
client: RemoteAgentClient, caplog
):
rows = [
{"id": "act-1", "data": {"source": "canvas_user", "text": "poison"}},
{"id": "act-2", "data": {"source": "canvas_user", "text": "next"}},
]
client._session.get.return_value = FakeResponse(200, rows)
delivery = PollDelivery(client, interval=0.0)
seen: list[str] = []
def handler(msg, _c):
seen.append(msg.text)
if msg.text == "poison":
raise RuntimeError("kaboom")
return None
n = delivery.run_once(handler)
# Both messages should be dispatched even though the first raised.
assert n == 2
assert seen == ["poison", "next"]
# Cursor advances past the failure so we don't get stuck on poison forever.
assert delivery.cursor == "act-2"
def test_poll_delivery_async_handler_awaited(client: RemoteAgentClient):
rows = [{"id": "act-1", "data": {"source": "canvas_user", "text": "ahoy"}}]
client._session.get.return_value = FakeResponse(200, rows)
delivery = PollDelivery(client, interval=0.0)
seen: list[str] = []
async def async_handler(msg, _c):
await asyncio.sleep(0)
seen.append(msg.text)
return None
n = delivery.run_once(async_handler)
assert n == 1
assert seen == ["ahoy"]
def test_poll_delivery_handler_returns_text_triggers_reply(client: RemoteAgentClient):
rows = [{"id": "act-1", "data": {"source": "canvas_user", "text": "hi"}}]
# First mock the GET (fetch_inbound), then the POST (reply).
client._session.get.return_value = FakeResponse(200, rows)
client._session.post.return_value = FakeResponse(200, {"status": "sent"})
delivery = PollDelivery(client, interval=0.0)
def handler(msg, _c):
return f"echo:{msg.text}"
n = delivery.run_once(handler)
assert n == 1
# /notify should have been called with the echo body.
post_call = client._session.post.call_args
assert "/notify" in post_call.args[0]
assert post_call.kwargs["json"] == {"message": "echo:hi"}
def test_poll_delivery_handler_returns_none_no_reply(client: RemoteAgentClient):
rows = [{"id": "act-1", "data": {"source": "canvas_user", "text": "hi"}}]
client._session.get.return_value = FakeResponse(200, rows)
delivery = PollDelivery(client, interval=0.0)
def handler(_msg, _c):
return None
delivery.run_once(handler)
client._session.post.assert_not_called()
def test_poll_delivery_410_resets_cursor(client: RemoteAgentClient):
delivery = PollDelivery(client, interval=0.0)
delivery._cursor = "act-stale"
client._session.get.return_value = FakeResponse(410, {"error": "gone"})
n = delivery.run_once(lambda *_: None)
# No messages dispatched, cursor reset to None.
assert n == 0
assert delivery.cursor is None
def test_poll_delivery_cursor_file_persistence(
client: RemoteAgentClient, tmp_path: Path
):
cursor_file = tmp_path / "cursor"
rows = [{"id": "act-XYZ", "data": {"source": "canvas_user", "text": "hi"}}]
client._session.get.return_value = FakeResponse(200, rows)
delivery = PollDelivery(client, interval=0.0, cursor_file=cursor_file)
assert delivery.cursor is None # nothing on disk yet
delivery.run_once(lambda *_: None)
assert cursor_file.read_text() == "act-XYZ"
# New delivery instance reads the cursor from disk.
fresh = PollDelivery(client, interval=0.0, cursor_file=cursor_file)
assert fresh.cursor == "act-XYZ"
def test_poll_delivery_stop_makes_run_once_noop(client: RemoteAgentClient):
delivery = PollDelivery(client, interval=0.0)
delivery.stop()
n = delivery.run_once(lambda *_: None)
assert n == 0
# GET should not have been issued.
client._session.get.assert_not_called()
# ---------------------------------------------------------------------------
# PushDelivery
# ---------------------------------------------------------------------------
def test_push_delivery_run_once_is_noop(client: RemoteAgentClient):
fake_server = MagicMock()
delivery = PushDelivery(client, fake_server)
n = delivery.run_once(lambda *_: None)
assert n == 0
def test_push_delivery_stop_calls_server_stop(client: RemoteAgentClient):
fake_server = MagicMock()
delivery = PushDelivery(client, fake_server)
delivery.stop()
fake_server.stop.assert_called_once()
def test_push_delivery_stop_swallows_server_exception(
client: RemoteAgentClient, caplog
):
fake_server = MagicMock()
fake_server.stop.side_effect = RuntimeError("server down hard")
delivery = PushDelivery(client, fake_server)
# Should not raise.
delivery.stop()
# ---------------------------------------------------------------------------
# run_agent_loop
# ---------------------------------------------------------------------------
def _stub_state(client: RemoteAgentClient, paused=False, deleted=False, status="online"):
"""Make poll_state return a stub WorkspaceState."""
client.poll_state = MagicMock( # type: ignore[method-assign]
return_value=WorkspaceState(
workspace_id=client.workspace_id,
status=status,
paused=paused,
deleted=deleted,
)
)
def test_run_agent_loop_exits_on_paused(client: RemoteAgentClient, monkeypatch):
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client, paused=True, status="paused")
delivery = MagicMock()
delivery.run_once.return_value = 0
delivery.interval = 0.0
terminal = client.run_agent_loop(lambda *_: None, delivery=delivery)
assert terminal == "paused"
delivery.stop.assert_called_once()
def test_run_agent_loop_exits_on_deleted(client: RemoteAgentClient, monkeypatch):
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client, deleted=True, status="removed")
delivery = MagicMock()
delivery.run_once.return_value = 0
delivery.interval = 0.0
terminal = client.run_agent_loop(lambda *_: None, delivery=delivery)
assert terminal == "removed"
def test_run_agent_loop_max_iterations(client: RemoteAgentClient, monkeypatch):
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client) # online forever
delivery = MagicMock()
delivery.run_once.return_value = 0
delivery.interval = 0.0
terminal = client.run_agent_loop(lambda *_: None, delivery=delivery, max_iterations=3)
assert terminal == "max_iterations"
assert delivery.run_once.call_count == 3
assert client.heartbeat.call_count == 3
def test_run_agent_loop_default_delivery_is_poll(client: RemoteAgentClient, monkeypatch):
"""When delivery=None, run_agent_loop should construct a PollDelivery."""
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client, paused=True, status="paused")
# fetch_inbound returns an empty list once for the default-poll path.
client.fetch_inbound = MagicMock(return_value=[]) # type: ignore[method-assign]
terminal = client.run_agent_loop(lambda *_: None)
assert terminal == "paused"
client.fetch_inbound.assert_called()
def test_run_agent_loop_swallows_heartbeat_exception(
client: RemoteAgentClient, monkeypatch
):
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock(side_effect=RuntimeError("hb down")) # type: ignore[method-assign]
_stub_state(client, paused=True, status="paused")
delivery = MagicMock()
delivery.run_once.return_value = 0
delivery.interval = 0.0
terminal = client.run_agent_loop(lambda *_: None, delivery=delivery)
# Heartbeat failure does NOT stop the loop — we still detect 'paused'.
assert terminal == "paused"
def test_run_agent_loop_swallows_delivery_exception(
client: RemoteAgentClient, monkeypatch
):
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client, paused=True, status="paused")
delivery = MagicMock()
delivery.run_once.side_effect = RuntimeError("delivery exploded")
delivery.interval = 0.0
terminal = client.run_agent_loop(lambda *_: None, delivery=delivery)
# Delivery failure logged + continued; loop still exits cleanly on paused.
assert terminal == "paused"
def test_run_agent_loop_uses_min_of_intervals(client: RemoteAgentClient, monkeypatch):
"""The loop should sleep min(heartbeat_interval, delivery.interval)."""
sleeps: list[float] = []
monkeypatch.setattr("time.sleep", lambda s: sleeps.append(s))
client.heartbeat_interval = 30.0
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client) # online; uses max_iterations to exit
delivery = MagicMock()
delivery.run_once.return_value = 0
delivery.interval = 5.0
client.run_agent_loop(lambda *_: None, delivery=delivery, max_iterations=2)
assert sleeps == [5.0, 5.0]
def test_run_agent_loop_calls_task_supplier(client: RemoteAgentClient, monkeypatch):
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client, paused=True, status="paused")
delivery = MagicMock()
delivery.run_once.return_value = 0
delivery.interval = 0.0
def supplier():
return {"current_task": "doing-thing", "active_tasks": 2}
client.run_agent_loop(lambda *_: None, delivery=delivery, task_supplier=supplier)
# Heartbeat receives the supplied report.
hb_kwargs = client.heartbeat.call_args.kwargs
assert hb_kwargs["current_task"] == "doing-thing"
assert hb_kwargs["active_tasks"] == 2
def test_run_agent_loop_swallows_task_supplier_exception(
client: RemoteAgentClient, monkeypatch
):
monkeypatch.setattr("time.sleep", lambda _s: None)
client.heartbeat = MagicMock() # type: ignore[method-assign]
_stub_state(client, paused=True, status="paused")
delivery = MagicMock()
delivery.run_once.return_value = 0
delivery.interval = 0.0
def supplier():
raise RuntimeError("supplier broken")
terminal = client.run_agent_loop(
lambda *_: None, delivery=delivery, task_supplier=supplier
)
assert terminal == "paused"
# Heartbeat called with empty task fields (the default when supplier fails).
hb_kwargs = client.heartbeat.call_args.kwargs
assert hb_kwargs["current_task"] == ""
assert hb_kwargs["active_tasks"] == 0