diff --git a/apps/server/src/agents/lineage.ts b/apps/server/src/agents/lineage.ts new file mode 100644 index 00000000..c6f5d752 --- /dev/null +++ b/apps/server/src/agents/lineage.ts @@ -0,0 +1,177 @@ +/** + * 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. + * + * 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. + */ + +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"; + +export type LineageIndex = { + get(agentId: string): LineageAgent | undefined; + /** Ancestors of `agentId`, nearest first: [parent, grandparent, ...root]. */ + ancestors(agentId: string): LineageNode[]; +}; + +/** + * 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( + index: LineageIndex, + agentId: string +): LineageNode[] { + return index.ancestors(agentId); +} + +/** + * Where `otherId` sits relative to `viewerId`. Ancestry is checked before + * siblinghood so a parent is never also reported as a sibling. + */ +export function relationTo( + index: LineageIndex, + viewerId: string, + otherId: string +): AgentRelation { + 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 (index.ancestors(otherId).some((a) => a.id === viewerId)) { + return "descendant"; + } + if (index.ancestors(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 describes the sender's own provenance to the one agent being + * messaged, which is exactly the information the recipient was missing. + */ +export function delegationChain( + index: LineageIndex, + senderId: string, + recipientId: string +): LineageNode[] { + const sender = index.get(senderId); + const chain: LineageNode[] = sender + ? [{ id: sender.id, name: sender.name }] + : []; + + 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[], + recipientId: string +): string { + return chain + .map( + (node) => + `${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 acea86c2..376aee67 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -41,6 +41,14 @@ import { type PinListing, 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"; import { isMediaFile, isTextFile, resolveMediaDir } from "../shared/media.js"; import type { PublishUiEvent, SendAgentPrompt } from "./mcp-handler-types.js"; @@ -704,8 +712,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 +757,36 @@ 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 lineage = createLineageIndex(everyAgent); + const senderRelation = relationTo(lineage, target.id, agentId); + const chain = delegationChain(lineage, 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: ${sanitizeAgentNameForPrompt(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 +869,52 @@ 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 ); + // 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; name: string; status: string; latestEvent: { type: string; message: string } | null; + parentAgentId: string | null; + parentName: string | null; + relation: AgentRelation; }> = []; for (const a of agents) { + 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, @@ -861,6 +922,9 @@ async function handleListAgentsForAgent( latestEvent: a.latestEvent ? { type: a.latestEvent.type, message: a.latestEvent.message } : null, + parentAgentId: visibleParentId, + parentName, + relation: relationTo(lineage, 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..86ff59f8 Binary files /dev/null and b/apps/server/test/agent-lineage.test.ts differ diff --git a/apps/server/test/mcp-handlers.test.ts b/apps/server/test/mcp-handlers.test.ts index db1dffc3..0d7e080a 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,214 @@ 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("redacts 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, 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 () => { + 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..d57c30de 100644 --- a/plugins/dispatch/skills/subagents/SKILL.md +++ b/plugins/dispatch/skills/subagents/SKILL.md @@ -42,15 +42,26 @@ 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. + +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