fix(agent): preserve multi-round ReAct tool results in message history - #59
Conversation
Tool result messages pointed into a reusable scratch buffer that was overwritten on each iteration, corrupting prior rounds before the next provider chat. Own each result with strdup and free ReAct-owned slots on cleanup. Cast through uintptr_t so const tool_calls free cleanly under -Werror=discarded-qualifiers. Refs: #59
43ab5a4 to
ccff48c
Compare
There was a problem hiding this comment.
Stale comment
Could not approve: GitHub rejects self-approval on this Cursor-authored PR. Cursor Bugbot and Cursor Security Agent completed successfully with no findings that need human review. adriannoes is already assigned; no additional reviewers were assigned.
Sent by Cursor Approval Agent: Pull Request Router and Approver
There was a problem hiding this comment.
Summary
This is a correct, tightly scoped fix for a real ReAct bug. Tool-result messages used to alias tool_result_bufs, so round N overwrote what the model still needed from round 1. The loop now strdups each result, records ReAct-owned slots from base_msg_count, and frees them in agent_run_cleanup. Dropping prev_assistant / prev_calls is not incidental: those frees ran while the pointers were still in the message list (UAF of assistant text, tool_calls, and tool_use_id). Local CI=true make test_agent passed, and a GCC ASan+UBSan rebuild of test_agent passed with leak detection on.
Must Fix
None.
Should Fix
test_react_loop_preserves_prior_tool_resultsonly searchesmessages[i].contentfortool_output_1(tests/test_agent.c608–618). It never readstool_callsortool_use_id. Production Anthropic and OpenAI body builders dereference both on laterchat()calls. Assert those pointers are still live on the thirdchat()so the UAF half cannot regress behind a green content check.
Nice to Have
- The
call_count >= 2content check also runs on the secondchat(), which is before the secondexecuteoverwrites scratch. Tightening it tocall_count >= 3makes the test’s intent obvious. tool_use_idis an alias ofour_calls[k].id(src/core/agent.c551).agent_free_heap_messagesfrees the assistant’stool_callsfirst, then walks tool-result slots with a danglingtool_use_id. Harmless today because teardown does not dereference it. Clearingtool_use_idin that loop, or giving results their own copy of the id, would make teardown order-safe.multi_tool_round_initis never called (agent_rundoes not invokeprovider->init). Reset the counters at the start of the test function if you keep the helper. Same pattern as the older ReAct tests.
Positive Highlights
- Ownership is explicit: scratch stays scratch, history gets heap copies, and
base_msg_countkeeps system/history/user pointers out offree. - The mid-round
strdupOOM path (src/core/agent.c540–547) frees only the new slot’s copies and leavesctx->messageson the previous array, so cleanup cannot double-free. - Combining
response.content ? strdup(response.content) : strdup("")into one OOM check is cleaner than the old empty-string fallback that could skip the failure path. - CHANGELOG
[Unreleased]includes the fix in the same PR.
Sent by Cursor Automation: Adrianno’s personal code review
The content-only check would still pass if prev_calls were freed after chat() returned. On the third chat(), require a readable tool_calls id and a matching tool_use_id. Null tool_use_id before freeing copies. Refs: #59
There was a problem hiding this comment.
Could not approve: GitHub rejects self-approval on this Cursor-authored PR. Cursor Bugbot and Cursor Security Agent completed successfully with no findings that need human review. adriannoes is already assigned; no additional reviewers were assigned.
Sent by Cursor Approval Agent: Pull Request Router and Approver


Summary
strdups each tool result instead of pointing at reusedtool_result_bufs, so earlier rounds stay intact in message history.tool_callscopies) are tracked frombase_msg_countand freed on cleanup.consttool_calls are released viauintptr_tto compile under-Werror=discarded-qualifiers.development(includes fix(memory): preserve existing DB when sqlite3_open fails #63). Not a cherry-pick of the original Bot commit.Test plan
test_react_loop_preserves_prior_tool_results(red: thirdchat()saw overwrittentool_output_1; green: two tool rounds thenmulti tool done)make test_agentCI=true make testmake staticRefs: #59
Note
Medium Risk
Changes ReAct message ownership and lifetime in the core agent loop; behavior fix is localized but affects every multi-tool-turn run and adds per-round allocations.
Overview
Fixes a bug where multi-round ReAct left tool-result messages pointing at reused
tool_result_bufsscratch, so a later tool round could overwrite what the provider still saw in earlier turns.Each tool output is now
strdup'd into heap-owned message content before the conversation grows for the next iteration. ReAct-added slots (assistant text, tool results, copiedtool_calls) are tracked frombase_msg_countand released inagent_run_cleanupviaagent_free_heap_messages, replacing per-iterationprev_assistant/prev_callsfrees. Tool-call teardown uses auintptr_tcast sofree_tool_calls_copystays valid underconstand-Werror=discarded-qualifiers.Adds
test_react_loop_preserves_prior_tool_results(two tool rounds; thirdchat()must still seetool_output_1and matchingtool_use_id). Changelog [Unreleased] notes the fix (Refs: #59).Reviewed by Cursor Bugbot for commit c7a0b13. Bugbot is set up for automated code reviews on this repo. Configure here.