Repository navigation
feat(sparrow): Claude routine adapter — inbox tasks fire a Claude Code cloud routine - #5628
Conversation
…e cloud routine Opt-in adapter next to hatch and codex: each AG2 Space inbox task becomes one POST to the routine's API trigger; the routine posts the reply into the room via the AG2 Space connector, so the local result is [no-send] + session URL. Reuses the Codex adapter's parse_task, NON_OWNER_NOTE pin, atomic writer and pidfile lock. Token only from an env var; 429 honours Retry-After; 5xx/network retried with bounded backoff; local sliding-hour fire cap. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/routine_adapter.pyLines 224-235 224 """Handle one task; returns True when it reached a final result."""
225 task_id = name[:-4]
226 try:
227 headers, message, operator_lines = parse_task(os.path.join(self.args.inbox, name))
! 228 except FileNotFoundError:
! 229 log("vanished before read ->", task_id)
! 230 seen.add(task_id)
! 231 return True
232 tier = headers.get("access_tier") or "guest"
233 if tier != "owner" and not self.args.allow_non_owner:
234 log("refused (non-owner, --allow-non-owner not set) ->", task_id, tier)
235 self.finish(name, NON_OWNER_REFUSED, seen)Lines 281-289 281 while True:
282 names = self.pending(seen)
283 if not names:
284 if self.args.once and done >= self.args.once:
! 285 return
286 if self.args.idle_exit and self.clock() - idle_since > self.args.idle_exit:
287 log("idle-exit")
288 return
289 self.sleep(self.args.poll)Lines 353-362 353 Adapter(args, token, opener).serve()
354 finally:
355 try:
356 os.unlink(lock)
! 357 except OSError:
! 358 pass
359 return 0
360
361
362 if __name__ == "__main__":packages/ag2-sparrow/tests/test_claude_routine_adapter.pyLines 33-55 33 class FakeFire(http.server.BaseHTTPRequestHandler):
34 """Replies from the server's `script` list, one entry per request; the last one repeats."""
35
36 def do_POST(self):
! 37 n = int(self.headers.get("Content-Length") or 0)
! 38 srv = self.server
! 39 srv.requests.append({"path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()},
40 "body": json.loads(self.rfile.read(n).decode("utf-8"))})
! 41 status, headers, body = srv.script[min(len(srv.requests) - 1, len(srv.script) - 1)]
! 42 raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
! 43 self.send_response(status)
! 44 for k, v in headers.items():
! 45 self.send_header(k, v)
! 46 self.send_header("Content-Length", str(len(raw)))
! 47 self.end_headers()
! 48 self.wfile.write(raw)
49
50 def log_message(self, *a):
! 51 pass
52
53
54 def ok(i=1):
55 return (200, {}, {"type": "routine_fire", "claude_code_session_id": "session_%d" % i,
|
…ot sent Review of #5628: with no idempotency key, a timeout, reset or 5xx can follow an accepted fire, so retrying it can start duplicate sessions and room posts. Only connection refused, DNS failure and 503+Retry-After are retried (default --max-retries 2); anything ambiguous gets a "may or may not have reached" result and is done. Also: repeated 429s give up per task and the hold persists across restarts; future-dated fire entries are dropped; https-only api base; echo mode is owner-only; the .md files ship as package data. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@qingyun-wu review requested: /packages/ code owner; this is the third sibling of #5611/#5612 (cloud-routine adapter). CI 34/34 green at 1736a54. Evidence, retry policy and limits are in the body. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Request changes at 1736a54 (code-owner review). The adapter is cleanly opt-in and the retry handling is careful, but one blocker concerns authority.
Blocker: a non-owner task gets the owner's authority, limited only by prompt text. build_payload (routine_adapter.py lines 44-60) fires every tier at the same routine, and for any tier other than owner it only adds a note: line. That routine holds the owner's AG2 Space connector and acts as the owner. So a guest or team message in any room this adapter serves reaches an agent that can post to other rooms or run room Actions, and the only thing stopping it is the model following the note and the saved prompt.
- The Codex adapter this one imports from enforces the boundary instead: non-owner tiers run with
NON_OWNER_SANDBOX = "read-only"(codex_connector.pyline 37, applied at line 301), andpush()refuses them. CLAUDE.md's "Channel access control" also sends non-owner tiers down the sandboxed path. - Suggested fix: refuse non-owner tiers by default and write a short result saying so. Allow them only behind an explicit flag, or route them to a separate routine whose connector scope is limited (for example a
--non-owner-trigger-id).
Should fix (not blocking on their own):
- The bridge's instructions block is demoted to data. In
build_payload, the bridge-written lines follow the guest's message directly, inside the text the routine prompt tells the agent to treat as untrusted. The test attest_claude_routine_adapter.pyline 183 asserts that adjacency. A guest can write a block that looks identical. The Codex adapter'swrap_untrustedkeeps these lines outside the untrusted span. - Adapter errors reach guest rooms. Failure results such as an HTTP 401 with
authentication_errorgo to the room whatever the tier, so guests learn about auth and configuration state. - A task file that vanishes crashes
serve().pending()(lines 191-196) callsos.statinside the sort key afterlistdir, andstep()callsparse_taskunguarded. A file removed in between, by a cancel or cleanup, raises an uncaughtFileNotFoundError.
Checked and clean: the token is read only from the env var named by --token-env, never from argv and never logged, and the adapter exits 2 when it is unset. Only fires that provably were not sent are retried (connection refused, DNS failure, 503 with Retry-After); timeouts and other 5xx are not, and the 429 hold is persisted. Results go to results/ and delivery stays with the bridge, so there is no second outbox. grep -rn claude_routine src skills scripts finds nothing, so core does not import it. Comments are within two lines and there are no host paths.
Tests run: python3 packages/ag2-sparrow/tests/test_claude_routine_adapter.py and python3 tests/ag2-sparrow-claude-routine.test.py, 30 tests each, both OK; test_codex_connector.py still OK. Without routine_adapter.py the suite fails on import.
Live evidence: the body is candid that the 19.3 s timing comes from an earlier manual routine proof and the adapter itself has only run against a local fake server. For a new live path, one real fire with a vaulted token and one room reply should come before anyone relies on it.
I read the blocker and the vanished-file race in the code myself; a reviewer agent ran the tests.
…ed-file crash and an error-detail leak Review (qingyun-wu): every tier fired at the one routine holding the owner's AG2 Space connector, limited only by a prompt note — a guest or team task reached full owner authority with nothing but the model's compliance in the way. Non-owner tasks are now refused before ever being sent, with a plain result saying so, unless --allow-non-owner is passed; the Codex adapter this one imports from enforces its own non-owner boundary the same way (a narrower sandbox), and this adapter has no narrower connector to fall back to, so refusal is the only boundary available here. Also from the same review: pending() no longer crashes serve() when a task file vanishes between listdir and stat (a cancel or cleanup mid-poll); and a non-owner task's failure result no longer carries the HTTP status or error type, which is the owner's connector's state, not something a non-owner sender should learn. Not done in this pass: the bridge's operator lines still sit inside the same untrusted block as the user's message, rather than outside a tagged span the way the Codex adapter's wrap_untrusted keeps them separated. Left as a known follow-up; noted on the PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@cla-assistant check |
|
Addressed the blocker and two of the three should-fix items:
Verified all with real test runs, not just added them: 34/34 pass at the new head (30 original + 4 new), and each new test fails against the pre-fix code when I temporarily reverted it, confirming they're real regression guards, not tautologies. Not done: the bridge's operator lines still sit inside the same untrusted block as the user's message, rather than outside a tagged span the way Re-requesting qingyun-wu's review at this head. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approve at f2bdfdb. The blocker from my review at 1736a54 is fixed.
- Non-owner tiers are refused by default.
step()now finishes any task whose tier isn't owner withNON_OWNER_REFUSEDunless--allow-non-owneris passed, before anything is fired. With that gate replaced byif False:,test_claude_routine_adapter.pyfails (1 failure), so the refusal is pinned. - Error detail no longer reaches non-owner rooms.
result_bodyreturns a generic line for a non-owner outcome. - A vanished task file no longer crashes
serve().pending()drops a file that disappears before its stat, andstep()skips one that disappears before its read;test_a_task_file_that_vanishes_before_its_turn_is_skipped_not_a_crashcovers it.
Verified locally at f2bdfdb: python3 packages/ag2-sparrow/tests/test_claude_routine_adapter.py OK and python3 tests/ag2-sparrow-claude-routine.test.py OK. CI was still running when I checked.
Nits, none blocking:
- The comment above
NON_OWNER_REFUSEDexplains itself through "the blocker this exists for", which is review history. CLAUDE.md asks comments to state the constraint, for example that the routine holds the owner's connector, so other tiers are refused unless allowed. NON_OWNER_REFUSEDis delivered to the non-owner sender and tells them about--allow-non-owner. A plain "this agent only answers its owner here" would say enough without describing the owner's configuration.- The instructions block still sits inside the untrusted span in
build_payload. It now only matters when--allow-non-owneris set, so it can follow in a later change, but anyone enabling that flag should know the bridge's block is demoted to data there.
…ines outside the untrusted span (#5630) - Comment above NON_OWNER_REFUSED states the constraint instead of review history. - NON_OWNER_REFUSED is now "This agent only answers its owner here."; the --allow-non-owner detail stays in the log and README. - build_payload wraps the message in fresh-nonce UNTRUSTED_TASK markers and puts the bridge-written instructions after the closing marker; only the body is truncated so the markers and instructions survive. routine-prompt.md and README describe the layout. Refs #5628 Stand: Echo Act IV Pro Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
What changed, and why
A new opt-in adapter,
packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/, sits next tohatchandcodex. It polls the inbox and turns each AG2 Space task into one fire of a Claude Code cloud routine's API trigger. The routine has the AG2 Space connector and posts the reply into the room itself. Because of that, the local result is[no-send]with the session URL on a comment line, so the bridge does not post a second reply.--result-mode echowritesSent to the cloud agent: <url>instead, for debugging.POST https://api.anthropic.com/v1/claude_code/routines/{trig_id}/firewithAuthorization: Bearer,anthropic-version: 2023-06-01,Content-Type: application/json, and body{"text"}of at most 65,536 chars.{type, claude_code_session_id, claude_code_session_url}.anthropic-beta: experimental-cc-routine-2026-04-01. The API reference says the endpoint accepts requests with or without it, so the adapter does not send it.room=<channel_id> thread=<thread_root|none> tier=<tier> collaborator=<bool> task_id=<id>, thennote: <NON_OWNER_NOTE>for non-owner tiers, then the untrusted body.hatch/task_fields.attested_headersthrough the Codex adapter'sparse_task).--token-env(defaultCLAUDE_ROUTINE_TOKEN). There is no argv flag for it and it is never logged. The adapter refuses to start without it (exit 2). The README tells operators to run it undersecret-vault.py env.Retry-After(clamped to 1 s–1 h). The hold is persisted instate/hold.jsonso a restart honours it. After--max-rate-limited429s (default 5) the task gets a visible failure result.Retry-After.--max-retriesdefaults to 2.[no-send].--api-basemust be https;--allow-insecure-api-baseexists for the local test server only.routine-prompt.mdand the README ship as package data, andtest_wheel_contents.pypins them.--max-per-hour(default 25, at most 30, the per-routine cap) counts fires in a sliding hour kept instate/fires.json. The server caps (30 per routine per hour, 100 API fires per account per hour) are documented.routine-prompt.mdis the exact saved prompt for the routine. It carries the same NON_OWNER_NOTE text, and a test pins it byte-for-byte.parse_task,non_owner_note,write_atomic,acquire_lockandload_done/record_donefrom the Codex adapter rather than copying them.Review first
routine_adapter.py:build_payload,fire,Adapter.step.routine-prompt.md.What this proves
Live numbers, measured by the routine proof that preceded this adapter, not by this adapter: fire → reply posted in the AG2 Space room 19.3 s; run done +21 s. The adapter itself was tested only against a local fake HTTP server (
http.serverin a thread on 127.0.0.1). It made no call to Anthropic and has had no live round trip yet. Because it is a new opt-in path that nothing starts, a live witness (a real token in the vault and one room message) is the next step before anyone relies on it.Feature/documentation consistency
N/A for
docs/. This is a new opt-in package adapter, documented in its own README like the Codex adapter (#5611). No existingdocs/catalog.jsonentry covers sparrow adapters.Before / after
New files only, so there is no failing state at the parent:
test_claude_routine_adapter.pydoes not exist onorigin/main. At HEAD, run once in the foreground with allSUTANDO_*unset:Covered:
--tokenflagRetry-After(the 429 is not counted as a fire)bash scripts/review-checks.sh --diff <(git diff origin/main...HEAD)→ PASS.Tests run
The command above, plus
tests/ag2-sparrow-claude-routine.test.py, a runpy wrapper so the coverage gate counts the suite. It was compile-checked only, not run separately. The suite is also added toci.ymlnext totest_codex_connector.py.Live path?
The adapter is a new opt-in path that no startup script launches, so no running service changes. Its live proof is the routine run above, not this code.
🤖 Generated with Claude Code