Skip to content

fix(gateway): coerce streaming tool-call argument deltas to object in client tools#57061

Merged
obviyus merged 1 commit into
openclaw:mainfrom
openperf:fix/issue-57009-streaming-tool-params
Mar 30, 2026
Merged

fix(gateway): coerce streaming tool-call argument deltas to object in client tools#57061
obviyus merged 1 commit into
openclaw:mainfrom
openperf:fix/issue-57009-streaming-tool-params

Conversation

@openperf
Copy link
Copy Markdown
Member

Summary

  • Problem: When using models that stream tool-call arguments as incremental string deltas (e.g., Gemini), client tools (hosted tools) receive empty parameters {} instead of the actual arguments. This happens in src/agents/pi-tool-definition-adapter.ts around line 201.
  • Root Cause: The toClientToolDefinitions function intercepts tool execution to delegate it to the client. It uses isPlainObject(adjustedParams) to ensure the parameters are a valid object before passing them to the onClientToolCall callback. However, when arguments are streamed, adjustedParams can be an accumulated JSON string rather than a parsed object. Since isPlainObject() strictly returns false for strings, the valid JSON string is silently coerced into an empty object {}.
  • Fix: Introduced a coerceParamsRecord helper function that first checks isPlainObject. If the value is a string, it attempts to parse it via JSON.parse(). If the parsed result is a plain object, it returns that object; otherwise, it safely falls back to {}. This ensures that stringified JSON arguments from streaming providers are correctly deserialized before being passed to the client tool handler, without breaking existing object-based flows. Added comprehensive unit tests to verify the coercion logic.
  • What changed:
    • src/agents/pi-tool-definition-adapter.ts: Added coerceParamsRecord helper function.
    • src/agents/pi-tool-definition-adapter.ts: Replaced isPlainObject(adjustedParams) ? adjustedParams : {} with coerceParamsRecord(adjustedParams) in toClientToolDefinitions.
    • src/agents/pi-tool-definition-adapter.test.ts: Added a new test suite toClientToolDefinitions – param coercion to cover all edge cases of stringified parameter parsing.
  • What did NOT change (scope boundary): The execution path for standard (non-client) tools in toToolDefinitions remains untouched, as it passes raw parameters directly to the underlying tool execution which handles its own parsing. The core isPlainObject utility behavior is also unchanged to prevent global side effects.

Reproduction

  1. Configure a provider that streams tool-call arguments (e.g., Gemini 2.5 Flash).
  2. Trigger a client-hosted tool (e.g., a custom OpenResponses tool) that requires parameters.
  3. Observe that the tool is invoked on the client side, but the parameters object is empty {}.

Risk / Mitigation

  • Risk: The JSON.parse attempt might throw an error if the accumulated string is malformed or incomplete, potentially crashing the tool execution flow.
  • Mitigation: The JSON.parse call is wrapped in a try/catch block. If parsing fails, it safely falls through to returning an empty object {}, which exactly matches the previous fallback behavior. The newly added unit tests verify this safe fallback behavior across various invalid inputs (empty string, malformed JSON, arrays, primitives).

Change Type (select all)

  • Bug fix

Scope (select all touched areas)

  • Gateway / orchestration
  • App: web-ui

Linked Issue/PR

Fixes #57009

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: S labels Mar 29, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 29, 2026

Greptile Summary

This PR fixes a bug where client (hosted) tools receive empty parameters {} when using providers (e.g. Gemini) that stream tool-call arguments as JSON string deltas. A small coerceParamsRecord helper is introduced in pi-tool-definition-adapter.ts that first checks for a plain object (fast path), then attempts JSON.parse on string values (guarded by try/catch), and falls back to {} only when parsing genuinely fails. The change is correctly scoped to toClientToolDefinitions and leaves toToolDefinitions untouched. A comprehensive suite of nine unit tests covers every edge case (plain objects, valid JSON strings, whitespace-padded strings, invalid JSON, empty strings, null, undefined, JSON arrays, and nested objects).

  • coerceParamsRecord is file-private and well-documented — good encapsulation.
  • The isPlainObject fast path preserves existing behaviour for already-parsed objects.
  • Empty-string guard (trimmed.length > 0) prevents a JSON.parse("") error before even entering the try/catch.
  • All non-object JSON parse results (arrays, numbers, booleans, strings) are caught by the isPlainObject(parsed) check and fall back to {}.
  • No issues found — the implementation is correct and the test coverage is thorough.

Confidence Score: 5/5

Safe to merge — targeted bug fix with no regressions on the existing code path and comprehensive unit tests.

The change is minimal, well-tested, and correctly scoped. The coerceParamsRecord helper handles all edge cases (try/catch around JSON.parse, isPlainObject guard on the parsed result, empty-string short-circuit). No existing paths are affected. All findings are P2 or better — no blocking issues found.

No files require special attention.

Important Files Changed

Filename Overview
src/agents/pi-tool-definition-adapter.ts Adds coerceParamsRecord helper and replaces the inline isPlainObject ternary with it — correct and well-documented fix.
src/agents/pi-tool-definition-adapter.test.ts Adds nine-case test suite covering all coercion edge cases for toClientToolDefinitions.

Reviews (1): Last reviewed commit: "fix(gateway): coerce streaming tool-call..." | Re-trigger Greptile

@openperf
Copy link
Copy Markdown
Member Author

I've thoroughly reviewed the implementation and added a comprehensive test suite covering all edge cases for stringified parameter parsing — valid JSON strings, invalid JSON, empty strings, arrays, primitives, null, undefined, and nested objects.

The new coerceParamsRecord helper safely handles accumulated JSON strings from streaming providers like Gemini, while strictly preserving the fast path for already-parsed objects. If parsing fails, it safely falls back to {}, exactly matching the previous behavior, so there is zero regression risk on existing code paths.

Awaiting review, thanks.

@obviyus obviyus force-pushed the fix/issue-57009-streaming-tool-params branch from 2f21f39 to 937d61e Compare March 30, 2026 02:22
Copy link
Copy Markdown
Contributor

@obviyus obviyus left a comment

Choose a reason for hiding this comment

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

Reviewed latest changes; landing now.

@obviyus obviyus merged commit c6ded0f into openclaw:main Mar 30, 2026
18 of 20 checks passed
@obviyus
Copy link
Copy Markdown
Contributor

obviyus commented Mar 30, 2026

Landed on main.

Thanks @openperf.

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]: Streaming tool-call argument deltas coerced to {} in toClientToolDefinitions — client tools receive empty params

2 participants