Skip to content

fix(tool-loop): strict-model tool-call history (assistant+tool_calls / role:tool) — not a user-message fold#286

Merged
drewstone merged 1 commit into
mainfrom
fix/tool-loop-strict-tool-history
Jun 14, 2026
Merged

fix(tool-loop): strict-model tool-call history (assistant+tool_calls / role:tool) — not a user-message fold#286
drewstone merged 1 commit into
mainfrom
fix/tool-loop-strict-tool-history

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

The bug

runToolLoop and streamToolLoop (the canonical turn-level tool-dispatch loop in src/tool-loop.ts) folded every tool outcome into a single { role: 'user', content: 'Tool results:\n…' } message and dropped the assistant turn's tool_calls. A strict tool-history validator — Claude, and any OpenAI-compatible provider that validates tool history — can't see its own tool use that way, so it re-issues the same tool call in a loop (until the stuck-loop backstop fires). ToolLoopMessage was { role: string; content: string } only, so the correct shape couldn't even be expressed.

The fix

The loop now appends the OpenAI function-calling contract:

  • the assistant turn that emitted the calls is preserved as an assistant message carrying its tool_calls[] array (content is null when the turn was tool-only);
  • each result is its own { role: 'tool', tool_call_id, content } message keyed to the call that produced it;
  • a missing tool-call id is derived deterministically from the tool name (call_<toolName>) so the assistant entry and its result still match.

Changes (src/tool-loop.ts)

  • Type wideningToolLoopMessage gains optional tool_calls?: ToolLoopAssistantToolCall[] and tool_call_id?: string, and content widens to string | null. New exported ToolLoopAssistantToolCall interface ({ id, type: 'function', function: { name, arguments } }). Added two helpers: assistantToolCallMessage(turnText, pending) and toolResultMessage(call, content).
  • runToolLoop (src/tool-loop.ts:218 assistant push, :257 budget-path tool result, :273 per-result tool message) — replaces the conditional { role: 'assistant', content: turnText } push + the terminal { role: 'user', content: 'Tool results:…' } fold with one assistant+tool_calls message then one role: 'tool' message per result.
  • streamToolLoop (src/tool-loop.ts:392 assistant push, :437 budget-path, :460 per-result) — same replacement.

Back-compat

This is additive. A streamTurn that reads only role + content is unaffected; one that forwards the whole message array to an OpenAI-compatible endpoint now sends correct tool history. Public function signatures (runToolLoop / streamToolLoop) are unchanged.

Tests

New regression tests in src/tool-loop.test.ts cover both loops:

  • the loop emits an assistant+tool_calls message then a role: 'tool' message per result, and no role: 'user' "Tool results" fold;
  • a missing tool-call id is derived from the tool name (call_submit_proposal) and matches across the assistant entry and its result (and tool-only turns carry content: null);
  • a model that re-issues its call when results are folded into a user message but completes when given the tool-history shape finishes in one call (mirrors the strict-model pathology directly).

Verified: npx tsc --noEmit clean, pnpm run lint clean (the 2 remaining warnings are pre-existing, in an unrelated test file), pnpm test green (80 files / 880 passed / 1 skipped), pnpm run build clean.

Why now

This is the upstream of agent-app's local fix (its #20 / ad2ceaa). agent-app currently carries its own runToolLoop/streamToolLoop bodies and cannot migrate onto the canonical loop until this lands. Once a release carrying this ships, agent-app collapses its hand-rolled loop to a thin re-export — a ~200-LOC delete.

…/ role:tool) — not a user-message fold

runToolLoop and streamToolLoop folded every tool outcome into a single
{ role: 'user', content: 'Tool results:\n…' } message and dropped the
assistant turn's tool_calls. A strict tool-history validator (Claude, and
any OpenAI-compatible provider that validates) can't see its own tool use
that way and re-issues the same call in a loop.

The loop now appends OpenAI function-calling shape: the assistant turn that
emitted the calls is preserved as an assistant message carrying its
tool_calls array (content null when the turn was tool-only), and each result
is its own { role: 'tool', tool_call_id, content } message keyed to its call.
A missing tool-call id is derived deterministically from the tool name so the
assistant entry and its result still match.

ToolLoopMessage widens additively: tool_calls / tool_call_id are optional and
content is string | null. A streamTurn that reads only role + content is
unaffected; one that forwards the whole message array to an OpenAI-compatible
endpoint now sends correct tool history.

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Auto-approved PR — 687a35d3

Blanket team auto-approval is enabled for this reviewer service.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: blanket_auto_approve · 2026-06-14T01:00:20Z

@drewstone drewstone merged commit ce57653 into main Jun 14, 2026
1 check passed
drewstone added a commit that referenced this pull request Jun 14, 2026
- feat(conversation): runPersonaConversation + runPersonaDispatch — the persona
  loop runner; any AgentProfile evaluated as a multi-round conversation, drops
  into runProfileMatrix as dispatch (#282)
- feat(personify): connect the dormant analyst→steer wire + registryScopeAnalyst (#284)
- feat(skills): build-with-agent-runtime canonical spine (#285)
- fix(tool-loop): strict-model tool-call history (#286)
- docs: canonical-api.md API reference (#283)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants