From c234ea2b54ad0f1fccbfe5a0eeef6c1c1fa84d0b Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sat, 8 Nov 2025 12:36:52 -0800 Subject: [PATCH] fix: parallel tool calling misc fixes (#85) --- src/agent/create.ts | 13 +++- src/agent/modify.ts | 42 ++++++------ src/cli/helpers/stream.ts | 30 ++++++--- src/headless.ts | 132 +++++++++++++++++++------------------- 4 files changed, 123 insertions(+), 94 deletions(-) diff --git a/src/agent/create.ts b/src/agent/create.ts index a0a62f6..be1b0bb 100644 --- a/src/agent/create.ts +++ b/src/agent/create.ts @@ -220,9 +220,18 @@ export async function createAgent( enable_sleeptime: enableSleeptime, }); - // Apply updateArgs if provided (e.g., reasoningEffort, contextWindow, etc.) + // Apply updateArgs if provided (e.g., reasoningEffort, verbosity, etc.) + // Skip if updateArgs only contains context_window (already set in create) if (updateArgs && Object.keys(updateArgs).length > 0) { - await updateAgentLLMConfig(agent.id, modelHandle, updateArgs); + const { context_window, ...otherArgs } = updateArgs; + if (Object.keys(otherArgs).length > 0) { + await updateAgentLLMConfig( + agent.id, + modelHandle, + otherArgs, + true, // preserve parallel_tool_calls + ); + } } // Always retrieve the agent to ensure we get the full state with populated memory blocks diff --git a/src/agent/modify.ts b/src/agent/modify.ts index f9055ad..6e2e236 100644 --- a/src/agent/modify.ts +++ b/src/agent/modify.ts @@ -14,36 +14,42 @@ import { getClient } from "./client"; * @param agentId - The agent ID * @param modelHandle - The model handle (e.g., "anthropic/claude-sonnet-4-5-20250929") * @param updateArgs - Additional LLM config args (contextWindow, reasoningEffort, verbosity, etc.) + * @param preserveParallelToolCalls - If true, preserves the parallel_tool_calls setting when updating the model * @returns The updated LLM configuration from the server */ export async function updateAgentLLMConfig( agentId: string, modelHandle: string, updateArgs?: Record, + preserveParallelToolCalls?: boolean, ): Promise { const client = await getClient(); - // Step 1: Update model (top-level field) - await client.agents.modify(agentId, { model: modelHandle }); + // Get current agent to preserve parallel_tool_calls if requested + const currentAgent = await client.agents.retrieve(agentId); + const originalParallelToolCalls = preserveParallelToolCalls + ? (currentAgent.llm_config?.parallel_tool_calls ?? undefined) + : undefined; - // Step 2: Get updated agent to retrieve current llm_config - const agent = await client.agents.retrieve(agentId); - let finalConfig = agent.llm_config; + // Strategy: Do everything in ONE modify call via llm_config + // This avoids the backend resetting parallel_tool_calls when we update the model + const updatedLlmConfig = { + ...currentAgent.llm_config, + ...updateArgs, + // Explicitly preserve parallel_tool_calls + ...(originalParallelToolCalls !== undefined && { + parallel_tool_calls: originalParallelToolCalls, + }), + } as LlmConfig; - // Step 3: If we have updateArgs, merge them into llm_config and patch again - if (updateArgs && Object.keys(updateArgs).length > 0) { - const updatedLlmConfig = { - ...finalConfig, - ...updateArgs, - } as LlmConfig; - await client.agents.modify(agentId, { llm_config: updatedLlmConfig }); + await client.agents.modify(agentId, { + llm_config: updatedLlmConfig, + parallel_tool_calls: originalParallelToolCalls, + }); - // Retrieve final state - const finalAgent = await client.agents.retrieve(agentId); - finalConfig = finalAgent.llm_config; - } - - return finalConfig; + // Retrieve and return final state + const finalAgent = await client.agents.retrieve(agentId); + return finalAgent.llm_config; } export interface LinkResult { diff --git a/src/cli/helpers/stream.ts b/src/cli/helpers/stream.ts index d9055de..6854a1a 100644 --- a/src/cli/helpers/stream.ts +++ b/src/cli/helpers/stream.ts @@ -91,14 +91,21 @@ export async function drainStream( // tool_call_message = auto-executed server-side (e.g., web_search) // approval_request_message = needs user approval (e.g., Bash) if (chunk.message_type === "approval_request_message") { - // Use deprecated tool_call or new tool_calls array - const toolCall = - chunk.tool_call || - (Array.isArray(chunk.tool_calls) && chunk.tool_calls.length > 0 - ? chunk.tool_calls[0] - : null); + // console.log( + // "[drainStream] approval_request_message chunk:", + // JSON.stringify(chunk, null, 2), + // ); + + // Normalize tool calls: support both legacy tool_call and new tool_calls array + const toolCalls = Array.isArray(chunk.tool_calls) + ? chunk.tool_calls + : chunk.tool_call + ? [chunk.tool_call] + : []; + + for (const toolCall of toolCalls) { + if (!toolCall?.tool_call_id) continue; // strict: require id - if (toolCall?.tool_call_id) { // Get or create entry for this tool_call_id const existing = pendingApprovals.get(toolCall.tool_call_id) || { toolCallId: toolCall.tool_call_id, @@ -149,7 +156,13 @@ export async function drainStream( if (stopReason === "requires_approval") { // Convert map to array, filtering out incomplete entries - approvals = Array.from(pendingApprovals.values()).filter( + const allPending = Array.from(pendingApprovals.values()); + // console.log( + // "[drainStream] All pending approvals before filter:", + // JSON.stringify(allPending, null, 2), + // ); + + approvals = allPending.filter( (a) => a.toolCallId && a.toolName && a.toolArgs, ); @@ -157,6 +170,7 @@ export async function drainStream( console.error( "[drainStream] No valid approvals collected despite requires_approval stop reason", ); + console.error("[drainStream] Pending approvals map:", allPending); } else { // Set legacy singular field for backward compatibility approval = approvals[0] || null; diff --git a/src/headless.ts b/src/headless.ts index cd418d2..d22b561 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -406,7 +406,7 @@ export async function handleHeadlessCommand( chunk.message_type === "approval_request_message"; let shouldOutputChunk = true; - // Track approval requests + // Track approval requests (stream-aware: accumulate by tool_call_id) if (isApprovalRequest) { const chunkWithTools = chunk as typeof chunk & { tool_call?: { @@ -428,77 +428,77 @@ export async function handleHeadlessCommand( : []; for (const toolCall of toolCalls) { - if (toolCall?.tool_call_id && toolCall?.name) { - const id = toolCall.tool_call_id; - _lastApprovalId = id; + const id = toolCall?.tool_call_id; + if (!id) continue; // remain strict: do not invent ids - // Prefer the most complete args we have seen so far; concatenate deltas - const prev = approvalRequests.get(id); - const base = prev && prev.args !== "{}" ? prev.args : ""; - const incomingArgs = - toolCall.arguments && toolCall.arguments.trim().length > 0 - ? `${base}${toolCall.arguments}` - : base || "{}"; + _lastApprovalId = id; - approvalRequests.set(id, { - toolName: toolCall.name, - args: incomingArgs, - }); + // Concatenate argument deltas; do not inject placeholder JSON + const prev = approvalRequests.get(id); + const base = prev?.args ?? ""; + const incomingArgs = + toolCall?.arguments && toolCall.arguments.trim().length > 0 + ? base + toolCall.arguments + : base; - // Keep an up-to-date approvals array for downstream handling - // Update existing approval if present, otherwise add new one - const existingIndex = approvals.findIndex( - (a) => a.toolCallId === id, + // Preserve previously seen name; set if provided in this chunk + const nextName = toolCall?.name || prev?.toolName || ""; + approvalRequests.set(id, { + toolName: nextName, + args: incomingArgs, + }); + + // Keep an up-to-date approvals array for downstream handling + // Update existing approval if present, otherwise add new one + const existingIndex = approvals.findIndex( + (a) => a.toolCallId === id, + ); + const approvalObj = { + toolCallId: id, + toolName: nextName, + toolArgs: incomingArgs, + }; + if (existingIndex >= 0) { + approvals[existingIndex] = approvalObj; + } else { + approvals.push(approvalObj); + } + + // Check if this approval will be auto-approved. Dedup per tool_call_id + if (!autoApprovalEmitted.has(id) && nextName) { + const parsedArgs = safeJsonParseOr | null>(incomingArgs || "{}", null); + const permission = await checkToolPermission( + nextName, + parsedArgs || {}, ); - const approvalObj = { - toolCallId: id, - toolName: toolCall.name, - toolArgs: incomingArgs, - }; - if (existingIndex >= 0) { - approvals[existingIndex] = approvalObj; - } else { - approvals.push(approvalObj); - } - - // Check if this approval will be auto-approved. Dedup per tool_call_id - if (!autoApprovalEmitted.has(id)) { - const parsedArgs = safeJsonParseOr | null>(incomingArgs || "{}", null); - const permission = await checkToolPermission( - toolCall.name, - parsedArgs || {}, + if (permission.decision === "allow" && parsedArgs) { + // Only emit auto_approval if we already have all required params + const { getToolSchema } = await import("./tools/manager"); + const schema = getToolSchema(nextName); + const required = + (schema?.input_schema?.required as string[] | undefined) || + []; + const missing = required.filter( + (key) => + !(key in parsedArgs) || + String((parsedArgs as Record)[key] ?? "") + .length === 0, ); - if (permission.decision === "allow" && parsedArgs) { - // Only emit auto_approval if we already have all required params - const { getToolSchema } = await import("./tools/manager"); - const schema = getToolSchema(toolCall.name); - const required = - (schema?.input_schema?.required as - | string[] - | undefined) || []; - const missing = required.filter( - (key) => - !(key in parsedArgs) || - String( - (parsedArgs as Record)[key] ?? "", - ).length === 0, + if (missing.length === 0) { + shouldOutputChunk = false; + console.log( + JSON.stringify({ + type: "auto_approval", + tool_name: nextName, + tool_call_id: id, + reason: permission.reason, + matched_rule: permission.matchedRule, + }), ); - if (missing.length === 0) { - shouldOutputChunk = false; - console.log( - JSON.stringify({ - type: "auto_approval", - tool_name: toolCall.name, - tool_call_id: id, - reason: permission.reason, - matched_rule: permission.matchedRule, - }), - ); - autoApprovalEmitted.add(id); - } + autoApprovalEmitted.add(id); } } }