/**
* Unified Tool Dispatch — ONE implementation replacing 4 duplicates.
*
* Handles: loop detection, parallel execution, result truncation, bail-out.
* Used by agent-loop, subagent, teammate, and server-agent-loop.
*/
import { LoopDetector } from "./agent-core.js";
import type { ToolUseBlock, ToolResultMessage } from "./types.js";
// ============================================================================
// TYPES
// ============================================================================
export interface ToolDispatchOptions {
/** Loop detector instance (shared across turns) */
loopDetector: LoopDetector;
/** Max concurrent tool executions (7 for main, 1 for sequential) */
maxConcurrent?: number;
/** Max chars per tool result (effort-aware or fixed) */
maxResultChars?: number;
/** Called when a tool starts executing */
onStart?: (name: string, input: Record<string, unknown>) => void;
/** Called when a tool finishes */
onResult?: (name: string, success: boolean, result: string, durationMs: number) => void;
/** Abort signal */
signal?: AbortSignal;
}
export type ToolExecutor = (
name: string,
input: Record<string, unknown>,
) => Promise<{ success: boolean; output: string }>;
// ============================================================================
// DISPATCH
// ============================================================================
/**
* Execute tool calls with loop detection, parallel batching, and truncation.
* Returns tool result messages in original order.
*/
export async function dispatchTools(
toolCalls: ToolUseBlock[],
executor: ToolExecutor,
opts: ToolDispatchOptions,
): Promise<{ results: ToolResultMessage[]; bailOut: boolean; bailMessage?: string }> {
const {
loopDetector,
maxConcurrent = 7,
maxResultChars = 20_000,
onStart,
onResult,
signal,
} = opts;
const resultMap = new Map<string, ToolResultMessage>();
async function executeSingle(tu: ToolUseBlock): Promise<void> {
if (signal?.aborted) {
resultMap.set(tu.id, {
type: "tool_result",
tool_use_id: tu.id,
content: JSON.stringify({ error: "Aborted" }),
});
return;
}
// Loop detection check
const loopCheck = loopDetector.recordCall(tu.name, tu.input);
if (loopCheck.blocked) {
onResult?.(tu.name, false, loopCheck.reason!, 0);
resultMap.set(tu.id, {
type: "tool_result",
tool_use_id: tu.id,
content: JSON.stringify({ error: loopCheck.reason }),
});
return;
}
onStart?.(tu.name, tu.input);
const toolStart = Date.now();
let result: { success: boolean; output: string };
try {
result = await executor(tu.name, tu.input);
} catch (err: unknown) {
result = {
success: false,
output: `Tool execution error: ${(err as Error).message || String(err)}`,
};
}
const durationMs = Date.now() - toolStart;
loopDetector.recordResult(tu.name, result.success, tu.input);
onResult?.(tu.name, result.success, result.output, durationMs);
// Check for image marker — convert to image content block
const imageMatch = result.success && typeof result.output === "string"
? result.output.match(/^__IMAGE__(.+?)__(.+)$/)
: null;
let resultBlock: ToolResultMessage;
if (imageMatch) {
resultBlock = {
type: "tool_result",
tool_use_id: tu.id,
content: [
{
type: "image",
source: {
type: "base64",
media_type: imageMatch[1],
data: imageMatch[2],
},
},
],
};
} else {
const rawContent = result.success
? result.output
: JSON.stringify({ error: result.output });
let contentStr = typeof rawContent === "string"
? rawContent
: JSON.stringify(rawContent);
if (contentStr.length > maxResultChars) {
contentStr =
contentStr.slice(0, maxResultChars) +
`\n\n... (truncated — ${contentStr.length.toLocaleString()} chars total. Ask for a narrower query.)`;
}
resultBlock = {
type: "tool_result",
tool_use_id: tu.id,
content: contentStr,
};
}
resultMap.set(tu.id, resultBlock);
}
// Execute in parallel batches
for (let i = 0; i < toolCalls.length; i += maxConcurrent) {
if (signal?.aborted) break;
const batch = toolCalls.slice(i, i + maxConcurrent);
await Promise.all(batch.map((tu) => executeSingle(tu)));
}
// Collect results in original order
const results: ToolResultMessage[] = [];
for (const tu of toolCalls) {
const r = resultMap.get(tu.id);
if (r) results.push(r);
}
// Bail-out detection
const bailCheck = loopDetector.endTurn();
if (bailCheck.shouldBail && results.length > 0) {
const lastResult = results[results.length - 1];
if (typeof lastResult.content === "string") {
lastResult.content = `[SYSTEM WARNING] ${bailCheck.message}\n\n${lastResult.content}`;
}
}
return {
results,
bailOut: bailCheck.shouldBail,
bailMessage: bailCheck.message,
};
}
// ============================================================================
// ASSISTANT CONTENT BUILDER — builds message content for next turn
// ============================================================================
/**
* Build assistant content array from stream result for the next conversation turn.
*/
export function buildAssistantContent(opts: {
text: string;
toolUseBlocks: ToolUseBlock[];
thinkingBlocks?: Array<{ type: "thinking"; thinking: string; signature: string }>;
compactionContent?: string | null;
}): Array<Record<string, unknown>> {
const content: Array<Record<string, unknown>> = [];
// Compaction block must come first
if (opts.compactionContent !== null && opts.compactionContent !== undefined) {
content.push({ type: "compaction", content: opts.compactionContent });
}
// Thinking blocks before text/tool_use
if (opts.thinkingBlocks) {
for (const tb of opts.thinkingBlocks) {
content.push(tb);
}
}
if (opts.text) {
content.push({ type: "text" as const, text: opts.text });
}
content.push(
...opts.toolUseBlocks.map((t) => ({
type: "tool_use" as const,
id: t.id,
name: t.name,
input: t.input,
// Preserve Gemini thought signatures through function call round-trips
...(t.thoughtSignature ? { thought_signature: t.thoughtSignature } : {}),
})),
);
return content;
}