mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +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 { EmailView } from '@/components/email-view';
|
||||||
import { WorkspaceView } from '@/components/workspace-view';
|
import { WorkspaceView } from '@/components/workspace-view';
|
||||||
import { CodingRunBlock } from '@/components/coding-run';
|
import { CodingRunBlock } from '@/components/coding-run';
|
||||||
|
import { SubAgentBlock } from '@/components/sub-agent-block';
|
||||||
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
|
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
|
||||||
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
|
import { GoogleDocPickerDialog } from '@/components/google-doc-picker-dialog';
|
||||||
import { ChatHistoryView } from '@/components/chat-history-view';
|
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)
|
const appActionData = getAppActionCardData(item)
|
||||||
if (appActionData) {
|
if (appActionData) {
|
||||||
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
|
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.
|
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
|
||||||
codeRunEvents?: CodeRunEvent[]
|
codeRunEvents?: CodeRunEvent[]
|
||||||
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
|
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 {
|
export interface ErrorMessage {
|
||||||
|
|
@ -668,6 +670,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
|
||||||
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
|
||||||
if (!isToolCall(item)) return false
|
if (!isToolCall(item)) return false
|
||||||
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
|
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 (getWebSearchCardData(item)) return false
|
||||||
if (getComposioConnectCardData(item)) return false
|
if (getComposioConnectCardData(item)) return false
|
||||||
if (getAppActionCardData(item)) return false
|
if (getAppActionCardData(item)) return false
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,49 @@ describe('buildTurnConversation', () => {
|
||||||
expect(settled.status).toBe('completed')
|
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', () => {
|
it('renders user attachments and a failed turn as an error item', () => {
|
||||||
const input = {
|
const input = {
|
||||||
role: 'user' as const,
|
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
|
// One turn's contribution to the conversation: the user input, then per
|
||||||
// completed model call its text and tool calls (with live status/results).
|
// completed model call its text and tool calls (with live status/results).
|
||||||
export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
||||||
|
|
@ -295,6 +322,7 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
||||||
status: tc ? toolStatus(tc) : 'running',
|
status: tc ? toolStatus(tc) : 'running',
|
||||||
timestamp: ts(),
|
timestamp: ts(),
|
||||||
...(tc ? codeRunViewOf(tc) : {}),
|
...(tc ? codeRunViewOf(tc) : {}),
|
||||||
|
...(tc ? subAgentViewOf(tc) : {}),
|
||||||
} satisfies ToolCall)
|
} 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 calls may complete in any order. The next model request must always contain
|
||||||
tool results in the original order emitted by the model.
|
tool results in the original order emitted by the model.
|
||||||
|
|
||||||
The initial implementation may execute sync tools sequentially for simplicity.
|
Sync tools in one batch execute concurrently: invocation events are appended
|
||||||
Async tools naturally complete independently. No behavior may rely on physical
|
serially in source order before any execution starts, then all sync executions
|
||||||
completion order.
|
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
|
## 5. Storage design
|
||||||
|
|
||||||
|
|
@ -904,7 +913,12 @@ For one completed assistant response:
|
||||||
2. Determine permission requirements.
|
2. Determine permission requirements.
|
||||||
3. Apply automatic permission decisions when enabled.
|
3. Apply automatic permission decisions when enabled.
|
||||||
4. Advance each tool independently as its permission is resolved.
|
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.
|
6. Expose allowed async tool requests.
|
||||||
7. Suspend when any permissions or async results remain outstanding.
|
7. Suspend when any permissions or async results remain outstanding.
|
||||||
8. Once all calls have terminal results, build the next model request with
|
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
|
parent tool result. No subflow or parent-turn concept is added to this initial
|
||||||
turn schema.
|
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
|
### 29.3 Reliability enhancements
|
||||||
|
|
||||||
- External-input idempotency.
|
- External-input idempotency.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type { AssistantMessage } from "@x/shared/dist/message.js";
|
import type { AssistantMessage } from "@x/shared/dist/message.js";
|
||||||
import {
|
import {
|
||||||
|
type RequestedAgent,
|
||||||
reduceTurn,
|
reduceTurn,
|
||||||
type TurnState,
|
type TurnState,
|
||||||
} from "@x/shared/dist/turns.js";
|
} from "@x/shared/dist/turns.js";
|
||||||
|
|
@ -26,7 +27,10 @@ export class HeadlessRunError extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HeadlessAgentOptions {
|
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;
|
message: string;
|
||||||
// Model id; when set without provider, the app-default provider applies.
|
// Model id; when set without provider, the app-default provider applies.
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
@ -121,22 +125,29 @@ export class HeadlessAgentRunner implements IHeadlessAgentRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> {
|
async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> {
|
||||||
let modelOverride: { provider: string; model: string } | undefined;
|
let agent = options.agent;
|
||||||
if (options.model && options.provider) {
|
if (!agent) {
|
||||||
modelOverride = { provider: options.provider, model: options.model };
|
if (!options.agentId) {
|
||||||
} else if (options.model || options.provider) {
|
throw new Error("headless agent needs an agentId or an agent request");
|
||||||
const defaults = await this.defaultModelResolver.resolve();
|
}
|
||||||
modelOverride = {
|
let modelOverride: { provider: string; model: string } | undefined;
|
||||||
provider: options.provider ?? defaults.provider,
|
if (options.model && options.provider) {
|
||||||
model: options.model ?? defaults.model,
|
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({
|
const turnId = await this.turnRuntime.createTurn({
|
||||||
agent: {
|
agent,
|
||||||
agentId: options.agentId,
|
|
||||||
...(modelOverride ? { overrides: { model: modelOverride } } : {}),
|
|
||||||
},
|
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
context: [],
|
context: [],
|
||||||
input: { role: "user", content: options.message },
|
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.
|
*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.
|
**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.
|
**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 { 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 { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js";
|
||||||
import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.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 { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js";
|
||||||
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
|
import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js";
|
||||||
import type { CodeModeManager } from "../../code-mode/acp/manager.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.
|
- 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.
|
- 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
|
# Triggers
|
||||||
|
|
||||||
The run message tells you which trigger fired and how to interpret it:
|
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 { IToolRegistry } from "../turns/tool-registry.js";
|
||||||
import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js";
|
import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js";
|
||||||
import { RealAgentResolver } from "../turns/bridges/real-agent-resolver.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 { RealModelRegistry } from "../turns/bridges/real-model-registry.js";
|
||||||
import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js";
|
import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js";
|
||||||
import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js";
|
import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js";
|
||||||
|
|
@ -123,7 +125,13 @@ container.register({
|
||||||
).singleton(),
|
).singleton(),
|
||||||
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
|
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
|
||||||
usageReporter: asClass<IUsageReporter>(RealUsageReporter).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(),
|
modelRegistry: asFunction<IModelRegistry>(() => new RealModelRegistry()).singleton(),
|
||||||
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
|
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
|
||||||
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),
|
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
type ModelDescriptor,
|
type ModelDescriptor,
|
||||||
type ToolResultData,
|
type ToolResultData,
|
||||||
deriveTurnStatus,
|
deriveTurnStatus,
|
||||||
|
inlineAgentId,
|
||||||
|
isInlineAgentRequest,
|
||||||
reduceTurn,
|
reduceTurn,
|
||||||
} from "@x/shared/dist/turns.js";
|
} from "@x/shared/dist/turns.js";
|
||||||
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||||
|
|
@ -189,7 +191,9 @@ export class SessionsImpl implements ISessions {
|
||||||
ts: this.clock.now(),
|
ts: this.clock.now(),
|
||||||
turnId,
|
turnId,
|
||||||
sessionSeq: state.turns.length + 1,
|
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),
|
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,
|
execute: async () => null,
|
||||||
isAvailable: async () => false,
|
isAvailable: async () => false,
|
||||||
},
|
},
|
||||||
|
"spawn-agent": {
|
||||||
|
description: "Spawn a sub-agent",
|
||||||
|
inputSchema: z.object({ task: z.string() }),
|
||||||
|
execute: async () => null,
|
||||||
|
},
|
||||||
} as unknown as typeof BuiltinTools;
|
} as unknown as typeof BuiltinTools;
|
||||||
|
|
||||||
function makeResolver(agent: z.infer<typeof Agent>, deps: Partial<ConstructorParameters<typeof RealAgentResolver>[0]> = {}) {
|
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);
|
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 () => {
|
it("does not load notes/work-dir for non-copilot agents", async () => {
|
||||||
let notesLoaded = 0;
|
let notesLoaded = 0;
|
||||||
const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), {
|
const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Agent } from "@x/shared/dist/agent.js";
|
import type { Agent } from "@x/shared/dist/agent.js";
|
||||||
import {
|
import {
|
||||||
type JsonValue,
|
AgentByIdRequest,
|
||||||
RequestedAgent,
|
|
||||||
ResolvedAgent,
|
ResolvedAgent,
|
||||||
|
SPAWN_AGENT_TOOL_NAME,
|
||||||
type ToolDescriptor,
|
type ToolDescriptor,
|
||||||
} from "@x/shared/dist/turns.js";
|
} from "@x/shared/dist/turns.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,7 +14,10 @@ import {
|
||||||
} from "../../agents/runtime.js";
|
} from "../../agents/runtime.js";
|
||||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||||
import { getDefaultModelAndProvider } from "../../models/defaults.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";
|
export const ASK_HUMAN_TOOL = "ask-human";
|
||||||
|
|
||||||
|
|
@ -56,6 +59,9 @@ const CompositionOverrides = z.object({
|
||||||
codeCwd: z.string().nullable().optional(),
|
codeCwd: z.string().nullable().optional(),
|
||||||
videoMode: z.boolean().optional(),
|
videoMode: z.boolean().optional(),
|
||||||
coachMode: 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 {
|
export interface RealAgentResolverDeps {
|
||||||
|
|
@ -69,8 +75,10 @@ export interface RealAgentResolverDeps {
|
||||||
// Bridges the existing agent system (loadAgent + dynamic builders, the
|
// Bridges the existing agent system (loadAgent + dynamic builders, the
|
||||||
// BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent
|
// BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent
|
||||||
// snapshot. The composed system prompt is byte-identical to the old
|
// snapshot. The composed system prompt is byte-identical to the old
|
||||||
// runtime's streamAgent assembly for the same inputs.
|
// runtime's streamAgent assembly for the same inputs. Resolves only the
|
||||||
export class RealAgentResolver implements IAgentResolver {
|
// by-id RequestedAgent variant; inline agents go through
|
||||||
|
// InlineAgentResolver via DispatchingAgentResolver.
|
||||||
|
export class RealAgentResolver {
|
||||||
private readonly load: typeof loadAgent;
|
private readonly load: typeof loadAgent;
|
||||||
private readonly builtins: typeof BuiltinTools;
|
private readonly builtins: typeof BuiltinTools;
|
||||||
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
|
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
|
||||||
|
|
@ -86,7 +94,7 @@ export class RealAgentResolver implements IAgentResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(
|
async resolve(
|
||||||
requested: z.infer<typeof RequestedAgent>,
|
requested: z.infer<typeof AgentByIdRequest>,
|
||||||
): Promise<z.infer<typeof ResolvedAgent>> {
|
): Promise<z.infer<typeof ResolvedAgent>> {
|
||||||
const agent = await this.load(requested.agentId);
|
const agent = await this.load(requested.agentId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
|
|
@ -127,7 +135,9 @@ export class RealAgentResolver implements IAgentResolver {
|
||||||
coachMode: composition.coachMode ?? false,
|
coachMode: composition.coachMode ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tools = await this.resolveTools(agent);
|
const tools = await this.resolveTools(agent, {
|
||||||
|
subagent: composition.subagent ?? false,
|
||||||
|
});
|
||||||
return ResolvedAgent.parse({
|
return ResolvedAgent.parse({
|
||||||
agentId: requested.agentId,
|
agentId: requested.agentId,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
|
|
@ -138,6 +148,7 @@ export class RealAgentResolver implements IAgentResolver {
|
||||||
|
|
||||||
private async resolveTools(
|
private async resolveTools(
|
||||||
agent: z.infer<typeof Agent>,
|
agent: z.infer<typeof Agent>,
|
||||||
|
options: { subagent: boolean },
|
||||||
): Promise<Array<z.infer<typeof ToolDescriptor>>> {
|
): Promise<Array<z.infer<typeof ToolDescriptor>>> {
|
||||||
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
|
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
|
||||||
for (const [name, attachment] of Object.entries(agent.tools ?? {})) {
|
for (const [name, attachment] of Object.entries(agent.tools ?? {})) {
|
||||||
|
|
@ -161,6 +172,14 @@ export class RealAgentResolver implements IAgentResolver {
|
||||||
tools.push(ASK_HUMAN_DESCRIPTOR);
|
tools.push(ASK_HUMAN_DESCRIPTOR);
|
||||||
continue;
|
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];
|
const builtin = this.builtins[attachment.name];
|
||||||
if (!builtin) {
|
if (!builtin) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -169,34 +188,10 @@ export class RealAgentResolver implements IAgentResolver {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tools.push({
|
tools.push({
|
||||||
toolId: `builtin:${attachment.name}`,
|
...builtinToolDescriptor(attachment.name, builtin),
|
||||||
name,
|
name,
|
||||||
description: builtin.description,
|
|
||||||
inputSchema: toJsonSchema(builtin.inputSchema),
|
|
||||||
execution: "sync",
|
|
||||||
requiresHuman: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return tools;
|
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}`);
|
this.calls.push(`create:${runId}`);
|
||||||
return new AbortController().signal;
|
return new AbortController().signal;
|
||||||
}
|
}
|
||||||
registerProcess(): void {}
|
registerProcess(runId: string): void {
|
||||||
|
this.calls.push(`register:${runId}`);
|
||||||
|
}
|
||||||
unregisterProcess(): void {}
|
unregisterProcess(): void {}
|
||||||
abort(runId: string): void {
|
abort(runId: string): void {
|
||||||
this.calls.push(`abort:${runId}`);
|
this.calls.push(`abort:${runId}`);
|
||||||
|
|
@ -94,8 +96,30 @@ describe("RealToolRegistry", () => {
|
||||||
expect(calls[0].attachment).toEqual({ type: "builtin", name: "echo" });
|
expect(calls[0].attachment).toEqual({ type: "builtin", name: "echo" });
|
||||||
expect(calls[0].input).toEqual({ text: "hi" });
|
expect(calls[0].input).toEqual({ text: "hi" });
|
||||||
expect(calls[0].ctx).toMatchObject({ runId: "turn-1", toolCallId: "tc-1" });
|
expect(calls[0].ctx).toMatchObject({ runId: "turn-1", toolCallId: "tc-1" });
|
||||||
// Abort registry bracketed per call.
|
// Abort registry bracketed and keyed per tool call (sync tools in a
|
||||||
expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]);
|
// 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 () => {
|
it("normalizes undefined results to null and serializes objects", async () => {
|
||||||
|
|
@ -187,9 +211,9 @@ describe("RealToolRegistry", () => {
|
||||||
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
|
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
|
||||||
await tool.execute({}, makeCtx({ signal: controller.signal }));
|
await tool.execute({}, makeCtx({ signal: controller.signal }));
|
||||||
expect(abortRegistry.calls).toEqual([
|
expect(abortRegistry.calls).toEqual([
|
||||||
"create:turn-1",
|
"create:turn-1:tc-1",
|
||||||
"abort:turn-1",
|
"abort:turn-1:tc-1",
|
||||||
"cleanup:turn-1",
|
"cleanup:turn-1:tc-1",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -199,7 +223,10 @@ describe("RealToolRegistry", () => {
|
||||||
});
|
});
|
||||||
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
|
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
|
||||||
await expect(tool.execute({}, makeCtx())).rejects.toThrowError("tool exploded");
|
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 () => {
|
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 { z } from "zod";
|
||||||
import type { ToolAttachment } from "@x/shared/dist/agent.js";
|
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 { execTool } from "../../application/lib/exec-tool.js";
|
||||||
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,6 +27,47 @@ export interface RealToolRegistryDeps {
|
||||||
builtins?: typeof BuiltinTools;
|
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
|
// Bridges persisted tool descriptors to the existing dispatch: builtins via
|
||||||
// the BuiltinTools catalog, MCP tools via execTool's MCP path. toolId encodes
|
// the BuiltinTools catalog, MCP tools via execTool's MCP path. toolId encodes
|
||||||
// the attachment: "builtin:<name>" or "mcp:<server>:<tool>". ask-human is the
|
// 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:")) {
|
if (descriptor.toolId.startsWith("builtin:")) {
|
||||||
const name = descriptor.toolId.slice("builtin:".length);
|
const name = descriptor.toolId.slice("builtin:".length);
|
||||||
const builtin = this.builtins[name];
|
const builtin = this.builtins[name];
|
||||||
|
|
@ -89,9 +157,14 @@ export class RealToolRegistry implements IToolRegistry {
|
||||||
execute: async (input, ctx: ToolExecutionContext) => {
|
execute: async (input, ctx: ToolExecutionContext) => {
|
||||||
// AbortSignal is the primary kill path; the abort registry is
|
// AbortSignal is the primary kill path; the abort registry is
|
||||||
// the secondary force-kill for spawned child processes,
|
// the secondary force-kill for spawned child processes,
|
||||||
// bracketed per call and keyed by turn.
|
// bracketed and keyed per tool call (sync tools in one turn
|
||||||
this.abortRegistry.createForRun(ctx.turnId);
|
// run concurrently).
|
||||||
const onAbort = () => this.abortRegistry.abort(ctx.turnId);
|
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 });
|
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||||
try {
|
try {
|
||||||
const value = await this.execToolImpl(
|
const value = await this.execToolImpl(
|
||||||
|
|
@ -101,7 +174,7 @@ export class RealToolRegistry implements IToolRegistry {
|
||||||
runId: ctx.turnId,
|
runId: ctx.turnId,
|
||||||
toolCallId: ctx.toolCallId,
|
toolCallId: ctx.toolCallId,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
abortRegistry: this.abortRegistry,
|
abortRegistry,
|
||||||
publish: async (event) => {
|
publish: async (event) => {
|
||||||
if (event.type === "tool-output-stream") {
|
if (event.type === "tool-output-stream") {
|
||||||
await ctx.reportProgress({
|
await ctx.reportProgress({
|
||||||
|
|
@ -147,7 +220,7 @@ export class RealToolRegistry implements IToolRegistry {
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
ctx.signal.removeEventListener("abort", onAbort);
|
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
|
// Durable barrier: persist, re-reduce (the reducer doubles as a runtime
|
||||||
// assertion that the appended history is legal), then stream.
|
// assertion that the appended history is legal), then stream. Commits are
|
||||||
private async append(...batch: TEvent[]): Promise<void> {
|
// 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);
|
await this.turnRepo.append(this.turnId, batch);
|
||||||
this.events.push(...batch);
|
this.events.push(...batch);
|
||||||
this.state = reduceTurn(this.events);
|
this.state = reduceTurn(this.events);
|
||||||
|
|
@ -741,8 +757,13 @@ class TurnAdvance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// §10.5: execute allowed sync tools sequentially and expose allowed async
|
// §10.5: record invocations for allowed tools serially in source order,
|
||||||
// tools, in source order. Tool failures are conversational, not terminal.
|
// 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> {
|
private async executeAllowedTools(): Promise<void> {
|
||||||
const executable = this.state.toolCalls.filter(
|
const executable = this.state.toolCalls.filter(
|
||||||
(tc) =>
|
(tc) =>
|
||||||
|
|
@ -751,8 +772,11 @@ class TurnAdvance {
|
||||||
(this.checkerAllowed.has(tc.toolCallId) ||
|
(this.checkerAllowed.has(tc.toolCallId) ||
|
||||||
tc.permission?.resolved?.decision === "allow"),
|
tc.permission?.resolved?.decision === "allow"),
|
||||||
);
|
);
|
||||||
|
const started: Array<{ tc: ToolCallState; tool: SyncRuntimeTool }> = [];
|
||||||
for (const tc of executable) {
|
for (const tc of executable) {
|
||||||
if (this.signal.aborted) {
|
if (this.signal.aborted) {
|
||||||
|
// Invoked-but-unexecuted calls get their cancelled results
|
||||||
|
// from cancel(), same as before this loop ran.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tool = this.toolsByName.get(tc.toolName);
|
const tool = this.toolsByName.get(tc.toolName);
|
||||||
|
|
@ -771,52 +795,63 @@ class TurnAdvance {
|
||||||
if (tool.descriptor.execution === "async") {
|
if (tool.descriptor.execution === "async") {
|
||||||
continue; // exposed; the result arrives through advanceTurn
|
continue; // exposed; the result arrives through advanceTurn
|
||||||
}
|
}
|
||||||
const syncTool = tool as SyncRuntimeTool;
|
started.push({ tc, tool: tool as SyncRuntimeTool });
|
||||||
try {
|
}
|
||||||
const result = await syncTool.execute(tc.input, {
|
// Each task settles its own call (result or error), so Promise.all
|
||||||
turnId: this.turnId,
|
// never rejects and one slow tool never blocks its siblings.
|
||||||
toolCallId: tc.toolCallId,
|
await Promise.all(
|
||||||
signal: this.signal,
|
started.map(({ tc, tool }) => this.executeSyncTool(tc, tool)),
|
||||||
reportProgress: async (progress) => {
|
);
|
||||||
await this.append({
|
}
|
||||||
type: "tool_progress",
|
|
||||||
turnId: this.turnId,
|
private async executeSyncTool(
|
||||||
ts: this.now(),
|
tc: ToolCallState,
|
||||||
toolCallId: tc.toolCallId,
|
syncTool: SyncRuntimeTool,
|
||||||
source: "sync",
|
): Promise<void> {
|
||||||
progress,
|
try {
|
||||||
});
|
const result = await syncTool.execute(tc.input, {
|
||||||
},
|
turnId: this.turnId,
|
||||||
});
|
toolCallId: tc.toolCallId,
|
||||||
await this.append({
|
signal: this.signal,
|
||||||
type: "tool_result",
|
reportProgress: async (progress) => {
|
||||||
turnId: this.turnId,
|
await this.append({
|
||||||
ts: this.now(),
|
type: "tool_progress",
|
||||||
toolCallId: tc.toolCallId,
|
turnId: this.turnId,
|
||||||
toolName: tc.toolName,
|
ts: this.now(),
|
||||||
source: "sync",
|
toolCallId: tc.toolCallId,
|
||||||
result: ToolResultData.parse(result),
|
source: "sync",
|
||||||
});
|
progress,
|
||||||
} catch (error) {
|
});
|
||||||
if (this.signal.aborted) {
|
},
|
||||||
await this.append(
|
});
|
||||||
runtimeResultEvent(this.turnId, this.now(), tc, {
|
await this.append({
|
||||||
output: "Tool execution was cancelled.",
|
type: "tool_result",
|
||||||
isError: true,
|
turnId: this.turnId,
|
||||||
}),
|
ts: this.now(),
|
||||||
);
|
toolCallId: tc.toolCallId,
|
||||||
return;
|
toolName: tc.toolName,
|
||||||
}
|
source: "sync",
|
||||||
await this.append({
|
result: ToolResultData.parse(result),
|
||||||
type: "tool_result",
|
});
|
||||||
turnId: this.turnId,
|
} catch (error) {
|
||||||
ts: this.now(),
|
if (this.signal.aborted) {
|
||||||
toolCallId: tc.toolCallId,
|
await this.append(
|
||||||
toolName: tc.toolName,
|
runtimeResultEvent(this.turnId, this.now(), tc, {
|
||||||
source: "sync",
|
output: "Tool execution was cancelled.",
|
||||||
result: { output: errorMessage(error), isError: true },
|
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(),
|
model: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RequestedAgent = z.object({
|
export const AgentByIdRequest = z.object({
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
overrides: z
|
overrides: z
|
||||||
.object({
|
.object({
|
||||||
|
|
@ -42,6 +42,42 @@ export const RequestedAgent = z.object({
|
||||||
.optional(),
|
.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({
|
export const ToolDescriptor = z.object({
|
||||||
toolId: z.string(),
|
toolId: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue