feat(shared-ai): runSubAgent() primitive — Claude-Code I2A pattern (M3.1)

New packages/shared-ai/src/planner/sub-agent.ts implementing the
"one level deep, fresh messages, restricted tools, single-string
return" sub-agent contract from Claude Code's KN5/I2A launcher.

Four invariants enforced at the primitive level:

  1. FRESH messages[] — parent's history never leaks in. The sub-agent
     only sees its own system prompt + the task description. Hundreds
     of scanned files stay inside the sub-agent.
  2. RESTRICTED tool-whitelist — parent's full catalog is filtered
     per SubAgentType ('research' = auto-policy only, 'general' =
     everything, 'plan' = auto-policy + 3-round cap). Custom filter
     overrides the type default.
  3. SINGLE RETURN VALUE — sub-agent returns summary:string for
     the parent to render as task-tool-result. Individual tool calls
     stay in rawResult for debug capture but never cross the boundary.
  4. ONE LEVEL DEEP — MAX_SUB_AGENT_DEPTH = 1. parentDepth >= 1 throws
     SubAgentRecursionError; the consumer task-tool handler will
     also check, this is defense-in-depth.

Model is required (no default) — routing to a cheaper tier like the
compactor does is an explicit decision, not a sneaky default.

Belt-and-suspenders wrapper on onToolCall rejects any tool call
whose name isn't in the whitelist, even if the LLM fabricates one.

14 new tests covering recursion guard, tool filtering per type,
custom filter, whitelist rejection, fresh-messages isolation, usage
roll-up, default summary on max-rounds, type-specific system prompt,
system-prompt override, and end-to-end tool-call -> result -> summary.

93 shared-ai tests green total (was 79).

M3.2 (task tool in registry) and M3.3 (consumer wiring) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 18:59:05 +02:00
parent d56ad396d8
commit 66b7e08df2
7 changed files with 1138 additions and 2 deletions

View file

@ -90,14 +90,24 @@ export {
DEFAULT_COMPACT_KEEP_RECENT,
DEFAULT_COMPACT_MODEL,
DEFAULT_COMPACT_THRESHOLD,
MAX_SUB_AGENT_DEPTH,
MockLlmClient,
parseCompactSummary,
parsePlannerResponse,
renderCompactSummary,
runPlannerLoop,
runSubAgent,
shouldCompact,
SubAgentRecursionError,
} from './planner';
export type {
CompactHistoryOptions,
CompactHistoryResult,
CompactSummary,
RunSubAgentInput,
SubAgentResult,
SubAgentType,
} from './planner';
export type { CompactHistoryOptions, CompactHistoryResult, CompactSummary } from './planner';
export {
AI_PROPOSABLE_TOOL_NAMES,

View file

@ -21,6 +21,8 @@ export {
shouldCompact,
} from './compact';
export type { CompactHistoryOptions, CompactHistoryResult, CompactSummary } from './compact';
export { MAX_SUB_AGENT_DEPTH, SubAgentRecursionError, runSubAgent } from './sub-agent';
export type { RunSubAgentInput, SubAgentResult, SubAgentType } from './sub-agent';
export { MockLlmClient } from './mock-llm';
export type { MockLlmTurn } from './mock-llm';
export type {

View file

@ -0,0 +1,288 @@
import { describe, expect, it, vi } from 'vitest';
import {
MAX_SUB_AGENT_DEPTH,
SubAgentRecursionError,
runSubAgent,
type SubAgentType,
} from './sub-agent';
import { MockLlmClient } from './mock-llm';
import type { ToolCallRequest, ToolResult } from './loop';
import type { ToolSchema } from '../tools/schemas';
// ─── Fixtures ──────────────────────────────────────────────────────
const tools: ToolSchema[] = [
{
name: 'list_things',
module: 'test',
description: 'read-only listing',
defaultPolicy: 'auto',
parameters: [],
},
{
name: 'get_thing',
module: 'test',
description: 'read one',
defaultPolicy: 'auto',
parameters: [{ name: 'id', type: 'string', description: 'id', required: true }],
},
{
name: 'create_thing',
module: 'test',
description: 'writes',
defaultPolicy: 'propose',
parameters: [{ name: 'title', type: 'string', description: 'title', required: true }],
},
{
name: 'delete_thing',
module: 'test',
description: 'destructive',
defaultPolicy: 'propose',
parameters: [{ name: 'id', type: 'string', description: 'id', required: true }],
},
];
function baseInput(type: SubAgentType) {
return {
type,
task: 'Find all todo items that mention foo and summarise.',
parentTools: tools,
parentDepth: 0,
model: 'google/gemini-2.5-flash',
};
}
// ─── Recursion guard ───────────────────────────────────────────────
describe('runSubAgent — recursion guard', () => {
it('throws SubAgentRecursionError when parentDepth >= MAX_SUB_AGENT_DEPTH', async () => {
const llm = new MockLlmClient();
await expect(
runSubAgent({
...baseInput('research'),
parentDepth: MAX_SUB_AGENT_DEPTH,
llm,
onToolCall: async () => ({ success: true, message: '' }),
})
).rejects.toBeInstanceOf(SubAgentRecursionError);
});
it('proceeds at parentDepth = 0', async () => {
const llm = new MockLlmClient().enqueueStop('ok');
const res = await runSubAgent({
...baseInput('research'),
parentDepth: 0,
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
expect(res.summary).toBe('ok');
});
});
// ─── Tool filtering by type ────────────────────────────────────────
describe('runSubAgent — tool whitelisting', () => {
it('research type exposes only auto-policy tools to the LLM', async () => {
const llm = new MockLlmClient().enqueueStop('done');
const res = await runSubAgent({
...baseInput('research'),
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
expect(res.availableToolCount).toBe(2); // list_things + get_thing
// The LLM saw the filtered toolset in its schema
const toolNames = llm.calls[0].toolNames;
expect(toolNames).toEqual(expect.arrayContaining(['list_things', 'get_thing']));
expect(toolNames).not.toContain('create_thing');
expect(toolNames).not.toContain('delete_thing');
});
it('general type passes every tool through', async () => {
const llm = new MockLlmClient().enqueueStop('done');
const res = await runSubAgent({
...baseInput('general'),
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
expect(res.availableToolCount).toBe(tools.length);
});
it('plan type also exposes read-only (same filter as research)', async () => {
const llm = new MockLlmClient().enqueueStop('done');
const res = await runSubAgent({
...baseInput('plan'),
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
expect(res.availableToolCount).toBe(2);
});
it('custom toolFilter overrides the type default', async () => {
const llm = new MockLlmClient().enqueueStop('done');
const res = await runSubAgent({
...baseInput('general'),
toolFilter: (t) => t.name === 'get_thing',
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
expect(res.availableToolCount).toBe(1);
});
it('belt-and-suspenders: rejects tool calls outside the whitelist', async () => {
// LLM (misbehaving) asks for create_thing inside a research agent
const llm = new MockLlmClient()
.enqueueToolCalls([{ name: 'create_thing', args: { title: 'nope' } }])
.enqueueStop('fell back to a summary');
const dispatcherCalls: string[] = [];
const onToolCall = async (call: ToolCallRequest): Promise<ToolResult> => {
dispatcherCalls.push(call.name);
return { success: true, message: 'should-not-be-called' };
};
const res = await runSubAgent({
...baseInput('research'),
llm,
onToolCall,
});
// The caller's dispatcher was NEVER invoked — the wrapper rejected it.
expect(dispatcherCalls).toEqual([]);
// The LLM received a failure tool-message so it can change course.
const secondCall = llm.calls[1].messages;
const toolMsg = secondCall[secondCall.length - 1];
expect(toolMsg.role).toBe('tool');
expect(toolMsg.content).toContain('nicht freigegeben');
expect(res.summary).toBe('fell back to a summary');
});
});
// ─── Isolation (context-laundering) ────────────────────────────────
describe('runSubAgent — context isolation', () => {
it('starts with a fresh messages array — no parent context leaks in', async () => {
const llm = new MockLlmClient().enqueueStop('clean');
await runSubAgent({
...baseInput('research'),
task: 'scan things',
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
// What the LLM saw: [system, user] — no prior-messages leakage
const seen = llm.calls[0].messages;
expect(seen).toHaveLength(2);
expect(seen[0].role).toBe('system');
expect(seen[0].content).toContain('Sub-Agent');
expect(seen[1].role).toBe('user');
expect(seen[1].content).toBe('scan things');
});
it('exposes usage roll-up from the underlying loop', async () => {
const llm = new MockLlmClient();
(llm as unknown as { queue: unknown[] }).queue.push({
content: 'done',
toolCalls: [],
finishReason: 'stop',
usage: { promptTokens: 500, completionTokens: 120, totalTokens: 620 },
});
const res = await runSubAgent({
...baseInput('research'),
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
expect(res.usage.promptTokens).toBe(500);
expect(res.usage.completionTokens).toBe(120);
expect(res.usage.totalTokens).toBe(620);
});
it('falls back to a default summary when the LLM hits maxRounds without stopping', async () => {
const llm = new MockLlmClient();
for (let i = 0; i < 10; i++) {
llm.enqueueToolCalls([{ name: 'list_things', args: {} }]);
}
const res = await runSubAgent({
...baseInput('research'),
maxRounds: 3,
llm,
onToolCall: async () => ({ success: true, message: 'ok' }),
});
expect(res.rawResult.stopReason).toBe('max-rounds');
expect(res.summary).toContain('3 Runden ohne Summary');
});
});
// ─── System prompt customisation ──────────────────────────────────
describe('runSubAgent — system prompt', () => {
it('uses a type-specific default prompt', async () => {
const llm = new MockLlmClient().enqueueStop('done');
await runSubAgent({
...baseInput('research'),
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
const seen = llm.calls[0].messages;
expect(seen[0].content).toContain('research');
});
it('honours an explicit systemPrompt override', async () => {
const llm = new MockLlmClient().enqueueStop('done');
await runSubAgent({
...baseInput('general'),
systemPrompt: 'CUSTOM SYSTEM: do exactly X.',
llm,
onToolCall: async () => ({ success: true, message: '' }),
});
const seen = llm.calls[0].messages;
expect(seen[0].content).toBe('CUSTOM SYSTEM: do exactly X.');
});
});
// ─── Model contract ────────────────────────────────────────────────
describe('runSubAgent — model routing', () => {
it('throws when no model is supplied', async () => {
const llm = new MockLlmClient();
await expect(
runSubAgent({
...baseInput('research'),
model: undefined,
llm,
onToolCall: async () => ({ success: true, message: '' }),
})
).rejects.toThrow(/no model supplied/);
});
});
// ─── End-to-end: tool executed + summary returned ──────────────────
describe('runSubAgent — end-to-end', () => {
it('loops: tool call → result → summary', async () => {
const llm = new MockLlmClient()
.enqueueToolCalls([{ name: 'list_things', args: {} }])
.enqueueStop('Found 3 things: a, b, c');
const onToolCall = vi.fn(
async (_call: ToolCallRequest): Promise<ToolResult> => ({
success: true,
data: ['a', 'b', 'c'],
message: '3 items',
})
);
const res = await runSubAgent({
...baseInput('research'),
llm,
onToolCall,
});
expect(onToolCall).toHaveBeenCalledTimes(1);
expect(res.summary).toBe('Found 3 things: a, b, c');
expect(res.rawResult.executedCalls).toHaveLength(1);
});
});

View file

@ -0,0 +1,259 @@
/**
* In-process sub-agent loop the `I2A` pattern from Claude Code.
*
* A sub-agent is `runPlannerLoop` run with four invariants flipped:
*
* 1. FRESH `messages[]` the parent's history never leaks into the
* sub-agent. The sub-agent only sees its own system prompt + task
* description. This is the "context-laundering" point: hundreds
* of scanned files, retry loops, or noisy tool results stay
* inside the sub-agent and never pollute the parent log.
*
* 2. RESTRICTED tool-whitelist the parent's full tool-set is
* filtered down to a subset appropriate for the sub-agent's type
* (e.g. `research` gets read-only tools, `general` gets whatever
* the parent had). The whitelist is enforced at THIS layer, not
* left to the LLM to "please don't use write tools".
*
* 3. SINGLE RETURN VALUE the sub-agent loop produces one string
* summary back to the parent (rendered as the parent's `task`
* tool-result). The parent NEVER sees the sub-agent's individual
* tool calls. This is the Claude-Code contract and matches the
* original paper's sub-episode recipe from RL.
*
* 4. ONE LEVEL DEEP, STRICT a sub-agent cannot launch another
* sub-agent. `parentDepth` in the input enforces this; the
* consumer-level `task` tool handler is the other guard.
*
* Token usage from the sub-agent rolls up to the caller (returned as
* part of `SubAgentResult.usage`) so budget tracking in mana-ai's
* agent snapshots sees the full sub-tree cost, not just the parent loop.
*/
import type { ToolSchema } from '../tools/schemas';
import { runPlannerLoop } from './loop';
import type {
LlmClient,
PlannerLoopResult,
ReminderChannel,
TokenUsage,
ToolCallRequest,
ToolResult,
} from './loop';
/**
* Named sub-agent archetypes. Each type declares a default tool-filter
* predicate that the launcher uses to carve the allowed tool-set out of
* the parent's full catalog.
*
* - `research`: read-only. LLM may list/get/search but not mutate.
* Default for "go scan these things and tell me what's
* there" tasks. Matches Claude Code's `Explore` agent.
*
* - `general`: anything the parent could do (minus recursion). For
* heterogeneous tasks where the sub-agent may need
* writes. Equivalent to Claude Code's `general-purpose`.
*
* - `plan`: read-only, small round budget. For "think through
* this before acting" where the summary IS the value.
* Matches Claude Code's `Plan` mode.
*
* Consumers can supply a custom `toolFilter` to override these defaults.
*/
export type SubAgentType = 'research' | 'general' | 'plan';
const DEFAULT_TOOL_FILTERS: Record<SubAgentType, (tool: ToolSchema) => boolean> = {
research: (t) => t.defaultPolicy === 'auto',
general: () => true,
plan: (t) => t.defaultPolicy === 'auto',
};
const DEFAULT_MAX_ROUNDS: Record<SubAgentType, number> = {
research: 5,
general: 5,
plan: 3,
};
/**
* Hard cap on recursion one level deep, period. Matches Claude Code's
* `KN5` launcher behaviour.
*/
export const MAX_SUB_AGENT_DEPTH = 1;
export interface RunSubAgentInput {
/** LLM transport. Typically the same client as the parent; can be
* swapped for a cheaper-tier model for research-type sub-agents. */
readonly llm: LlmClient;
/** Model id in `provider/model` form. If omitted, the sub-agent
* falls back to the parent-supplied `model`. */
readonly model?: string;
/** Archetype — see `SubAgentType` docs. */
readonly type: SubAgentType;
/** Free-text task description the parent wants the sub-agent to
* execute. Becomes the sub-agent's `userPrompt`. */
readonly task: string;
/** Parent's full tool catalog. The launcher applies the type's
* filter (or the caller's override) to produce the sub-agent's
* restricted set. */
readonly parentTools: readonly ToolSchema[];
/** Optional tool-filter override. Takes precedence over the
* type's default predicate. */
readonly toolFilter?: (tool: ToolSchema) => boolean;
/** Tool dispatcher. Receives the sub-agent's tool calls NOT the
* parent's. The dispatcher MUST validate against the restricted
* whitelist too (belt-and-suspenders); otherwise a malformed
* LLM response could invoke a filtered-out tool. */
readonly onToolCall: (call: ToolCallRequest) => Promise<ToolResult>;
/** Current recursion depth. Parent callers pass 0. A sub-agent
* spawning ANOTHER sub-agent must pass 1, which this function
* then rejects. */
readonly parentDepth: number;
/** Optional per-round reminder channel for the sub-agent. Typically
* different from the parent's e.g. a "you are a research
* sub-agent, don't write" nudge instead of the parent's budget
* warnings. */
readonly reminderChannel?: ReminderChannel;
/** Max LLM rounds inside this sub-agent. Defaults to the type's
* value (research: 5, general: 5, plan: 3). */
readonly maxRounds?: number;
/** Explicit system prompt. Defaults to a short generic "you are a
* sub-agent, return a summary" prompt matching the type. */
readonly systemPrompt?: string;
}
export interface SubAgentResult {
readonly type: SubAgentType;
/** Single-string digest the parent sees as `ToolResult.message`.
* Falls back to a generic line when the LLM hit the round budget
* without producing assistant text. */
readonly summary: string;
/** Raw planner result for debug capture. Consumers typically do NOT
* forward this to the parent only the summary crosses the
* boundary. Kept here so a debug log can record the full
* sub-episode if the caller wants to. */
readonly rawResult: PlannerLoopResult;
/** Rolled-up usage so the caller can attribute tokens to the
* parent's mission/agent budget. */
readonly usage: TokenUsage;
/** How many restricted tools the sub-agent ultimately got. Useful
* for debug logs and dashboards; if it drops to 0 the filter was
* too aggressive and the sub-agent probably couldn't do anything. */
readonly availableToolCount: number;
}
/**
* Thrown when a sub-agent tries to spawn another sub-agent. Callers
* at the tool-registry `task` handler layer also check this, but the
* primitive throws as a defense-in-depth signal the consumer handler
* shouldn't swallow silently.
*/
export class SubAgentRecursionError extends Error {
constructor(depth: number) {
super(
`Sub-agents are one-level-deep only; caller passed parentDepth=${depth}. ` +
`MAX_SUB_AGENT_DEPTH=${MAX_SUB_AGENT_DEPTH}.`
);
this.name = 'SubAgentRecursionError';
}
}
function defaultSystemPrompt(type: SubAgentType): string {
const base =
'Du bist ein Sub-Agent. Fuehre genau die Aufgabe aus, die dir der Parent ' +
'Agent gibt, und liefere eine knappe Summary am Ende. Keine Seitendiskussion.';
if (type === 'research') {
return (
base +
'\n\nArchetyp: research. Du darfst nur Lese-Tools verwenden. Schreibe ' +
'nichts. Ergebnis = Summary deiner Funde in maximal 10 Zeilen.'
);
}
if (type === 'plan') {
return (
base +
'\n\nArchetyp: plan. Keine Tool-Calls wenn moeglich. Denke durch die ' +
'Aufgabe und formuliere einen strukturierten Plan (3-5 Schritte) als ' +
'Summary.'
);
}
return (
base +
'\n\nArchetyp: general. Nutze Tools wie ein Parent-Agent es tun wuerde, ' +
'aber halte die Summary auf das Wesentliche beschraenkt.'
);
}
/**
* Launch an in-process sub-agent. See module docstring for the four
* invariants this enforces.
*
* The returned `summary` is the single artifact that should cross back
* to the parent (typically as a `task` tool-result message). Everything
* else (`rawResult`, individual tool calls) is kept for the caller's
* own debug log but NEVER rendered into the parent's messages array.
*/
export async function runSubAgent(input: RunSubAgentInput): Promise<SubAgentResult> {
if (input.parentDepth >= MAX_SUB_AGENT_DEPTH) {
throw new SubAgentRecursionError(input.parentDepth);
}
const filter = input.toolFilter ?? DEFAULT_TOOL_FILTERS[input.type];
const restrictedTools = input.parentTools.filter(filter);
const maxRounds = input.maxRounds ?? DEFAULT_MAX_ROUNDS[input.type];
const systemPrompt = input.systemPrompt ?? defaultSystemPrompt(input.type);
const model = input.model ?? '';
// The loop requires a model string; surface a clear error rather
// than letting the LLM client fail with a cryptic provider error.
if (!model) {
throw new Error(
`runSubAgent: no model supplied. Pass opts.model explicitly — sub-agents ` +
`default to nothing on purpose so routing to a cheaper tier (Haiku) is ` +
`an explicit decision by the caller.`
);
}
const rawResult = await runPlannerLoop({
llm: input.llm,
input: {
systemPrompt,
userPrompt: input.task,
tools: restrictedTools,
model,
maxRounds,
reminderChannel: input.reminderChannel,
// No compactor for sub-agents: they are short-lived by
// construction (maxRounds ≤ 5). If the caller needs a
// deeper sub-agent, lift that decision up — don't double
// the LLM call count inside a disposable context.
},
onToolCall: async (call: ToolCallRequest): Promise<ToolResult> => {
// Belt-and-suspenders: even though `restrictedTools` was
// passed to the loop, a buggy LLM response could still
// name a tool outside the whitelist. Reject it here so the
// caller's dispatcher never runs an unauthorised tool.
const isWhitelisted = restrictedTools.some((t) => t.name === call.name);
if (!isWhitelisted) {
return {
success: false,
message:
`Tool ${call.name} ist fuer diesen Sub-Agent (${input.type}) ` +
`nicht freigegeben. Wechsel die Strategie oder brich ab.`,
};
}
return input.onToolCall(call);
},
});
const summary =
rawResult.summary ??
`(Sub-Agent ${input.type} beendet nach ${rawResult.rounds} Runden ohne Summary.)`;
return {
type: input.type,
summary,
rawResult,
usage: rawResult.usage,
availableToolCount: restrictedTools.length,
};
}