Agent loops on malformed tool-call JSON; raw JSON blocks render expanded in chat #91
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_shrimp#91
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
After a normal reply (e.g. say "hello"), the agent starts emitting repeated
file_writetool calls and loops, with reasoning like "the system is telling me my file_write call didn't actually execute." The calls also render as fully-expanded rawjsoncode blocks in the chat (see screenshot).Root cause
The model emits the wrong tool-call schema:
but the recovery parser expects:
No
namekey → call is silently dropped intool_call_recovery_module.rs(~L143-146) → no execution → norole: "tool"result appended (result_processing_loop.rsL401-409) → model sees no response, assumes it failed, retries forever (until repetition/iteration guards trip).Because the calls are never recognized as tool calls, they stay in the assistant's text as
```jsonfences and render viaMarkdown.tsxCodeBlock(no collapse) → always expanded. The UI is correct; the upstream misclassification is the bug.Fix
orchestration/autonomy/prompt_builder.rs): steer the model to the{name, arguments}schema with explicit examples.agent_core/agent/tool_call_recovery.rs): tolerate the{"tool":..., <flat args>}dialect (maptool->name, collect remaining keys intoarguments).Implementation Spec for Issue #91
Objective
Stop the agent from looping when a model emits the malformed tool-call dialect
[{"tool":"file_write","path":"...","content":"..."}]. Achieve this by (1) teaching the recovery parser to recognize the{"tool": ..., <flat args>}dialect so the call is actually executed and arole:"tool"result is appended, and (2) steering the model toward the canonical{name, arguments}schema in the executor prompt with explicit examples.Root cause (verified)
recover_tool_calls_from_content->tool_call_from_valueintool_call_recovery.rs. The actual drop happens intool_call_from_value(around L374-411).tool_call_from_valueonly accepts two shapes: nested{"function":{"name",...}}and bare{"name":..., "arguments":...}. Both require anamekey. The{"tool":..., <flat args>}shape has neither, so it is silently dropped.ToolCallpromoted -> nothing executed -> norole:"tool"message pushed (result_processing_loop.rsL401-409). Model sees no tool result, assumes failure, retries forever.prompt_builder.rs::build_lean_executor_prompt) references tool names but never states the JSON wire schema, so a model not using native tool-calling guesses the shape.Requirements
{"tool": "<name>", ...}object to aToolCallwithfunction.name = <name>andfunction.arguments= JSON of the remaining keys.tool,name,function,arguments,id,tool_call_id,type) must NOT be folded into the synthesizedarguments.{name,arguments},{function:{...}}, arrays, code-fenced, repaired JSON) must keep working unchanged.toolvalue must be rejected.{"name": "...", "arguments": {...}}schema and warn against the flat{"tool":...}form.Files to Modify
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rs- Add a third branch totool_call_from_valuehandling the flat-args dialect; update the module doc comment; add unit tests.crates/hero_shrimp_engine/src/orchestration/autonomy/prompt_builder.rs- Add a tool-call schema directive to the executor prompt inbuild_lean_executor_prompt.No new files. No changes needed to
result_processing_loop.rsortool_call_recovery_module.rs- they work correctly once the parser recognizes the call.Implementation Plan
Step 1: Parser - accept the flat
{"tool":..., <flat args>}dialectFiles:
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rs{name, arguments}branch conditional onobj.contains_key("name")so a missingnamefalls through instead of short-circuiting the whole function.toolstring (reusenonempty_name); clone the object map and remove reserved keys (tool,name,function,arguments,id,tool_call_id,type); serialize the remainder asarguments; derive the callidfromobj.get("id")orrecovered_id(fallback_idx); return theToolCall.Dependencies: none
Step 2: Parser - update module doc + add tests
Files:
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rsnamewins overtool.Dependencies: Step 1
Step 3: Prompt - state the canonical tool-call schema
Files:
crates/hero_shrimp_engine/src/orchestration/autonomy/prompt_builder.rsbuild_lean_executor_prompt, add a short directive to the shared trailing template so all modes get it once: the only accepted JSON shape is{"name": "<tool_name>", "arguments": { ... }}(or an array of these); include a concretefile_writeexample; warn against emitting{"tool":...}flat fields or wrapping tool calls in json fences.Dependencies: none (independent of Steps 1-2)
Step 4: Build & test
cargo build -p hero_shrimp_enginecargo test -p hero_shrimp_engine tool_call_recoverycargo test -p hero_shrimp_engine prompt_builderAcceptance Criteria
tool_call_from_valuereturns aToolCallfor{"tool":"file_write","path":"a","content":"b"}with namefile_writeand arguments containingpath/contentbut nottool.idvalue, if present, becomes the call id.toolvalue yields no call.nameandtoolare present, the canonicalnameshape is used.tool_call_recovery.rsstill pass.cargo buildandcargo test -p hero_shrimp_enginepass.Notes
toolvalue is harmless.Test Results
cargo test -p hero_shrimp_engineAll tests pass, including the 5 new tool-call recovery tests for the flat
{tool, ...}dialect (recovers_flat_dialect_in_array, recovers_flat_dialect_bare_object, flat_dialect_excludes_reserved_keys, flat_dialect_empty_tool_is_dropped, canonical_name_wins_over_flat_tool) and the existing prompt_builder tests.Implementation Summary
Changes
crates/hero_shrimp_engine/src/agent_core/agent/tool_call_recovery.rstool_call_from_value: the bare{name, arguments}branch is now conditional on the presence of anamekey (falls through instead of short-circuiting when absent).{"tool": "<name>", <flat args>}: mapstool-> name, collects the remaining keys intoarguments, and excludes reserved keys (tool,name,function,arguments,id,tool_call_id,type). The callidis taken from anidfield if present, otherwise generated. Canonical{name, ...}and nested{function:{...}}shapes still take priority.recovers_flat_dialect_in_array,recovers_flat_dialect_bare_object,flat_dialect_excludes_reserved_keys,flat_dialect_empty_tool_is_dropped,canonical_name_wins_over_flat_tool.crates/hero_shrimp_engine/src/orchestration/autonomy/prompt_builder.rsbuild_lean_executor_prompt: added a single TOOL CALLS directive to the shared trailing prompt template (applies to all modes once). It states the canonical accepted shape{"name": "<tool_name>", "arguments": { ... }}with afile_writeexample, and warns against the flat{"tool": ...}form and against wrapping tool calls in json code fences.Effect
A model that emits
[{"tool":"file_write","path":"...","content":"..."}]is now parsed into an executable tool call, so it runs once and arole:"tool"result is appended — breaking the retry loop. The prompt change reduces the chance the malformed dialect is produced in the first place. Because the calls are now classified as tool calls rather than left as assistant text, they no longer render as expanded raw json blocks in chat.Tests
cargo test -p hero_shrimp_engine(rustc 1.96): 1746 passed, 0 failed.Notes
result_processing_loop.rsandtool_call_recovery_module.rsrequired no changes — they work correctly once the parser recognizes the call.rust-version); usecargo +1.96.Model generation issue, not shrimp issue.