Skip to content

Commit 055753b

Browse files
author
SidQin-cyber
committed
fix(agents): support parallel_tool_calls config per model
OpenClaw sends parallel_tool_calls:true to all OpenAI-compatible providers via the upstream pi-agent-core library. Models that don't support parallel tool calling (e.g. kimi-k2.5 on NVIDIA NIM) return 400, breaking all tool execution. Read parallel_tool_calls from model params and inject it into the LLM request payload via an onPayload wrapper, letting users set it to false per provider/model. Closes #37048 Made-with: Cursor
1 parent 16f9f4d commit 055753b

File tree

2 files changed

+69
-0
lines changed

2 files changed

+69
-0
lines changed

src/agents/pi-embedded-runner-extraparams.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,3 +1353,46 @@ describe("applyExtraParamsToAgent", () => {
13531353
},
13541354
);
13551355
});
1356+
1357+
describe("parallel_tool_calls wrapper", () => {
1358+
function capturePayload(provider: string, modelId: string, paramValue: boolean) {
1359+
let captured: Record<string, unknown> = {};
1360+
const fakeStreamFn: StreamFn = (_model, _context, options) => {
1361+
options?.onPayload?.(captured);
1362+
return undefined as never;
1363+
};
1364+
const agent = { streamFn: fakeStreamFn };
1365+
applyExtraParamsToAgent(
1366+
agent,
1367+
{
1368+
agents: {
1369+
defaults: {
1370+
models: {
1371+
[`${provider}/${modelId}`]: {
1372+
params: { parallel_tool_calls: paramValue },
1373+
},
1374+
},
1375+
},
1376+
},
1377+
},
1378+
provider,
1379+
modelId,
1380+
);
1381+
void agent.streamFn(
1382+
{ api: "openai-completions", provider, id: modelId } as Model<"openai-completions">,
1383+
{} as Context,
1384+
{},
1385+
);
1386+
return captured;
1387+
}
1388+
1389+
it("injects parallel_tool_calls=false when configured", () => {
1390+
const payload = capturePayload("nvidia-nim", "moonshotai/kimi-k2.5", false);
1391+
expect(payload.parallel_tool_calls).toBe(false);
1392+
});
1393+
1394+
it("injects parallel_tool_calls=true when configured", () => {
1395+
const payload = capturePayload("openai", "gpt-4", true);
1396+
expect(payload.parallel_tool_calls).toBe(true);
1397+
});
1398+
});

src/agents/pi-embedded-runner/extra-params.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,25 @@ function createZaiToolStreamWrapper(
963963
};
964964
}
965965

966+
function createParallelToolCallsWrapper(
967+
baseStreamFn: StreamFn | undefined,
968+
parallelToolCalls: boolean,
969+
): StreamFn {
970+
const underlying = baseStreamFn ?? streamSimple;
971+
return (model, context, options) => {
972+
const originalOnPayload = options?.onPayload;
973+
return underlying(model, context, {
974+
...options,
975+
onPayload: (payload) => {
976+
if (payload && typeof payload === "object") {
977+
(payload as Record<string, unknown>).parallel_tool_calls = parallelToolCalls;
978+
}
979+
originalOnPayload?.(payload);
980+
},
981+
});
982+
};
983+
}
984+
966985
/**
967986
* Apply extra params (like temperature) to an agent's streamFn.
968987
* Also adds OpenRouter app attribution headers when using the OpenRouter provider.
@@ -1069,6 +1088,13 @@ export function applyExtraParamsToAgent(
10691088
}
10701089
}
10711090

1091+
if (typeof merged?.parallel_tool_calls === "boolean") {
1092+
log.debug(
1093+
`applying parallel_tool_calls=${merged.parallel_tool_calls} for ${provider}/${modelId}`,
1094+
);
1095+
agent.streamFn = createParallelToolCallsWrapper(agent.streamFn, merged.parallel_tool_calls);
1096+
}
1097+
10721098
// Guard Google payloads against invalid negative thinking budgets emitted by
10731099
// upstream model-ID heuristics for Gemini 3.1 variants.
10741100
agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel);

0 commit comments

Comments
 (0)