Skip to content

Run every tool request on @qduc/agent-core; remove both legacy tool loops - #43

Merged
qduc merged 3 commits into
mainfrom
claude/festive-allen-qctobz
Nov 10, 2026
Merged

qduc merged 3 commits into
mainfrom
claude/festive-allen-qctobz

Conversation

@qduc

@qduc qduc commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Summary

Finishes the agent-harness adoption (#40). Every request with tools now runs one @qduc/agent-core turn. Streaming requests reply with SSE; stream: false requests reply with one JSON body. Both hand-written loops (toolsStreaming.js, toolsJson.js) and the AGENT_HARNESS_TOOL_LOOP flag are removed. On the Responses API, conversations now chain across messages again, with a stricter safety check than before.

Changes

One tool loop for both reply formats (448520d, 7f6bb13)

  • The harness loop is the only tool loop. TurnEventSink handles persistence and passes client output to a TurnOutput:
    • SseTurnOutput writes the stream exactly as before.
    • JsonTurnOutput returns the shape the frontend already reads: choices[0].message, tool_events and _conversation. Failures return a 500 with error and tool_events.
  • Gaps closed before removing the legacy code:
    • provider_stream: false is honoured. The frontend falls back to it for models that can't stream.
    • Malformed tool-call JSON gets the legacy message, which shows the model what it sent.
    • Responses API tool rounds chain within a turn. This is gated on the model's continuity capability, because chaining Chat Completions, Anthropic or Gemini would drop their history.
    • The Responses adapter now sends a chained request that has no assistant message. Before, it fell back to "last user message" and sent nothing.
    • The UI indents JSON tool results that arrive as text.
  • Tool call indexes are numbered per model response. Two calls in one response could both get index: 0, in the stream, in persistence, and in the text-offset lookup.
  • Legacy JSON-loop bugs found while recording its behaviour, fixed by the move:
    • A single tool call ran without the user, so the journal failed.
    • The iteration cap made one extra model call and stored empty content.
    • provider_stream: true serialized a raw Response object into the reply.
  • executeToolCall and executeToolCallsParallel had no callers left and are removed.

Responses API chaining across messages (2c7ed36)

  • A turn's first request continues from the previous turn's stored response and sends only the new user messages.
  • The full-history request is kept aside. If the provider refuses the id (any 400 or 404 naming previous_response_id), the request is retried with it. Before, only invalid_value was retried.
  • resumableResponseId is used by both the tool loop and the plain path. It chains only from the newest stored reply on the active branch, and only when:
    • that reply is final and has a resp_ id;
    • any tool rows after it answer its own calls;
    • only new user messages follow it.
  • The old lookup could skip a newer reply that had no id, and the provider then lost that exchange.
  • Only a completed turn keeps its response id. After a cap, cancel or failure the id is cleared, and a cancelled plain reply clears it too.
  • The plain path sends every new user message after the stored reply, not just the last one.
  • The Responses stream translator ends once. [DONE] arriving after response.completed wrote after the end and stored a good reply as an error.

Verification

  • Backend: npm run typecheck and npm run lint are clean. 1275 tests pass.
  • npm run test:provider-replay: 30/30.
  • Frontend: typecheck and lint are clean. 532 tests pass.
  • Streaming tool-loop expectations are the legacy loop's results for the same scripts, recorded before it was removed. The legacy JSON loop's results were recorded the same way and diffed; the differences are the fixes listed above.
  • New route-level coverage:
    • JSON reply;
    • text before tool calls;
    • two calls in one response;
    • special characters in arguments;
    • a 200 KB tool output;
    • malformed SSE;
    • the idle timeout;
    • a second message in the same conversation, for every provider family over recorded traffic;
    • plain-path chaining;
    • plain cancel.
  • Mutation checks: undoing each fix makes its test fail.

Known limits

  • Not run against real providers or in a browser. Everything is tested against fakes and recorded traffic.
  • While a Responses conversation is chained, a system-prompt change made mid-conversation doesn't reach the model. Legacy behaved the same.
  • With store: false, each message makes one refused request before falling back to full history.
  • jest.setup.js makes structuredClone realm-local for all backend tests. Under Jest's VM modules its copies used foreign prototypes, so engine continuity's isDeepStrictEqual checks failed. That doesn't happen in production.
  • tool_orchestration_deep_dive.md still describes the legacy loops under a note.

Generated by Claude Code

Every streaming request with tools now runs on @qduc/agent-core. The
legacy loop (toolsStreaming.js) and the AGENT_HARNESS_TOOL_LOOP flag are
removed. The non-streaming JSON tool path is unchanged.

Closed the gaps that would otherwise have regressed:

- provider_stream:false is honoured. The frontend falls back to it when
  a model can't stream; the harness used to send stream:true anyway.
- Responses API tool rounds chain with previous_response_id again, via
  engine continuity. It is gated on the model's continuity capability:
  every wrapper reports a response id, and chaining Chat Completions,
  Anthropic or Gemini would drop their history.
- The Responses adapter sends a chained delta that has no assistant
  message (tool outputs only) instead of falling back to the last user
  message and sending nothing.
- Malformed tool-call JSON tells the model what it sent, with the
  legacy loop's text.
- The UI indents JSON tool results given as text, so a result looks the
  same while streaming and after reload.

Tests: the parity test becomes toolLoop.test.js, asserting the legacy
loop's results for the same scripts (captured before removal), plus a
throwing tool, malformed arguments and provider_stream:false. The replay
suite drops its legacy runs and asserts Responses chaining. Jest setup
makes structuredClone realm-local so engine continuity compares copies
as it does in production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqbJB9PPzDrqCr5EPj6yNc
Every request with tools now runs the same agent-core turn. The sink
keeps persistence and hands client output to a TurnOutput:
SseTurnOutput writes the stream exactly as before, JsonTurnOutput
answers once with the shape the frontend reads (choices[0].message,
tool_events, _conversation; 500 with error and tool_events on failure).

This fixes three bugs in the legacy JSON loop, found by capturing its
route-level behaviour before removal:
- a single tool call ran without the user, so the journal failed
  ("requires an authenticated user context"); only parallel calls
  passed it
- the iteration cap made one more model call and stored empty content
- provider_stream:true serialized the raw upstream Response into the
  reply and left the message in "streaming"

Also fixes tool call indexes: they were numbered per flush, so two
calls in one response could both get index 0 (in the stream, in
persistence, and when looking up each call's text offset). They are
now numbered per model response.

executeToolCall and executeToolCallsParallel had no callers left and
are removed with their tests.

Tests: JSON reply (tool round, text before tools, two calls, reasoning,
iteration cap, throwing tool, upstream failure, provider_stream:true),
plus streaming cases the deleted suites covered: text before tool
calls, two calls in one response, special characters, a large output,
malformed SSE lines and the idle timeout. The fake upstream accepts
bodies up to 10mb, as real providers do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqbJB9PPzDrqCr5EPj6yNc
On the Responses API, a turn's first request now continues from the
previous turn's stored response (previous_response_id) and sends only
the new user messages; tool rounds keep chaining within the turn. The
model wrapper keeps the full-history request aside and retries with it
if the provider refuses the id.

resumableResponseId decides when that is safe, for the tool loop and
the plain no-tools path: only from the newest stored reply on the
active branch, when it is final, has a resp_ id, any tool rows after it
answer its own calls, and only new user messages follow. The old lookup
took the newest reply that had an id among the last 25, so a newer
reply without one was skipped and the provider lost that exchange.

Related fixes:
- Only a completed turn keeps its response id. After a cap, cancel or
  failure it pointed at an earlier step, or at tool calls whose outputs
  the provider never received. Plain replies clear it on cancel too
  (their chunk ids can be placeholders).
- A refused previous_response_id (unknown, expired, not stored: any
  400/404 naming it) retries with full history; it was only retried on
  code invalid_value.
- The plain path sends every user message after the stored reply (more
  than one after a failed turn), not just the last.
- The Responses stream translator ends once: [DONE] after
  response.completed in the same chunk wrote after end and stored a
  good reply as an error.

Tests: resume, refusal fallback and Chat Completions ignoring it (model
unit tests); resumableResponseId cases; clearing by turn outcome (sink);
a second message per provider family over recorded traffic; plain-path
chaining over the route; plain cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqbJB9PPzDrqCr5EPj6yNc
@qduc
qduc merged commit b4409a0 into main Nov 10, 2026
4 checks passed
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