Repository navigation
Run every tool request on @qduc/agent-core; remove both legacy tool loops - #43
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Finishes the agent-harness adoption (#40). Every request with tools now runs one
@qduc/agent-coreturn. Streaming requests reply with SSE;stream: falserequests reply with one JSON body. Both hand-written loops (toolsStreaming.js,toolsJson.js) and theAGENT_HARNESS_TOOL_LOOPflag 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)TurnEventSinkhandles persistence and passes client output to aTurnOutput:SseTurnOutputwrites the stream exactly as before.JsonTurnOutputreturns the shape the frontend already reads:choices[0].message,tool_eventsand_conversation. Failures return a 500 witherrorandtool_events.provider_stream: falseis honoured. The frontend falls back to it for models that can't stream.index: 0, in the stream, in persistence, and in the text-offset lookup.provider_stream: trueserialized a rawResponseobject into the reply.executeToolCallandexecuteToolCallsParallelhad no callers left and are removed.Responses API chaining across messages (
2c7ed36)previous_response_id), the request is retried with it. Before, onlyinvalid_valuewas retried.resumableResponseIdis 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:finaland has aresp_id;[DONE]arriving afterresponse.completedwrote after the end and stored a good reply as an error.Verification
npm run typecheckandnpm run lintare clean. 1275 tests pass.npm run test:provider-replay: 30/30.Known limits
store: false, each message makes one refused request before falling back to full history.jest.setup.jsmakesstructuredClonerealm-local for all backend tests. Under Jest's VM modules its copies used foreign prototypes, so engine continuity'sisDeepStrictEqualchecks failed. That doesn't happen in production.tool_orchestration_deep_dive.mdstill describes the legacy loops under a note.Generated by Claude Code