Skip to content

feat(sparrow): Claude routine adapter — inbox tasks fire a Claude Code cloud routine - #5628

Merged
qingyun-wu merged 4 commits into
mainfrom
feat/sparrow-claude-routine-adapter
Oct 16, 2026
Merged

qingyun-wu merged 4 commits into
mainfrom
feat/sparrow-claude-routine-adapter

Conversation

@sonichi

@sonichi sonichi commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

What changed, and why

A new opt-in adapter, packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/, sits next to hatch and codex. 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 echo writes Sent to the cloud agent: <url> instead, for debugging.

  • Wire, checked against the current docs (code.claude.com/docs/en/routines and platform.claude.com/docs/en/api/claude-code/routines-fire):
    • Request: POST https://api.anthropic.com/v1/claude_code/routines/{trig_id}/fire with Authorization: Bearer, anthropic-version: 2023-06-01, Content-Type: application/json, and body {"text"} of at most 65,536 chars.
    • Response: {type, claude_code_session_id, claude_code_session_url}.
    • One difference from the brief: the Claude Code page's curl still shows 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.
    • The docs also state there is no idempotency key. A 5xx or network retry can start a second session, so the routine could post twice. The README says this.
  • Payload: room=<channel_id> thread=<thread_root|none> tier=<tier> collaborator=<bool> task_id=<id>, then note: <NON_OWNER_NOTE> for non-owner tiers, then the untrusted body.
    • Tier and collaborator come from the attested headers (hatch/task_fields.attested_headers through the Codex adapter's parse_task).
    • Header values are stripped of whitespace, so a value cannot add a line.
    • A task with no room is not fired.
  • Token: read only from the env var named by --token-env (default CLAUDE_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 under secret-vault.py env.
  • Errors:
    • 429: all fires are held for Retry-After (clamped to 1 s–1 h). The hold is persisted in state/hold.json so a restart honours it. After --max-rate-limited 429s (default 5) the task gets a visible failure result.
    • Any other 4xx: a plain-text failure result.
    • Retried only when the fire provably was not sent: connection refused, DNS failure, or 503 with Retry-After. --max-retries defaults to 2.
    • Any ambiguous outcome is never retried, because it could duplicate a session: a timeout, a reset, or any other 5xx or network error. The task gets "may or may not have reached the cloud agent; it was not retried".
    • A crash between a 200 and the result write re-fires on restart. The README documents this.
  • Echo mode writes the session URL only for owner tasks; non-owner rooms still get [no-send].
  • --api-base must be https; --allow-insecure-api-base exists for the local test server only.
  • Packaging: routine-prompt.md and the README ship as package data, and test_wheel_contents.py pins them.
  • Rate limit: --max-per-hour (default 25, at most 30, the per-routine cap) counts fires in a sliding hour kept in state/fires.json. The server caps (30 per routine per hour, 100 API fires per account per hour) are documented.
  • Prompt: routine-prompt.md is the exact saved prompt for the routine. It carries the same NON_OWNER_NOTE text, and a test pins it byte-for-byte.
  • Reuse: the adapter imports parse_task, non_owner_note, write_atomic, acquire_lock and load_done/record_done from the Codex adapter rather than copying them.

Review first

  1. routine_adapter.py: build_payload, fire, Adapter.step.
  2. routine-prompt.md.
  3. The README's Results, Errors and Trust sections.

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.server in 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 existing docs/catalog.json entry covers sparrow adapters.

Before / after

New files only, so there is no failing state at the parent: test_claude_routine_adapter.py does not exist on origin/main. At HEAD, run once in the foreground with all SUTANDO_* unset:

$ env -u SUTANDO_… COVERAGE_FILE=… python3 -m coverage run --rcfile=.coveragerc -m unittest packages/ag2-sparrow/tests/test_claude_routine_adapter.py
..........................
Ran 30 tests in 9.270s

OK
$ python3 -m coverage report --rcfile=.coveragerc -m --include='packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/*'
Name                                                                          Stmts   Miss  Cover   Missing
packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/__init__.py              0      0   100%
packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/routine_adapter.py     256      3    99%
TOTAL                                                                           256      3    99%

Covered:

  • payload shape for each tier (owner, team+collaborator, guest, unknown tier becoming guest)
  • a body claiming a tier, and header values trying to add a line
  • mid-layout guardrail lines
  • truncation
  • the token is absent from logs and there is no --token flag
  • request path and headers
  • 429 handling with Retry-After (the 429 is not counted as a fire)
  • 4xx failure body
  • 5xx retry, then success; 5xx give-up
  • network error retried
  • the rate limiter, across a restart and with a corrupt state file
  • atomic result write, and the pidfile being released and refusing a live holder

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 to ci.yml next to test_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

…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>
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/routine_adapter.py (97.5%): Missing lines 228-231,285,357-358
  • packages/ag2-sparrow/tests/test_claude_routine_adapter.py (96.9%): Missing lines 37-39,41-48,51

Summary

  • Total: 666 lines
  • Missing: 19 lines
  • Coverage: 97%

packages/ag2-sparrow/ag2_sparrow/adapters/claude_routine/routine_adapter.py

Lines 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.py

Lines 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,

⚠️ Not measured

Changed, but absent from coverage.xml — outside [run] source in
.coveragerc, so diff-cover never examined these lines:

  • packages/ag2-sparrow/tests/test_wheel_contents.py

sonichi and others added 2 commits October 16, 2026 11:31
…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>
@sonichi

sonichi commented Oct 16, 2026

Copy link
Copy Markdown
Owner Author

@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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py line 37, applied at line 301), and push() 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 at test_claude_routine_adapter.py line 183 asserts that adjacency. A guest can write a block that looks identical. The Codex adapter's wrap_untrusted keeps these lines outside the untrusted span.
  • Adapter errors reach guest rooms. Failure results such as an HTTP 401 with authentication_error go 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) calls os.stat inside the sort key after listdir, and step() calls parse_task unguarded. A file removed in between, by a cancel or cleanup, raises an uncaught FileNotFoundError.

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>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Oct 16, 2026

Copy link
Copy Markdown
Owner Author

Addressed the blocker and two of the three should-fix items:

  • Blocker: non-owner tiers now refused by default before any fire (NON_OWNER_REFUSED result), gated by a new --allow-non-owner flag. The Codex adapter this imports from falls back to an ephemeral read-only sandbox for non-owner tasks; this adapter has no narrower connector to fall back to (one routine = one connector scope), so refusal is the only boundary available here.
  • Should-fix: pending() no longer crashes serve() when a task file vanishes between listdir and stat (reproduced the crash against the pre-fix code, confirmed the new test catches it).
  • Should-fix: a non-owner task's failure result no longer carries the HTTP status or error type (owner-only now); reproduced both the old and new behavior with a test.

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 wrap_untrusted keeps them in the Codex adapter. That's a real structural difference from the established pattern, but restructuring the payload format also means updating routine-prompt.md (the saved prompt text that has to be re-pasted into any already-configured external routine) — wanted to flag it honestly rather than rush a partial version. Happy to take it on as a follow-up if that's wanted.

Re-requesting qingyun-wu's review at this head.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with NON_OWNER_REFUSED unless --allow-non-owner is passed, before anything is fired. With that gate replaced by if False:, test_claude_routine_adapter.py fails (1 failure), so the refusal is pinned.
  • Error detail no longer reaches non-owner rooms. result_body returns 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, and step() skips one that disappears before its read; test_a_task_file_that_vanishes_before_its_turn_is_skipped_not_a_crash covers 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_REFUSED explains 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_REFUSED is 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-owner is 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.

@qingyun-wu
qingyun-wu merged commit 9d70abf into main Oct 16, 2026
37 checks passed
@qingyun-wu
qingyun-wu deleted the feat/sparrow-claude-routine-adapter branch October 16, 2026 19:10
sonichi added a commit that referenced this pull request Oct 16, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants