From 3b2e9937c62af0b8ff6e1f5400c7bfae70ff171d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sun, 16 Aug 2026 10:50:28 -0600 Subject: [PATCH 1/3] Surface agent delegation lineage in list_agents and messages 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 --- apps/server/src/agents/lineage.ts | 140 +++++++++ apps/server/src/server/mcp-handlers.ts | 52 +++- apps/server/src/shared/mcp/messaging-tools.ts | 30 +- apps/server/src/shared/mcp/server.ts | 14 +- apps/server/test/agent-lineage.test.ts | 108 +++++++ apps/server/test/mcp-handlers.test.ts | 273 ++++++++++++++++++ plugins/dispatch/skills/subagents/SKILL.md | 8 +- 7 files changed, 602 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/agents/lineage.ts create mode 100644 apps/server/test/agent-lineage.test.ts diff --git a/apps/server/src/agents/lineage.ts b/apps/server/src/agents/lineage.ts new file mode 100644 index 00000000..f20bc6a8 --- /dev/null +++ b/apps/server/src/agents/lineage.ts @@ -0,0 +1,140 @@ +/** + * Delegation lineage: who launched whom. + * + * `agents.parent_agent_id` already records the launcher of every agent spawned + * via dispatch_launch_agent / dispatch_launch_persona, but nothing surfaced it, + * so an orchestrator saw a flat list of agents and a message that carried only a + * sender name. A message from a grandchild was indistinguishable from a message + * from a direct child until someone said so out of band. + * + * These helpers turn that column into the two things callers actually need: the + * ancestor chain of an agent, and the relationship between two agents. + */ + +/** Bounds chain walking so a corrupted parent link can never produce huge output. */ +const MAX_LINEAGE_DEPTH = 20; + +export type LineageAgent = { + id: string; + name: string; + parentAgentId?: string | null; +}; + +export type LineageNode = { id: string; name: string }; + +/** + * How another agent sits relative to a viewer in the delegation tree. + * "sibling" means both were launched by the same agent; "unrelated" means no + * ancestor path connects them (including two independently rooted agents). + */ +export type AgentRelation = + | "parent" + | "child" + | "ancestor" + | "descendant" + | "sibling" + | "unrelated"; + +function indexById(agents: T[]): Map { + const byId = new Map(); + for (const agent of agents) byId.set(agent.id, agent); + return byId; +} + +/** + * The ancestors of `agentId`, nearest first: [parent, grandparent, ...root]. + * + * Ancestors missing from `agents` (archived, or filtered out of the caller's + * visible set) terminate the walk — a chain is only ever reported as far as it + * can be resolved, never with holes. A cycle terminates it too. + */ +export function ancestorChain( + agents: LineageAgent[], + agentId: string +): LineageNode[] { + const byId = indexById(agents); + const chain: LineageNode[] = []; + const seen = new Set([agentId]); + + let current = byId.get(agentId)?.parentAgentId ?? null; + while (current && !seen.has(current) && chain.length < MAX_LINEAGE_DEPTH) { + seen.add(current); + const parent = byId.get(current); + if (!parent) break; + chain.push({ id: parent.id, name: parent.name }); + current = parent.parentAgentId ?? null; + } + return chain; +} + +/** + * Where `otherId` sits relative to `viewerId`. Ancestry is checked before + * siblinghood so a parent is never also reported as a sibling. + */ +export function relationTo( + agents: LineageAgent[], + viewerId: string, + otherId: string +): AgentRelation { + const byId = indexById(agents); + const viewer = byId.get(viewerId); + const other = byId.get(otherId); + if (!viewer || !other) return "unrelated"; + + if (other.parentAgentId === viewerId) return "child"; + if (viewer.parentAgentId === otherId) return "parent"; + + if (ancestorChain(agents, otherId).some((a) => a.id === viewerId)) { + return "descendant"; + } + if (ancestorChain(agents, viewerId).some((a) => a.id === otherId)) { + return "ancestor"; + } + + const viewerParent = viewer.parentAgentId ?? null; + if (viewerParent && viewerParent === (other.parentAgentId ?? null)) { + return "sibling"; + } + return "unrelated"; +} + +/** + * The delegation chain of a message: sender first, then each ancestor up to and + * including the recipient when the recipient is one of them. When the recipient + * is not an ancestor, the chain still walks to the sender's root so the + * recipient can see where in the tree the sender actually lives. + * + * The chain is resolved from the full agent set rather than the sender's + * addressable set: it describes the sender's own provenance to the one agent + * being messaged, which is exactly the information the recipient was missing. + */ +export function delegationChain( + agents: LineageAgent[], + senderId: string, + recipientId: string +): LineageNode[] { + const byId = indexById(agents); + const sender = byId.get(senderId); + const chain: LineageNode[] = sender + ? [{ id: sender.id, name: sender.name }] + : []; + + for (const ancestor of ancestorChain(agents, senderId)) { + chain.push(ancestor); + if (ancestor.id === recipientId) break; + } + return chain; +} + +/** Renders a chain as `A -> B -> C` for injection into a message prompt. */ +export function formatDelegationChain( + chain: LineageNode[], + recipientId: string +): string { + return chain + .map( + (node) => + `${node.name} (${node.id}${node.id === recipientId ? ", you" : ""})` + ) + .join(" -> "); +} diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index acea86c2..2ef318bf 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -41,6 +41,12 @@ import { type PinListing, type PinSummary, } from "./pin-listing.js"; +import { + delegationChain, + formatDelegationChain, + relationTo, + type AgentRelation, +} from "../agents/lineage.js"; import { resolveRepoRoot } from "../shared/git/git-context.js"; import { isMediaFile, isTextFile, resolveMediaDir } from "../shared/media.js"; import type { PublishUiEvent, SendAgentPrompt } from "./mcp-handler-types.js"; @@ -704,8 +710,9 @@ async function handleSendMessage( const senderRepoRoot = input.senderRepoRoot; const crossRepo = await isCrossRepoMessagingEnabled(deps.pool); + const everyAgent = await deps.agentManager.listAgents(); const allAgents = await addressableAgents( - await deps.agentManager.listAgents(), + everyAgent, agentId, senderRepoRoot, crossRepo @@ -748,13 +755,35 @@ async function handleSendMessage( ); } + // Provenance: without this the recipient sees only a sender name, so a + // message from a grandchild is indistinguishable from one from a direct + // child. Resolved against every agent so an unaddressable intermediate still + // appears in the chain rather than collapsing two levels into one. + const senderRelation = relationTo(everyAgent, target.id, agentId); + const chain = delegationChain(everyAgent, agentId, target.id); + const envelope = JSON.stringify({ from: sender.name, senderId: agentId, + senderRelation, + ...(chain.length > 1 + ? { delegationChain: chain.map((node) => `${node.name} (${node.id})`) } + : {}), message: input.message, replyTarget: agentId, }); - const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---\nOptional reply channel: If a response is necessary, use dispatch_send_message with the replyTarget above. Do not acknowledge routine status updates or completion messages unless a reply is explicitly requested.`; + // The prose line only fires when it tells the recipient something the sender + // name alone does not: that the sender is further down its tree than a direct + // child, or that the sender belongs to a tree the recipient is not part of. + // A direct child's chain is just [child, you], so it stays silent. + const recipientInChain = chain.some((node) => node.id === target.id); + const provenanceLine = + senderRelation === "descendant" + ? `\nProvenance: ${sender.name} is not your direct child — delegation chain: ${formatDelegationChain(chain, target.id)}.` + : !recipientInChain && chain.length > 1 + ? `\nProvenance: ${formatDelegationChain(chain, target.id)}.` + : ""; + const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---${provenanceLine}\nOptional reply channel: If a response is necessary, use dispatch_send_message with the replyTarget above. Do not acknowledge routine status updates or completion messages unless a reply is explicitly requested.`; // Deliver first: a persistence failure must never block delivery. let delivered = false; @@ -837,23 +866,37 @@ async function handleListAgentsForAgent( name: string; status: string; latestEvent: { type: string; message: string } | null; + parentAgentId: string | null; + parentName: string | null; + relation: AgentRelation; }> > { const crossRepo = await isCrossRepoMessagingEnabled(deps.pool); + const allAgents = await deps.agentManager.listAgents(); const agents = await addressableAgents( - await deps.agentManager.listAgents(), + allAgents, agentId, senderRepoRoot, crossRepo ); + // Lineage is resolved against every agent, not just the addressable subset: + // an intermediate that the caller cannot address (different repo root, or + // archived) must still be reported by name rather than silently flattening a + // grandchild into a child. + const namesById = new Map(allAgents.map((a) => [a.id, a.name])); + const result: Array<{ id: string; name: string; status: string; latestEvent: { type: string; message: string } | null; + parentAgentId: string | null; + parentName: string | null; + relation: AgentRelation; }> = []; for (const a of agents) { + const parentAgentId = a.parentAgentId ?? null; result.push({ id: a.id, name: a.name, @@ -861,6 +904,9 @@ async function handleListAgentsForAgent( latestEvent: a.latestEvent ? { type: a.latestEvent.type, message: a.latestEvent.message } : null, + parentAgentId, + parentName: parentAgentId ? (namesById.get(parentAgentId) ?? null) : null, + relation: relationTo(allAgents, agentId, a.id), }); } return result; diff --git a/apps/server/src/shared/mcp/messaging-tools.ts b/apps/server/src/shared/mcp/messaging-tools.ts index 3df8f80f..04182e4d 100644 --- a/apps/server/src/shared/mcp/messaging-tools.ts +++ b/apps/server/src/shared/mcp/messaging-tools.ts @@ -1,23 +1,27 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as z from "zod/v4"; +import type { AgentRelation } from "../../agents/lineage.js"; import { jsonText } from "./response.js"; import { toToolError } from "./tool-error.js"; +export type AgentListing = { + id: string; + name: string; + status: string; + latestEvent: { type: string; message: string } | null; + parentAgentId: string | null; + parentName: string | null; + relation: AgentRelation; +}; + export type MessagingToolsContext = { agentId: string; repoRoot: string | null; listAgentsForAgent?: ( agentId: string, senderRepoRoot: string | null - ) => Promise< - Array<{ - id: string; - name: string; - status: string; - latestEvent: { type: string; message: string } | null; - }> - >; + ) => Promise; sendMessage?: ( agentId: string, input: { target: string; message: string; senderRepoRoot: string | null } @@ -42,7 +46,11 @@ export function registerMessagingTools( { description: "List other agents on this Dispatch server with their IDs, names, statuses, and latest activity. " + - "Use this to discover agents you can communicate with via dispatch_send_message.", + "Use this to discover agents you can communicate with via dispatch_send_message. " + + "Each entry also carries its delegation lineage: parentAgentId/parentName name the agent that launched it, " + + "and relation says how it sits relative to you (child, descendant, parent, ancestor, sibling, unrelated). " + + "Build the delegation tree from parentAgentId rather than assuming the list is flat — a 'descendant' is a " + + "grandchild or deeper, not something you launched yourself.", inputSchema: {}, }, async () => { @@ -70,7 +78,9 @@ export function registerMessagingTools( "Send a message to another running agent. The message is injected into the target agent's session. " + "The target agent can reply using the same tool. Use list_agents to discover available agents. " + "Target can be an agent ID (agt_xxx) or a name (partial match). " + - "Only works for agents that are currently running.", + "Only works for agents that are currently running. " + + "The recipient also sees your delegation chain (you, then each agent that launched you, up to them), " + + "so it can tell a message from a direct child apart from one from further down the tree.", inputSchema: { target: z .string() diff --git a/apps/server/src/shared/mcp/server.ts b/apps/server/src/shared/mcp/server.ts index 2f9b4acf..2994c962 100644 --- a/apps/server/src/shared/mcp/server.ts +++ b/apps/server/src/shared/mcp/server.ts @@ -16,7 +16,10 @@ import { registerAnalyticsTools } from "./analytics-tools.js"; import { registerBrainTools } from "./brain-tools.js"; import { registerCrudTools, type CrudToolCallbacks } from "./crud-tools.js"; import { registerJobTools, type JobTools } from "./job-tools.js"; -import { registerMessagingTools } from "./messaging-tools.js"; +import { + registerMessagingTools, + type AgentListing, +} from "./messaging-tools.js"; import { registerPersonalityTools } from "./personality-tools.js"; import { registerWhiteboardTools } from "./whiteboard-tools.js"; import type { @@ -502,14 +505,7 @@ export type McpRequestContext = { listAgentsForAgent?: ( agentId: string, senderRepoRoot: string | null - ) => Promise< - Array<{ - id: string; - name: string; - status: string; - latestEvent: { type: string; message: string } | null; - }> - >; + ) => Promise; getActivitySummary?: (params: { start: Date; end: Date; diff --git a/apps/server/test/agent-lineage.test.ts b/apps/server/test/agent-lineage.test.ts new file mode 100644 index 00000000..4ed46c54 --- /dev/null +++ b/apps/server/test/agent-lineage.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + ancestorChain, + delegationChain, + formatDelegationChain, + relationTo, +} from "../src/agents/lineage.js"; + +const TREE = [ + { id: "agt_root", name: "orchestrator", parentAgentId: null }, + { id: "agt_planner", name: "planner", parentAgentId: "agt_root" }, + { id: "agt_research", name: "researcher", parentAgentId: "agt_planner" }, + { id: "agt_writer", name: "writer", parentAgentId: "agt_planner" }, + { id: "agt_solo", name: "solo", parentAgentId: null }, +]; + +describe("ancestorChain", () => { + it("walks from the nearest parent up to the root", () => { + expect(ancestorChain(TREE, "agt_research").map((a) => a.id)).toEqual([ + "agt_planner", + "agt_root", + ]); + }); + + it("is empty for a rootless agent", () => { + expect(ancestorChain(TREE, "agt_root")).toEqual([]); + }); + + it("is empty for an agent that is not in the set", () => { + expect(ancestorChain(TREE, "agt_missing")).toEqual([]); + }); + + it("stops at the first ancestor missing from the set", () => { + const partial = [ + { id: "agt_a", name: "a", parentAgentId: "agt_gone" }, + { id: "agt_root", name: "root", parentAgentId: null }, + ]; + expect(ancestorChain(partial, "agt_a")).toEqual([]); + }); + + it("terminates on a parent cycle instead of looping forever", () => { + const cyclic = [ + { id: "agt_a", name: "a", parentAgentId: "agt_b" }, + { id: "agt_b", name: "b", parentAgentId: "agt_a" }, + ]; + expect(ancestorChain(cyclic, "agt_a").map((a) => a.id)).toEqual(["agt_b"]); + }); + + it("caps a long chain rather than emitting unbounded output", () => { + const deep = Array.from({ length: 40 }, (_, i) => ({ + id: `agt_${i}`, + name: `a${i}`, + parentAgentId: i === 0 ? null : `agt_${i - 1}`, + })); + expect(ancestorChain(deep, "agt_39")).toHaveLength(20); + }); +}); + +describe("relationTo", () => { + it.each([ + ["agt_root", "agt_planner", "child"], + ["agt_planner", "agt_root", "parent"], + ["agt_root", "agt_research", "descendant"], + ["agt_research", "agt_root", "ancestor"], + ["agt_research", "agt_writer", "sibling"], + ["agt_root", "agt_solo", "unrelated"], + ["agt_solo", "agt_root", "unrelated"], + ] as const)("%s -> %s is %s", (viewer, other, expected) => { + expect(relationTo(TREE, viewer, other)).toBe(expected); + }); + + it("does not treat two rootless agents as siblings", () => { + expect(relationTo(TREE, "agt_solo", "agt_root")).toBe("unrelated"); + }); + + it("returns unrelated when either agent is unknown", () => { + expect(relationTo(TREE, "agt_root", "agt_missing")).toBe("unrelated"); + expect(relationTo(TREE, "agt_missing", "agt_root")).toBe("unrelated"); + }); +}); + +describe("delegationChain", () => { + it("stops at the recipient when the recipient is an ancestor", () => { + expect( + delegationChain(TREE, "agt_research", "agt_root").map((n) => n.id) + ).toEqual(["agt_research", "agt_planner", "agt_root"]); + }); + + it("walks to the root when the recipient is not an ancestor", () => { + expect( + delegationChain(TREE, "agt_research", "agt_solo").map((n) => n.id) + ).toEqual(["agt_research", "agt_planner", "agt_root"]); + }); + + it("is just the sender when the sender has no resolvable parent", () => { + expect( + delegationChain(TREE, "agt_solo", "agt_root").map((n) => n.id) + ).toEqual(["agt_solo"]); + }); + + it("marks the recipient in the formatted chain", () => { + const chain = delegationChain(TREE, "agt_research", "agt_root"); + expect(formatDelegationChain(chain, "agt_root")).toBe( + "researcher (agt_research) -> planner (agt_planner) -> orchestrator (agt_root, you)" + ); + }); +}); diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index db1dffc3..8b0c9b46 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -1664,6 +1664,7 @@ describe("createMcpHandlers", () => { `--- DISPATCH MESSAGE ---\n${JSON.stringify({ from: "test-agent", senderId: "agt_test1", + senderRelation: "unrelated", message: "hello", replyTarget: "agt_test1", })}\n--- END MESSAGE ---\nOptional reply channel: If a response is necessary, use dispatch_send_message with the replyTarget above. Do not acknowledge routine status updates or completion messages unless a reply is explicitly requested.`, @@ -1678,6 +1679,149 @@ describe("createMcpHandlers", () => { ); }); + it("surfaces the delegation chain when the sender is a grandchild", async () => { + deps.agentManager.getAgent.mockResolvedValue({ + id: "agt_researcher", + name: "researcher", + cwd: "/repo", + status: "running", + parentAgentId: "agt_planner", + } as any); + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_orchestrator", + name: "orchestrator", + cwd: "/repo", + status: "running", + parentAgentId: null, + }, + { + id: "agt_planner", + name: "planner", + cwd: "/repo", + status: "running", + parentAgentId: "agt_orchestrator", + }, + { + id: "agt_researcher", + name: "researcher", + cwd: "/repo", + status: "running", + parentAgentId: "agt_planner", + }, + ]); + vi.mocked(resolveRepoRoot).mockResolvedValue("/repo"); + + await handlers.sendMessage("agt_researcher", { + target: "agt_orchestrator", + message: "hello", + senderRepoRoot: "/repo", + }); + + const prompt = deps.sendAgentPrompt.mock.calls[0][1] as string; + const envelope = JSON.parse( + prompt.slice( + prompt.indexOf("\n") + 1, + prompt.indexOf("\n--- END MESSAGE ---") + ) + ); + expect(envelope.senderRelation).toBe("descendant"); + expect(envelope.delegationChain).toEqual([ + "researcher (agt_researcher)", + "planner (agt_planner)", + "orchestrator (agt_orchestrator)", + ]); + expect(prompt).toContain( + "Provenance: researcher is not your direct child — delegation chain: " + + "researcher (agt_researcher) -> planner (agt_planner) -> orchestrator (agt_orchestrator, you)." + ); + }); + + it("marks a direct child as a child and adds no provenance line", async () => { + deps.agentManager.getAgent.mockResolvedValue({ + id: "agt_child", + name: "child", + cwd: "/repo", + status: "running", + parentAgentId: "agt_parent", + } as any); + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_parent", + name: "parent", + cwd: "/repo", + status: "running", + parentAgentId: null, + }, + { + id: "agt_child", + name: "child", + cwd: "/repo", + status: "running", + parentAgentId: "agt_parent", + }, + ]); + vi.mocked(resolveRepoRoot).mockResolvedValue("/repo"); + + await handlers.sendMessage("agt_child", { + target: "agt_parent", + message: "done", + senderRepoRoot: "/repo", + }); + + const prompt = deps.sendAgentPrompt.mock.calls[0][1] as string; + expect(prompt).toContain('"senderRelation":"child"'); + // The chain is [child, parent] and the recipient is the parent, so it adds + // nothing the recipient did not already know. + expect(prompt).not.toContain("Provenance:"); + }); + + it("still reports the sender's own tree when the recipient is unrelated", async () => { + deps.agentManager.getAgent.mockResolvedValue({ + id: "agt_child", + name: "child", + cwd: "/repo", + status: "running", + parentAgentId: "agt_parent", + } as any); + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_parent", + name: "parent", + cwd: "/repo", + status: "running", + parentAgentId: null, + }, + { + id: "agt_child", + name: "child", + cwd: "/repo", + status: "running", + parentAgentId: "agt_parent", + }, + { + id: "agt_stranger", + name: "stranger", + cwd: "/repo", + status: "running", + parentAgentId: null, + }, + ]); + vi.mocked(resolveRepoRoot).mockResolvedValue("/repo"); + + await handlers.sendMessage("agt_child", { + target: "agt_stranger", + message: "fyi", + senderRepoRoot: "/repo", + }); + + const prompt = deps.sendAgentPrompt.mock.calls[0][1] as string; + expect(prompt).toContain('"senderRelation":"unrelated"'); + expect(prompt).toContain( + "Provenance: child (agt_child) -> parent (agt_parent)." + ); + }); + it("throws when sender not found", async () => { deps.agentManager.getAgent.mockResolvedValue(null); await expect( @@ -1958,6 +2102,9 @@ describe("createMcpHandlers", () => { name: "peer", status: "running", latestEvent: null, + parentAgentId: null, + parentName: null, + relation: "unrelated", }); }); @@ -2139,6 +2286,132 @@ describe("createMcpHandlers", () => { const result = await handlers.listAgentsForAgent("agt_self", null); expect(result.map((a) => a.id)).toEqual(["agt_other"]); }); + + it("labels each agent's lineage relative to the caller", async () => { + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_self", + name: "orchestrator", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: null, + }, + { + id: "agt_planner", + name: "planner", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: "agt_self", + }, + { + id: "agt_researcher", + name: "researcher", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: "agt_planner", + }, + { + id: "agt_stranger", + name: "stranger", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: null, + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + + const result = await handlers.listAgentsForAgent("agt_self", "/repo"); + expect( + result.map((a) => [a.id, a.relation, a.parentAgentId, a.parentName]) + ).toEqual([ + ["agt_planner", "child", "agt_self", "orchestrator"], + ["agt_researcher", "descendant", "agt_planner", "planner"], + ["agt_stranger", "unrelated", null, null], + ]); + }); + + it("names a parent the caller cannot address", async () => { + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_self", + name: "self", + cwd: "/repo-a", + status: "running", + latestEvent: null, + parentAgentId: null, + }, + { + id: "agt_hidden", + name: "hidden-parent", + cwd: "/repo-b", + status: "running", + latestEvent: null, + parentAgentId: null, + }, + { + id: "agt_peer", + name: "peer", + cwd: "/repo-a", + status: "running", + latestEvent: null, + parentAgentId: "agt_hidden", + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + + const result = await handlers.listAgentsForAgent("agt_self", "/repo-a"); + expect(result.map((a) => a.id)).toEqual(["agt_peer"]); + // agt_hidden is not addressable from /repo-a, but the caller still learns + // that agt_peer belongs to someone else's tree rather than being rootless. + expect(result[0].parentAgentId).toBe("agt_hidden"); + expect(result[0].parentName).toBe("hidden-parent"); + }); + + it("reports siblings launched by the same parent", async () => { + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_parent", + name: "parent", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: null, + }, + { + id: "agt_self", + name: "self", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: "agt_parent", + }, + { + id: "agt_sibling", + name: "sibling", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: "agt_parent", + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + + const result = await handlers.listAgentsForAgent("agt_self", "/repo"); + expect(result.map((a) => [a.id, a.relation])).toEqual([ + ["agt_parent", "parent"], + ["agt_sibling", "sibling"], + ]); + }); }); describe("shareMedia", () => { diff --git a/plugins/dispatch/skills/subagents/SKILL.md b/plugins/dispatch/skills/subagents/SKILL.md index 1ea5144f..3482fdba 100644 --- a/plugins/dispatch/skills/subagents/SKILL.md +++ b/plugins/dispatch/skills/subagents/SKILL.md @@ -42,10 +42,16 @@ of retyping the prompt — see the `templates` skill. ## Coordinating ``` -list_agents — who exists, their IDs, names, statuses, latest activity +list_agents — who exists, their IDs, names, statuses, latest activity, + plus parentAgentId and relation (child, descendant, …) dispatch_send_message target, message ``` +The list is not flat. Each entry names the agent that launched it, so a +`descendant` is a grandchild or deeper — something your own child spawned, not +something you did. Incoming messages carry the same lineage: the delegation +chain runs from the sender up through whoever launched it to you. + `dispatch_send_message` injects a message directly into the target's session, and it can reply the same way. `target` accepts an agent ID (`agt_…`) or a name, which is fuzzy-matched. **It only works for agents that are currently running** — a From 7349e9fe9233c4b59b185ba43c524c24a458d3f6 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sun, 16 Aug 2026 10:58:41 -0600 Subject: [PATCH 2/3] Address lineage review: drop depth cap, index lineage, close two leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/agents/lineage.ts | 119 ++++++++++++++++--------- apps/server/src/server/mcp-handlers.ts | 42 ++++++--- apps/server/test/agent-lineage.test.ts | Bin 3699 -> 5786 bytes apps/server/test/mcp-handlers.test.ts | 92 +++++++++++++++++-- 4 files changed, 195 insertions(+), 58 deletions(-) diff --git a/apps/server/src/agents/lineage.ts b/apps/server/src/agents/lineage.ts index f20bc6a8..c6f5d752 100644 --- a/apps/server/src/agents/lineage.ts +++ b/apps/server/src/agents/lineage.ts @@ -9,11 +9,14 @@ * * These helpers turn that column into the two things callers actually need: the * ancestor chain of an agent, and the relationship between two agents. + * + * Everything hangs off a `LineageIndex` built once per request. Listing agents + * asks for a relation per returned agent and each relation inspects two ancestor + * chains, so re-deriving the id map and re-walking the tree per call would make + * the common path quadratic in the number of agents for no benefit — the tree is + * identical for every question asked within one request. */ -/** Bounds chain walking so a corrupted parent link can never produce huge output. */ -const MAX_LINEAGE_DEPTH = 20; - export type LineageAgent = { id: string; name: string; @@ -35,36 +38,56 @@ export type AgentRelation = | "sibling" | "unrelated"; -function indexById(agents: T[]): Map { - const byId = new Map(); - for (const agent of agents) byId.set(agent.id, agent); - return byId; -} +export type LineageIndex = { + get(agentId: string): LineageAgent | undefined; + /** Ancestors of `agentId`, nearest first: [parent, grandparent, ...root]. */ + ancestors(agentId: string): LineageNode[]; +}; /** - * The ancestors of `agentId`, nearest first: [parent, grandparent, ...root]. - * - * Ancestors missing from `agents` (archived, or filtered out of the caller's - * visible set) terminate the walk — a chain is only ever reported as far as it - * can be resolved, never with holes. A cycle terminates it too. + * Build the lineage view of an agent set. Pass every agent the server knows + * about, not a filtered subset: an ancestor missing from the set terminates the + * walk, which would silently report a grandchild as a child. */ +export function createLineageIndex(agents: LineageAgent[]): LineageIndex { + const byId = new Map(); + for (const agent of agents) byId.set(agent.id, agent); + const chains = new Map(); + + function ancestors(agentId: string): LineageNode[] { + const cached = chains.get(agentId); + if (cached) return cached; + + // Walked iteratively rather than composed from the parent's cached chain: + // under a parent cycle the parent's chain contains this agent, so composing + // would splice an agent into its own ancestry. + const chain: LineageNode[] = []; + const seen = new Set([agentId]); + let current = byId.get(agentId)?.parentAgentId ?? null; + while (current && !seen.has(current)) { + seen.add(current); + const parent = byId.get(current); + // An unresolvable parent ends the chain rather than leaving a hole in it. + if (!parent) break; + chain.push({ id: parent.id, name: parent.name }); + current = parent.parentAgentId ?? null; + } + // `seen` bounds the walk at the size of the agent set, so a corrupted + // parent link terminates without an arbitrary depth cap that would report a + // legitimately deep descendant as unrelated. + chains.set(agentId, chain); + return chain; + } + + return { get: (agentId) => byId.get(agentId), ancestors }; +} + +/** Ancestors of `agentId`, nearest first: [parent, grandparent, ...root]. */ export function ancestorChain( - agents: LineageAgent[], + index: LineageIndex, agentId: string ): LineageNode[] { - const byId = indexById(agents); - const chain: LineageNode[] = []; - const seen = new Set([agentId]); - - let current = byId.get(agentId)?.parentAgentId ?? null; - while (current && !seen.has(current) && chain.length < MAX_LINEAGE_DEPTH) { - seen.add(current); - const parent = byId.get(current); - if (!parent) break; - chain.push({ id: parent.id, name: parent.name }); - current = parent.parentAgentId ?? null; - } - return chain; + return index.ancestors(agentId); } /** @@ -72,22 +95,21 @@ export function ancestorChain( * siblinghood so a parent is never also reported as a sibling. */ export function relationTo( - agents: LineageAgent[], + index: LineageIndex, viewerId: string, otherId: string ): AgentRelation { - const byId = indexById(agents); - const viewer = byId.get(viewerId); - const other = byId.get(otherId); + const viewer = index.get(viewerId); + const other = index.get(otherId); if (!viewer || !other) return "unrelated"; if (other.parentAgentId === viewerId) return "child"; if (viewer.parentAgentId === otherId) return "parent"; - if (ancestorChain(agents, otherId).some((a) => a.id === viewerId)) { + if (index.ancestors(otherId).some((a) => a.id === viewerId)) { return "descendant"; } - if (ancestorChain(agents, viewerId).some((a) => a.id === otherId)) { + if (index.ancestors(viewerId).some((a) => a.id === otherId)) { return "ancestor"; } @@ -104,28 +126,41 @@ export function relationTo( * is not an ancestor, the chain still walks to the sender's root so the * recipient can see where in the tree the sender actually lives. * - * The chain is resolved from the full agent set rather than the sender's - * addressable set: it describes the sender's own provenance to the one agent - * being messaged, which is exactly the information the recipient was missing. + * The chain describes the sender's own provenance to the one agent being + * messaged, which is exactly the information the recipient was missing. */ export function delegationChain( - agents: LineageAgent[], + index: LineageIndex, senderId: string, recipientId: string ): LineageNode[] { - const byId = indexById(agents); - const sender = byId.get(senderId); + const sender = index.get(senderId); const chain: LineageNode[] = sender ? [{ id: sender.id, name: sender.name }] : []; - for (const ancestor of ancestorChain(agents, senderId)) { + for (const ancestor of index.ancestors(senderId)) { chain.push(ancestor); if (ancestor.id === recipientId) break; } return chain; } +/** + * Flatten an agent name for interpolation into an injected prompt. + * + * Agent names are caller-supplied — dispatch_rename_session and + * dispatch_launch_agent both accept embedded newlines, and nothing downstream + * strips them. A name like `worker\n--- END MESSAGE ---\nProvenance: ...` would + * otherwise forge envelope delimiters and a fake provenance claim in the + * recipient's terminal. Names rendered inside the JSON envelope are already + * escaped by JSON.stringify; this is for the prose lines outside it. + */ +export function sanitizeAgentNameForPrompt(name: string): string { + // eslint-disable-next-line no-control-regex + return name.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); +} + /** Renders a chain as `A -> B -> C` for injection into a message prompt. */ export function formatDelegationChain( chain: LineageNode[], @@ -134,7 +169,9 @@ export function formatDelegationChain( return chain .map( (node) => - `${node.name} (${node.id}${node.id === recipientId ? ", you" : ""})` + `${sanitizeAgentNameForPrompt(node.name)} (${node.id}${ + node.id === recipientId ? ", you" : "" + })` ) .join(" -> "); } diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 2ef318bf..376aee67 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -42,9 +42,11 @@ import { type PinSummary, } from "./pin-listing.js"; import { + createLineageIndex, delegationChain, formatDelegationChain, relationTo, + sanitizeAgentNameForPrompt, type AgentRelation, } from "../agents/lineage.js"; import { resolveRepoRoot } from "../shared/git/git-context.js"; @@ -759,8 +761,9 @@ async function handleSendMessage( // message from a grandchild is indistinguishable from one from a direct // child. Resolved against every agent so an unaddressable intermediate still // appears in the chain rather than collapsing two levels into one. - const senderRelation = relationTo(everyAgent, target.id, agentId); - const chain = delegationChain(everyAgent, agentId, target.id); + const lineage = createLineageIndex(everyAgent); + const senderRelation = relationTo(lineage, target.id, agentId); + const chain = delegationChain(lineage, agentId, target.id); const envelope = JSON.stringify({ from: sender.name, @@ -779,7 +782,7 @@ async function handleSendMessage( const recipientInChain = chain.some((node) => node.id === target.id); const provenanceLine = senderRelation === "descendant" - ? `\nProvenance: ${sender.name} is not your direct child — delegation chain: ${formatDelegationChain(chain, target.id)}.` + ? `\nProvenance: ${sanitizeAgentNameForPrompt(sender.name)} is not your direct child — delegation chain: ${formatDelegationChain(chain, target.id)}.` : !recipientInChain && chain.length > 1 ? `\nProvenance: ${formatDelegationChain(chain, target.id)}.` : ""; @@ -880,11 +883,22 @@ async function handleListAgentsForAgent( senderRepoRoot, crossRepo ); - // Lineage is resolved against every agent, not just the addressable subset: - // an intermediate that the caller cannot address (different repo root, or - // archived) must still be reported by name rather than silently flattening a - // grandchild into a child. - const namesById = new Map(allAgents.map((a) => [a.id, a.name])); + // Two different scopes, deliberately. + // + // `relation` is computed against every agent, because a grandchild must not + // flatten into a child just because the intermediate sits in another repo — + // and a relation names nobody. + // + // `parentAgentId`/`parentName` identify a specific agent, so they are + // resolved against the addressable set only. Naming the out-of-repo parent of + // a visible agent would hand the caller an identity it is not allowed to + // address; an unaddressable parent is reported as null instead. + const lineage = createLineageIndex(allAgents); + // Self is excluded from the addressable set but is obviously not a secret + // from itself, so the caller's own children still name their parent. + const visibleNamesById = new Map(agents.map((a) => [a.id, a.name])); + const self = lineage.get(agentId); + if (self) visibleNamesById.set(self.id, self.name); const result: Array<{ id: string; @@ -896,7 +910,11 @@ async function handleListAgentsForAgent( relation: AgentRelation; }> = []; for (const a of agents) { - const parentAgentId = a.parentAgentId ?? null; + const rawParentId = a.parentAgentId ?? null; + const parentName = rawParentId + ? (visibleNamesById.get(rawParentId) ?? null) + : null; + const visibleParentId = parentName === null ? null : rawParentId; result.push({ id: a.id, name: a.name, @@ -904,9 +922,9 @@ async function handleListAgentsForAgent( latestEvent: a.latestEvent ? { type: a.latestEvent.type, message: a.latestEvent.message } : null, - parentAgentId, - parentName: parentAgentId ? (namesById.get(parentAgentId) ?? null) : null, - relation: relationTo(allAgents, agentId, a.id), + parentAgentId: visibleParentId, + parentName, + relation: relationTo(lineage, agentId, a.id), }); } return result; diff --git a/apps/server/test/agent-lineage.test.ts b/apps/server/test/agent-lineage.test.ts index 4ed46c54c75dbe0fd25545c63bae75bfe4237d7f..86ff59f870a20048b1bfbaeb6ebea4dec7629349 100644 GIT binary patch literal 5786 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 x2yuHcPaTBuji(F3XytW>FfQ_*hA2>^~eW)A=W diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index 8b0c9b46..0d7e080a 100644 --- a/apps/server/test/mcp-handlers.test.ts +++ b/apps/server/test/mcp-handlers.test.ts @@ -2336,7 +2336,7 @@ describe("createMcpHandlers", () => { ]); }); - it("names a parent the caller cannot address", async () => { + it("redacts a parent the caller cannot address", async () => { deps.agentManager.listAgents.mockResolvedValue([ { id: "agt_self", @@ -2369,10 +2369,92 @@ describe("createMcpHandlers", () => { const result = await handlers.listAgentsForAgent("agt_self", "/repo-a"); expect(result.map((a) => a.id)).toEqual(["agt_peer"]); - // agt_hidden is not addressable from /repo-a, but the caller still learns - // that agt_peer belongs to someone else's tree rather than being rootless. - expect(result[0].parentAgentId).toBe("agt_hidden"); - expect(result[0].parentName).toBe("hidden-parent"); + // agt_hidden is not addressable from /repo-a, so naming it here would + // hand the caller an identity it is not allowed to address. + expect(result[0].parentAgentId).toBeNull(); + expect(result[0].parentName).toBeNull(); + }); + + it("still labels a descendant whose intermediate is unaddressable", async () => { + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_self", + name: "orchestrator", + cwd: "/repo-a", + status: "running", + latestEvent: null, + parentAgentId: null, + }, + { + id: "agt_planner", + name: "planner", + cwd: "/repo-a", + status: "running", + latestEvent: null, + parentAgentId: "agt_self", + }, + { + // Neither same-repo nor a direct child of the caller, so the + // addressable set excludes it. + id: "agt_subplanner", + name: "subplanner", + cwd: "/repo-b", + status: "running", + latestEvent: null, + parentAgentId: "agt_planner", + }, + { + id: "agt_research", + name: "researcher", + cwd: "/repo-a", + status: "running", + latestEvent: null, + parentAgentId: "agt_subplanner", + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + + const result = await handlers.listAgentsForAgent("agt_self", "/repo-a"); + expect(result.map((a) => a.id)).toEqual(["agt_planner", "agt_research"]); + const researcher = result.find((a) => a.id === "agt_research"); + // The relation is computed over every agent, so the descendant does not + // flatten just because agt_subplanner sits in another repo — but + // agt_subplanner itself is still not named. + expect(researcher?.relation).toBe("descendant"); + expect(researcher?.parentAgentId).toBeNull(); + expect(researcher?.parentName).toBeNull(); + }); + + it("names the caller as its own children's parent", async () => { + deps.agentManager.listAgents.mockResolvedValue([ + { + id: "agt_self", + name: "orchestrator", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: null, + }, + { + id: "agt_child", + name: "child", + cwd: "/repo", + status: "running", + latestEvent: null, + parentAgentId: "agt_self", + }, + ]); + vi.mocked(resolveRepoRoot).mockImplementation( + async (cwd) => cwd as string + ); + + // Self is excluded from the addressable set, but is not a secret from + // itself — a caller's own children must still name their parent. + const result = await handlers.listAgentsForAgent("agt_self", "/repo"); + expect(result[0].parentAgentId).toBe("agt_self"); + expect(result[0].parentName).toBe("orchestrator"); }); it("reports siblings launched by the same parent", async () => { From 4484cee1e36432d30d1709db1e49cdca51f82e4d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sun, 16 Aug 2026 11:09:23 -0600 Subject: [PATCH 3/3] Tell agents lineage is keyed by ID, not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plugins/dispatch/skills/subagents/SKILL.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/dispatch/skills/subagents/SKILL.md b/plugins/dispatch/skills/subagents/SKILL.md index 3482fdba..d57c30de 100644 --- a/plugins/dispatch/skills/subagents/SKILL.md +++ b/plugins/dispatch/skills/subagents/SKILL.md @@ -52,11 +52,16 @@ The list is not flat. Each entry names the agent that launched it, so a something you did. Incoming messages carry the same lineage: the delegation chain runs from the sender up through whoever launched it to you. +Lineage is keyed by agent ID. Agents rename themselves as their work shifts, so +a name is a label for reading, not a handle for remembering — build the tree +from `parentAgentId`, and hold on to the ID of anyone you plan to contact later. + `dispatch_send_message` injects a message directly into the target's session, and it can reply the same way. `target` accepts an agent ID (`agt_…`) or a name, which -is fuzzy-matched. **It only works for agents that are currently running** — a -message to a stopped agent goes nowhere, so check `list_agents` when a send fails -rather than assuming it was delivered. +is fuzzy-matched. A remembered name can drift onto a different agent or match +nothing at all, so prefer the ID whenever you have it. **It only works for agents +that are currently running** — a message to a stopped agent goes nowhere, so +check `list_agents` when a send fails rather than assuming it was delivered. Messaging is for coordination, not for streaming progress. A parent that wants a start and an end does not want twelve interim pings; fold the detail into the