Repository navigation
feat(llm-helper): the loop contract - llm.chat serves one turn of the engines' agent loop - #47
Merged
Merged
Conversation
… engines' agent loop
RFC-0011 build plan step 1 (sections 3.2 and 3.3 of the design spec). A request that
carries tools is one model turn of llm.run, the agent loop of the Java and Rust engines:
the transcript stays in the helper's store and the loop holds a reference to it. A later
turn sends conversation, turn and results back; the helper appends the one tool_result
message in call order and the model's turn exactly as the API returned it, thinking
blocks included. The reply carries status (done, tool_use, refusal, cap), calls, cap for
a cut-off turn (whose calls never run), the four usage fields, conversation and turn.
A stale turn count or an ended run is a 409 and each write is a compare-and-set on the
count, so a duplicated turn never forks a conversation; an ended or expired one is a 404.
The model is pinned at the first turn, server-side fallbacks are never sent on a loop
turn, and the helper places the cache breakpoints (system, plus a top-level one). The
store is llm.transcript.store: memory for one process, redis for replicas (a new extra;
a hash under transcript:{conversation}, a Lua compare-and-set, llm.transcript.redis.*).
Every write sets ttl; a run the model ends takes retention (0 deletes it).
A new shared vector file, tests/vectors/llm-loop-vectors.json (66 cases), pins the
contract for the Node twin and runs on the memory store, a Redis fake and, with
LLM_TRANSCRIPT_REDIS_URL, a live Redis. A request without tools behaves as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
acn-ericlaw
added a commit
that referenced
this pull request
Nov 3, 2026
…M helper in extensions/, its loop contract); llm-chat-loop-contract closed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
What
toolsis one model turn of an agent loop (_loop_turn); withouttools,llm.chatis unchanged. Request:tools(nameper^[a-zA-Z0-9_-]{1,128}$and unique,description,input_schema,strict; any other key is a 400, so a catalog'srouteorapprovalnever reaches the model),conversation,turn,results([{id, output}]or[{id, error}]),ttlandretention(seconds or30m,12h,2d). The loop fields needtoolsonllm.chatand are a 400 onllm.stream.status(done,tool_use,refusal,cap),textordata,calls [{id, name, input}],refusal {category},cap(max_tokensorcontext),conversation,turn,model,stop_reason, the fourusagefields,request_id. A cut-off turn returns no calls, so a truncated call never runs. An end with nothing usable stays the 422.params.modelis a 400) and keeps each block as the SDK serializes a returned block it is handed back (to_dict(mode="json")), so thinking signatures replay unchanged. The helper builds the onetool_resultmessage in call order (non-text results as compact JSON). A stale turn or an ended run is a 409, an ended or expired conversation a 404, and each write is a compare-and-set on the turn count: a duplicated turn never forks a conversation.llm.transcript.store, required for a loop turn;memoryin the sample config):memoryfor one process,redisfor any number of replicas - the newredisextra, one hash undertranscript:{conversation}(a Redis ACL can confine the helper to~transcript:*beside the suspend store'sgraph:*), a single-key Lua compare-and-set, and a connection fromllm.transcript.redis.*, the engines'redis.*suffixes. Every write setsttl(default7d); a run the model ends takesretention(default0, which deletes it).status: refusalinstead); the helper places the cache breakpoints (one on the system block plus top-level automatic caching), never on a stored block; noeager_input_streaming, since the helper reads only the final message and the buffered default keeps the API's input validation.tests/vectors/llm-loop-vectors.json(contractllm-chat-loopv1, 66 cases, digest pinned for the Node twin), runs on the memory store and on a Redis fake (fakeredis[lua]), and on a live Redis whenLLM_TRANSCRIPT_REDIS_URLnames one; plus unit tests for the stored blocks, the logs and traces (no content, no conversation reference), the store failures (503 by name, 409 on a lost compare-and-set) and the Redis keys and connection settings. 351 passed and 64 skipped (the live variants) locally; ruff and basedpyright clean. The 64 live cases passed on redis-server 6.2.6 (mercury-composablehelpers/redis-standalone).cache_control); the sample configuration's transcript keys; CHANGELOG Unreleased Added 2 with the upgrade note.Why
Step 1 of the agent-loop build plan (RFC-0011 in mercury-composable, design spec
draft-design-specs/llm-helper-agent-loop.md, sections 3.2 and 3.3). The loop,llm.run, runs in the engines and dispatches each tool call to a composable-function route; the model turn stays in this pack, and the transcript stays with it. That keeps provider blocks off every engine codec, keeps the graph's state machine small (it holds a reference, never the transcript), keeps the transcript append-only as the current models' thinking blocks require, and lets any replica serve any turn, a resume days later included. The append check and the compare-and-set make a duplicated resume harmless.Not built here: a Redis cluster client, the design spec's grammar-budget fallback (dropping
strictwhen the combined grammar is too large - its API error is not yet observed), anddecisionin the reply (the loop's job in step 2). The Node twin follows in mercury-nodejs with the same vector file. Upgrade note: a request withouttoolsbehaves as before;tools,conversation,turn,results,ttlandretentionare now loop fields, and a loop turn needsllm.transcript.store.Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com
🤖 Generated with Claude Code