mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(x): spawn-agent — run sub-agents as headless child turns
Adds the agent-as-tool capability deferred in turn-runtime-design §29.2:
a `spawn-agent` builtin that runs a sub-agent in its own standalone
headless turn and returns its final answer to the parent. Multiple
spawn calls in one assistant batch run concurrently (previous commit).
- RequestedAgent is now a union: by-id (unchanged shape) | inline
{name, instructions, model?, tools?}. Inline definitions persist
verbatim in turn_created and resolve to the same immutable snapshot.
- Agent resolution splits by variant: DispatchingAgentResolver narrows
the union once; InlineAgentResolver materializes inline specs
(builtin catalog validation, headless default profile when tools are
omitted); RealAgentResolver keeps the by-id path byte-identical. The
builtin→ToolDescriptor conversion is extracted to a shared helper.
- The spawn handler (RealToolRegistry branch → runSpawnedAgent) runs
the child via HeadlessAgentRunner on the parent's model by default,
clamps the model-call budget at 20, cascades the parent's abort
signal, and records {kind:"subagent", childTurnId} as durable tool
progress — the only parent→child link; no parentTurnId is added to
the schema. Task-level failures return as conversational isError
results, never terminal.
- Depth is capped at 1: both resolvers strip spawn-agent from children
(inline always; by-id via the new subagent composition flag) and the
handler refuses child-shaped parents outright.
- Renderer: spawn-agent calls render as a SubAgentBlock — a collapsed
status card that expands to the child's live transcript
(CompactConversation over sessions:getTurn, polled at 1s while
running; standalone child turns don't reach the session bus).
- The BuiltinTools entry gives copilot (and other catalog-attached
agents) the tool automatically; its execute is the degraded legacy
path only, since the turn runtime intercepts builtin:spawn-agent.
Schema note: RequestedAgent widened under schemaVersion 1 (pre-release)
— requires wiping ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d2b68a4684
commit
b62ed6a2f4
21 changed files with 1145 additions and 49 deletions
|
|
@ -33,6 +33,7 @@ import { AppsView } from '@/components/apps/apps-view';
|
|||
import { EmailView } from '@/components/email-view';
|
||||
import { WorkspaceView } from '@/components/workspace-view';
|
||||
import { CodingRunBlock } from '@/components/coding-run';
|
||||
import { SubAgentBlock } from '@/components/sub-agent-block';
|
||||
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
|
||||
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
|
||||
import { ChatHistoryView } from '@/components/chat-history-view';
|
||||
|
|
@ -5950,6 +5951,16 @@ function App() {
|
|||
/>
|
||||
)
|
||||
}
|
||||
if (item.name === 'spawn-agent') {
|
||||
return (
|
||||
<SubAgentBlock
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={isToolOpenForTab(tabId, item.id)}
|
||||
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const appActionData = getAppActionCardData(item)
|
||||
if (appActionData) {
|
||||
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
|
||||
|
|
|
|||
92
apps/x/apps/renderer/src/components/sub-agent-block.tsx
Normal file
92
apps/x/apps/renderer/src/components/sub-agent-block.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bot } from 'lucide-react'
|
||||
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
|
||||
|
||||
// Rendered for a spawn-agent tool call: a collapsed status card that expands
|
||||
// into the child turn's live transcript. The child is a standalone turn
|
||||
// (sessionId null) whose events never reach the session bus, so while it
|
||||
// runs we poll sessions:getTurn via fetchAgentRunTranscript — the file is
|
||||
// local and append-only, making this cheap — and do one final fetch when the
|
||||
// parent tool call settles.
|
||||
|
||||
const POLL_MS = 1000
|
||||
|
||||
function useChildTranscript(
|
||||
childTurnId: string | undefined,
|
||||
running: boolean,
|
||||
open: boolean,
|
||||
): AgentRunTranscript | null {
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
useEffect(() => {
|
||||
if (!childTurnId || !open) return
|
||||
let alive = true
|
||||
const fetchOnce = async () => {
|
||||
try {
|
||||
const next = await fetchAgentRunTranscript(childTurnId)
|
||||
if (alive) setTranscript(next)
|
||||
} catch {
|
||||
// Child file may not be readable yet; the next tick retries.
|
||||
}
|
||||
}
|
||||
void fetchOnce()
|
||||
if (!running) return () => { alive = false }
|
||||
const timer = setInterval(() => void fetchOnce(), POLL_MS)
|
||||
return () => {
|
||||
alive = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [childTurnId, running, open])
|
||||
return transcript
|
||||
}
|
||||
|
||||
export function SubAgentBlock({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
item: ToolCall
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const input = item.input as
|
||||
| { name?: string; agent_id?: string; task?: string }
|
||||
| undefined
|
||||
const agentName = item.subAgent?.agentName ?? input?.agent_id ?? input?.name ?? 'subagent'
|
||||
const task = item.subAgent?.task ?? input?.task ?? ''
|
||||
const running = item.status === 'pending' || item.status === 'running'
|
||||
const transcript = useChildTranscript(item.subAgent?.childTurnId, running, open)
|
||||
|
||||
return (
|
||||
<Tool open={open} onOpenChange={onOpenChange}>
|
||||
<ToolHeader
|
||||
title={`Sub-agent: ${agentName}`}
|
||||
type="tool-spawn-agent"
|
||||
state={toToolState(item.status)}
|
||||
/>
|
||||
<ToolContent>
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
{task && (
|
||||
<div className="flex items-start gap-2 rounded-2xl bg-secondary/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<Bot className="mt-0.5 size-4 shrink-0" />
|
||||
<span className="whitespace-pre-wrap">{task}</span>
|
||||
</div>
|
||||
)}
|
||||
{transcript ? (
|
||||
<CompactConversation items={transcript.items} />
|
||||
) : (
|
||||
<div className="px-1 text-sm text-muted-foreground">
|
||||
{item.subAgent
|
||||
? 'Loading sub-agent transcript…'
|
||||
: running
|
||||
? 'Starting sub-agent…'
|
||||
: 'No sub-agent transcript was recorded.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
)
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ export interface ToolCall {
|
|||
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
|
||||
codeRunEvents?: CodeRunEvent[]
|
||||
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
|
||||
// spawn-agent only: the durable parent→child link recorded as tool progress.
|
||||
subAgent?: { childTurnId: string; agentName: string; task: string }
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
|
|
@ -668,6 +670,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
|||
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||
if (!isToolCall(item)) return false
|
||||
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
|
||||
if (item.name === 'spawn-agent') return false // rich standalone block, never grouped
|
||||
if (getWebSearchCardData(item)) return false
|
||||
if (getComposioConnectCardData(item)) return false
|
||||
if (getAppActionCardData(item)) return false
|
||||
|
|
|
|||
|
|
@ -309,6 +309,49 @@ describe('buildTurnConversation', () => {
|
|||
expect(settled.status).toBe('completed')
|
||||
})
|
||||
|
||||
it('derives the sub-agent child link from spawn-agent progress', () => {
|
||||
const state = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(
|
||||
T1,
|
||||
0,
|
||||
assistantCalls(
|
||||
toolCallPart('sa1', 'spawn-agent', { task: 'research X', instructions: 'You research.' }),
|
||||
),
|
||||
),
|
||||
invocation(T1, 'sa1', 'spawn-agent'),
|
||||
{
|
||||
type: 'tool_progress',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'sa1',
|
||||
source: 'sync',
|
||||
progress: {
|
||||
kind: 'subagent',
|
||||
childTurnId: 'child-turn-1',
|
||||
agentName: 'researcher',
|
||||
task: 'research X',
|
||||
} as never,
|
||||
},
|
||||
])
|
||||
const tool = buildTurnConversation(state).filter(isToolCall)[0]
|
||||
expect(tool.subAgent).toEqual({
|
||||
childTurnId: 'child-turn-1',
|
||||
agentName: 'researcher',
|
||||
task: 'research X',
|
||||
})
|
||||
|
||||
// Without the progress entry the link is simply absent.
|
||||
const bare = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('sa2', 'spawn-agent', { task: 't' }))),
|
||||
invocation(T1, 'sa2', 'spawn-agent'),
|
||||
])
|
||||
expect(buildTurnConversation(bare).filter(isToolCall)[0].subAgent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders user attachments and a failed turn as an error item', () => {
|
||||
const input = {
|
||||
role: 'user' as const,
|
||||
|
|
|
|||
|
|
@ -241,6 +241,33 @@ function codeRunViewOf(
|
|||
}
|
||||
}
|
||||
|
||||
// spawn-agent's durable trail in tool_progress: one 'subagent' entry recorded
|
||||
// the moment the child turn exists (see the spawn-agent branch in
|
||||
// real-tool-registry.ts). It is the parent→child link the card uses to fetch
|
||||
// and render the child transcript.
|
||||
function subAgentViewOf(tc: ToolCallState): Pick<ToolCall, 'subAgent'> {
|
||||
for (const p of tc.progress) {
|
||||
const entry = p.progress
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
||||
const { kind, childTurnId, agentName, task } = entry as {
|
||||
kind?: unknown
|
||||
childTurnId?: unknown
|
||||
agentName?: unknown
|
||||
task?: unknown
|
||||
}
|
||||
if (kind === 'subagent' && typeof childTurnId === 'string') {
|
||||
return {
|
||||
subAgent: {
|
||||
childTurnId,
|
||||
agentName: typeof agentName === 'string' ? agentName : 'subagent',
|
||||
task: typeof task === 'string' ? task : '',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// One turn's contribution to the conversation: the user input, then per
|
||||
// completed model call its text and tool calls (with live status/results).
|
||||
export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
||||
|
|
@ -295,6 +322,7 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
|||
status: tc ? toolStatus(tc) : 'running',
|
||||
timestamp: ts(),
|
||||
...(tc ? codeRunViewOf(tc) : {}),
|
||||
...(tc ? subAgentViewOf(tc) : {}),
|
||||
} satisfies ToolCall)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1895,6 +1895,17 @@ tool handler may create and execute child turns, forward progress, and return a
|
|||
parent tool result. No subflow or parent-turn concept is added to this initial
|
||||
turn schema.
|
||||
|
||||
Implemented (v1) as the `spawn-agent` builtin: a sync tool whose handler
|
||||
(`RealToolRegistry`) runs the child as a standalone headless turn
|
||||
(`runSpawnedAgent`), records `{kind: "subagent", childTurnId}` as durable
|
||||
tool progress (the only parent→child link), and returns the child's final
|
||||
text plus a status envelope. `RequestedAgent` is a union of by-id and inline
|
||||
variants; inline agents resolve through `InlineAgentResolver`. Depth is
|
||||
capped at 1: both resolvers strip the spawn tool from children, and the
|
||||
handler refuses child-shaped parents. Parallel fan-out comes from concurrent
|
||||
sync-tool execution (§10.5), not from async suspension; async (restart
|
||||
survivability for long children) remains future work.
|
||||
|
||||
### 29.3 Reliability enhancements
|
||||
|
||||
- External-input idempotency.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { z } from "zod";
|
||||
import type { AssistantMessage } from "@x/shared/dist/message.js";
|
||||
import {
|
||||
type RequestedAgent,
|
||||
reduceTurn,
|
||||
type TurnState,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
|
|
@ -26,7 +27,10 @@ export class HeadlessRunError extends Error {
|
|||
}
|
||||
|
||||
export interface HeadlessAgentOptions {
|
||||
agentId: string;
|
||||
agentId?: string;
|
||||
// Full agent request, used verbatim when set (inline agents, composition
|
||||
// overrides). Takes precedence over agentId/model/provider.
|
||||
agent?: z.infer<typeof RequestedAgent>;
|
||||
message: string;
|
||||
// Model id; when set without provider, the app-default provider applies.
|
||||
model?: string;
|
||||
|
|
@ -121,22 +125,29 @@ export class HeadlessAgentRunner implements IHeadlessAgentRunner {
|
|||
}
|
||||
|
||||
async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> {
|
||||
let modelOverride: { provider: string; model: string } | undefined;
|
||||
if (options.model && options.provider) {
|
||||
modelOverride = { provider: options.provider, model: options.model };
|
||||
} else if (options.model || options.provider) {
|
||||
const defaults = await this.defaultModelResolver.resolve();
|
||||
modelOverride = {
|
||||
provider: options.provider ?? defaults.provider,
|
||||
model: options.model ?? defaults.model,
|
||||
let agent = options.agent;
|
||||
if (!agent) {
|
||||
if (!options.agentId) {
|
||||
throw new Error("headless agent needs an agentId or an agent request");
|
||||
}
|
||||
let modelOverride: { provider: string; model: string } | undefined;
|
||||
if (options.model && options.provider) {
|
||||
modelOverride = { provider: options.provider, model: options.model };
|
||||
} else if (options.model || options.provider) {
|
||||
const defaults = await this.defaultModelResolver.resolve();
|
||||
modelOverride = {
|
||||
provider: options.provider ?? defaults.provider,
|
||||
model: options.model ?? defaults.model,
|
||||
};
|
||||
}
|
||||
agent = {
|
||||
agentId: options.agentId,
|
||||
...(modelOverride ? { overrides: { model: modelOverride } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const turnId = await this.turnRuntime.createTurn({
|
||||
agent: {
|
||||
agentId: options.agentId,
|
||||
...(modelOverride ? { overrides: { model: modelOverride } } : {}),
|
||||
},
|
||||
agent,
|
||||
sessionId: null,
|
||||
context: [],
|
||||
input: { role: "user", content: options.message },
|
||||
|
|
|
|||
222
apps/x/packages/core/src/agents/spawn-agent.test.ts
Normal file
222
apps/x/packages/core/src/agents/spawn-agent.test.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import type { TurnEvent, TurnState } from "@x/shared/dist/turns.js";
|
||||
import type { ITurnRuntime } from "../turns/api.js";
|
||||
import type {
|
||||
HeadlessAgentHandle,
|
||||
HeadlessAgentOptions,
|
||||
HeadlessAgentResult,
|
||||
IHeadlessAgentRunner,
|
||||
} from "./headless.js";
|
||||
import { runSpawnedAgent } from "./spawn-agent.js";
|
||||
|
||||
const TS = "2026-07-07T10:00:00Z";
|
||||
|
||||
function parentCreated(
|
||||
overrides: {
|
||||
requested?: z.infer<typeof TurnEvent> extends never ? never : unknown;
|
||||
} & Record<string, unknown> = {},
|
||||
): Array<z.infer<typeof TurnEvent>> {
|
||||
return [
|
||||
{
|
||||
type: "turn_created",
|
||||
schemaVersion: 1,
|
||||
turnId: "parent-1",
|
||||
ts: TS,
|
||||
sessionId: null,
|
||||
agent: {
|
||||
requested: (overrides.requested ?? { agentId: "copilot" }) as never,
|
||||
resolved: {
|
||||
agentId: "copilot",
|
||||
systemPrompt: "s",
|
||||
model: { provider: "parent-p", model: "parent-m" },
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
context: [],
|
||||
input: { role: "user", content: "hi" },
|
||||
config: {
|
||||
autoPermission: true,
|
||||
humanAvailable: false,
|
||||
maxModelCalls: 20,
|
||||
},
|
||||
} as z.infer<typeof TurnEvent>,
|
||||
];
|
||||
}
|
||||
|
||||
function fakeServices(opts: {
|
||||
parentEvents?: Array<z.infer<typeof TurnEvent>>;
|
||||
childResult?: Partial<HeadlessAgentResult>;
|
||||
startError?: string;
|
||||
}) {
|
||||
const started: HeadlessAgentOptions[] = [];
|
||||
const turnRuntime = {
|
||||
getTurn: async () => ({
|
||||
turnId: "parent-1",
|
||||
events: opts.parentEvents ?? parentCreated(),
|
||||
}),
|
||||
} as unknown as ITurnRuntime;
|
||||
const headlessRunner: IHeadlessAgentRunner = {
|
||||
start: async (options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> => {
|
||||
if (opts.startError) {
|
||||
throw new Error(opts.startError);
|
||||
}
|
||||
started.push(options);
|
||||
const result: HeadlessAgentResult = {
|
||||
outcome: {
|
||||
status: "completed",
|
||||
output: { role: "assistant", content: "answer" },
|
||||
finishReason: "stop",
|
||||
usage: { totalTokens: 7 },
|
||||
},
|
||||
state: { modelCalls: [{}, {}] } as unknown as TurnState,
|
||||
summary: "answer",
|
||||
...opts.childResult,
|
||||
};
|
||||
return { turnId: "child-1", done: Promise.resolve(result) };
|
||||
},
|
||||
run: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
};
|
||||
return { services: { turnRuntime, headlessRunner }, started };
|
||||
}
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
describe("runSpawnedAgent", () => {
|
||||
it("runs an inline child on the parent's model and returns the result envelope", async () => {
|
||||
const { services, started } = fakeServices({});
|
||||
const progress: unknown[] = [];
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "find things", name: "researcher", instructions: "You research." },
|
||||
{
|
||||
parentTurnId: "parent-1",
|
||||
signal,
|
||||
services,
|
||||
onChildStarted: async (info) => {
|
||||
progress.push(info);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(started[0].agent).toEqual({
|
||||
inline: {
|
||||
name: "researcher",
|
||||
instructions: "You research.",
|
||||
model: { provider: "parent-p", model: "parent-m" },
|
||||
},
|
||||
});
|
||||
expect(started[0].maxModelCalls).toBe(20);
|
||||
expect(started[0].signal).toBe(signal);
|
||||
expect(progress).toEqual([
|
||||
{ childTurnId: "child-1", agentName: "researcher", task: "find things" },
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
isError: false,
|
||||
output: {
|
||||
status: "completed",
|
||||
result: "answer",
|
||||
childTurnId: "child-1",
|
||||
agent: "researcher",
|
||||
modelCalls: 2,
|
||||
usage: { totalTokens: 7 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("marks by-id children with the subagent composition flag", async () => {
|
||||
const { services, started } = fakeServices({});
|
||||
await runSpawnedAgent(
|
||||
{ task: "t", agent_id: "background-task-agent", max_model_calls: 5 },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(started[0].agent).toEqual({
|
||||
agentId: "background-task-agent",
|
||||
overrides: {
|
||||
model: { provider: "parent-p", model: "parent-m" },
|
||||
composition: { subagent: true },
|
||||
},
|
||||
});
|
||||
expect(started[0].maxModelCalls).toBe(5);
|
||||
});
|
||||
|
||||
it("requires exactly one of agent_id and instructions", async () => {
|
||||
const { services } = fakeServices({});
|
||||
for (const input of [
|
||||
{ task: "t" },
|
||||
{ task: "t", agent_id: "a", instructions: "b" },
|
||||
]) {
|
||||
const result = await runSpawnedAgent(input, {
|
||||
parentTurnId: "parent-1",
|
||||
signal,
|
||||
services,
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toMatch(/exactly one/);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a model-call budget above the cap at the schema boundary", async () => {
|
||||
const { services } = fakeServices({});
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x", max_model_calls: 50 },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toMatch(/invalid input/);
|
||||
});
|
||||
|
||||
it("refuses to spawn from a child parent (depth cap)", async () => {
|
||||
for (const requested of [
|
||||
{ inline: { name: "c", instructions: "x" } },
|
||||
{ agentId: "copilot", overrides: { composition: { subagent: true } } },
|
||||
]) {
|
||||
const { services, started } = fakeServices({
|
||||
parentEvents: parentCreated({ requested }),
|
||||
});
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toMatch(/cannot spawn further/);
|
||||
expect(started).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports resolution failures conversationally", async () => {
|
||||
const { services } = fakeServices({ startError: "agent not found: nope" });
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "t", agent_id: "nope" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
isError: true,
|
||||
output: "spawn-agent: agent not found: nope",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns child failure as an error envelope with the partial answer", async () => {
|
||||
const { services } = fakeServices({
|
||||
childResult: {
|
||||
outcome: {
|
||||
status: "failed",
|
||||
error: "model exploded",
|
||||
usage: {},
|
||||
},
|
||||
summary: "got halfway",
|
||||
},
|
||||
});
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "t", instructions: "x" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toMatchObject({
|
||||
status: "failed",
|
||||
error: "model exploded",
|
||||
partialResult: "got halfway",
|
||||
childTurnId: "child-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
253
apps/x/packages/core/src/agents/spawn-agent.ts
Normal file
253
apps/x/packages/core/src/agents/spawn-agent.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
DEFAULT_MAX_MODEL_CALLS,
|
||||
type JsonValue,
|
||||
type ModelDescriptor,
|
||||
type RequestedAgent,
|
||||
type ToolResultData,
|
||||
isInlineAgentRequest,
|
||||
reduceTurn,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
|
||||
// The spawn-agent tool: runs a sub-agent as a standalone headless child turn
|
||||
// and returns its final answer. The input schema and description live here
|
||||
// (imported by the BuiltinTools catalog entry); execution resolves runtime
|
||||
// services lazily so this module stays import-cycle-free.
|
||||
|
||||
export const SpawnAgentInput = z.object({
|
||||
task: z
|
||||
.string()
|
||||
.describe(
|
||||
"The task for the sub-agent. It starts with NO other context — include everything it needs (facts, constraints, expected output format).",
|
||||
),
|
||||
agent_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Run a stored agent by id. Mutually exclusive with `instructions`.",
|
||||
),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Short display name for an inline agent (e.g. 'researcher')."),
|
||||
instructions: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"System instructions for an agent constructed on the fly. Mutually exclusive with `agent_id`.",
|
||||
),
|
||||
model: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Model id for the sub-agent; defaults to the current model."),
|
||||
provider: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Provider for `model`; defaults to the current provider."),
|
||||
tools: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Builtin tool names for an inline agent. Omit for the default headless profile (files, web, search, knowledge).",
|
||||
),
|
||||
max_model_calls: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(DEFAULT_MAX_MODEL_CALLS)
|
||||
.optional()
|
||||
.describe(
|
||||
`Model-call budget for the sub-agent (default and cap: ${DEFAULT_MAX_MODEL_CALLS}).`,
|
||||
),
|
||||
});
|
||||
|
||||
export const SPAWN_AGENT_DESCRIPTION =
|
||||
"Launch a sub-agent that works on a task in its own isolated, headless turn and returns its final answer. " +
|
||||
"Issue several spawn-agent calls in ONE response to run sub-agents in parallel — use this to fan out independent research, analysis, or file work. " +
|
||||
"Provide either `agent_id` (a stored agent) or `instructions` (construct a specialist on the fly, optionally with `name` and `tools`). " +
|
||||
"The sub-agent cannot ask the user questions and cannot spawn further sub-agents; give it a complete, self-contained task.";
|
||||
|
||||
export interface SpawnedAgentCallbacks {
|
||||
parentTurnId: string;
|
||||
signal: AbortSignal;
|
||||
// Invoked as soon as the child turn exists — the caller records the
|
||||
// durable parent→child link before the child settles.
|
||||
onChildStarted?: (info: {
|
||||
childTurnId: string;
|
||||
agentName: string;
|
||||
task: string;
|
||||
}) => Promise<void>;
|
||||
// Test seam; production resolves these from the DI container.
|
||||
services?: {
|
||||
turnRuntime: import("../turns/api.js").ITurnRuntime;
|
||||
headlessRunner: import("./headless.js").IHeadlessAgentRunner;
|
||||
};
|
||||
}
|
||||
|
||||
// Runs one spawned child to completion. Never throws for task-level problems
|
||||
// (bad input, unknown agent/tool, child failure) — those come back as
|
||||
// isError results so the parent model can react conversationally.
|
||||
export async function runSpawnedAgent(
|
||||
rawInput: unknown,
|
||||
opts: SpawnedAgentCallbacks,
|
||||
): Promise<z.infer<typeof ToolResultData>> {
|
||||
const parsed = SpawnAgentInput.safeParse(rawInput);
|
||||
if (!parsed.success) {
|
||||
return spawnError(`invalid input: ${parsed.error.message}`);
|
||||
}
|
||||
const input = parsed.data;
|
||||
if (!input.agent_id === !input.instructions) {
|
||||
return spawnError(
|
||||
"provide exactly one of `agent_id` or `instructions`",
|
||||
);
|
||||
}
|
||||
|
||||
// Lazy: this module is imported by the BuiltinTools catalog, which the
|
||||
// DI container's bridges import at startup.
|
||||
const { turnRuntime, headlessRunner } =
|
||||
opts.services ?? (await resolveServices());
|
||||
|
||||
let parentModel: z.infer<typeof ModelDescriptor> | undefined;
|
||||
try {
|
||||
const parent = reduceTurn(
|
||||
(await turnRuntime.getTurn(opts.parentTurnId)).events,
|
||||
);
|
||||
const parentAgent = parent.definition.agent;
|
||||
// Defense in depth for the depth-1 cap: resolvers already strip the
|
||||
// spawn tool from children, but a child that somehow holds it must
|
||||
// still not recurse.
|
||||
if (hasSubagentFlag(parentAgent.requested)) {
|
||||
return spawnError("sub-agents cannot spawn further sub-agents");
|
||||
}
|
||||
parentModel = parentAgent.resolved.model;
|
||||
} catch {
|
||||
// Parent unreadable (legacy runs path): fall back to app defaults.
|
||||
parentModel = undefined;
|
||||
}
|
||||
|
||||
const model: z.infer<typeof ModelDescriptor> | undefined = input.model
|
||||
? {
|
||||
provider: input.provider ?? parentModel?.provider ?? "",
|
||||
model: input.model,
|
||||
}
|
||||
: parentModel;
|
||||
if (model && !model.provider) {
|
||||
return spawnError(
|
||||
"`model` was set but no provider could be determined; pass `provider` too",
|
||||
);
|
||||
}
|
||||
|
||||
const agentName = input.agent_id ?? input.name ?? "subagent";
|
||||
const agent: z.infer<typeof RequestedAgent> = input.agent_id
|
||||
? {
|
||||
agentId: input.agent_id,
|
||||
overrides: {
|
||||
...(model ? { model } : {}),
|
||||
composition: { subagent: true },
|
||||
},
|
||||
}
|
||||
: {
|
||||
inline: {
|
||||
name: agentName,
|
||||
instructions: input.instructions!,
|
||||
...(model ? { model } : {}),
|
||||
...(input.tools ? { tools: input.tools } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const maxModelCalls = Math.min(
|
||||
input.max_model_calls ?? DEFAULT_MAX_MODEL_CALLS,
|
||||
DEFAULT_MAX_MODEL_CALLS,
|
||||
);
|
||||
|
||||
let handle: Awaited<ReturnType<typeof headlessRunner.start>>;
|
||||
try {
|
||||
handle = await headlessRunner.start({
|
||||
agent,
|
||||
message: input.task,
|
||||
maxModelCalls,
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
// Resolution failures (unknown agent id, unknown tool name) reject
|
||||
// createTurn before any turn file exists.
|
||||
return spawnError(errorText(error));
|
||||
}
|
||||
|
||||
await opts.onChildStarted?.({
|
||||
childTurnId: handle.turnId,
|
||||
agentName,
|
||||
task: input.task,
|
||||
});
|
||||
|
||||
const result = await handle.done;
|
||||
const base = {
|
||||
childTurnId: handle.turnId,
|
||||
agent: agentName,
|
||||
modelCalls: result.state.modelCalls.length,
|
||||
usage: result.outcome.usage as JsonValue,
|
||||
};
|
||||
if (result.outcome.status === "completed") {
|
||||
return {
|
||||
output: {
|
||||
status: "completed",
|
||||
result: result.summary ?? "",
|
||||
...base,
|
||||
},
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
output: {
|
||||
status: result.outcome.status,
|
||||
error:
|
||||
result.outcome.status === "failed"
|
||||
? result.outcome.error
|
||||
: `sub-agent turn was ${result.outcome.status}`,
|
||||
// A partial answer is often still useful to the parent.
|
||||
...(result.summary ? { partialResult: result.summary } : {}),
|
||||
...base,
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveServices(): Promise<
|
||||
NonNullable<SpawnedAgentCallbacks["services"]>
|
||||
> {
|
||||
const { default: container } = await import("../di/container.js");
|
||||
return {
|
||||
turnRuntime:
|
||||
container.resolve<import("../turns/api.js").ITurnRuntime>(
|
||||
"turnRuntime",
|
||||
),
|
||||
headlessRunner: container.resolve<
|
||||
import("./headless.js").IHeadlessAgentRunner
|
||||
>("headlessAgentRunner"),
|
||||
};
|
||||
}
|
||||
|
||||
function spawnError(message: string): z.infer<typeof ToolResultData> {
|
||||
return { output: `spawn-agent: ${message}`, isError: true };
|
||||
}
|
||||
|
||||
// True for any child-shaped parent: inline agents only exist as spawned
|
||||
// children, and by-id children carry the subagent composition flag.
|
||||
function hasSubagentFlag(
|
||||
requested: z.infer<typeof RequestedAgent>,
|
||||
): boolean {
|
||||
if (isInlineAgentRequest(requested)) {
|
||||
return true;
|
||||
}
|
||||
const composition = requested.overrides?.composition;
|
||||
return (
|
||||
typeof composition === "object" &&
|
||||
composition !== null &&
|
||||
!Array.isArray(composition) &&
|
||||
composition.subagent === true
|
||||
);
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import { composioAccountsRepo } from "../../composio/repo.js";
|
|||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js";
|
||||
import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||
import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.js";
|
||||
import { SPAWN_AGENT_TOOL_NAME } from "@x/shared/dist/turns.js";
|
||||
import { SPAWN_AGENT_DESCRIPTION, SpawnAgentInput } from "../../agents/spawn-agent.js";
|
||||
import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
||||
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
|
||||
import type { CodeModeManager } from "../../code-mode/acp/manager.js";
|
||||
|
|
@ -2011,4 +2013,27 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
},
|
||||
},
|
||||
|
||||
[SPAWN_AGENT_TOOL_NAME]: {
|
||||
description: SPAWN_AGENT_DESCRIPTION,
|
||||
inputSchema: SpawnAgentInput,
|
||||
// Legacy runs-runtime path only: the turn runtime intercepts
|
||||
// builtin:spawn-agent in RealToolRegistry with a dedicated handler
|
||||
// that also records the parent→child link as durable tool progress.
|
||||
execute: async (input: unknown, ctx?: ToolContext) => {
|
||||
const { runSpawnedAgent } = await import("../../agents/spawn-agent.js");
|
||||
const result = await runSpawnedAgent(input, {
|
||||
parentTurnId: ctx?.runId ?? "",
|
||||
signal: ctx?.signal ?? new AbortController().signal,
|
||||
});
|
||||
if (result.isError) {
|
||||
throw new Error(
|
||||
typeof result.output === "string"
|
||||
? result.output
|
||||
: JSON.stringify(result.output),
|
||||
);
|
||||
}
|
||||
return result.output;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import type { IModelRegistry } from "../turns/model-registry.js";
|
|||
import type { IToolRegistry } from "../turns/tool-registry.js";
|
||||
import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js";
|
||||
import { RealAgentResolver } from "../turns/bridges/real-agent-resolver.js";
|
||||
import { InlineAgentResolver } from "../turns/bridges/inline-agent-resolver.js";
|
||||
import { DispatchingAgentResolver } from "../turns/bridges/agent-resolver-dispatch.js";
|
||||
import { RealModelRegistry } from "../turns/bridges/real-model-registry.js";
|
||||
import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js";
|
||||
import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js";
|
||||
|
|
@ -123,7 +125,13 @@ container.register({
|
|||
).singleton(),
|
||||
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
|
||||
usageReporter: asClass<IUsageReporter>(RealUsageReporter).singleton(),
|
||||
agentResolver: asFunction<IAgentResolver>(() => new RealAgentResolver()).singleton(),
|
||||
agentResolver: asFunction<IAgentResolver>(
|
||||
() =>
|
||||
new DispatchingAgentResolver(
|
||||
new RealAgentResolver(),
|
||||
new InlineAgentResolver(),
|
||||
),
|
||||
).singleton(),
|
||||
modelRegistry: asFunction<IModelRegistry>(() => new RealModelRegistry()).singleton(),
|
||||
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
|
||||
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
type ModelDescriptor,
|
||||
type ToolResultData,
|
||||
deriveTurnStatus,
|
||||
inlineAgentId,
|
||||
isInlineAgentRequest,
|
||||
reduceTurn,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
|
|
@ -189,7 +191,9 @@ export class SessionsImpl implements ISessions {
|
|||
ts: this.clock.now(),
|
||||
turnId,
|
||||
sessionSeq: state.turns.length + 1,
|
||||
agentId: config.agent.agentId,
|
||||
agentId: isInlineAgentRequest(config.agent)
|
||||
? inlineAgentId(config.agent.inline.name)
|
||||
: config.agent.agentId,
|
||||
model: await this.resolvedModelOf(turnId),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ResolvedAgent } from "@x/shared/dist/turns.js";
|
||||
import type { z } from "zod";
|
||||
import { DispatchingAgentResolver } from "./agent-resolver-dispatch.js";
|
||||
import type { InlineAgentResolver } from "./inline-agent-resolver.js";
|
||||
import type { RealAgentResolver } from "./real-agent-resolver.js";
|
||||
|
||||
function stub(agentId: string) {
|
||||
return {
|
||||
resolve: async () =>
|
||||
({
|
||||
agentId,
|
||||
systemPrompt: "s",
|
||||
model: { provider: "p", model: "m" },
|
||||
tools: [],
|
||||
}) satisfies z.infer<typeof ResolvedAgent>,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DispatchingAgentResolver", () => {
|
||||
it("routes by-id requests to the by-id resolver and inline to the inline resolver", async () => {
|
||||
const resolver = new DispatchingAgentResolver(
|
||||
stub("from-by-id") as unknown as RealAgentResolver,
|
||||
stub("from-inline") as unknown as InlineAgentResolver,
|
||||
);
|
||||
expect((await resolver.resolve({ agentId: "copilot" })).agentId).toBe(
|
||||
"from-by-id",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await resolver.resolve({
|
||||
inline: { name: "x", instructions: "y" },
|
||||
})
|
||||
).agentId,
|
||||
).toBe("from-inline");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { z } from "zod";
|
||||
import {
|
||||
type RequestedAgent,
|
||||
type ResolvedAgent,
|
||||
isInlineAgentRequest,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import type { IAgentResolver } from "../agent-resolver.js";
|
||||
import type { InlineAgentResolver } from "./inline-agent-resolver.js";
|
||||
import type { RealAgentResolver } from "./real-agent-resolver.js";
|
||||
|
||||
// The only IAgentResolver implementation: narrows the RequestedAgent union
|
||||
// exactly once and hands each variant to a resolver that never sees the
|
||||
// other's concerns (loadAgent/composition vs. catalog validation/defaults).
|
||||
export class DispatchingAgentResolver implements IAgentResolver {
|
||||
constructor(
|
||||
private readonly byId: RealAgentResolver,
|
||||
private readonly inline: InlineAgentResolver,
|
||||
) {}
|
||||
|
||||
resolve(
|
||||
requested: z.infer<typeof RequestedAgent>,
|
||||
): Promise<z.infer<typeof ResolvedAgent>> {
|
||||
return isInlineAgentRequest(requested)
|
||||
? this.inline.resolve(requested)
|
||||
: this.byId.resolve(requested);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { z } from "zod";
|
||||
import type { JsonValue, ToolDescriptor } from "@x/shared/dist/turns.js";
|
||||
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
|
||||
type BuiltinToolDef = (typeof BuiltinTools)[string];
|
||||
|
||||
// The one place a builtin catalog entry becomes a persisted ToolDescriptor.
|
||||
// Shared by the by-id and inline agent resolvers so both produce
|
||||
// byte-identical descriptors (which is what makes agent-snapshot inheritance
|
||||
// work across turns).
|
||||
export function builtinToolDescriptor(
|
||||
name: string,
|
||||
builtin: BuiltinToolDef,
|
||||
): z.infer<typeof ToolDescriptor> {
|
||||
return {
|
||||
toolId: `builtin:${name}`,
|
||||
name,
|
||||
description: builtin.description,
|
||||
inputSchema: toJsonSchema(builtin.inputSchema),
|
||||
execution: "sync",
|
||||
requiresHuman: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function toJsonSchema(schema: unknown): JsonValue {
|
||||
try {
|
||||
return toJsonValue(z.toJSONSchema(schema as z.ZodType)) ?? {
|
||||
type: "object",
|
||||
properties: {},
|
||||
};
|
||||
} catch {
|
||||
// An exotic zod schema must not break the whole turn.
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function toJsonValue(value: unknown): JsonValue | undefined {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
import { InlineAgentResolver } from "./inline-agent-resolver.js";
|
||||
|
||||
const fakeBuiltins = {
|
||||
"web-search": {
|
||||
description: "Search the web",
|
||||
inputSchema: z.object({ query: z.string() }),
|
||||
execute: async () => null,
|
||||
},
|
||||
"file-readText": {
|
||||
description: "Read a file",
|
||||
inputSchema: z.object({ path: z.string() }),
|
||||
execute: async () => null,
|
||||
},
|
||||
"flaky-tool": {
|
||||
description: "Sometimes available",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => null,
|
||||
isAvailable: async () => false,
|
||||
},
|
||||
"spawn-agent": {
|
||||
description: "Spawn",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => null,
|
||||
},
|
||||
executeCommand: {
|
||||
description: "Run a command",
|
||||
inputSchema: z.object({ command: z.string() }),
|
||||
execute: async () => null,
|
||||
},
|
||||
} as unknown as typeof BuiltinTools;
|
||||
|
||||
function makeResolver() {
|
||||
return new InlineAgentResolver({
|
||||
builtins: fakeBuiltins,
|
||||
defaultModel: async () => ({ model: "m-default", provider: "p-default" }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("InlineAgentResolver", () => {
|
||||
it("materializes the spec verbatim: inline agentId, instructions as prompt, spec model", async () => {
|
||||
const resolved = await makeResolver().resolve({
|
||||
inline: {
|
||||
name: "researcher",
|
||||
instructions: "You research things.",
|
||||
model: { provider: "p1", model: "m1" },
|
||||
tools: ["web-search"],
|
||||
},
|
||||
});
|
||||
expect(resolved.agentId).toBe("inline:researcher");
|
||||
expect(resolved.systemPrompt).toBe("You research things.");
|
||||
expect(resolved.model).toEqual({ provider: "p1", model: "m1" });
|
||||
expect(resolved.tools).toEqual([
|
||||
expect.objectContaining({
|
||||
toolId: "builtin:web-search",
|
||||
name: "web-search",
|
||||
execution: "sync",
|
||||
requiresHuman: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the app-default model when the spec has none", async () => {
|
||||
const resolved = await makeResolver().resolve({
|
||||
inline: { name: "a", instructions: "x", tools: [] },
|
||||
});
|
||||
expect(resolved.model).toEqual({ provider: "p-default", model: "m-default" });
|
||||
});
|
||||
|
||||
it("rejects unknown builtin names loudly", async () => {
|
||||
await expect(
|
||||
makeResolver().resolve({
|
||||
inline: { name: "a", instructions: "x", tools: ["nope"] },
|
||||
}),
|
||||
).rejects.toThrowError(/unknown builtin tool: nope/);
|
||||
});
|
||||
|
||||
it("strips spawn-agent (depth cap) and skips unavailable tools", async () => {
|
||||
const resolved = await makeResolver().resolve({
|
||||
inline: {
|
||||
name: "a",
|
||||
instructions: "x",
|
||||
tools: ["spawn-agent", "flaky-tool", "file-readText"],
|
||||
},
|
||||
});
|
||||
expect(resolved.tools.map((t) => t.name)).toEqual(["file-readText"]);
|
||||
});
|
||||
|
||||
it("default profile is the catalog minus the headless/child exclusions", async () => {
|
||||
const resolved = await makeResolver().resolve({
|
||||
inline: { name: "a", instructions: "x" },
|
||||
});
|
||||
const names = resolved.tools.map((t) => t.name);
|
||||
expect(names).toEqual(["web-search", "file-readText"]);
|
||||
// Excluded by policy, not by absence:
|
||||
expect(names).not.toContain("executeCommand");
|
||||
expect(names).not.toContain("spawn-agent");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import type { z } from "zod";
|
||||
import {
|
||||
type InlineAgentRequest,
|
||||
type ResolvedAgent,
|
||||
SPAWN_AGENT_TOOL_NAME,
|
||||
type ToolDescriptor,
|
||||
inlineAgentId,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import { ResolvedAgent as ResolvedAgentSchema } from "@x/shared/dist/turns.js";
|
||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
import { getDefaultModelAndProvider } from "../../models/defaults.js";
|
||||
import { builtinToolDescriptor } from "./builtin-descriptors.js";
|
||||
|
||||
// Default tool profile for inline agents that omit `tools`: every builtin
|
||||
// except the ones that make no sense headlessly or in a child. Mirrors the
|
||||
// background-task agent's exclusions (no interactive approval surface) plus
|
||||
// the task/session launchers — an ephemeral child should do its own work,
|
||||
// not schedule more.
|
||||
const DEFAULT_PROFILE_EXCLUDED = new Set([
|
||||
"executeCommand", // headless: no interactive approval
|
||||
"code_agent_run", // headless: needs interactive permission UI
|
||||
"launch-code-task",
|
||||
"run-background-task-agent",
|
||||
"create-background-task",
|
||||
"patch-background-task",
|
||||
"run-live-note-agent",
|
||||
SPAWN_AGENT_TOOL_NAME,
|
||||
]);
|
||||
|
||||
export interface InlineAgentResolverDeps {
|
||||
builtins?: typeof BuiltinTools;
|
||||
defaultModel?: () => Promise<{ model: string; provider: string }>;
|
||||
}
|
||||
|
||||
// Materializes an inline (spawned) agent definition into the same immutable
|
||||
// ResolvedAgent snapshot a stored agent gets. Deliberately knows nothing
|
||||
// about loadAgent, composition overrides, or copilot context — an inline
|
||||
// agent is exactly its persisted spec.
|
||||
export class InlineAgentResolver {
|
||||
private readonly builtins: typeof BuiltinTools;
|
||||
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
|
||||
|
||||
constructor(deps: InlineAgentResolverDeps = {}) {
|
||||
this.builtins = deps.builtins ?? BuiltinTools;
|
||||
this.defaultModel = deps.defaultModel ?? getDefaultModelAndProvider;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
requested: z.infer<typeof InlineAgentRequest>,
|
||||
): Promise<z.infer<typeof ResolvedAgent>> {
|
||||
const spec = requested.inline;
|
||||
|
||||
let model = spec.model;
|
||||
if (!model) {
|
||||
const fallback = await this.defaultModel();
|
||||
model = { provider: fallback.provider, model: fallback.model };
|
||||
}
|
||||
|
||||
const names =
|
||||
spec.tools ??
|
||||
Object.keys(this.builtins).filter(
|
||||
(name) => !DEFAULT_PROFILE_EXCLUDED.has(name),
|
||||
);
|
||||
|
||||
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
|
||||
for (const name of names) {
|
||||
// Depth is capped at 1: a child never spawns, even when its spec
|
||||
// naively asks for the tool. Stripped rather than rejected so a
|
||||
// model that includes it out of habit still gets a working agent.
|
||||
if (name === SPAWN_AGENT_TOOL_NAME) {
|
||||
continue;
|
||||
}
|
||||
const builtin = this.builtins[name];
|
||||
if (!builtin) {
|
||||
// A typo'd explicit selection should fail the spawn loudly
|
||||
// (createTurn rejects; the spawn tool reports it), not run a
|
||||
// silently under-tooled agent.
|
||||
throw new Error(
|
||||
`inline agent requested unknown builtin tool: ${name}`,
|
||||
);
|
||||
}
|
||||
if (builtin.isAvailable && !(await builtin.isAvailable())) {
|
||||
continue;
|
||||
}
|
||||
tools.push(builtinToolDescriptor(name, builtin));
|
||||
}
|
||||
|
||||
return ResolvedAgentSchema.parse({
|
||||
agentId: inlineAgentId(spec.name),
|
||||
systemPrompt: spec.instructions,
|
||||
model,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,11 @@ const fakeBuiltins = {
|
|||
execute: async () => null,
|
||||
isAvailable: async () => false,
|
||||
},
|
||||
"spawn-agent": {
|
||||
description: "Spawn a sub-agent",
|
||||
inputSchema: z.object({ task: z.string() }),
|
||||
execute: async () => null,
|
||||
},
|
||||
} as unknown as typeof BuiltinTools;
|
||||
|
||||
function makeResolver(agent: z.infer<typeof Agent>, deps: Partial<ConstructorParameters<typeof RealAgentResolver>[0]> = {}) {
|
||||
|
|
@ -172,6 +177,26 @@ describe("RealAgentResolver", () => {
|
|||
expect(b.systemPrompt).toBe(a.systemPrompt);
|
||||
});
|
||||
|
||||
it("strips spawn-agent from by-id children (subagent composition flag)", async () => {
|
||||
const agent = makeAgent({
|
||||
tools: {
|
||||
"spawn-agent": { type: "builtin", name: "spawn-agent" },
|
||||
"file-list": { type: "builtin", name: "file-list" },
|
||||
},
|
||||
});
|
||||
const asParent = await makeResolver(agent).resolve({ agentId: "copilot" });
|
||||
expect(asParent.tools.map((t) => t.name)).toEqual([
|
||||
"spawn-agent",
|
||||
"file-list",
|
||||
]);
|
||||
|
||||
const asChild = await makeResolver(agent).resolve({
|
||||
agentId: "copilot",
|
||||
overrides: { composition: { subagent: true } },
|
||||
});
|
||||
expect(asChild.tools.map((t) => t.name)).toEqual(["file-list"]);
|
||||
});
|
||||
|
||||
it("does not load notes/work-dir for non-copilot agents", async () => {
|
||||
let notesLoaded = 0;
|
||||
const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { z } from "zod";
|
||||
import type { Agent } from "@x/shared/dist/agent.js";
|
||||
import {
|
||||
type JsonValue,
|
||||
RequestedAgent,
|
||||
AgentByIdRequest,
|
||||
ResolvedAgent,
|
||||
SPAWN_AGENT_TOOL_NAME,
|
||||
type ToolDescriptor,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import {
|
||||
|
|
@ -14,7 +14,10 @@ import {
|
|||
} from "../../agents/runtime.js";
|
||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
import { getDefaultModelAndProvider } from "../../models/defaults.js";
|
||||
import type { IAgentResolver } from "../agent-resolver.js";
|
||||
import {
|
||||
builtinToolDescriptor,
|
||||
toJsonValue,
|
||||
} from "./builtin-descriptors.js";
|
||||
|
||||
export const ASK_HUMAN_TOOL = "ask-human";
|
||||
|
||||
|
|
@ -56,6 +59,9 @@ const CompositionOverrides = z.object({
|
|||
codeCwd: z.string().nullable().optional(),
|
||||
videoMode: z.boolean().optional(),
|
||||
coachMode: z.boolean().optional(),
|
||||
// Set by spawn-agent for by-id children: strips the spawn tool so depth
|
||||
// is capped at 1 regardless of which stored agent is spawned.
|
||||
subagent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export interface RealAgentResolverDeps {
|
||||
|
|
@ -69,8 +75,10 @@ export interface RealAgentResolverDeps {
|
|||
// Bridges the existing agent system (loadAgent + dynamic builders, the
|
||||
// BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent
|
||||
// snapshot. The composed system prompt is byte-identical to the old
|
||||
// runtime's streamAgent assembly for the same inputs.
|
||||
export class RealAgentResolver implements IAgentResolver {
|
||||
// runtime's streamAgent assembly for the same inputs. Resolves only the
|
||||
// by-id RequestedAgent variant; inline agents go through
|
||||
// InlineAgentResolver via DispatchingAgentResolver.
|
||||
export class RealAgentResolver {
|
||||
private readonly load: typeof loadAgent;
|
||||
private readonly builtins: typeof BuiltinTools;
|
||||
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
|
||||
|
|
@ -86,7 +94,7 @@ export class RealAgentResolver implements IAgentResolver {
|
|||
}
|
||||
|
||||
async resolve(
|
||||
requested: z.infer<typeof RequestedAgent>,
|
||||
requested: z.infer<typeof AgentByIdRequest>,
|
||||
): Promise<z.infer<typeof ResolvedAgent>> {
|
||||
const agent = await this.load(requested.agentId);
|
||||
if (!agent) {
|
||||
|
|
@ -127,7 +135,9 @@ export class RealAgentResolver implements IAgentResolver {
|
|||
coachMode: composition.coachMode ?? false,
|
||||
});
|
||||
|
||||
const tools = await this.resolveTools(agent);
|
||||
const tools = await this.resolveTools(agent, {
|
||||
subagent: composition.subagent ?? false,
|
||||
});
|
||||
return ResolvedAgent.parse({
|
||||
agentId: requested.agentId,
|
||||
systemPrompt,
|
||||
|
|
@ -138,6 +148,7 @@ export class RealAgentResolver implements IAgentResolver {
|
|||
|
||||
private async resolveTools(
|
||||
agent: z.infer<typeof Agent>,
|
||||
options: { subagent: boolean },
|
||||
): Promise<Array<z.infer<typeof ToolDescriptor>>> {
|
||||
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
|
||||
for (const [name, attachment] of Object.entries(agent.tools ?? {})) {
|
||||
|
|
@ -161,6 +172,14 @@ export class RealAgentResolver implements IAgentResolver {
|
|||
tools.push(ASK_HUMAN_DESCRIPTOR);
|
||||
continue;
|
||||
}
|
||||
// Depth cap: a spawned child never spawns, whichever stored
|
||||
// agent it happens to be.
|
||||
if (
|
||||
options.subagent &&
|
||||
attachment.name === SPAWN_AGENT_TOOL_NAME
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const builtin = this.builtins[attachment.name];
|
||||
if (!builtin) {
|
||||
continue;
|
||||
|
|
@ -169,34 +188,10 @@ export class RealAgentResolver implements IAgentResolver {
|
|||
continue;
|
||||
}
|
||||
tools.push({
|
||||
toolId: `builtin:${attachment.name}`,
|
||||
...builtinToolDescriptor(attachment.name, builtin),
|
||||
name,
|
||||
description: builtin.description,
|
||||
inputSchema: toJsonSchema(builtin.inputSchema),
|
||||
execution: "sync",
|
||||
requiresHuman: false,
|
||||
});
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
}
|
||||
|
||||
function toJsonSchema(schema: unknown): JsonValue {
|
||||
try {
|
||||
return toJsonValue(z.toJSONSchema(schema as z.ZodType)) ?? {
|
||||
type: "object",
|
||||
properties: {},
|
||||
};
|
||||
} catch {
|
||||
// An exotic zod schema must not break the whole turn.
|
||||
return { type: "object", properties: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown): JsonValue | undefined {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value)) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { ChildProcess } from "child_process";
|
||||
import type { z } from "zod";
|
||||
import type { ToolAttachment } from "@x/shared/dist/agent.js";
|
||||
import type { JsonValue, ToolDescriptor } from "@x/shared/dist/turns.js";
|
||||
import {
|
||||
type JsonValue,
|
||||
SPAWN_AGENT_TOOL_NAME,
|
||||
type ToolDescriptor,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import { execTool } from "../../application/lib/exec-tool.js";
|
||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||
import {
|
||||
|
|
@ -89,6 +93,28 @@ export class RealToolRegistry implements IToolRegistry {
|
|||
>,
|
||||
};
|
||||
}
|
||||
if (descriptor.toolId === `builtin:${SPAWN_AGENT_TOOL_NAME}`) {
|
||||
// The dedicated agent-tool handler (turn-runtime-design §29.2):
|
||||
// runs the child as a standalone headless turn and records the
|
||||
// parent→child link as durable tool progress. Bypasses execTool
|
||||
// so it gets the runtime's reportProgress channel.
|
||||
return {
|
||||
descriptor: descriptor as { execution: "sync" } & z.infer<
|
||||
typeof ToolDescriptor
|
||||
>,
|
||||
execute: async (input, ctx: ToolExecutionContext) => {
|
||||
const { runSpawnedAgent } = await import(
|
||||
"../../agents/spawn-agent.js"
|
||||
);
|
||||
return runSpawnedAgent(input, {
|
||||
parentTurnId: ctx.turnId,
|
||||
signal: ctx.signal,
|
||||
onChildStarted: (info) =>
|
||||
ctx.reportProgress({ kind: "subagent", ...info }),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
if (descriptor.toolId.startsWith("builtin:")) {
|
||||
const name = descriptor.toolId.slice("builtin:".length);
|
||||
const builtin = this.builtins[name];
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const ModelDescriptor = z.object({
|
|||
model: z.string(),
|
||||
});
|
||||
|
||||
export const RequestedAgent = z.object({
|
||||
export const AgentByIdRequest = z.object({
|
||||
agentId: z.string(),
|
||||
overrides: z
|
||||
.object({
|
||||
|
|
@ -42,6 +42,42 @@ export const RequestedAgent = z.object({
|
|||
.optional(),
|
||||
});
|
||||
|
||||
// An agent constructed at request time (sub-agents spawned by a parent turn).
|
||||
// Persisted verbatim in turn_created.agent.requested, so the definition
|
||||
// self-documents in the turn file; the resolver materializes it into the same
|
||||
// immutable ResolvedAgent snapshot as a stored agent.
|
||||
export const InlineAgentSpec = z.object({
|
||||
name: z.string(),
|
||||
instructions: z.string(),
|
||||
model: ModelDescriptor.optional(),
|
||||
// Builtin tool names; resolution validates against the live catalog and
|
||||
// substitutes the default headless profile when omitted.
|
||||
tools: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const InlineAgentRequest = z.object({
|
||||
inline: InlineAgentSpec,
|
||||
});
|
||||
|
||||
// The builtin that spawns sub-agent turns. Named here (not in core) because
|
||||
// resolvers, the tool registry, and the renderer's card dispatch all key on
|
||||
// it, and children must never receive it (depth is capped at 1).
|
||||
export const SPAWN_AGENT_TOOL_NAME = "spawn-agent";
|
||||
|
||||
// The ResolvedAgent.agentId convention for inline agents; also what sessions
|
||||
// denormalize into their index for inline-agent turns.
|
||||
export function inlineAgentId(name: string): string {
|
||||
return `inline:${name}`;
|
||||
}
|
||||
|
||||
export const RequestedAgent = z.union([AgentByIdRequest, InlineAgentRequest]);
|
||||
|
||||
export function isInlineAgentRequest(
|
||||
requested: z.infer<typeof RequestedAgent>,
|
||||
): requested is z.infer<typeof InlineAgentRequest> {
|
||||
return "inline" in requested;
|
||||
}
|
||||
|
||||
export const ToolDescriptor = z.object({
|
||||
toolId: z.string(),
|
||||
name: z.string(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue