agentscope-ai / agentscope-ai/agentscope-java
[Bug]: ToolCallsAccumulator silently swallows JSON parse failures for streamed tool call arguments and the raw string is lost
- Dominant language
- Java
- Stars
- 5.6k
- Forks
- 1.3k
- Avg merge
- 4d 12h
- Merged PRs (30d)
- 77
Description
## Describe the bug
`ToolCallsAccumulator$ToolCallBuilder.build()` parses the fully accumulated tool-call
arguments with `JsonUtils.getJsonCodec().fromJson(rawContentStr, Map.class)` inside a
`try/catch (Exception)` that does nothing on failure — no log, no event, no counter.
When the accumulated arguments are not valid JSON, the parsed input map stays empty and
`content` falls back to `"{}"`, so the string the model actually emitted is gone from the
entire system.
We hit this with Chinese LLMs (Qwen via OpenAI-compatible streaming), which fairly often
emit unescaped ASCII double quotes inside string values — a *complete* argument JSON that
is broken. App-side symptom: the tool is invoked with an empty parameter map and fails with
"missing required parameter". That looks like a model/tool-schema mismatch, and nothing in
the framework logs, events or traces hints at the real cause. We could only find the root
cause by decompiling the jar — the framework gives zero signal.
Verified at three points in time:
- agentscope-core 2.0.1: the bytecode catch handler is empty (it only stores the exception)
- agentscope-core 2.0.2 (latest release): bytecode identical to 2.0.1
- main branch (checked 2026-08-25): still `catch (Exception ignored) { // Parsing failed, keep previously merged args }` (ToolCallsAccumulator.java:116-118)
## To Reproduce
A streamed tool call whose accumulated arguments string is:
```json
{"projectName": "改造"文化礼堂"及配套设施"}
```
Minimal reproduction via the public API (same shape as `ToolCallsAccumulatorTest`):
```java
ToolCallsAccumulator accumulator = new ToolCallsAccumulator();
accumulator.add(ToolUseBlock.builder()
.id("call_1")
.name("write_text_file")
.content("{\"projectName\": \"改造\"文化礼堂\"及配套设施\"}")
.build());
ToolUseBlock toolCall = accumulator.buildAllToolCalls().get(0);
// toolCall.getInput() -> {} (empty)
// toolCall.getContent() -> "{}"
```
What `build()` does (ToolCallsAccumulator.java:100-131 on main):
1. `fromJson(rawContent, Map.class)` throws (unescaped quotes) — caught and completely ignored.
2. `rawContent` is non-empty, but `JsonUtils.isValidJsonObject(rawContent)` is false, so `content` becomes `"{}"`.
3. Result: `ToolUseBlock` with `input = {}` and `content = "{}"`. The raw string is gone and this code path produces no log line at all.
The tool then fails with its own missing-parameter error, which is a misleading dead end.
## Expected behavior
The `{}` fallback itself is reasonable — the comment at lines 121-123 explains it exists
to prevent persisting malformed JSON when streaming is interrupted. The problem is that
the failure is completely unobservable:
1. Minimum: log a `warn` in the catch handler, including the raw string and the underlying
exception. A `catch (Exception ignored)` that silently drops a parse error makes this
whole class of failures effectively undiagnosable.
2. Discussion: expose the raw arguments string somewhere reachable for diagnostics without
polluting the persisted `content` — e.g. a dedicated field on `ToolUseBlock`, or via its
existing metadata, so applications can retry, repair, or at least show the user what the
model actually emitted.
## Environment
- agentscope-core 2.0.1 (also verified in 2.0.2 and on main as of 2026-08-25)
- JDK 21, Qwen via OpenAI-compatible endpoint, streaming tool calls
## Additional context
Malformed tool-call arguments are a recurring pain point here, but nobody has raised the
diagnosability angle:
- #847 — feature request for built-in multi-stage repair of malformed arguments (its
examples even include curly quotes). We deliberately do *not* ask for that here: we
repair app-side via the existing `JsonUtils.setJsonCodec()` extension point. The
blocker is that the default path gives no observable signal at all.
- #2648 — describes the same `{}` fallback for arguments truncated by `finish_reason=length`;
it likewise treats the fallback as existing behavior and asks for truncation detection.
- #1309 — stream=true malformed arguments (`unexpected end-of-input in property name`);
different root cause (incomplete chunks), same silent-failure symptom.
- #2451 (merged) — made `build()` always parse the accumulated raw JSON and repair null
input values, but only when the raw string is valid JSON; the invalid-JSON path is
still silent.
Happy to submit a PR if you agree on the direction (warn log in the catch handler plus an
observable channel for the raw string).
Contributor guide
Assessment
This issue has not been assessed yet.