Deterministic delegation receipts: route delegated outcomes back to the delegator - #583
Open
QuanCheng-QC wants to merge 8 commits into
Open
Deterministic delegation receipts: route delegated outcomes back to the delegator#583QuanCheng-QC wants to merge 8 commits into
QuanCheng-QC wants to merge 8 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ack to the delegator Agent-to-agent delegation had no completion signal: a sub-agent's report was routed by the LLM router, which prefers "stop" and silently swallowed it, so users had to relay results between agents by hand. Backend (workspace_mod): - strip client-supplied server-owned metadata (delegated_by/delegated_to/ receipt_from) so a forged delegation chain can never enter routing - mark explicit @mention delegations from agents with delegated_by/to - deterministically route structured receipts (in_reply_to + reply_kind) back to the delegator, bypassing the LLM router; validated for same workspace/channel, source consistency, membership, TTL (DELEGATION_RECEIPT_TTL_HOURS, default 24h), single terminal receipt per (E1, replier) stamped onto E1 as receipt_from - mentioning the original delegator stays a plain receipt; mentioning a third agent dual-routes [delegator, C] and marks the onward delegation; master mode keeps its star topology (onward mentions ignored) Connector: - BaseAdapter gains sendFinalResult/sendFinalError/sendNeedsInput/ sendCancelled, stamping reply_kind + in_reply_to; synthetic triggers (system:*, no event id) degrade to plain replies - _inflightTurns registry (strict lifecycle) supplies the trigger for control-action cancellations only; Claude's late trailing output stays unstamped rather than guessing attribution - all adapters migrated at their terminal send points; result vs error vs needs_input vs cancelled mapped per path Launcher: reply_kind badge on agent messages (returned / failed / needs input / cancelled) — deliberately not "task completed". Known limits (phase 2): no durable receipt inbox across daemon restarts (_skipExistingEvents jumps to head); Python SDK prompt mirror not updated.
…once, scoped badges Follow-up review fixes on the receipt-routing branch: - migrate every remaining turn-ending sendError in aider/amp/cline/copilot/ cursor/gemini/goose/llm-direct/mini to sendFinalError so delegated failures (missing CLI, config errors, spawn/timeout/non-zero exit) still return a deterministic receipt; goose threads the trigger through _runGoose including its retry recursion - claim the single terminal receipt under SELECT ... FOR UPDATE on E1 so concurrent duplicate replies cannot both route to the delegator or lose a receipt_from stamp (SQLite tests unaffected — FOR UPDATE is a no-op) - verify delegator AND replier against live WorkspaceMember rows (hard- and soft-removed) in addition to channel membership: ChannelMember rows outlive workspace removal and could re-target an unreachable agent - duplicate receipts no longer fall back to the LLM router (which could re-select the delegator): the delegator is suppressed deterministically, explicit onward @mentions are still honoured; needs_input is non-consuming so an ask followed by the real result both deliver - stamp reply_kind only when the trigger carries the server-written delegated_by/delegated_to naming this agent — ordinary human-triggered replies no longer render as delegation receipts in Launcher; in_reply_to correlation is kept for every real trigger Tests: backend 29 receipt tests (at-most-once vs router re-route, onward on duplicate, non-consuming needs_input, workspace-removed delegator), connector 787 passing incl. reply_kind gating.
_buildClaudeCmd throws when the Claude CLI is unavailable or turn configuration cannot be built; that catch ends the turn, so a delegated turn must return a deterministic error receipt instead of a plain sendError. The watchdog notice stays non-terminal — its path resolves into retry/exit handling that posts the final outcome itself.
…ation An authenticated receipt whose delegator is no longer deliverable (left the channel, hard- or soft-removed from the workspace) used to return None from _receipt_route, i.e. "not a receipt" — normal routing resumed and _master_targets would route the sub-agent's reply straight back to the stale master (channel.master_agent survives every removal path), bypassing the very guard that had just failed. The LLM router could do the same in dynamic mode. Membership failures and consumed duplicates now return (onward, onward): the delegator is suppressed deterministically (empty targets → sentinel) while an explicit onward @mention is still honoured, and orchestration is never re-entered. Deliberate deviation from the review on TTL expiry: it still falls back to normal routing. At that point the delegator is verified alive and present, so best-effort orchestration (master star rule, router "reports back") can still deliver a long-running task's late result — suppressing would guarantee exactly the loss this feature exists to prevent. The TTL only stops stale E1s from forcing deterministic routing; an expired E1 is never claimed. Duplicate check moved before the TTL so at-most-once holds regardless of E1 age. Tests: stale-master suppression across all three removal paths in master mode, expired-E1 star delivery to a live master, onward mention surviving suppression, consumed+removed combination, plain sub-agent chatter still star-routed; connector regression for _buildClaudeCmd throwing on a delegated turn (reply_kind=error receipt). Backend 31 receipt cases, connector 788 passing.
Two edge cases from review of the suppression change: - Drop the TTL gate entirely. A first result arriving after the TTL used to fall through unstamped, so master/fallback routing delivered it — and every later duplicate again, forever: "expired E1 never claimed" and "at-most-once regardless of age" cannot both hold. receipt_from already caps delivery at one per (E1, replier), which is the only guarantee the TTL meaningfully added; late results from long-running tasks are the normal case, not abuse. DELEGATION_RECEIPT_TTL_HOURS is removed with it. - Filter onward delegates against live WorkspaceMember rows. Soft removal keeps both the WorkspaceMember row (status=removed) and the stale ChannelMember row, and mention parsing does not exclude removed members, so "@C" could mint a server-written delegation to an agent that can neither poll nor rejoin — and when the delegator was also gone, the receipt targeted ONLY the dead C. One membership query now covers delegator, replier, and all onward candidates. Tests: late first result delivers once then suppressed (dynamic and master), soft-removed onward dropped without a delegation mark, removed delegator + removed onward → sentinel without router, duplicate with removed onward → sentinel. Backend 37 receipt cases.
- A replier that is no longer a live channel + workspace member is fully suppressed — onward included. The events entry point validates only the workspace token/session (_validate_session passes for removed members), so a departed B could otherwise use a stale E1 to mint a server-written delegated_by=B/delegated_to=[C] delegation, and when the delegator was also gone the receipt targeted only C. Only a gone DELEGATOR keeps valid onward mentions alive. - needs_input gets its own replay bound: it deliberately never consumes the terminal slot, so "receipt_from caps delivery at one" did not apply and a single stale E1 was an unlimited deterministic router bypass. Server-owned needs_input_from now counts wake-ups per (E1, replier); up to _NEEDS_INPUT_LIMIT (3) deliver deterministically (several questions in one turn are legitimate — Cline can ask more than once), after which the delegator is suppressed like a duplicate. The terminal slot stays independent, and the field is stripped from inbound metadata like the other server-owned keys. Tests: departed-replier @C across all three removal paths (no routing, no delegation mark, no router call), needs_input limit boundary plus terminal delivery after exhaustion, forged needs_input_from stripped. 38 receipt cases collected.
The departed-replier check lived only inside _receipt_route, so a removed B could drop in_reply_to/reply_kind and send a plain "@C do X" — ordinary routing then still delivered it and wrote a server-marked delegated_by=B/delegated_to=[C] (_validate_session passes for missing and soft-removed members). Two general gates now sit in front of ALL agent-chat routing: - sender gate: an agent message whose sender is not a live WorkspaceMember AND a current ChannelMember goes straight to the no-response sentinel — no receipt handling, no router, no marks. Routine channels register their owner as a ChannelMember on creation, so system job queues are unaffected. - target gate: agent-sourced routing results are filtered to live current participants — the mention fallback can name a live agent outside the channel, and router/master candidate lists can include the stale ChannelMember row of a removed agent. Human messages keep the wider net (their targets are auto-added to the channel). Mention parsing now excludes soft-removed members up front (live_agents replaces known_agents), so a removed name is no candidate anywhere — routing, delegation marks, or receipt onward. Tests: departed sender's plain @C across all three removal paths (no routing, no marks, no router call), live sender's plain @C to a soft-removed target dropped even when the router picks the stale name. 42 receipt cases, router suite unchanged.
…utright
Follow-up on the membership gates:
- The live-member filter now applies to EVERY routing result, not just
agent-sourced ones. channel.master_agent and ChannelMember rows both
survive member removal, so a human message in master mode was handed
straight to the deleted master (and the dynamic router's all-offline
fallback re-admitted removed names). Master dispatch falls back when
the recorded master is not a live member, _fallback_targets skips
non-live masters/participants, and the router's candidate list drops
removed members before the model ever sees them (its human safety net
fallback is live-filtered too). Only the "must be a current channel
participant" restriction stays agent-only — human-targeted agents are
auto-added to the channel.
- The departed-sender gate raises EventRejected ("agent_removed" /
"channel_membership_required") instead of routing to the sentinel. A
sentineled event still persisted and pushed to humans, so a zombie
daemon of a removed agent could keep writing visible messages and
firing notifications. Rejection keeps the event out of persistence,
push fan-out, SSE, and cloud-agent invocation entirely; the reason
string matches the join-path rejection so clients handle one code.
Known trust boundary (for the PR): these gates trust event.source. The
workspace token is shared and missing session_ids pass as legacy, so
this stops honest-but-stale daemons, not a token-holding client that
impersonates a live agent.
Tests: departed sender/replier rejection with exact reasons across all
three removal paths, human message with deleted master falls back to a
live participant, router picking a removed name falls back for humans.
44 receipt cases; full backend 544 passed with the same pre-existing
environment failures.
QuanCheng-QC
force-pushed
the
feature/delegation-receipt-routing
branch
from
August 4, 2026 14:39
bcb0e58 to
0039d99
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. What was broken
Symptom. When agent A delegates work to agent B (
@B do X), A never learns thatB finished. B's report goes through the LLM router, which is prompted to prefer
stop— so the report lands in the channel and dies there. Agents had no way toknow whether the other side had finished, because no task-completion notification
existed at all.
The user absorbed the gap by hand: read B's message, recognise it as a report,
then manually
@A B is done, here's the resultto wake A up. Chasing an agent forstatus and relaying the outcome to the next one is coordination the system should
be doing — a human was standing in for the router.
Source. Product feedback on multi-agent workspaces: delegation worked, hand-off
did not. Users reported having to babysit every delegated task and manually
@-mention agents to pass results between them.
Blast area. Any multi-agent channel in
dynamicormastermode where oneagent delegates to another. Single-agent channels and human→agent messages are
unaffected.
2. Reproduce it
Two agents in one
dynamicchannel, LLM router enabled.Post to the channel — note there is no
@on agent-b, mentioning it wouldroute around agent-a and the delegation would never form:
Then watch the connector log for
Processing message from ...lines.3. Before
15:03:31 agent-b ← "@agent-b please write ..." (A delegates, fine)
15:06:56 —— ← nothing. B's report sits in the channel.
A is idle. The human must @A to relay it.
Backend log shows
LLM router decision: stopfor B's report.4. After
15:03:31 agent-b ← "@agent-b please write ..." E1, stamped delegated_by/delegated_to
15:06:56 agent-a ← "draft is done, file 5429ab70" receipt, routed deterministically
Backend log shows:
workspace_mod: receipt from openagents:agent-b routed to ['agent-a'] in
A wakes up with no human involvement. B's message carries a
returnedbadge in theLauncher.
Failure and interruption are covered the same way, so A never waits forever:
error(B's turn failed),needs_input(B has a question),cancelled(user hitstop) — each is a distinct badge, and each reaches A.
Deliberately not named "task completed". A finished turn is not proof the task
is done, and the UI says
returned, nevercompleted.5. What else this touches
Two of these change behaviour for flows that have nothing to do with delegation.
They're the ones worth your attention.
EventRejected: agent_removed/channel_membership_required) — not persisted, no pushchannel.master_agentandChannelMemberrows survive member removal, so master dispatch / fallback / router candidates could all name a deleted agent — the message strandedin_reply_to;reply_kindonly when the trigger was a server-marked delegation@removed-agentmentionssendResponse/sendErrorsendFinalResult/sendFinalError/sendNeedsInput/sendCancelledUnchanged: human→agent routing, single-agent channels, agent replies that
weren't delegated, message types (
status/thinking/todos), master mode'sstar topology.
6. Risk and rollback
The riskiest piece is the ingress rejection in §5 row 1 — it's the only change that
drops traffic that used to be accepted. It's gated on live workspace membership
and current channel membership, both read from the DB per message.
Rollback is clean: the feature is additive at the routing layer. Reverting the
branch restores LLM-router-only behaviour; no migration, no schema change (the new
fields are JSON metadata on existing event rows).
7. Known gaps — read before approving
delegated_by, so it correctly does not deterministically bounce back to B —but it falls through to the LLM router, which may still pick B by conversational
continuity. Observed in manual testing: A's final answer for the user woke B
twice for nothing. The backend test asserts the receipt path doesn't bounce (with
the router mocked to
stop); it does not assert the router won't. This is areal remaining hole, not a hypothetical.
(
_skipExistingEventsjumps to stream head). Durable receipt inbox = phase 2.FOR UPDATEmutual exclusion is unproven by CI. SQLite ignores row locks, sothe 44 backend cases verify logic, not concurrency. Needs a Postgres test.
event.source. The workspace token is sharedand missing
session_ids pass as legacy — this stops honest-but-stale daemons,not a token-holding client impersonating a live agent. Per-agent credentials are
future work.
know the "don't @ the delegator in your report" rule and may ping-pong.
status/thinkingmessages still persist (they trigger norouting); would need the gate hoisted above the message-type early-return.
8. Test evidence
test_receipt_routing.pydevelop(verified by diffing failure sets); this branch adds 44 passes and zero failurestsc -bclass-variance-authorityandradix-uiare absent from the local install, so the typecheck cannot run here. Relying on CI; the only launcher change is thereply_kindbadge inMessageBubble.tsxRebased onto
develop@c3a18260. The only rebase conflict wasMessageBubble.tsx,resolved onto develop's shadcn
BadgeAPI (variant+size="sm", replacing thedeleted legacy
success-smcompound variants).Design detail — expand if you're reviewing the implementation
Delegation marking (server-owned)
When an agent's message routes via its own explicit @mentions, the backend stamps
delegated_by/delegated_toonto the event. Client-supplied values for allserver-owned keys (
delegated_by,delegated_to,receipt_from,needs_input_from) are stripped on ingress, so a forged delegation chain can neverenter routing. Router-inferred hops are not marked — otherwise the recipient's
acknowledgement would bounce back as a receipt.
Structured receipts (connector)
BaseAdaptergainssendFinalResult/sendFinalError/sendNeedsInput/sendCancelled, stampingreply_kind+in_reply_to. Every turn-ending path inall 15 adapters is migrated.
reply_kindis only stamped when the trigger carriesa server-written delegation naming this agent, so ordinary human-triggered replies
never render as receipts.
Receipt routing (backend)
An authenticated receipt bypasses the LLM router and routes straight back to the
delegator. Validation: same workspace + channel,
delegated_byconsistent withE1's source, replier in
delegated_to, both ends live workspace members andcurrent channel participants. The claim is atomic (
SELECT ... FOR UPDATEon E1)and at-most-once per
(E1, replier)viareceipt_from. Duplicates andundeliverable receipts are suppressed deterministically — never re-entering
master/LLM orchestration — with explicit onward @mentions still honoured.
Membership gates (backend)
Agent messages from senders that are not live members + current participants are
rejected outright — no persistence, no push fan-out. No routing result, from any
source, may target a removed agent (master dispatch, fallback, and router
candidates are all live-filtered).
Semantic decisions
needs_inputreceipts are non-consuming (an ask followed by the real result bothdeliver) but bounded: at most 3 deterministic wake-ups per
(E1, replier)vianeeds_input_from, then suppressed like a duplicate.of E1's age. A TTL created a window where a late first result bypassed the claim
and its duplicates were re-delivered forever; long-running tasks finishing days
later are the normal case this feature serves.
delegation → no ack ping-pong from the deterministic path). Mentioning a third
agent dual-routes
[delegator, C]and marks the onward delegation. Master modekeeps its star topology (onward mentions from sub-agents are ignored).
proven after the turn resolved, and a wrong receipt is worse than a missing one.
Cancellation uses a strict-lifecycle inflight-turn registry (cancellation only,
never late-output attribution).