/**
* Server Subagent — enables Opus to delegate tool execution to Haiku/Sonnet.
*
* The parent agent loop injects all dependencies; this module has zero imports
* from index.ts (avoids circular deps) and is fully testable in isolation.
*/
import { randomUUID } from "node:crypto";
import type Anthropic from "@anthropic-ai/sdk";
import type { SupabaseClient } from "@supabase/supabase-js";
import {
LoopDetector,
estimateCostUsd,
truncateToolResult,
sanitizeError,
isRetryableError,
} from "../../shared/agent-core.js";
import { buildAPIRequest } from "../../shared/api-client.js";
import { MODELS, MODEL_MAP } from "../../shared/constants.js";
// ============================================================================
// TYPES
// ============================================================================
export interface SubagentResult {
success: boolean;
output: string;
tokensUsed: { input: number; output: number };
costUsd: number;
toolsUsed: string[];
turnCount: number;
}
export interface SubagentProgressEvent {
subagentId: string;
event: "started" | "tool_start" | "tool_result" | "turn" | "done" | "error";
model?: string;
toolName?: string;
toolSuccess?: boolean;
turn?: number;
maxTurns?: number;
output?: string;
}
export type SubagentProgressCallback = (event: SubagentProgressEvent) => void;
export interface RunServerSubagentOptions {
anthropic: Anthropic;
supabase: SupabaseClient;
storeId: string | undefined;
prompt: string;
model: "haiku" | "sonnet";
maxTurns: number;
tools: Array<{ name: string; description: string; input_schema: Record<string, unknown> }>;
executeTool: (
toolName: string,
args: Record<string, unknown>,
) => Promise<{ success: boolean; data?: unknown; error?: string }>;
onProgress?: SubagentProgressCallback;
clientDisconnected: { value: boolean };
startedAt: number;
maxDurationMs: number;
}
// ============================================================================
// TOOL DEFINITION — exposed to parent Opus agent
// ============================================================================
export const DELEGATE_TASK_TOOL_DEF = {
name: "delegate_task",
description:
"Delegate a focused task to a faster sub-agent. Use for: data lookups, " +
"multi-step tool chains, analytics queries, inventory checks, customer lookups. " +
"Do NOT delegate decisions requiring your judgment or creative writing.",
input_schema: {
type: "object" as const,
properties: {
prompt: {
type: "string",
description: "Clear instruction for the sub-agent. Include all context needed.",
},
model: {
type: "string",
enum: ["haiku", "sonnet"],
description: "Model to use. haiku (fast, cheap) for simple lookups. sonnet for complex multi-step chains. Default: haiku.",
},
max_turns: {
type: "number",
description: "Max agentic turns (1-12). Default: 6.",
},
},
required: ["prompt"],
},
};
// ============================================================================
// SUBAGENT SYSTEM PROMPT
// ============================================================================
const SUBAGENT_SYSTEM_PROMPT = `You are a focused tool executor. Your job is to complete the delegated task using the available tools, then return a clear summary of the results.
Rules:
- Execute tools as needed to fulfill the request
- Return factual results — do not add opinions or recommendations
- If a tool fails, try an alternative approach or report the failure
- Be concise in your final response
- Always include store_id in tool calls when it's available in your context`;
// ============================================================================
// CORE LOOP
// ============================================================================
const SUBAGENT_MAX_OUTPUT_TOKENS = 8192;
const SUBAGENT_MAX_TOOL_RESULT_CHARS = 15_000;
export async function runServerSubagent(opts: RunServerSubagentOptions): Promise<SubagentResult> {
const {
anthropic,
prompt,
model: modelAlias,
maxTurns,
tools,
executeTool,
onProgress,
clientDisconnected,
startedAt,
maxDurationMs,
storeId,
} = opts;
const subagentId = `sub-${randomUUID().slice(0, 8)}`;
const modelId = MODEL_MAP[modelAlias] || MODELS.HAIKU;
const loopDetector = new LoopDetector();
const toolsUsed: string[] = [];
let totalIn = 0;
let totalOut = 0;
let cacheCreation = 0;
let cacheRead = 0;
// Context management for subagent: clear at 60K, keep 2, no compaction
const apiConfig = buildAPIRequest({ model: modelId, contextProfile: "subagent", maxOutputTokens: SUBAGENT_MAX_OUTPUT_TOKENS });
const system = storeId
? `${SUBAGENT_SYSTEM_PROMPT}\n\nstore_id: ${storeId}`
: SUBAGENT_SYSTEM_PROMPT;
const messages: Anthropic.MessageParam[] = [{ role: "user", content: prompt }];
onProgress?.({ subagentId, event: "started", model: modelAlias });
let turn = 0;
let lastText = "";
while (turn < maxTurns) {
// Abort checks
if (clientDisconnected.value) {
return makeResult(false, "Client disconnected", totalIn, totalOut, modelId, toolsUsed, turn, cacheRead, cacheCreation);
}
if (Date.now() - startedAt > maxDurationMs) {
return makeResult(false, "Parent request timeout", totalIn, totalOut, modelId, toolsUsed, turn, cacheRead, cacheCreation);
}
turn++;
loopDetector.resetTurn();
onProgress?.({ subagentId, event: "turn", turn, maxTurns });
// Non-streaming API call with retry
let response: Anthropic.Message;
try {
response = await withSubagentRetry(async () => {
return await anthropic.beta.messages.create({
model: modelId,
max_tokens: apiConfig.maxTokens,
temperature: 0.3,
system,
tools: tools as any,
messages: messages as any,
betas: apiConfig.betas,
context_management: apiConfig.contextManagement,
} as any) as unknown as Anthropic.Message;
});
} catch (err) {
const errMsg = sanitizeError(err);
onProgress?.({ subagentId, event: "error", output: errMsg });
return makeResult(false, `API error: ${errMsg}`, totalIn, totalOut, modelId, toolsUsed, turn, cacheRead, cacheCreation);
}
// Track tokens (including cache for accurate cost)
totalIn += response.usage?.input_tokens || 0;
totalOut += response.usage?.output_tokens || 0;
cacheCreation += (response.usage as any)?.cache_creation_input_tokens ?? 0;
cacheRead += (response.usage as any)?.cache_read_input_tokens ?? 0;
// Extract text and tool_use blocks
const textBlocks: string[] = [];
const toolUseBlocks: Array<{ id: string; name: string; input: Record<string, unknown> }> = [];
for (const block of response.content) {
if (block.type === "text") {
textBlocks.push(block.text);
} else if (block.type === "tool_use") {
toolUseBlocks.push({ id: block.id, name: block.name, input: block.input as Record<string, unknown> });
}
}
lastText = textBlocks.join("\n");
// No tool calls → done
if (toolUseBlocks.length === 0) {
onProgress?.({ subagentId, event: "done", output: lastText });
return makeResult(true, lastText, totalIn, totalOut, modelId, toolsUsed, turn, cacheRead, cacheCreation);
}
// Execute tools sequentially (subagent tasks are focused)
const toolResults: Array<{ type: "tool_result"; tool_use_id: string; content: string }> = [];
for (const tu of toolUseBlocks) {
toolsUsed.push(tu.name);
// Loop detection
const loopCheck = loopDetector.recordCall(tu.name, tu.input);
if (loopCheck.blocked) {
toolResults.push({
type: "tool_result",
tool_use_id: tu.id,
content: JSON.stringify({ error: loopCheck.reason }),
});
loopDetector.recordResult(tu.name, false, tu.input);
onProgress?.({ subagentId, event: "tool_result", toolName: tu.name, toolSuccess: false });
continue;
}
onProgress?.({ subagentId, event: "tool_start", toolName: tu.name });
try {
const result = await executeTool(tu.name, tu.input);
loopDetector.recordResult(tu.name, result.success, tu.input);
let resultJson = JSON.stringify(result.success ? result.data : { error: result.error });
resultJson = truncateToolResult(resultJson, SUBAGENT_MAX_TOOL_RESULT_CHARS);
toolResults.push({ type: "tool_result", tool_use_id: tu.id, content: resultJson });
onProgress?.({ subagentId, event: "tool_result", toolName: tu.name, toolSuccess: result.success });
} catch (err) {
const errMsg = sanitizeError(err);
loopDetector.recordResult(tu.name, false, tu.input);
toolResults.push({ type: "tool_result", tool_use_id: tu.id, content: JSON.stringify({ error: errMsg }) });
onProgress?.({ subagentId, event: "tool_result", toolName: tu.name, toolSuccess: false });
}
}
// Bail-out check
const bailCheck = loopDetector.endTurn();
if (bailCheck.shouldBail) {
onProgress?.({ subagentId, event: "error", output: bailCheck.message });
return makeResult(false, bailCheck.message || "Too many errors", totalIn, totalOut, modelId, toolsUsed, turn, cacheRead, cacheCreation);
}
// Append for next turn
messages.push({
role: "assistant",
content: response.content as any,
});
messages.push({
role: "user",
content: toolResults,
});
}
// Exhausted max turns
onProgress?.({ subagentId, event: "done", output: lastText });
return makeResult(true, lastText || "(subagent completed without text response)", totalIn, totalOut, modelId, toolsUsed, turn, cacheRead, cacheCreation);
}
// ============================================================================
// HELPERS
// ============================================================================
function makeResult(
success: boolean,
output: string,
inputTokens: number,
outputTokens: number,
modelId: string,
toolsUsed: string[],
turnCount: number,
cacheReadTokens = 0,
cacheCreationTokens = 0,
): SubagentResult {
return {
success,
output,
tokensUsed: { input: inputTokens, output: outputTokens },
costUsd: estimateCostUsd(inputTokens, outputTokens, modelId, 0, cacheReadTokens, cacheCreationTokens),
toolsUsed: [...new Set(toolsUsed)],
turnCount,
};
}
async function withSubagentRetry<T>(fn: () => Promise<T>, maxRetries = 2): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt < maxRetries && isRetryableError(err)) {
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
continue;
}
throw err;
}
}
throw lastError;
}