Skip to content

fix(agents): support parallel_tool_calls config per model#37201

Closed
Sid-Qin wants to merge 1 commit intoopenclaw:mainfrom
Sid-Qin:fix/parallel-tool-calls-config-37048
Closed

fix(agents): support parallel_tool_calls config per model#37201
Sid-Qin wants to merge 1 commit intoopenclaw:mainfrom
Sid-Qin:fix/parallel-tool-calls-config-37048

Conversation

@Sid-Qin
Copy link
Copy Markdown
Contributor

@Sid-Qin Sid-Qin commented Mar 6, 2026

Summary

  • Problem: OpenClaw unconditionally sends parallel_tool_calls: true in LLM requests to all OpenAI-compatible providers. Models that don't support parallel tool calling (e.g. moonshotai/kimi-k2.5 on NVIDIA NIM) return a 400 error, breaking ALL tool execution for the agent.
  • Why it matters: Users on custom providers with models that only support single tool calls cannot use any tools at all. There was no config option to disable parallel_tool_calls.
  • What changed: Added a createParallelToolCallsWrapper that reads parallel_tool_calls from model params and injects it into the LLM request payload via onPayload. Users can now set parallel_tool_calls: false per provider/model in their config.
  • What did NOT change: Default behavior for providers that support parallel tool calls, any other request param handling, or the upstream pi-agent-core library.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • Users can now configure parallel_tool_calls: false per model to disable parallel tool calling for providers that don't support it:
    {
      "agents": {
        "defaults": {
          "models": {
            "nvidia-nim/moonshotai/kimi-k2.5": {
              "params": { "parallel_tool_calls": false }
            }
          }
        }
      }
    }

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No — same endpoint, modified request body field
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: macOS / Linux
  • Runtime: Node.js 22
  • Integration/channel: Any (NVIDIA NIM, custom providers)

Steps

  1. Configure a custom provider with a model that doesn't support parallel tool calls
  2. Set parallel_tool_calls: false in model params
  3. Send a message that triggers tool use

Expected

  • Tools work correctly with single tool calls

Actual

  • Before fix: Provider returns 400, all tools break
  • After fix: parallel_tool_calls: false injected into payload, single tool calls work

Evidence

 src/agents/pi-embedded-runner/extra-params.ts         | 25 +++++++++++++++++++
 src/agents/pi-embedded-runner-extraparams.test.ts | 44 ++++++++++++++++++++++++++++++++
 2 files changed, 69 insertions(+)

Two new tests: "injects parallel_tool_calls=false when configured" and "injects parallel_tool_calls=true when configured" verify the wrapper.

Human Verification (required)

  • Verified scenarios: parallel_tool_calls: false (injected), parallel_tool_calls: true (injected), no param set (no wrapper applied, default behavior preserved)
  • Edge cases checked: Config without agents.defaults.models (no crash), non-boolean values (ignored)
  • What I did not verify: Live NVIDIA NIM endpoint with kimi-k2.5

Compatibility / Migration

  • Backward compatible? Yes — only applies when parallel_tool_calls is explicitly set in model params
  • Config/env changes? No — new optional param
  • Migration needed? No

Failure Recovery (if this breaks)

  • How to disable/revert: Remove parallel_tool_calls from model params, or revert this commit
  • Files/config to restore: src/agents/pi-embedded-runner/extra-params.ts
  • Known bad symptoms: Models that don't support parallel tool calls would break again

Risks and Mitigations

  • Risk: Setting parallel_tool_calls: false for models that support it may reduce performance. Mitigation: The setting is per-model and opt-in only.

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 openclaw#37048

Made-with: Cursor
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 6, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🔵 Low Unscoped parallel_tool_calls request-body injection into all provider payloads may break strict APIs and trigger retry-loop DoS

1. 🔵 Unscoped parallel_tool_calls request-body injection into all provider payloads may break strict APIs and trigger retry-loop DoS

Property Value
Severity Low
CWE CWE-400
Location src/agents/pi-embedded-runner/extra-params.ts:966-982

Description

applyExtraParamsToAgent() installs a stream wrapper that unconditionally mutates the outgoing request payload object by adding a top-level parallel_tool_calls field whenever it is configured in model params.

Key issues:

  • Provider/API not gated: the wrapper runs regardless of model.api/provider (Anthropic, Google, Bedrock, Ollama native /api/chat, OpenAI Responses WebSocket, etc.). Many non-OpenAI APIs use strict request-body schemas and may reject unknown top-level fields with a 4xx.
  • Mutation affects the real upstream request (not just logging): e.g. in openai-ws-stream.ts, options.onPayload(payload) is called before session.manager.send(payload). Since this wrapper mutates payload inside onPayload, the injected field is included in the WebSocket response.create message sent to OpenAI.
  • Availability risk (DoS via retry loops / repeated failures): When a provider rejects the unknown field, the run loop can re-attempt/failover across profiles/models (up to high retry limits). A misconfigured parallel_tool_calls on an incompatible provider/model can therefore cause repeated failing requests, resource consumption, and degraded service.

Vulnerable code (payload injection):

onPayload: (payload) => {
  if (payload && typeof payload === "object") {
    (payload as Record<string, unknown>).parallel_tool_calls = parallelToolCalls;
  }
  originalOnPayload?.(payload);
}

Provider-specific compatibility risks (examples):

  • google-generative-ai: request bodies are structured differently (often nested config objects); unknown top-level keys are likely invalid.
  • anthropic-messages / Bedrock non-OpenAI adapters: generally strict schemas; unknown fields can cause 400s.
  • ollama native /api/chat: request schema is model/messages/stream/tools/options; an extra root key may be rejected by some versions.
  • OpenAI Responses WS (response.create): if parallel_tool_calls is not accepted for this event type/version, this breaks the WS transport entirely.

Recommendation

Gate injection to APIs/providers that are known to support this field, and avoid mutating payloads for strict non-OpenAI request schemas.

Suggested fix (example allowlist by model.api):

const PARALLEL_TOOL_CALLS_APIS = new Set(["openai-completions", "openai-responses"]);

onPayload: (payload) => {
  if (
    PARALLEL_TOOL_CALLS_APIS.has(model.api) &&
    payload &&
    typeof payload === "object"
  ) {
    (payload as Record<string, unknown>).parallel_tool_calls = parallelToolCalls;
  }
  originalOnPayload?.(payload);
}

Additionally:

  • Consider an explicit provider allowlist (e.g., openai, openrouter, other confirmed OpenAI-compatible backends) since some openai-completions adapters are strict.
  • Add tests ensuring this field is not injected for anthropic-messages, google-generative-ai, ollama, etc.
  • If unsupported-field errors occur, ensure they are not retried excessively (fail fast / classify as non-retryable configuration error).

Analyzed PR: #37201 at commit 055753b

Last updated on: 2026-03-06T05:24:26Z

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR adds a createParallelToolCallsWrapper that lets users set parallel_tool_calls: false (or true) per model in their OpenClaw config, fixing a 400 error from providers like NVIDIA NIM that don't support parallel tool calls. The wrapper injects the boolean into the LLM request payload via onPayload, following the same pattern already used by createZaiToolStreamWrapper. The change is strictly opt-in and has no effect on default behavior.

  • Implementation is clean and consistent with existing wrapper patterns in the file.
  • The typeof merged?.parallel_tool_calls === "boolean" guard correctly prevents the wrapper from being applied when the param is absent, preserving backward compatibility.
  • The test suite covers the true and false injection cases, but is missing a test for the "no config → no injection" path — the most important backward-compatibility guarantee. It would be worth adding that test case.

Confidence Score: 4/5

  • This PR is safe to merge; it is a small, opt-in change with no risk to existing behavior.
  • The implementation is correct, follows established patterns, and is gated behind an explicit boolean config check that preserves all current default behavior. The only gap is a missing automated test for the "no config → no injection" case, which is the primary backward-compatibility guarantee. Everything else is well-covered.
  • No files require special attention beyond adding the missing backward-compatibility test in src/agents/pi-embedded-runner-extraparams.test.ts.

Last reviewed commit: 055753b

Comment on lines +1357 to +1398
describe("parallel_tool_calls wrapper", () => {
function capturePayload(provider: string, modelId: string, paramValue: boolean) {
let captured: Record<string, unknown> = {};
const fakeStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(captured);
return undefined as never;
};
const agent = { streamFn: fakeStreamFn };
applyExtraParamsToAgent(
agent,
{
agents: {
defaults: {
models: {
[`${provider}/${modelId}`]: {
params: { parallel_tool_calls: paramValue },
},
},
},
},
},
provider,
modelId,
);
void agent.streamFn(
{ api: "openai-completions", provider, id: modelId } as Model<"openai-completions">,
{} as Context,
{},
);
return captured;
}

it("injects parallel_tool_calls=false when configured", () => {
const payload = capturePayload("nvidia-nim", "moonshotai/kimi-k2.5", false);
expect(payload.parallel_tool_calls).toBe(false);
});

it("injects parallel_tool_calls=true when configured", () => {
const payload = capturePayload("openai", "gpt-4", true);
expect(payload.parallel_tool_calls).toBe(true);
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing "no config" backward-compatibility test

The two tests verify that parallel_tool_calls is injected when it is explicitly configured, but there is no automated test for the most critical case: when the param is absent from the config, the payload should remain unmodified. The PR description mentions this was verified manually, but codifying it prevents future regressions. Consider adding:

it("does not inject parallel_tool_calls when not configured", () => {
  let captured: Record<string, unknown> = {};
  const fakeStreamFn: StreamFn = (_model, _context, options) => {
    options?.onPayload?.(captured);
    return undefined as never;
  };
  const agent = { streamFn: fakeStreamFn };
  applyExtraParamsToAgent(
    agent,
    { agents: { defaults: { models: {} } } },
    "nvidia-nim",
    "moonshotai/kimi-k2.5",
  );
  void agent.streamFn(
    { api: "openai-completions", provider: "nvidia-nim", id: "moonshotai/kimi-k2.5" } as Model<"openai-completions">,
    {} as Context,
    {},
  );
  expect(captured.parallel_tool_calls).toBeUndefined();
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/pi-embedded-runner-extraparams.test.ts
Line: 1357-1398

Comment:
**Missing "no config" backward-compatibility test**

The two tests verify that `parallel_tool_calls` is injected when it is explicitly configured, but there is no automated test for the most critical case: when the param is **absent** from the config, the payload should remain unmodified. The PR description mentions this was verified manually, but codifying it prevents future regressions. Consider adding:

```ts
it("does not inject parallel_tool_calls when not configured", () => {
  let captured: Record<string, unknown> = {};
  const fakeStreamFn: StreamFn = (_model, _context, options) => {
    options?.onPayload?.(captured);
    return undefined as never;
  };
  const agent = { streamFn: fakeStreamFn };
  applyExtraParamsToAgent(
    agent,
    { agents: { defaults: { models: {} } } },
    "nvidia-nim",
    "moonshotai/kimi-k2.5",
  );
  void agent.streamFn(
    { api: "openai-completions", provider: "nvidia-nim", id: "moonshotai/kimi-k2.5" } as Model<"openai-completions">,
    {} as Context,
    {},
  );
  expect(captured.parallel_tool_calls).toBeUndefined();
});
```

How can I resolve this? If you propose a fix, please make it concise.

@cgdusek

This comment was marked as spam.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: [Bug] v2026.3.2 sends parallel_tool_calls to models that don't support it (breaks all tools)

3 participants