Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions apps/server/src/agents/lineage.ts
Original file line number Diff line number Diff line change
@@ -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<string, LineageAgent>();
for (const agent of agents) byId.set(agent.id, agent);
const chains = new Map<string, LineageNode[]>();

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<string>([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(" -> ");
}
70 changes: 67 additions & 3 deletions apps/server/src/server/mcp-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -837,30 +869,62 @@ 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,
status: a.status,
latestEvent: a.latestEvent
? { type: a.latestEvent.type, message: a.latestEvent.message }
: null,
parentAgentId: visibleParentId,
parentName,
relation: relationTo(lineage, agentId, a.id),
});
}
return result;
Expand Down
30 changes: 20 additions & 10 deletions apps/server/src/shared/mcp/messaging-tools.ts
Original file line number Diff line number Diff line change
@@ -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<AgentListing[]>;
sendMessage?: (
agentId: string,
input: { target: string; message: string; senderRepoRoot: string | null }
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 5 additions & 9 deletions apps/server/src/shared/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<AgentListing[]>;
getActivitySummary?: (params: {
start: Date;
end: Date;
Expand Down
Binary file added apps/server/test/agent-lineage.test.ts
Binary file not shown.
Loading
Loading