fix: parallel tool calling misc fixes (#85)

This commit is contained in:
Charles Packer
2025-11-08 12:36:52 -08:00
committed by GitHub
parent eab04aaee3
commit c234ea2b54
4 changed files with 123 additions and 94 deletions

View File

@@ -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

View File

@@ -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<string, unknown>,
preserveParallelToolCalls?: boolean,
): Promise<LlmConfig> {
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 {

View File

@@ -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;

View File

@@ -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<Record<
string,
unknown
> | 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<Record<
string,
unknown
> | 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<string, unknown>)[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<string, unknown>)[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);
}
}
}