Phase 3: Dynamic Workflow Engine - VM sandbox (node:vm) with agent/parallel/pipeline API, Claude Code compatible - Workflow file discovery (.boocode/workflows/*.js + ~/.boocode/workflows/*.js) - Workflow manager with session/chat creation and inference dispatch - Built-in catalog: deep-research, review-code, find-issues - Resumability cache: SHA-256 hash of agent spec, in-memory Map Phase 4: Background Subagents - background-task.ts service: spawn/poll/cancel lifecycle - spawn_subagent, subagent_status, subagent_result tools in ALL_TOOLS Phase 5: Multi-modal + Cache Shape - Multi-modal stub with type defs and hook point in payload.ts - CacheShapeBadge component in trace viewer (colored bar + %)
306 lines
9.5 KiB
TypeScript
306 lines
9.5 KiB
TypeScript
// v2.x: Background subagent tools. Three tools that let the model spawn
|
|
// non-blocking subagent tasks, poll their status, and retrieve results.
|
|
//
|
|
// spawn_subagent — Create a background session+chat, dispatch inference,
|
|
// return immediately with a task_id.
|
|
// subagent_status — Poll the status of a previously spawned task.
|
|
// subagent_result — Retrieve the full output of a completed task.
|
|
//
|
|
// These tools reuse the existing sessions/chats/messages/tables and the
|
|
// inference pipeline — no new tables or services needed.
|
|
//
|
|
// Registered in tools.ts ALL_TOOLS. Lives in its own file so tests can
|
|
// import executors without dragging in the full tool registry.
|
|
//
|
|
// Follows the read_tab_by_number.ts pattern: a pure executor function plus
|
|
// a ToolDef wrapper. Type-only import from tools.ts to dodge runtime cycles.
|
|
|
|
import { z } from 'zod';
|
|
import type { Sql } from '../../db.js';
|
|
import type { ToolDef, ToolExecCtx } from '../tools.js';
|
|
import {
|
|
spawnBackgroundTask,
|
|
getBackgroundTaskStatus,
|
|
getBackgroundTaskResult,
|
|
} from '../background-task.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// spawn_subagent
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const SpawnSubagentInput = z.object({
|
|
input: z.string().min(1).describe('The task to execute in the background'),
|
|
model: z
|
|
.string()
|
|
.min(1)
|
|
.optional()
|
|
.describe('Model to use (defaults to session model)'),
|
|
agent: z
|
|
.string()
|
|
.min(1)
|
|
.optional()
|
|
.describe('Agent to use (defaults to boocode)'),
|
|
label: z
|
|
.string()
|
|
.max(100)
|
|
.optional()
|
|
.describe('Human-readable label for display'),
|
|
});
|
|
|
|
export type SpawnSubagentInputT = z.infer<typeof SpawnSubagentInput>;
|
|
|
|
export async function executeSpawnSubagent(
|
|
input: SpawnSubagentInputT,
|
|
sql: Sql,
|
|
sessionId: string,
|
|
): Promise<Record<string, unknown>> {
|
|
// Resolve project_id + model from the current session.
|
|
const sessRows = await sql<
|
|
{ project_id: string; model: string }[]
|
|
>`
|
|
SELECT project_id, model FROM sessions WHERE id = ${sessionId}
|
|
`;
|
|
if (sessRows.length === 0) {
|
|
return { error: 'current session not found' };
|
|
}
|
|
const projectId = sessRows[0]!.project_id;
|
|
const model = input.model ?? sessRows[0]!.model;
|
|
|
|
const task = await spawnBackgroundTask(
|
|
sql,
|
|
// We pass a minimal logger shim — the real logger is wired by the
|
|
// inference pipeline. This keeps the tool's execute signature clean.
|
|
{ info: () => {}, warn: () => {}, error: () => {} } as unknown as import('fastify').FastifyBaseLogger,
|
|
projectId,
|
|
input.input,
|
|
model,
|
|
input.agent,
|
|
input.label,
|
|
);
|
|
|
|
// Elapsed time since creation is negligible (task was just spawned).
|
|
return {
|
|
task_id: task.id,
|
|
status: task.status,
|
|
session_id: task.session_id,
|
|
chat_id: task.chat_id,
|
|
created_at: task.created_at,
|
|
};
|
|
}
|
|
|
|
export const spawnSubagent: ToolDef<SpawnSubagentInputT> = {
|
|
name: 'spawn_subagent',
|
|
description:
|
|
'Spawn a background subagent task. Creates a new session and chat, dispatches inference asynchronously, and returns immediately with a task_id. Use subagent_status to poll for completion and subagent_result to retrieve the full output. Non-blocking — the model continues while the subagent works in the background.',
|
|
inputSchema: SpawnSubagentInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'spawn_subagent',
|
|
description:
|
|
'Spawn a background subagent task. Returns immediately with a task_id — poll with subagent_status.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
input: {
|
|
type: 'string',
|
|
description: 'The task to execute in the background',
|
|
},
|
|
model: {
|
|
type: 'string',
|
|
description: 'Model to use (defaults to session model)',
|
|
},
|
|
agent: {
|
|
type: 'string',
|
|
description: 'Agent to use (defaults to boocode)',
|
|
},
|
|
label: {
|
|
type: 'string',
|
|
maxLength: 100,
|
|
description: 'Human-readable label for display',
|
|
},
|
|
},
|
|
required: ['input'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
|
|
if (!toolCtx) {
|
|
return { error: 'spawn_subagent unavailable: no session context' };
|
|
}
|
|
try {
|
|
return await executeSpawnSubagent(input, toolCtx.sql, toolCtx.sessionId);
|
|
} catch (err) {
|
|
return {
|
|
error: `spawn_subagent failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// subagent_status
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const SubagentStatusInput = z.object({
|
|
task_id: z.string().uuid().describe('Task ID from spawn_subagent'),
|
|
});
|
|
|
|
export type SubagentStatusInputT = z.infer<typeof SubagentStatusInput>;
|
|
|
|
export async function executeSubagentStatus(
|
|
input: SubagentStatusInputT,
|
|
sql: Sql,
|
|
): Promise<Record<string, unknown>> {
|
|
const task = await getBackgroundTaskStatus(sql, input.task_id);
|
|
if (!task) {
|
|
return { error: 'task not found', task_id: input.task_id };
|
|
}
|
|
|
|
// Compute elapsed time from created_at (ISO string).
|
|
let elapsed_seconds: number | null = null;
|
|
try {
|
|
const created = new Date(task.created_at).getTime();
|
|
const finished = task.finished_at
|
|
? new Date(task.finished_at).getTime()
|
|
: Date.now();
|
|
elapsed_seconds = Math.round((finished - created) / 1000);
|
|
} catch {
|
|
elapsed_seconds = null;
|
|
}
|
|
|
|
return {
|
|
task_id: task.id,
|
|
status: task.status,
|
|
output_summary: task.output_summary,
|
|
finished_at: task.finished_at,
|
|
elapsed_seconds,
|
|
};
|
|
}
|
|
|
|
export const subagentStatus: ToolDef<SubagentStatusInputT> = {
|
|
name: 'subagent_status',
|
|
description:
|
|
'Poll the status of a background subagent task by task_id. Returns the current status (running/completed/failed/cancelled), an output summary if completed, and elapsed time. Useful after spawn_subagent to check if work is done.',
|
|
inputSchema: SubagentStatusInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'subagent_status',
|
|
description:
|
|
'Poll the status of a background subagent task. Returns status, output summary, and elapsed time.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
task_id: {
|
|
type: 'string',
|
|
format: 'uuid',
|
|
description: 'Task ID from spawn_subagent',
|
|
},
|
|
},
|
|
required: ['task_id'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
|
|
if (!toolCtx) {
|
|
return { error: 'subagent_status unavailable: no session context' };
|
|
}
|
|
try {
|
|
return await executeSubagentStatus(input, toolCtx.sql);
|
|
} catch (err) {
|
|
return {
|
|
error: `subagent_status failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// subagent_result
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const SubagentResultInput = z.object({
|
|
task_id: z.string().uuid().describe('Task ID from spawn_subagent'),
|
|
});
|
|
|
|
export type SubagentResultInputT = z.infer<typeof SubagentResultInput>;
|
|
|
|
export async function executeSubagentResult(
|
|
input: SubagentResultInputT,
|
|
sql: Sql,
|
|
): Promise<Record<string, unknown>> {
|
|
const task = await getBackgroundTaskStatus(sql, input.task_id);
|
|
if (!task) {
|
|
return { error: 'task not found', task_id: input.task_id };
|
|
}
|
|
|
|
if (task.status !== 'completed') {
|
|
return {
|
|
task_id: task.id,
|
|
status: task.status,
|
|
error: `task is not yet completed (status: ${task.status})`,
|
|
};
|
|
}
|
|
|
|
if (!task.chat_id) {
|
|
return { error: 'task has no chat data', task_id: input.task_id };
|
|
}
|
|
|
|
const result = await getBackgroundTaskResult(sql, input.task_id, task.chat_id);
|
|
if (!result) {
|
|
return {
|
|
task_id: task.id,
|
|
status: task.status,
|
|
error: 'task completed but no output message found',
|
|
};
|
|
}
|
|
|
|
return {
|
|
task_id: task.id,
|
|
output: result.output,
|
|
token_usage: result.token_usage,
|
|
};
|
|
}
|
|
|
|
export const subagentResult: ToolDef<SubagentResultInputT> = {
|
|
name: 'subagent_result',
|
|
description:
|
|
'Retrieve the full output of a completed background subagent task by task_id. Returns the response text and token usage. The task must be in completed status — poll with subagent_status first.',
|
|
inputSchema: SubagentResultInput,
|
|
jsonSchema: {
|
|
type: 'function',
|
|
function: {
|
|
name: 'subagent_result',
|
|
description:
|
|
'Retrieve the full output of a completed background subagent task. Returns output text and token usage.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
task_id: {
|
|
type: 'string',
|
|
format: 'uuid',
|
|
description: 'Task ID from spawn_subagent',
|
|
},
|
|
},
|
|
required: ['task_id'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
|
|
if (!toolCtx) {
|
|
return { error: 'subagent_result unavailable: no session context' };
|
|
}
|
|
try {
|
|
return await executeSubagentResult(input, toolCtx.sql);
|
|
} catch (err) {
|
|
return {
|
|
error: `subagent_result failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
},
|
|
};
|