mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #698 from rowboatlabs/feat/concurrent-sync-tools
feat(x): agent-as-a-tool — concurrent sync tools + spawn-agent sub-agents
This commit is contained in:
commit
53eddaa6a4
26 changed files with 1709 additions and 114 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} />
|
||||
|
|
|
|||
94
apps/x/apps/renderer/src/components/sub-agent-block.tsx
Normal file
94
apps/x/apps/renderer/src/components/sub-agent-block.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { useEffect, useState } from '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
|
||||
}
|
||||
|
||||
// "london-weather" / "meeting_prep" → "London weather" / "Meeting prep".
|
||||
function humanizeName(name: string): string {
|
||||
const words = name.replace(/[-_]+/g, ' ').trim()
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : ''
|
||||
}
|
||||
|
||||
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 rawName = item.subAgent?.agentName || input?.agent_id || input?.name || ''
|
||||
const name = rawName === 'subagent' ? '' : humanizeName(rawName)
|
||||
const task = (item.subAgent?.task || input?.task || '').trim()
|
||||
// The collapsed row must say what is happening, not just that an agent
|
||||
// exists: lead with the name when the model gave one, then the task (the
|
||||
// header truncates with a hover tooltip carrying the full text).
|
||||
const title = task ? `${name || 'Agent'}: ${task}` : name || 'Agent'
|
||||
const running = item.status === 'pending' || item.status === 'running'
|
||||
const transcript = useChildTranscript(item.subAgent?.childTurnId, running, open)
|
||||
|
||||
return (
|
||||
<Tool open={open} onOpenChange={onOpenChange}>
|
||||
<ToolHeader title={title} type="tool-spawn-agent" state={toToolState(item.status)} />
|
||||
<ToolContent>
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
{transcript ? (
|
||||
// The transcript opens with the child's user message — the task —
|
||||
// so no separate task chip is rendered here.
|
||||
<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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,9 +154,18 @@ semantics after ambiguous interruptions.
|
|||
Tool calls may complete in any order. The next model request must always contain
|
||||
tool results in the original order emitted by the model.
|
||||
|
||||
The initial implementation may execute sync tools sequentially for simplicity.
|
||||
Async tools naturally complete independently. No behavior may rely on physical
|
||||
completion order.
|
||||
Sync tools in one batch execute concurrently: invocation events are appended
|
||||
serially in source order before any execution starts, then all sync executions
|
||||
run at once, each appending its progress and result as it lands. Result order
|
||||
in the log is therefore physical completion order and is not deterministic
|
||||
across runs; any given log still replays deterministically. Async tools
|
||||
naturally complete independently. No behavior may rely on physical completion
|
||||
order.
|
||||
|
||||
Durable appends are serialized through a single internal queue per invocation:
|
||||
the persist → reduce → stream ritual runs to completion for one batch of
|
||||
events before the next begins, so file order, in-memory order, and stream
|
||||
order are identical by construction even while executions overlap.
|
||||
|
||||
## 5. Storage design
|
||||
|
||||
|
|
@ -904,7 +913,12 @@ For one completed assistant response:
|
|||
2. Determine permission requirements.
|
||||
3. Apply automatic permission decisions when enabled.
|
||||
4. Advance each tool independently as its permission is resolved.
|
||||
5. Execute allowed sync tools using a simple sequential policy initially.
|
||||
5. Record invocations for allowed tools serially in source order (sync and
|
||||
async alike), then execute all allowed sync tools concurrently. There is
|
||||
no concurrency cap and no per-tool serialization; tools that share state
|
||||
must tolerate racing (or reject stale operations, as file edits do via
|
||||
their search/replace precondition). Secondary kill-path state (the abort
|
||||
registry) is scoped per tool call, never per turn.
|
||||
6. Expose allowed async tool requests.
|
||||
7. Suspend when any permissions or async results remain outstanding.
|
||||
8. Once all calls have terminal results, build the next model request with
|
||||
|
|
@ -1881,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 },
|
||||
|
|
|
|||
234
apps/x/packages/core/src/agents/spawn-agent.test.ts
Normal file
234
apps/x/packages/core/src/agents/spawn-agent.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
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("rejects agent_id and instructions together", async () => {
|
||||
const { services } = fakeServices({});
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "t", agent_id: "a", instructions: "b" },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.output).toMatch(/at most one/);
|
||||
});
|
||||
|
||||
it("spawns a default worker when neither agent_id nor instructions is given", async () => {
|
||||
// Models routinely treat task (+ name/tools) as a complete spec; the
|
||||
// task-only form must work rather than cost a correction round-trip.
|
||||
const { services, started } = fakeServices({});
|
||||
const result = await runSpawnedAgent(
|
||||
{ task: "find the weather", name: "london-weather", tools: ["web-search"] },
|
||||
{ parentTurnId: "parent-1", signal, services },
|
||||
);
|
||||
expect(result.isError).toBe(false);
|
||||
const agent = started[0].agent as {
|
||||
inline: { name: string; instructions: string; tools?: string[] };
|
||||
};
|
||||
expect(agent.inline.name).toBe("london-weather");
|
||||
expect(agent.inline.tools).toEqual(["web-search"]);
|
||||
expect(agent.inline.instructions).toMatch(/london-weather/);
|
||||
expect(agent.inline.instructions).toMatch(/headlessly/);
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
265
apps/x/packages/core/src/agents/spawn-agent.ts
Normal file
265
apps/x/packages/core/src/agents/spawn-agent.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
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`. Optional: omitting both `agent_id` and `instructions` spawns a general-purpose worker driven by `task` alone.",
|
||||
),
|
||||
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 at most 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,
|
||||
// The task alone is usually a complete spec — models
|
||||
// routinely omit `instructions` for ad-hoc workers, and
|
||||
// rejecting that just costs a correction round-trip.
|
||||
instructions:
|
||||
input.instructions ?? defaultWorkerInstructions(agentName),
|
||||
...(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 };
|
||||
}
|
||||
|
||||
function defaultWorkerInstructions(name: string): string {
|
||||
return (
|
||||
`You are ${name}, a sub-agent spawned to complete a single task. ` +
|
||||
"You run headlessly: no user is available to clarify or approve, and your final message is your only output. " +
|
||||
"Work autonomously with the tools you have, then end with a complete, self-contained answer to the task."
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
|
@ -149,6 +149,12 @@ ${codeModeEnabled
|
|||
|
||||
*Medium signals (load the skill, answer the one-off, then offer):* one-off questions about decaying info ("what's the weather?", "top HN stories?"), "what's the latest on X / catch me up on X / any updates on X" about a person, company, project, or topic, recurring artifacts ("morning briefing", "weekly review", "Acme deal dashboard"). **Heuristic:** if you reach for \`web-search\` or a news tool to answer a recurring question, the answer is the kind of thing a bg-task would refresh on a schedule.
|
||||
|
||||
**Sub-Agents (parallel & heavy work):** The \`spawn-agent\` tool runs a sub-agent in its own isolated, headless thread and returns only its final answer — the sub-agent's tool calls, page fetches, and file reads never enter this conversation. Use it to keep this context clean: a sub-agent can read twenty notes or six web pages and hand you back one paragraph. Issue several spawn-agent calls in ONE response to run them in parallel.
|
||||
|
||||
*Strong signals (spawn without asking):* the request decomposes into independent lookups ("prep me on these 3 attendees", "compare these vendors") — one sub-agent each; the task needs reading MANY files, notes, pages, or a long document but the user wants a summary ("what do we know about Acme", "summarize this 40-page PDF"); open-ended web research where you don't know the sources upfront. **Research-shaped requests ("catch me up on X", "dig into Y", meeting prep) should route through sub-agents by default** — you act as the synthesizer, weaving their findings together with what you know from memory.
|
||||
|
||||
*Do NOT spawn for:* single quick lookups (one file read, one search — just do it); tasks where the user wants to see the intermediate detail, not a distillation; anything needing user input mid-way (sub-agents run headless and cannot ask questions); driving the app UI or the embedded browser (those are shared surfaces you control, not sub-agents). Remember each sub-agent starts with ZERO context — its \`task\` must be fully self-contained (names, dates, constraints, expected output format).
|
||||
|
||||
**Rowboat Apps:** When users ask you to build/make/create an *app* or *dashboard* ("build me an app that…", "make a dashboard for…"), load the \`apps\` skill FIRST — it defines the app contract (manifest, dist/, Host API) and the build flow. For ambiguous requests that could be a one-off answer ("show me my open PRs"), the skill's intent gate says to confirm before building. Do not hand-roll app folders without the skill.
|
||||
|
||||
**Live Notes:** If the user explicitly says "live note" or "live-note", load the \`live-note\` skill. Otherwise, do not propose live notes — prefer the \`background-task\` skill for anything recurring.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ Only available when the run message contains a **"# Coding task"** block (the ta
|
|||
- Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask.
|
||||
- If nothing is actionable, launch nothing and say so in your summary.
|
||||
|
||||
# Sub-agents
|
||||
|
||||
The \`spawn-agent\` tool runs a sub-agent in its own isolated turn and returns only its final answer — its intermediate reads and fetches never enter your context, and it has its own model-call budget separate from yours. Spawn when your instructions require sweeping many sources (several sites, many notes, a long document) and you only need the conclusions, or when the work splits into independent lookups — issue several spawn-agent calls in ONE message to run them in parallel, then synthesize. Do not spawn for single quick lookups. Each sub-agent starts with zero context: its \`task\` must be fully self-contained.
|
||||
|
||||
# Triggers
|
||||
|
||||
The run message tells you which trigger fired and how to interpret it:
|
||||
|
|
|
|||
|
|
@ -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,122 @@
|
|||
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,
|
||||
},
|
||||
"app-navigation": {
|
||||
description: "Drive the app UI",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => null,
|
||||
},
|
||||
"browser-control": {
|
||||
description: "Drive the embedded browser",
|
||||
inputSchema: z.object({}),
|
||||
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");
|
||||
// Shared visible surfaces: a headless child must not drive the UI
|
||||
// the user is watching or the single embedded browser pane.
|
||||
expect(names).not.toContain("app-navigation");
|
||||
expect(names).not.toContain("browser-control");
|
||||
});
|
||||
|
||||
it("shared-surface tools remain available via explicit selection", async () => {
|
||||
const resolved = await makeResolver().resolve({
|
||||
inline: { name: "a", instructions: "x", tools: ["browser-control"] },
|
||||
});
|
||||
expect(resolved.tools.map((t) => t.name)).toEqual(["browser-control"]);
|
||||
});
|
||||
});
|
||||
100
apps/x/packages/core/src/turns/bridges/inline-agent-resolver.ts
Normal file
100
apps/x/packages/core/src/turns/bridges/inline-agent-resolver.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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 — and the shared visible surfaces: a headless child
|
||||
// navigating the UI the user is looking at, or parallel children fighting
|
||||
// over the one embedded browser pane, is broken behavior, not just noise.
|
||||
// All remain available via an explicit `tools` selection.
|
||||
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",
|
||||
"app-navigation", // shared surface: drives the UI the user is watching
|
||||
"browser-control", // shared surface: the single embedded browser pane
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ class FakeAbortRegistry implements IAbortRegistry {
|
|||
this.calls.push(`create:${runId}`);
|
||||
return new AbortController().signal;
|
||||
}
|
||||
registerProcess(): void {}
|
||||
registerProcess(runId: string): void {
|
||||
this.calls.push(`register:${runId}`);
|
||||
}
|
||||
unregisterProcess(): void {}
|
||||
abort(runId: string): void {
|
||||
this.calls.push(`abort:${runId}`);
|
||||
|
|
@ -94,8 +96,30 @@ describe("RealToolRegistry", () => {
|
|||
expect(calls[0].attachment).toEqual({ type: "builtin", name: "echo" });
|
||||
expect(calls[0].input).toEqual({ text: "hi" });
|
||||
expect(calls[0].ctx).toMatchObject({ runId: "turn-1", toolCallId: "tc-1" });
|
||||
// Abort registry bracketed per call.
|
||||
expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]);
|
||||
// Abort registry bracketed and keyed per tool call (sync tools in a
|
||||
// turn execute concurrently; a shared turn key would let one call
|
||||
// tear down its siblings' force-kill scope).
|
||||
expect(abortRegistry.calls).toEqual([
|
||||
"create:turn-1:tc-1",
|
||||
"cleanup:turn-1:tc-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-keys registry calls a tool makes with ctx.runId to the call scope", async () => {
|
||||
// Builtins address the abort registry with ctx.runId (the turn id).
|
||||
// The scoped wrapper must pin those to the per-call key, or a
|
||||
// process registered by one tool would land in no scope at all.
|
||||
const { registry, abortRegistry } = makeRegistry(async ({ ctx }) => {
|
||||
ctx.abortRegistry.registerProcess(ctx.runId, {} as never);
|
||||
return "ok";
|
||||
});
|
||||
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
|
||||
await tool.execute({}, makeCtx());
|
||||
expect(abortRegistry.calls).toEqual([
|
||||
"create:turn-1:tc-1",
|
||||
"register:turn-1:tc-1",
|
||||
"cleanup:turn-1:tc-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes undefined results to null and serializes objects", async () => {
|
||||
|
|
@ -187,9 +211,9 @@ describe("RealToolRegistry", () => {
|
|||
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
|
||||
await tool.execute({}, makeCtx({ signal: controller.signal }));
|
||||
expect(abortRegistry.calls).toEqual([
|
||||
"create:turn-1",
|
||||
"abort:turn-1",
|
||||
"cleanup:turn-1",
|
||||
"create:turn-1:tc-1",
|
||||
"abort:turn-1:tc-1",
|
||||
"cleanup:turn-1:tc-1",
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -199,7 +223,10 @@ describe("RealToolRegistry", () => {
|
|||
});
|
||||
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
|
||||
await expect(tool.execute({}, makeCtx())).rejects.toThrowError("tool exploded");
|
||||
expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]);
|
||||
expect(abortRegistry.calls).toEqual([
|
||||
"create:turn-1:tc-1",
|
||||
"cleanup:turn-1:tc-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves mcp descriptors into mcp attachments (server:tool split on first colon)", async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +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 {
|
||||
|
|
@ -22,6 +27,47 @@ export interface RealToolRegistryDeps {
|
|||
builtins?: typeof BuiltinTools;
|
||||
}
|
||||
|
||||
// Sync tools within a turn execute concurrently, so abort-registry state must
|
||||
// be scoped per tool call: createForRun destroys any existing state under its
|
||||
// key, and cleanup would otherwise tear down the force-kill scope of
|
||||
// still-running siblings. Builtins address the registry with ctx.runId (the
|
||||
// turn id, which keeps its meaning elsewhere), so this wrapper pins every
|
||||
// operation to the call-scoped key regardless of the key the caller passes.
|
||||
class CallScopedAbortRegistry implements IAbortRegistry {
|
||||
constructor(
|
||||
private readonly inner: IAbortRegistry,
|
||||
private readonly key: string,
|
||||
) {}
|
||||
|
||||
createForRun(): AbortSignal {
|
||||
return this.inner.createForRun(this.key);
|
||||
}
|
||||
|
||||
registerProcess(_runId: string, process: ChildProcess): void {
|
||||
this.inner.registerProcess(this.key, process);
|
||||
}
|
||||
|
||||
unregisterProcess(_runId: string, process: ChildProcess): void {
|
||||
this.inner.unregisterProcess(this.key, process);
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.inner.abort(this.key);
|
||||
}
|
||||
|
||||
forceAbort(): void {
|
||||
this.inner.forceAbort(this.key);
|
||||
}
|
||||
|
||||
isAborted(): boolean {
|
||||
return this.inner.isAborted(this.key);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.inner.cleanup(this.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Bridges persisted tool descriptors to the existing dispatch: builtins via
|
||||
// the BuiltinTools catalog, MCP tools via execTool's MCP path. toolId encodes
|
||||
// the attachment: "builtin:<name>" or "mcp:<server>:<tool>". ask-human is the
|
||||
|
|
@ -47,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];
|
||||
|
|
@ -89,9 +157,14 @@ export class RealToolRegistry implements IToolRegistry {
|
|||
execute: async (input, ctx: ToolExecutionContext) => {
|
||||
// AbortSignal is the primary kill path; the abort registry is
|
||||
// the secondary force-kill for spawned child processes,
|
||||
// bracketed per call and keyed by turn.
|
||||
this.abortRegistry.createForRun(ctx.turnId);
|
||||
const onAbort = () => this.abortRegistry.abort(ctx.turnId);
|
||||
// bracketed and keyed per tool call (sync tools in one turn
|
||||
// run concurrently).
|
||||
const abortRegistry: IAbortRegistry = new CallScopedAbortRegistry(
|
||||
this.abortRegistry,
|
||||
`${ctx.turnId}:${ctx.toolCallId}`,
|
||||
);
|
||||
abortRegistry.createForRun(ctx.turnId);
|
||||
const onAbort = () => abortRegistry.abort(ctx.turnId);
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
const value = await this.execToolImpl(
|
||||
|
|
@ -101,7 +174,7 @@ export class RealToolRegistry implements IToolRegistry {
|
|||
runId: ctx.turnId,
|
||||
toolCallId: ctx.toolCallId,
|
||||
signal: ctx.signal,
|
||||
abortRegistry: this.abortRegistry,
|
||||
abortRegistry,
|
||||
publish: async (event) => {
|
||||
if (event.type === "tool-output-stream") {
|
||||
await ctx.reportProgress({
|
||||
|
|
@ -147,7 +220,7 @@ export class RealToolRegistry implements IToolRegistry {
|
|||
};
|
||||
} finally {
|
||||
ctx.signal.removeEventListener("abort", onAbort);
|
||||
this.abortRegistry.cleanup(ctx.turnId);
|
||||
abortRegistry.cleanup(ctx.turnId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1757,3 +1757,317 @@ describe("tool progress", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concurrent sync tool execution (10.5)", () => {
|
||||
const slowDescriptor: z.infer<typeof ToolDescriptor> = {
|
||||
toolId: "tool.slow",
|
||||
name: "slow",
|
||||
description: "Slow tool",
|
||||
inputSchema: {},
|
||||
execution: "sync",
|
||||
requiresHuman: false,
|
||||
};
|
||||
const fastDescriptor: z.infer<typeof ToolDescriptor> = {
|
||||
toolId: "tool.fast",
|
||||
name: "fast",
|
||||
description: "Fast tool",
|
||||
inputSchema: {},
|
||||
execution: "sync",
|
||||
requiresHuman: false,
|
||||
};
|
||||
const agent: z.infer<typeof ResolvedAgent> = {
|
||||
...defaultAgent,
|
||||
tools: [slowDescriptor, fastDescriptor],
|
||||
};
|
||||
|
||||
it("executes a batch concurrently: invocations in source order, results in completion order", async () => {
|
||||
// slow (first in source order) only finishes after fast has fully
|
||||
// completed AND reported progress. Under the old sequential loop this
|
||||
// deadlocks (fast never starts), so settling at all proves overlap.
|
||||
const order: string[] = [];
|
||||
let releaseSlow!: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
const tools: RuntimeTool[] = [
|
||||
syncTool(slowDescriptor, async () => {
|
||||
order.push("slow:start");
|
||||
await slowGate;
|
||||
order.push("slow:end");
|
||||
return { output: "slow-done", isError: false };
|
||||
}),
|
||||
syncTool(fastDescriptor, async (_input, ctx) => {
|
||||
order.push("fast:start");
|
||||
await ctx.reportProgress({ note: "while slow is pending" });
|
||||
order.push("fast:end");
|
||||
releaseSlow();
|
||||
return { output: "fast-done", isError: false };
|
||||
}),
|
||||
];
|
||||
const { runtime, repo, models } = makeRuntime({
|
||||
agent,
|
||||
tools,
|
||||
models: [
|
||||
respond(
|
||||
completedResp(
|
||||
assistantCalls(
|
||||
toolCallPart("S", "slow"),
|
||||
toolCallPart("F", "fast"),
|
||||
),
|
||||
),
|
||||
),
|
||||
respond(completedResp(assistantText("done"))),
|
||||
],
|
||||
});
|
||||
const turnId = await newTurn(runtime);
|
||||
const { outcome } = await advanceAndSettle(runtime, turnId);
|
||||
expect(outcome?.status).toBe("completed");
|
||||
expect(order).toEqual(["slow:start", "fast:start", "fast:end", "slow:end"]);
|
||||
|
||||
// The log stays legal under interleaving: both invocations precede
|
||||
// any result (source order), fast's progress and result land while
|
||||
// slow is still open, and the reducer accepts the whole history.
|
||||
const log = await persisted(repo, turnId);
|
||||
expect(typesOf(log)).toEqual([
|
||||
"turn_created",
|
||||
"model_call_requested",
|
||||
"model_call_completed",
|
||||
"tool_invocation_requested",
|
||||
"tool_invocation_requested",
|
||||
"tool_progress",
|
||||
"tool_result",
|
||||
"tool_result",
|
||||
"model_call_requested",
|
||||
"model_call_completed",
|
||||
"turn_completed",
|
||||
]);
|
||||
const invocations = log.filter(
|
||||
(e) => e.type === "tool_invocation_requested",
|
||||
);
|
||||
expect(invocations.map((e) => e.toolCallId)).toEqual(["S", "F"]);
|
||||
const results = log.filter((e) => e.type === "tool_result");
|
||||
expect(results.map((e) => e.toolCallId)).toEqual(["F", "S"]);
|
||||
const state = reduceTurn(log);
|
||||
expect(state.toolCalls.map((tc) => tc.result?.result.output)).toEqual([
|
||||
"slow-done",
|
||||
"fast-done",
|
||||
]);
|
||||
|
||||
// Wire ordering is insulated from completion order: the follow-up
|
||||
// request references tool results in the assistant message's source
|
||||
// order, and the composed payload sends them in that order.
|
||||
const followUp = log.find(
|
||||
(e) => e.type === "model_call_requested" && e.modelCallIndex === 1,
|
||||
);
|
||||
expect(followUp).toMatchObject({
|
||||
request: {
|
||||
messages: ["assistant:0", "toolResult:S", "toolResult:F"],
|
||||
},
|
||||
});
|
||||
const sent = sentMessages(models.requests[1]);
|
||||
expect(
|
||||
sent
|
||||
.filter((m) => m.role === "tool")
|
||||
.map((m) => (m as { toolCallId?: string }).toolCallId),
|
||||
).toEqual(["S", "F"]);
|
||||
});
|
||||
|
||||
it("one tool's failure never disturbs its concurrent siblings", async () => {
|
||||
let releaseSlow!: () => void;
|
||||
const slowGate = new Promise<void>((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
const tools: RuntimeTool[] = [
|
||||
syncTool(slowDescriptor, async () => {
|
||||
await slowGate;
|
||||
return { output: "slow-done", isError: false };
|
||||
}),
|
||||
syncTool(fastDescriptor, async () => {
|
||||
releaseSlow();
|
||||
throw new Error("fast exploded");
|
||||
}),
|
||||
];
|
||||
const { runtime, repo } = makeRuntime({
|
||||
agent,
|
||||
tools,
|
||||
models: [
|
||||
respond(
|
||||
completedResp(
|
||||
assistantCalls(
|
||||
toolCallPart("S", "slow"),
|
||||
toolCallPart("F", "fast"),
|
||||
),
|
||||
),
|
||||
),
|
||||
respond(completedResp(assistantText("done"))),
|
||||
],
|
||||
});
|
||||
const turnId = await newTurn(runtime);
|
||||
const { outcome } = await advanceAndSettle(runtime, turnId);
|
||||
expect(outcome?.status).toBe("completed");
|
||||
const log = await persisted(repo, turnId);
|
||||
const byId = new Map(
|
||||
log
|
||||
.filter((e) => e.type === "tool_result")
|
||||
.map((e) => [e.toolCallId, e]),
|
||||
);
|
||||
expect(byId.get("F")).toMatchObject({
|
||||
result: { output: "fast exploded", isError: true },
|
||||
});
|
||||
expect(byId.get("S")).toMatchObject({
|
||||
result: { output: "slow-done", isError: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("cancellation mid-batch settles every in-flight tool", async () => {
|
||||
const controller = new AbortController();
|
||||
const started: string[] = [];
|
||||
function hangingTool(name: string) {
|
||||
return async (
|
||||
_input: unknown,
|
||||
ctx: ToolExecutionContext,
|
||||
): Promise<{ output: string; isError: boolean }> => {
|
||||
started.push(name);
|
||||
if (started.length === 2) {
|
||||
controller.abort();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
if (ctx.signal.aborted) {
|
||||
resolve();
|
||||
} else {
|
||||
ctx.signal.addEventListener("abort", () => resolve(), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
throw new Error("aborted");
|
||||
};
|
||||
}
|
||||
const tools: RuntimeTool[] = [
|
||||
syncTool(slowDescriptor, hangingTool("slow")),
|
||||
syncTool(fastDescriptor, hangingTool("fast")),
|
||||
];
|
||||
const { runtime, repo } = makeRuntime({
|
||||
agent,
|
||||
tools,
|
||||
models: [
|
||||
respond(
|
||||
completedResp(
|
||||
assistantCalls(
|
||||
toolCallPart("S", "slow"),
|
||||
toolCallPart("F", "fast"),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
const turnId = await newTurn(runtime);
|
||||
const { outcome } = await advanceAndSettle(runtime, turnId, undefined, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(outcome?.status).toBe("cancelled");
|
||||
expect(started).toEqual(["slow", "fast"]);
|
||||
const log = await persisted(repo, turnId);
|
||||
const results = log.filter((e) => e.type === "tool_result");
|
||||
expect(results).toHaveLength(2);
|
||||
for (const result of results) {
|
||||
expect(result).toMatchObject({
|
||||
result: {
|
||||
output: "Tool execution was cancelled.",
|
||||
isError: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(typesOf(log)).toContain("turn_cancelled");
|
||||
});
|
||||
|
||||
it("recovers a crash that left multiple sync invocations open", async () => {
|
||||
const SEED_ID = "2026-07-02T10-00-00Z-0000001-000";
|
||||
const repo = new InMemoryTurnRepo();
|
||||
const batch = assistantCalls(
|
||||
toolCallPart("S", "slow"),
|
||||
toolCallPart("F", "fast"),
|
||||
);
|
||||
repo.seed([
|
||||
{
|
||||
type: "turn_created",
|
||||
schemaVersion: 1,
|
||||
turnId: SEED_ID,
|
||||
ts: TS,
|
||||
sessionId: null,
|
||||
agent: { requested: { agentId: "copilot" }, resolved: agent },
|
||||
context: [],
|
||||
input: user("hello"),
|
||||
config: {
|
||||
autoPermission: false,
|
||||
humanAvailable: true,
|
||||
maxModelCalls: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "model_call_requested",
|
||||
turnId: SEED_ID,
|
||||
ts: TS,
|
||||
modelCallIndex: 0,
|
||||
request: { messages: ["input"], parameters: {} },
|
||||
},
|
||||
{
|
||||
type: "model_call_completed",
|
||||
turnId: SEED_ID,
|
||||
ts: TS,
|
||||
modelCallIndex: 0,
|
||||
message: batch,
|
||||
finishReason: "stop",
|
||||
usage: {},
|
||||
},
|
||||
{
|
||||
type: "tool_invocation_requested",
|
||||
turnId: SEED_ID,
|
||||
ts: TS,
|
||||
toolCallId: "S",
|
||||
toolId: "tool.slow",
|
||||
toolName: "slow",
|
||||
execution: "sync",
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
type: "tool_invocation_requested",
|
||||
turnId: SEED_ID,
|
||||
ts: TS,
|
||||
toolCallId: "F",
|
||||
toolId: "tool.fast",
|
||||
toolName: "fast",
|
||||
execution: "sync",
|
||||
input: {},
|
||||
},
|
||||
]);
|
||||
const { runtime } = makeRuntime({
|
||||
repo,
|
||||
agent,
|
||||
tools: [
|
||||
syncTool(slowDescriptor, async () => ({
|
||||
output: "never",
|
||||
isError: false,
|
||||
})),
|
||||
syncTool(fastDescriptor, async () => ({
|
||||
output: "never",
|
||||
isError: false,
|
||||
})),
|
||||
],
|
||||
models: [respond(completedResp(assistantText("done")))],
|
||||
});
|
||||
const { outcome } = await advanceAndSettle(runtime, SEED_ID);
|
||||
expect(outcome?.status).toBe("completed");
|
||||
const log = await persisted(repo, SEED_ID);
|
||||
const indeterminate = log.filter(
|
||||
(e) =>
|
||||
e.type === "tool_result" &&
|
||||
e.source === "runtime" &&
|
||||
e.result.isError === true,
|
||||
);
|
||||
expect(indeterminate.map((e) => (e as { toolCallId: string }).toolCallId).sort()).toEqual([
|
||||
"F",
|
||||
"S",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -346,8 +346,24 @@ class TurnAdvance {
|
|||
}
|
||||
|
||||
// Durable barrier: persist, re-reduce (the reducer doubles as a runtime
|
||||
// assertion that the appended history is legal), then stream.
|
||||
private async append(...batch: TEvent[]): Promise<void> {
|
||||
// assertion that the appended history is legal), then stream. Commits are
|
||||
// serialized through an internal queue so concurrently executing tools
|
||||
// can never interleave the persist/reduce/stream ritual — file order,
|
||||
// in-memory order, and stream order stay identical by construction.
|
||||
private appendChain: Promise<void> = Promise.resolve();
|
||||
|
||||
private append(...batch: TEvent[]): Promise<void> {
|
||||
const task = this.appendChain.then(() => this.commit(batch));
|
||||
// A failed commit rejects for its caller only; the chain stays alive
|
||||
// so other in-flight tools can still record their results.
|
||||
this.appendChain = task.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return task;
|
||||
}
|
||||
|
||||
private async commit(batch: TEvent[]): Promise<void> {
|
||||
await this.turnRepo.append(this.turnId, batch);
|
||||
this.events.push(...batch);
|
||||
this.state = reduceTurn(this.events);
|
||||
|
|
@ -741,8 +757,13 @@ class TurnAdvance {
|
|||
}
|
||||
}
|
||||
|
||||
// §10.5: execute allowed sync tools sequentially and expose allowed async
|
||||
// tools, in source order. Tool failures are conversational, not terminal.
|
||||
// §10.5: record invocations for allowed tools serially in source order,
|
||||
// then execute the sync ones concurrently (async tools are exposed by
|
||||
// their invocation; results arrive through advanceTurn). Invocations are
|
||||
// durable before any execution starts, and commits are serialized by
|
||||
// append's internal queue, so the log prefix is deterministic while
|
||||
// results land in completion order. Tool failures are conversational,
|
||||
// not terminal.
|
||||
private async executeAllowedTools(): Promise<void> {
|
||||
const executable = this.state.toolCalls.filter(
|
||||
(tc) =>
|
||||
|
|
@ -751,8 +772,11 @@ class TurnAdvance {
|
|||
(this.checkerAllowed.has(tc.toolCallId) ||
|
||||
tc.permission?.resolved?.decision === "allow"),
|
||||
);
|
||||
const started: Array<{ tc: ToolCallState; tool: SyncRuntimeTool }> = [];
|
||||
for (const tc of executable) {
|
||||
if (this.signal.aborted) {
|
||||
// Invoked-but-unexecuted calls get their cancelled results
|
||||
// from cancel(), same as before this loop ran.
|
||||
return;
|
||||
}
|
||||
const tool = this.toolsByName.get(tc.toolName);
|
||||
|
|
@ -771,52 +795,63 @@ class TurnAdvance {
|
|||
if (tool.descriptor.execution === "async") {
|
||||
continue; // exposed; the result arrives through advanceTurn
|
||||
}
|
||||
const syncTool = tool as SyncRuntimeTool;
|
||||
try {
|
||||
const result = await syncTool.execute(tc.input, {
|
||||
turnId: this.turnId,
|
||||
toolCallId: tc.toolCallId,
|
||||
signal: this.signal,
|
||||
reportProgress: async (progress) => {
|
||||
await this.append({
|
||||
type: "tool_progress",
|
||||
turnId: this.turnId,
|
||||
ts: this.now(),
|
||||
toolCallId: tc.toolCallId,
|
||||
source: "sync",
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
await this.append({
|
||||
type: "tool_result",
|
||||
turnId: this.turnId,
|
||||
ts: this.now(),
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
source: "sync",
|
||||
result: ToolResultData.parse(result),
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.signal.aborted) {
|
||||
await this.append(
|
||||
runtimeResultEvent(this.turnId, this.now(), tc, {
|
||||
output: "Tool execution was cancelled.",
|
||||
isError: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.append({
|
||||
type: "tool_result",
|
||||
turnId: this.turnId,
|
||||
ts: this.now(),
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
source: "sync",
|
||||
result: { output: errorMessage(error), isError: true },
|
||||
});
|
||||
started.push({ tc, tool: tool as SyncRuntimeTool });
|
||||
}
|
||||
// Each task settles its own call (result or error), so Promise.all
|
||||
// never rejects and one slow tool never blocks its siblings.
|
||||
await Promise.all(
|
||||
started.map(({ tc, tool }) => this.executeSyncTool(tc, tool)),
|
||||
);
|
||||
}
|
||||
|
||||
private async executeSyncTool(
|
||||
tc: ToolCallState,
|
||||
syncTool: SyncRuntimeTool,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await syncTool.execute(tc.input, {
|
||||
turnId: this.turnId,
|
||||
toolCallId: tc.toolCallId,
|
||||
signal: this.signal,
|
||||
reportProgress: async (progress) => {
|
||||
await this.append({
|
||||
type: "tool_progress",
|
||||
turnId: this.turnId,
|
||||
ts: this.now(),
|
||||
toolCallId: tc.toolCallId,
|
||||
source: "sync",
|
||||
progress,
|
||||
});
|
||||
},
|
||||
});
|
||||
await this.append({
|
||||
type: "tool_result",
|
||||
turnId: this.turnId,
|
||||
ts: this.now(),
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
source: "sync",
|
||||
result: ToolResultData.parse(result),
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.signal.aborted) {
|
||||
await this.append(
|
||||
runtimeResultEvent(this.turnId, this.now(), tc, {
|
||||
output: "Tool execution was cancelled.",
|
||||
isError: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.append({
|
||||
type: "tool_result",
|
||||
turnId: this.turnId,
|
||||
ts: this.now(),
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
source: "sync",
|
||||
result: { output: errorMessage(error), isError: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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