Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1764,7 +1764,7 @@ static Prompt normalizeToolCallArguments(Prompt prompt) {
List<AssistantMessage.ToolCall> calls = am.getToolCalls();
for (int j = 0; j < calls.size(); j++) {
AssistantMessage.ToolCall tc = calls.get(j);
String safe = sanitizeToolCallArguments(tc.name(), tc.arguments());
String safe = sanitizeToolCallArguments(tc.name(), tc.arguments(), false);
if (!safe.equals(tc.arguments())) {
if (fixedCalls == null) fixedCalls = new ArrayList<>(calls);
fixedCalls.set(j, new AssistantMessage.ToolCall(tc.id(), tc.type(), tc.name(), safe));
Expand Down Expand Up @@ -2403,13 +2403,14 @@ private List<AssistantMessage.ToolCall> buildFinalToolCalls(List<ToolCallAccumul
acc.id,
acc.type != null ? acc.type : "function",
acc.name,
sanitizeToolCallArguments(acc.name, acc.arguments.toString())));
sanitizeToolCallArguments(acc.name, acc.arguments.toString(), true)));
}
return result;
}

/**
* Ensure {@code function.arguments} is always a well-formed JSON string.
* Ensure {@code function.arguments} is well-formed JSON before it is replayed
* to a provider in a follow-up request.
* <p>
* Some providers (e.g. aliyun-codingplan) reject the entire follow-up
* request with HTTP 400 when the assistant message in history carries a
Expand All @@ -2421,23 +2422,53 @@ private List<AssistantMessage.ToolCall> buildFinalToolCalls(List<ToolCallAccumul
* <li>The upstream stream is truncated mid-token, leaving a partial
* JSON fragment like {@code "{\"a\":"}.</li>
* </ul>
* Both cases are normalized to {@code "{}"} so the chat-completions
* round-trip stays valid. Tool execution downstream still re-validates
* arguments and surfaces a per-tool error if the empty payload is wrong
* for that tool.
*
* <p>The two call sites pass different {@code aggregationPoint} values:
* <ul>
* <li><b>Outbound chokepoint</b> ({@code aggregationPoint=false},
* {@link #normalizeToolCallArguments}): every assistant tool call
* replayed from history is harmonized to an empty JSON object when
* missing/invalid, so the chat-completions round-trip stays valid.</li>
* <li><b>Stream aggregation</b> ({@code aggregationPoint=true},
* {@link #buildFinalToolCalls}): the current turn's freshly
* accumulated calls. Blank arguments still become an empty JSON object
* (but now logged — a blank payload was previously invisible), while a
* truncated/invalid fragment is passed through <em>verbatim</em> so the
* tool executor's JSON pre-validation detects the truncation and returns
* the model a "shorten and retry" guidance instead of a generic per-tool
* "missing argument" error. History stays protected because the outbound
* chokepoint re-normalizes the persisted fragment on the next send.</li>
* </ul>
*/
private static String sanitizeToolCallArguments(String toolName, String arguments) {
private static String sanitizeToolCallArguments(String toolName, String arguments, boolean aggregationPoint) {
if (arguments == null || arguments.isBlank()) {
if (aggregationPoint) {
log.warn("Tool '{}' tool-call arguments are null/blank right after stream "
+ "aggregation; normalizing to an empty JSON object. Either the "
+ "model emitted an empty argument payload or every argument chunk "
+ "was lost before aggregation.",
toolName);
}
return "{}";
}
try {
TOOL_ARG_JSON_MAPPER.readTree(arguments);
return arguments;
} catch (Exception e) {
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
+ "(len={}, head={}); replacing with empty object so the "
+ "follow-up chat-completions request stays well-formed. "
+ "Parse error: {}",
if (aggregationPoint) {
log.warn("Tool '{}' arguments are not valid JSON after stream aggregation "
+ "(len={}, head={}); passing the raw fragment through so the tool "
+ "executor's truncation detection can tell the model to shorten and "
+ "retry. Parse error: {}",
toolName,
arguments.length(),
arguments.substring(0, Math.min(80, arguments.length())),
e.getMessage());
return arguments;
}
log.warn("Tool '{}' arguments are not valid JSON (len={}, head={}); replacing with "
+ "empty object so the follow-up chat-completions request stays "
+ "well-formed. Parse error: {}",
toolName,
arguments.length(),
arguments.substring(0, Math.min(80, arguments.length())),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,15 @@ public ToolExecutionResult execute(List<AssistantMessage.ToolCall> toolCalls,
} catch (Exception jsonEx) {
log.warn("[ToolExecutor] Tool {} arguments invalid/truncated JSON (len={}): {}",
toolName, arguments.length(), jsonEx.getMessage());
String truncationError = normalizeToolExecutionError(jsonEx);
// This guard wraps only readTree(arguments), so ANY exception reaching
// here is a JSON parse failure: the arguments were truncated mid-token
// (Jackson: "Unexpected end-of-input") or otherwise malformed (e.g.
// {"a":} → "Unexpected character"). Return the truncation guidance
// unconditionally instead of pattern-matching the parser message — the
// message phrasing varies with the malformation kind, but the remedy is
// always the same (re-emit the call). normalizeToolExecutionError still
// pattern-matches for genuine tool-execution errors at the other sites.
String truncationError = TRUNCATED_ARGS_GUIDANCE;
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, truncationError, false));
allResponses.add(new ToolResponseMessage.ToolResponse(
toolCall.id(), toolName, truncationError));
Expand Down Expand Up @@ -1174,7 +1182,27 @@ private String classifyBatchPhase(List<PreparedToolCall> preparedCalls) {
return memoryOnly ? "reading_memory" : "executing_tool";
}

private String normalizeToolExecutionError(Exception e) {
/**
* Guidance returned to the model when its tool_call arguments JSON was
* truncated or malformed mid-stream. The remedy always comes from the model
* (re-emit a shorter / split call), so the message tells it exactly that —
* without it, models tend to fall back to narrating the result as
* final_answer text. Shared by the JSON pre-validation gate in
* {@link #execute} (which returns this unconditionally — that gate only
* fires for parse failures) and by {@link #normalizeToolExecutionError}
* (which pattern-matches genuine tool-execution errors, which have many
* other causes).
*/
static final String TRUNCATED_ARGS_GUIDANCE =
"Tool execution failed: your tool_call arguments JSON was truncated mid-stream "
+ "(very likely you hit max_tokens while emitting a long content field). "
+ "Action required: re-call the SAME tool now in your next response, but "
+ "(1) make the content field shorter, OR (2) split the work into multiple "
+ "sequential tool calls (e.g. write the doc in 2-3 chunks via separate calls). "
+ "Do NOT describe the result as text — you must call the tool again to actually "
+ "produce the output.";

static String normalizeToolExecutionError(Exception e) {
String message = e != null && e.getMessage() != null ? e.getMessage() : "Unknown error";
String lower = message.toLowerCase(Locale.ROOT);

Expand All @@ -1187,16 +1215,8 @@ private String normalizeToolExecutionError(Exception e) {
// streaming a large `content` field (e.g. renderDocx with 7000+ char
// markdown body). The fix MUST come from the model: re-emit the same
// tool call with smaller content per call, OR split the work across
// multiple sequential tool calls. We tell the LLM directly so the
// next reasoning iteration knows what to do — without this, models
// tend to fall back to narrating the result as final_answer text.
return "Tool execution failed: your tool_call arguments JSON was truncated mid-stream "
+ "(very likely you hit max_tokens while emitting a long content field). "
+ "Action required: re-call the SAME tool now in your next response, but "
+ "(1) make the content field shorter, OR (2) split the work into multiple "
+ "sequential tool calls (e.g. write the doc in 2-3 chunks via separate calls). "
+ "Do NOT describe the result as text — you must call the tool again to actually "
+ "produce the output.";
// multiple sequential tool calls.
return TRUNCATED_ARGS_GUIDANCE;
}

if (lower.contains("access denied") && lower.contains("path outside allowed directories")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,14 @@ public class CodeExecuteTool {
Use this to act on a skill whose SKILL.md describes steps but ships no runnable script:
read the instructions, write the code they describe, and run it here.

IMPORTANT: You MUST pass the complete source code in the "code" parameter.
Never call this tool with "code" missing or empty — it fails with
"No code supplied". Do not describe the code in natural language; write the
actual code and put its full text in "code".

Parameters:
- language: one of "python", "bash", "node"
- code: the full source code to run
- code: REQUIRED — the full source code to run (never omit or leave empty)
- skillName: optional. When set, the code runs inside that skill's directory
(so it can read the skill's reference/template files by relative path)
and the skill's stored secrets are injected as environment variables.
Expand All @@ -95,7 +100,7 @@ public String execute_code(
String language,

@JsonProperty(required = true)
@JsonPropertyDescription("The full source code to run")
@JsonPropertyDescription("REQUIRED. The full source code to run — never omit or leave empty.")
String code,

@JsonProperty(required = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ void emptyArguments_replacedWithEmptyJsonObject() {
}

@Test
@DisplayName("Truncated/invalid JSON arguments normalized to '{}'")
void truncatedJsonArguments_replacedWithEmptyJsonObject() {
@DisplayName("Truncated/invalid JSON arguments pass through raw at aggregation")
void truncatedJsonArguments_passedThroughAtAggregation() {
// Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped.
// At the aggregation point the fragment is passed through verbatim (not
// replaced with '{}') so the tool executor's JSON pre-validation can detect
// the truncation and return the model a "shorten and retry" guidance.
String truncated = "{\"q\":\"hel";
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"id-truncated", "function", "search", "{\"q\":\"hel");
"id-truncated", "function", "search", truncated);
AssistantMessage msg = AssistantMessage.builder()
.content("")
.toolCalls(List.of(tc))
Expand All @@ -94,9 +98,10 @@ void truncatedJsonArguments_replacedWithEmptyJsonObject() {

assertTrue(result.hasToolCalls(), "tool call must survive");
assertEquals(1, result.toolCalls().size());
assertEquals("{}", result.toolCalls().get(0).arguments(),
"invalid JSON arguments must be replaced with '{}' so the follow-up "
+ "request stays well-formed");
assertEquals(truncated, result.toolCalls().get(0).arguments(),
"a truncated fragment must reach the executor raw so its truncation "
+ "detection fires; the outbound chokepoint still normalizes it "
+ "to '{}' before the next provider request");
}

@Test
Expand Down Expand Up @@ -147,6 +152,31 @@ void promptHistory_emptyArguments_normalized() {
"tool call id must be preserved so the tool_call pairing holds");
}

@Test
@DisplayName("Prompt-history tool call with truncated arguments normalized before send")
void promptHistory_truncatedArguments_normalized() {
// A truncated fragment that survived aggregation (passed through raw so the
// executor could detect it) is persisted into history. On the next send the
// outbound chokepoint must still normalize it to '{}' so strict providers
// (aliyun-codingplan, ...) accept the follow-up request.
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"id-hist-trunc", "function", "search", "{\"q\":\"hel");
AssistantMessage historyMsg = AssistantMessage.builder()
.content("calling tool")
.toolCalls(List.of(tc))
.build();
Prompt prompt = new Prompt(List.of(new UserMessage("hi"), historyMsg));

Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(prompt);

AssistantMessage out = (AssistantMessage) normalized.getInstructions().get(1);
assertEquals(1, out.getToolCalls().size());
assertEquals("{}", out.getToolCalls().get(0).arguments(),
"replayed truncated arguments must be normalized to '{}' at the chokepoint");
assertEquals("id-hist-trunc", out.getToolCalls().get(0).id(),
"tool call id must be preserved so the tool_call pairing holds");
}

@Test
@DisplayName("Prompt with only valid tool-call arguments returned unchanged")
void promptHistory_validArguments_returnsSameInstance() {
Expand Down
Loading