mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 05:34:32 +00:00
Pi Runner: gate parallel_tool_calls to compatible APIs (#39356)
* Pi Runner: gate parallel_tool_calls payload injection * Pi Runner: cover parallel_tool_calls alias precedence * Changelog: note parallel_tool_calls compatibility fix * Update CHANGELOG.md * Pi Runner: clarify null parallel_tool_calls override logging
This commit is contained in:
@@ -332,6 +332,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Discord/DM session-key normalization: rewrite legacy `discord:dm:*` and phantom direct-message `discord:channel:<user>` session keys to `discord:direct:*` when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.
|
- Discord/DM session-key normalization: rewrite legacy `discord:dm:*` and phantom direct-message `discord:channel:<user>` session keys to `discord:direct:*` when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.
|
||||||
- Discord/native slash session fallback: treat empty configured bound-session keys as missing so `/status` and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.
|
- Discord/native slash session fallback: treat empty configured bound-session keys as missing so `/status` and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.
|
||||||
- Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across `toolCall`, `toolUse`, and `functionCall` blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with `Tool not found`. (#39328) Thanks @vincentkoc.
|
- Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across `toolCall`, `toolUse`, and `functionCall` blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with `Tool not found`. (#39328) Thanks @vincentkoc.
|
||||||
|
- Agents/parallel tool-call compatibility: honor `parallel_tool_calls` / `parallelToolCalls` extra params only for `openai-completions` and `openai-responses` payloads, preserve higher-precedence alias overrides across config and runtime layers, and ignore invalid non-boolean values so single-tool-call providers like NVIDIA-hosted Kimi stop failing on forced parallel tool-call payloads. (#37048) Thanks @vincentkoc.
|
||||||
- Config/invalid-load fail-closed: stop converting `INVALID_CONFIG` into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.
|
- Config/invalid-load fail-closed: stop converting `INVALID_CONFIG` into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.
|
||||||
|
|
||||||
## 2026.3.2
|
## 2026.3.2
|
||||||
|
|||||||
@@ -116,6 +116,39 @@ describe("resolveExtraParams", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves higher-precedence agent parallelToolCalls override across alias styles", () => {
|
||||||
|
const result = resolveExtraParams({
|
||||||
|
cfg: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"openai/gpt-4.1": {
|
||||||
|
params: {
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
params: {
|
||||||
|
parallelToolCalls: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provider: "openai",
|
||||||
|
modelId: "gpt-4.1",
|
||||||
|
agentId: "main",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
parallel_tool_calls: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores per-agent params when agentId does not match", () => {
|
it("ignores per-agent params when agentId does not match", () => {
|
||||||
const result = resolveExtraParams({
|
const result = resolveExtraParams({
|
||||||
cfg: {
|
cfg: {
|
||||||
@@ -190,6 +223,32 @@ describe("applyExtraParamsToAgent", () => {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runParallelToolCallsPayloadMutationCase(params: {
|
||||||
|
applyProvider: string;
|
||||||
|
applyModelId: string;
|
||||||
|
model: Model<"openai-completions"> | Model<"openai-responses"> | Model<"anthropic-messages">;
|
||||||
|
cfg?: Record<string, unknown>;
|
||||||
|
extraParamsOverride?: Record<string, unknown>;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const payload = params.payload ?? {};
|
||||||
|
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||||
|
options?.onPayload?.(payload);
|
||||||
|
return {} as ReturnType<StreamFn>;
|
||||||
|
};
|
||||||
|
const agent = { streamFn: baseStreamFn };
|
||||||
|
applyExtraParamsToAgent(
|
||||||
|
agent,
|
||||||
|
params.cfg as Parameters<typeof applyExtraParamsToAgent>[1],
|
||||||
|
params.applyProvider,
|
||||||
|
params.applyModelId,
|
||||||
|
params.extraParamsOverride,
|
||||||
|
);
|
||||||
|
const context: Context = { messages: [] };
|
||||||
|
void agent.streamFn?.(params.model, context, {});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function runAnthropicHeaderCase(params: {
|
function runAnthropicHeaderCase(params: {
|
||||||
cfg: Record<string, unknown>;
|
cfg: Record<string, unknown>;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
@@ -350,6 +409,181 @@ describe("applyExtraParamsToAgent", () => {
|
|||||||
expect(payloads[0]).not.toHaveProperty("reasoning_effort");
|
expect(payloads[0]).not.toHaveProperty("reasoning_effort");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("injects parallel_tool_calls for openai-completions payloads when configured", () => {
|
||||||
|
const payload = runParallelToolCallsPayloadMutationCase({
|
||||||
|
applyProvider: "nvidia-nim",
|
||||||
|
applyModelId: "moonshotai/kimi-k2.5",
|
||||||
|
cfg: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"nvidia-nim/moonshotai/kimi-k2.5": {
|
||||||
|
params: {
|
||||||
|
parallel_tool_calls: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "nvidia-nim",
|
||||||
|
id: "moonshotai/kimi-k2.5",
|
||||||
|
} as Model<"openai-completions">,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.parallel_tool_calls).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects parallel_tool_calls for openai-responses payloads when configured", () => {
|
||||||
|
const payload = runParallelToolCallsPayloadMutationCase({
|
||||||
|
applyProvider: "openai",
|
||||||
|
applyModelId: "gpt-5",
|
||||||
|
cfg: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"openai/gpt-5": {
|
||||||
|
params: {
|
||||||
|
parallelToolCalls: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
api: "openai-responses",
|
||||||
|
provider: "openai",
|
||||||
|
id: "gpt-5",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
} as unknown as Model<"openai-responses">,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.parallel_tool_calls).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject parallel_tool_calls for unsupported APIs", () => {
|
||||||
|
const payload = runParallelToolCallsPayloadMutationCase({
|
||||||
|
applyProvider: "anthropic",
|
||||||
|
applyModelId: "claude-sonnet-4-6",
|
||||||
|
cfg: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-sonnet-4-6": {
|
||||||
|
params: {
|
||||||
|
parallel_tool_calls: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
api: "anthropic-messages",
|
||||||
|
provider: "anthropic",
|
||||||
|
id: "claude-sonnet-4-6",
|
||||||
|
} as Model<"anthropic-messages">,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).not.toHaveProperty("parallel_tool_calls");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets runtime override win across alias styles for parallel_tool_calls", () => {
|
||||||
|
const payload = runParallelToolCallsPayloadMutationCase({
|
||||||
|
applyProvider: "nvidia-nim",
|
||||||
|
applyModelId: "moonshotai/kimi-k2.5",
|
||||||
|
cfg: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"nvidia-nim/moonshotai/kimi-k2.5": {
|
||||||
|
params: {
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraParamsOverride: {
|
||||||
|
parallelToolCalls: false,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "nvidia-nim",
|
||||||
|
id: "moonshotai/kimi-k2.5",
|
||||||
|
} as Model<"openai-completions">,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.parallel_tool_calls).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets null runtime override suppress inherited parallel_tool_calls injection", () => {
|
||||||
|
const payload = runParallelToolCallsPayloadMutationCase({
|
||||||
|
applyProvider: "nvidia-nim",
|
||||||
|
applyModelId: "moonshotai/kimi-k2.5",
|
||||||
|
cfg: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"nvidia-nim/moonshotai/kimi-k2.5": {
|
||||||
|
params: {
|
||||||
|
parallel_tool_calls: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraParamsOverride: {
|
||||||
|
parallelToolCalls: null,
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "nvidia-nim",
|
||||||
|
id: "moonshotai/kimi-k2.5",
|
||||||
|
} as Model<"openai-completions">,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).not.toHaveProperty("parallel_tool_calls");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns and skips invalid parallel_tool_calls values", () => {
|
||||||
|
const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined);
|
||||||
|
try {
|
||||||
|
const payload = runParallelToolCallsPayloadMutationCase({
|
||||||
|
applyProvider: "nvidia-nim",
|
||||||
|
applyModelId: "moonshotai/kimi-k2.5",
|
||||||
|
cfg: {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"nvidia-nim/moonshotai/kimi-k2.5": {
|
||||||
|
params: {
|
||||||
|
parallelToolCalls: "false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "nvidia-nim",
|
||||||
|
id: "moonshotai/kimi-k2.5",
|
||||||
|
} as Model<"openai-completions">,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload).not.toHaveProperty("parallel_tool_calls");
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith("ignoring invalid parallel_tool_calls param: false");
|
||||||
|
} finally {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("normalizes thinking=off to null for SiliconFlow Pro models", () => {
|
it("normalizes thinking=off to null for SiliconFlow Pro models", () => {
|
||||||
const payloads: Record<string, unknown>[] = [];
|
const payloads: Record<string, unknown>[] = [];
|
||||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||||
|
|||||||
@@ -49,7 +49,18 @@ export function resolveExtraParams(params: {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.assign({}, globalParams, agentParams);
|
const merged = Object.assign({}, globalParams, agentParams);
|
||||||
|
const resolvedParallelToolCalls = resolveAliasedParamValue(
|
||||||
|
[globalParams, agentParams],
|
||||||
|
"parallel_tool_calls",
|
||||||
|
"parallelToolCalls",
|
||||||
|
);
|
||||||
|
if (resolvedParallelToolCalls !== undefined) {
|
||||||
|
merged.parallel_tool_calls = resolvedParallelToolCalls;
|
||||||
|
delete merged.parallelToolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CacheRetention = "none" | "short" | "long";
|
type CacheRetention = "none" | "short" | "long";
|
||||||
@@ -1108,6 +1119,53 @@ function createZaiToolStreamWrapper(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAliasedParamValue(
|
||||||
|
sources: Array<Record<string, unknown> | undefined>,
|
||||||
|
snakeCaseKey: string,
|
||||||
|
camelCaseKey: string,
|
||||||
|
): unknown {
|
||||||
|
let resolved: unknown = undefined;
|
||||||
|
let seen = false;
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hasSnakeCaseKey = Object.hasOwn(source, snakeCaseKey);
|
||||||
|
const hasCamelCaseKey = Object.hasOwn(source, camelCaseKey);
|
||||||
|
if (!hasSnakeCaseKey && !hasCamelCaseKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved = hasSnakeCaseKey ? source[snakeCaseKey] : source[camelCaseKey];
|
||||||
|
seen = true;
|
||||||
|
}
|
||||||
|
return seen ? resolved : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParallelToolCallsWrapper(
|
||||||
|
baseStreamFn: StreamFn | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
): StreamFn {
|
||||||
|
const underlying = baseStreamFn ?? streamSimple;
|
||||||
|
return (model, context, options) => {
|
||||||
|
if (model.api !== "openai-completions" && model.api !== "openai-responses") {
|
||||||
|
return underlying(model, context, options);
|
||||||
|
}
|
||||||
|
log.debug(
|
||||||
|
`applying parallel_tool_calls=${enabled} for ${model.provider ?? "unknown"}/${model.id ?? "unknown"} api=${model.api}`,
|
||||||
|
);
|
||||||
|
const originalOnPayload = options?.onPayload;
|
||||||
|
return underlying(model, context, {
|
||||||
|
...options,
|
||||||
|
onPayload: (payload) => {
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
(payload as Record<string, unknown>).parallel_tool_calls = enabled;
|
||||||
|
}
|
||||||
|
originalOnPayload?.(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply extra params (like temperature) to an agent's streamFn.
|
* Apply extra params (like temperature) to an agent's streamFn.
|
||||||
* Also adds OpenRouter app attribution headers when using the OpenRouter provider.
|
* Also adds OpenRouter app attribution headers when using the OpenRouter provider.
|
||||||
@@ -1123,7 +1181,7 @@ export function applyExtraParamsToAgent(
|
|||||||
thinkingLevel?: ThinkLevel,
|
thinkingLevel?: ThinkLevel,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
): void {
|
): void {
|
||||||
const extraParams = resolveExtraParams({
|
const resolvedExtraParams = resolveExtraParams({
|
||||||
cfg,
|
cfg,
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
@@ -1142,7 +1200,7 @@ export function applyExtraParamsToAgent(
|
|||||||
Object.entries(extraParamsOverride).filter(([, value]) => value !== undefined),
|
Object.entries(extraParamsOverride).filter(([, value]) => value !== undefined),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const merged = Object.assign({}, extraParams, override);
|
const merged = Object.assign({}, resolvedExtraParams, override);
|
||||||
const wrappedStreamFn = createStreamFnWithExtraParams(agent.streamFn, merged, provider);
|
const wrappedStreamFn = createStreamFnWithExtraParams(agent.streamFn, merged, provider);
|
||||||
|
|
||||||
if (wrappedStreamFn) {
|
if (wrappedStreamFn) {
|
||||||
@@ -1238,4 +1296,23 @@ export function applyExtraParamsToAgent(
|
|||||||
// Force `store=true` for direct OpenAI Responses models and auto-enable
|
// Force `store=true` for direct OpenAI Responses models and auto-enable
|
||||||
// server-side compaction for compatible OpenAI Responses payloads.
|
// server-side compaction for compatible OpenAI Responses payloads.
|
||||||
agent.streamFn = createOpenAIResponsesContextManagementWrapper(agent.streamFn, merged);
|
agent.streamFn = createOpenAIResponsesContextManagementWrapper(agent.streamFn, merged);
|
||||||
|
|
||||||
|
const rawParallelToolCalls = resolveAliasedParamValue(
|
||||||
|
[resolvedExtraParams, override],
|
||||||
|
"parallel_tool_calls",
|
||||||
|
"parallelToolCalls",
|
||||||
|
);
|
||||||
|
if (rawParallelToolCalls !== undefined) {
|
||||||
|
if (typeof rawParallelToolCalls === "boolean") {
|
||||||
|
agent.streamFn = createParallelToolCallsWrapper(agent.streamFn, rawParallelToolCalls);
|
||||||
|
} else if (rawParallelToolCalls === null) {
|
||||||
|
log.debug("parallel_tool_calls suppressed by null override, skipping injection");
|
||||||
|
} else {
|
||||||
|
const summary =
|
||||||
|
typeof rawParallelToolCalls === "string"
|
||||||
|
? rawParallelToolCalls
|
||||||
|
: typeof rawParallelToolCalls;
|
||||||
|
log.warn(`ignoring invalid parallel_tool_calls param: ${summary}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user