Skip to content

Deterministic delegation receipts: route delegated outcomes back to the delegator - #583

Open
QuanCheng-QC wants to merge 8 commits into
developfrom
feature/delegation-receipt-routing
Open

Deterministic delegation receipts: route delegated outcomes back to the delegator#583
QuanCheng-QC wants to merge 8 commits into
developfrom
feature/delegation-receipt-routing

Conversation

@QuanCheng-QC

@QuanCheng-QC QuanCheng-QC commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

1. What was broken

Symptom. When agent A delegates work to agent B (@B do X), A never learns that
B 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 to
know 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 result to wake A up. Chasing an agent for
status 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 dynamic or master mode where one
agent delegates to another. Single-agent channels and human→agent messages are
unaffected.

2. Reproduce it

Two agents in one dynamic channel, LLM router enabled.

Post to the channel — note there is no @ on agent-b, mentioning it would
route around agent-a and the delegation would never form:

@agent-a I need a Python decorator cheat sheet. Don't write it yourself —
have agent-b write it, then you review and give me the final version.

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: stop for 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 returned badge in the
Launcher.

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 hit
stop) — 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, never completed.

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.

Area Before After Who notices
Messages from departed agents A removed agent's zombie daemon could still post; the message persisted and routed Rejected at ingress (EventRejected: agent_removed / channel_membership_required) — not persisted, no push ⚠️ Anyone running a stale daemon after a member removal. Clients must handle the rejection code
Routing to removed agents channel.master_agent and ChannelMember rows survive member removal, so master dispatch / fallback / router candidates could all name a deleted agent — the message stranded Every routing result is filtered to live members; a channel with a deleted master now falls back instead of stranding ⚠️ Channels that lost a member. Behaviour is strictly better, but it is different
Agent final messages No metadata Always carry in_reply_to; reply_kind only when the trigger was a server-marked delegation Anything reading message metadata
@removed-agent mentions Resolved as a mention candidate Excluded from parsing Channels with stale member names
All 15 adapters' terminal send points sendResponse / sendError sendFinalResult / sendFinalError / sendNeedsInput / sendCancelled Nobody — same message type, same error-swallowing semantics, metadata added

Unchanged: human→agent routing, single-agent channels, agent replies that
weren't delegated, message types (status / thinking / todos), master mode's
star 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

  • Ack ping-pong is not closed. A's reply to a receipt carries no
    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 a
    real remaining hole, not a hypothetical.
  • Offline delivery is best-effort. A daemon restart skips backlogged receipts
    (_skipExistingEvents jumps to stream head). Durable receipt inbox = phase 2.
  • FOR UPDATE mutual exclusion is unproven by CI. SQLite ignores row locks, so
    the 44 backend cases verify logic, not concurrency. Needs a Postgres test.
  • Trust boundary. The gates trust event.source. The workspace token is shared
    and 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.
  • Python SDK prompt mirror not updated — agents started via the Python SDK don't
    know the "don't @ the delegator in your report" rule and may ping-pong.
  • Removed agents' status / thinking messages still persist (they trigger no
    routing); would need the gate hoisted above the message-type early-return.

8. Test evidence

Suite Result
Backend test_receipt_routing.py 44 passed (new)
Backend full suite 573 passed / 64 failed — the same 64 fail on develop (verified by diffing failure sets); this branch adds 44 passes and zero failures
Connector 788/788
Launcher tsc -b Not verified locally — class-variance-authority and radix-ui are absent from the local install, so the typecheck cannot run here. Relying on CI; the only launcher change is the reply_kind badge in MessageBubble.tsx
Manual end-to-end Delegation → receipt → delegator woken with no human input. The ping-pong gap in §7 was observed in the same run

Rebased onto develop @ c3a18260. The only rebase conflict was MessageBubble.tsx,
resolved onto develop's shadcn Badge API (variant + size="sm", replacing the
deleted legacy success-sm compound 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_to onto the event. Client-supplied values for all
server-owned keys (delegated_by, delegated_to, receipt_from,
needs_input_from) are stripped on ingress, so a forged delegation chain can never
enter routing. Router-inferred hops are not marked — otherwise the recipient's
acknowledgement would bounce back as a receipt.

Structured receipts (connector)

BaseAdapter gains sendFinalResult / sendFinalError / sendNeedsInput /
sendCancelled, stamping reply_kind + in_reply_to. Every turn-ending path in
all 15 adapters is migrated. reply_kind is only stamped when the trigger carries
a 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_by consistent with
E1's source, replier in delegated_to, both ends live workspace members and
current channel participants. The claim is atomic (SELECT ... FOR UPDATE on E1)
and at-most-once per (E1, replier) via receipt_from. Duplicates and
undeliverable 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_input receipts are non-consuming (an ask followed by the real result both
    deliver) but bounded: at most 3 deterministic wake-ups per (E1, replier) via
    needs_input_from, then suppressed like a duplicate.
  • No TTL. An authenticated receipt delivers once and claims the slot regardless
    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.
  • Mentioning the original delegator in a report stays a plain receipt (no reverse
    delegation → no ack ping-pong from the deterministic path). Mentioning a third
    agent dual-routes [delegator, C] and marks the onward delegation. Master mode
    keeps its star topology (onward mentions from sub-agents are ignored).
  • Claude's late trailing output is intentionally not stamped — attribution can't be
    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).

@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openagents-workspace Ready Ready Preview Aug 4, 2026 2:40pm

Request Review

…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.
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.

1 participant