feat: phase 3-5 — workflow engine, background subagents, multi-modal, cache shape, inline diff
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 + %)
This commit is contained in:
305
apps/server/src/services/tools/background-subagent-tools.ts
Normal file
305
apps/server/src/services/tools/background-subagent-tools.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
// 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)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user