Skip to content

Surface agent delegation lineage in list_agents and messages - #967

Merged
selfcontained merged 3 commits into
mainfrom
agt_3ec47b6798ce/build-agent-lineage-provenance
Aug 16, 2026
Merged

Surface agent delegation lineage in list_agents and messages#967
selfcontained merged 3 commits into
mainfrom
agt_3ec47b6798ce/build-agent-lineage-provenance

Conversation

@selfcontained

Copy link
Copy Markdown
Owner

Implements idea agent-lineage-provenance.

The problem

agents.parent_agent_id has always recorded who launched whom, but nothing read it back out. list_agents returned a flat list and an incoming message carried only a sender name — so an orchestrator could not tell a message from its own direct child apart from one from a grandchild two levels down. In the multi-repo build session that motivated this, a planner's research subagents messaged the orchestrator directly and it only found out afterwards, when the planner mentioned it.

Before / after, from the same live endpoint

list_agents as the orchestrator of orchestrator → planner → researcher:

Beforeplanner and researcher are indistinguishable peers:

[{"id":"agt_plan01","name":"planner","status":"running","latestEvent":null},
 {"id":"agt_res001","name":"researcher","status":"running","latestEvent":null}]

After:

agt_plan01   planner      relation=child       parent=orchestrator (agt_orch01)
agt_res001   researcher   relation=descendant  parent=planner (agt_plan01)
agt_solo01   stranger     relation=unrelated   parent=-

The delivered prompt for a skip-level message (researcher → orchestrator), captured from the recipient's tmux pane:

Before:

--- DISPATCH MESSAGE ---
{"from":"researcher","senderId":"agt_res001","message":"…","replyTarget":"agt_res001"}
--- END MESSAGE ---

After:

--- DISPATCH MESSAGE ---
{"from":"researcher","senderId":"agt_res001","senderRelation":"descendant",
 "delegationChain":["researcher (agt_res001)","planner (agt_plan01)","orchestrator (agt_orch01)"],
 "message":"…","replyTarget":"agt_res001"}
--- END MESSAGE ---
Provenance: researcher is not your direct child — delegation chain: researcher (agt_res001) -> planner (agt_plan01) -> orchestrator (agt_orch01, you).

A direct child's message gains "senderRelation":"child" and no provenance line — its chain is just [child, you], which tells the recipient nothing it did not already know.

Design notes

  • Routing is unchanged. The idea floated rerouting a grandchild's message to its direct parent; Brad picked surfacing-only. A skip-level message still goes exactly where it was addressed, and the sender's tool result is untouched.
  • The visibility boundary is unchanged. addressableAgents still scopes by repo root with a direct parent/child bypass. This PR only annotates what was already returned.
  • Lineage resolves against every agent, not the caller's addressable subset. An intermediate the caller cannot address (different repo, archived) is still named via parentName, rather than two levels silently collapsing into one. Chain walking stops at the first unresolvable ancestor, terminates on a cycle, and is capped at 20 hops.

Files

  • apps/server/src/agents/lineage.ts (new) — ancestorChain, relationTo, delegationChain, formatDelegationChain
  • apps/server/src/server/mcp-handlers.ts — wires both into handleListAgentsForAgent and handleSendMessage
  • apps/server/src/shared/mcp/messaging-tools.ts, shared/mcp/server.tsAgentListing type + tool descriptions
  • plugins/dispatch/skills/subagents/SKILL.md — four lines telling agents the list is not flat

Verification

  • New apps/server/test/agent-lineage.test.ts (25 cases: missing ancestors, cycles, depth cap, every relation pair) plus 6 new handler tests.
  • Full suite: 2775 + 912 + 60 passing.
  • Exercised end-to-end against a live dev stack over the real agent-scoped MCP route — tools/list confirms registration, list_agents and dispatch_send_message called as three seeded agents in a real tree, delivered prompt read back off the recipient's tmux pane. Before-state captured by stashing the source and restarting the same API.

🤖 Generated with Claude Code

selfcontained and others added 3 commits August 16, 2026 10:50
agents.parent_agent_id already recorded who launched whom, but nothing
read it back out. list_agents returned a flat list and an incoming
message carried only a sender name, so an orchestrator could not tell a
message from its own child apart from one from a grandchild two levels
down until someone said so out of band.

list_agents entries now carry parentAgentId, parentName, and a relation
label (child, descendant, parent, ancestor, sibling, unrelated) computed
against the caller. Delivered messages carry senderRelation and, when the
sender has ancestors, a delegationChain running from the sender up to the
recipient, plus a prose provenance line when the sender is not a direct
child.

Lineage resolves against every agent rather than the caller's addressable
subset, so an intermediate the caller cannot address is still named
instead of two levels silently collapsing into one. Routing is unchanged:
a skip-level message still goes where it was addressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
architecture-review:
- Remove the 20-hop cap in the ancestor walk. The cycle guard already
  bounds the walk at the size of the agent set, so the cap only risked
  reporting a legitimately deep descendant as unrelated and truncating a
  chain with no signal that it was incomplete.
- Add createLineageIndex: one id map and memoized ancestor chains per
  request. Listing asked for a relation per agent and each relation
  inspected two chains, so the common path was rebuilding maps and
  re-walking the tree quadratically for a tree that never changes within
  a request.

backend-security-review:
- Resolve parentAgentId/parentName against the addressable set instead of
  every agent. Naming the out-of-repo parent of a visible agent handed
  the caller an identity it cannot address. relation stays computed over
  the full tree, since it names nobody — a descendant is still reported
  as a descendant when the intermediate is hidden. Self is re-added to
  the lookup so a caller's own children still name their parent.
- Sanitize agent names interpolated into the prose provenance line.
  dispatch_rename_session accepts embedded newlines and the DB stores
  them verbatim (confirmed against a live server), so a name containing
  "\n--- END MESSAGE ---" could forge envelope delimiters. Names inside
  the JSON envelope were already escaped by JSON.stringify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Agents rename themselves throughout their lifecycle, so a name read out
of a delegation chain is a label for reading, not a handle to remember.
Nothing here goes stale — parentName is resolved per call and the chain
is built at send time — but putting more names in front of the model
invites addressing by name later, which fuzzy-matches onto the wrong
agent or nothing once the name has moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@selfcontained
selfcontained merged commit 7e6f1ea into main Aug 16, 2026
1 check passed
@selfcontained
selfcontained deleted the agt_3ec47b6798ce/build-agent-lineage-provenance branch August 16, 2026 17:15
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