Skip to content

feat(llm-helper): the loop contract - llm.chat serves one turn of the engines' agent loop - #47

Merged
acn-ericlaw merged 1 commit into
mainfrom
feat/llm-chat-loop-contract
Nov 3, 2026
Merged

acn-ericlaw merged 1 commit into
mainfrom
feat/llm-chat-loop-contract

Conversation

@acn-ericlaw

Copy link
Copy Markdown
Collaborator

What

  • A request that carries tools is one model turn of an agent loop (_loop_turn); without tools, llm.chat is unchanged. Request: tools (name per ^[a-zA-Z0-9_-]{1,128}$ and unique, description, input_schema, strict; any other key is a 400, so a catalog's route or approval never reaches the model), conversation, turn, results ([{id, output}] or [{id, error}]), ttl and retention (seconds or 30m, 12h, 2d). The loop fields need tools on llm.chat and are a 400 on llm.stream.
  • Reply: status (done, tool_use, refusal, cap), text or data, calls [{id, name, input}], refusal {category}, cap (max_tokens or context), conversation, turn, model, stop_reason, the four usage fields, request_id. A cut-off turn returns no calls, so a truncated call never runs. An end with nothing usable stays the 422.
  • The record pins the model at the first turn (another params.model is 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 one tool_result message 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.
  • The transcript store (llm.transcript.store, required for a loop turn; memory in the sample config): memory for one process, redis for any number of replicas - the new redis extra, one hash under transcript:{conversation} (a Redis ACL can confine the helper to ~transcript:* beside the suspend store's graph:*), a single-key Lua compare-and-set, and a connection from llm.transcript.redis.*, the engines' redis.* suffixes. Every write sets ttl (default 7d); a run the model ends takes retention (default 0, which deletes it).
  • Choices to review: no server-side refusal fallback on a loop turn (a fallback moves the conversation to another model, whose replay rules omit blocks, so the graph routes status: refusal instead); the helper places the cache breakpoints (one on the system block plus top-level automatic caching), never on a stored block; no eager_input_streaming, since the helper reads only the final message and the buffered default keeps the API's input validation.
  • Tests: a new shared vector file, tests/vectors/llm-loop-vectors.json (contract llm-chat-loop v1, 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 when LLM_TRANSCRIPT_REDIS_URL names 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-composable helpers/redis-standalone).
  • Negative controls, each restored: stripping the thinking signature fails 23 loop tests, fallbacks on a loop turn 60, results out of call order 2, no retention 28, no conversation cache marker 62, no turn check 4. On the live server the compare-and-set refuses a duplicate create and a stale append, and a script that always writes fails the same check.
  • Docs: the README's "The loop contract" and "The transcript store", the configuration rows, a Redis run, the Bedrock caveat (the legacy integration for Opus 4.6 and earlier rejects a top-level 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 strict when the combined grammar is too large - its API error is not yet observed), and decision in 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 without tools behaves as before; tools, conversation, turn, results, ttl and retention are now loop fields, and a loop turn needs llm.transcript.store.

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

🤖 Generated with Claude Code

… 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
acn-ericlaw merged commit 8f9ae34 into main Nov 3, 2026
5 checks passed
@acn-ericlaw
acn-ericlaw deleted the feat/llm-chat-loop-contract branch November 3, 2026 00:10
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>
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.

1 participant