looptape
v0.1.0
Published
Record an AI agent once. Replay it without a model. Prove it told the truth. A tape format, a recording proxy, a deterministic player, and 37 checks for the ways agent loops lie.
Maintainers
Readme
Your agent lied to you last Tuesday, and your test suite was green.
It said "on it, I'll pull that together" and the run ended. It said "I can check the health and open incidents" to a user who had just asked it to. It said "I can't access the agent list from here" with an empty tool trace. It wrote "Saved a memory for next time" and no memory tool ran. It reported "all 327 resources healthy" off a number it remembered, while the one listing it actually made returned 40. It was shown the first 60 entries of a 214-entry list, nothing told it the list was cut, and it told the user the missing repo did not exist.
None of those are model bugs. They are loop bugs. The loop, or harness, is everything around the model: the code that calls it, reads the reply, decides whether that was a tool call or a final answer, runs the tool, feeds the result back, and goes again. A plain-text reply is the terminal state of every such loop, so whatever the model says last, nothing runs afterwards, and a harness that accepts the sentence as an answer has shipped a lie.
You cannot unit-test this against a live model, because the model will not say the same thing twice. You can test it against what the model already said.
That is looptape. A tape is one recorded session: every completion, every tool call, every result as the model was shown it, the final answer. The player replays the tape into your real harness in place of the model. The checks read the result and say, mechanically, whether your code let the lie through.
$ looptape check tapes/01-promise-then-nothing.jsonl
tapes/01-promise-then-nothing.jsonl Promise, then nothing, forever [as expected: 1 error, 0 warn, 0 info]
x answer.promise the final answer promises future work: "Ich sammle dir offene Tasks, Roadmap-Punkte und Agenten-Funde zusammen."Sixty seconds
npm install -D looptape
# 1. record: point your SDK at the proxy, run your agent as usual (--check prints findings live)
npx looptape record --dir tapes/recorded --check
ANTHROPIC_BASE_URL=http://127.0.0.1:8787 node my-agent.js # or OPENAI_BASE_URL=http://127.0.0.1:8787/v1
# 2. check: 37 checks over what actually happened
npx looptape check tapes/recorded
# 3. replay: the same conversation through your harness, no model, no network
npx looptape replay tapes/recorded/<session>.jsonl --harness ./harness.js # examples/guarded-loop.js is the templateNo agent yet? npx looptape init writes a one-tape starter and
npx looptape check starter.jsonl shows what a finding looks like.
Or skip the recording and start from the seed corpus:
git clone https://github.com/chriszemmel/looptape && cd looptape
npm test # 88 tests, no network
node examples/replay-both.js # the same tape through a naive loop and a guarded one== naive loop: consumed 1/1 recorded completions, identical to the original run
answer: "Ich sammle dir offene Tasks, Roadmap-Punkte und Agenten-Funde zusammen." [answered]
x answer.promise the final answer promises future work: "Ich sammle dir offene Tasks, ..."
== guarded loop: consumed 1/1 recorded completions, diverged (step 1: harness notes differ (A 0, B 1))
answer: "I could not complete this: no tool was run this turn, so the answer the model produced had no data behind it and was discarded. ..." [deflected]
no findingsSame tape. Same model output. One harness shipped the promise; the other bounced it once, got nothing back, and said so. That is the entire method: fixture first, then the fix, then the fixture proves the fix.
What a tape is
JSON Lines. A header, then events in the order they happened. Two sources write them and both normalize to the same run:
{"t":"header","v":1,"id":"a1b2","recorded_at":"2026-09-04T09:00:00Z","source":"wire","provider":"anthropic","provenance":"recorded","model":"claude-fable-5-1"}
{"t":"request","body":{"model":"claude-fable-5-1","system":[…],"tools":[…],"messages":[{"role":"user","content":"Anything on fire?"}]}}
{"t":"response","status":200,"body":{"content":[{"type":"tool_use","id":"toolu_01","name":"list_incidents","input":{"status":"open"}}],"stop_reason":"tool_use"},"stream":true,"ms":1830}
{"t":"request","body":{"…":"…","messages":[…,{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"{\"total\":1,…}"}]}]}}
{"t":"response","status":200,"body":{"content":[{"type":"text","text":"One open incident: the main database is at 91% disk."}],"stop_reason":"end_turn"}}- wire tapes hold raw
request/responsepairs, verbatim, from the proxy or a wrapped client. Nothing is interpreted at write time, so a tape recorded today is still readable when the checks get smarter. - loop tapes hold what a harness did:
completion,call,result(with the raw size and the text the model was fed),note(a correction the harness injected),answer(with an end reason).
Every header says where the tape came from: recorded is a byte-exact
capture, reconstructed was rebuilt from a real incident, synthetic never
happened. A tape that lies about this is worth less than no tape.
The format is specified in docs/format.md. It is frozen at v1; a reader refuses a version it does not speak.
The lies
Every check came from an incident. The origin is kept on the check itself,
because a check whose origin nobody remembers gets deleted the first time it is
inconvenient. The full list with every origin is in
docs/checks.md; looptape checks prints it.
| check | what it catches | the incident |
|---|---|---|
| answer.promise | "I'll pull that together" as the final reply | /items got a promise, then nothing, forever. There is no worker behind that sentence. |
| answer.offer | "I can check X" with zero tools run | Asked twice; offered twice; system_health sat in the catalog the whole time. |
| answer.deflection | "I can't access X" with nothing tried | An invented inability is indistinguishable from a real one, and it shipped three times in one evening. |
| answer.narrated-plan | "First, I need to read greet.js" and the run ends | The most common way a coding agent loses a task. |
| answer.narrated-transcript | You called tool(…) / Result: {…} written as prose | A patrol report with a hallucinated status for the wrong domain. Nothing ran. |
| answer.raw-json | a tool-shaped blob shown to a human | The loop could not read the call, so the model's intent became the reply. |
| claim.artifact | "Saved a memory" with no remember call | The memory stayed unwritten; dedup broke the next run. |
| claim.count | "all 327 resources" off a remembered number | The one listing that run returned 40. |
| loop.stall | the same call, identical args, three times | A small model re-reading an answer it already had until the budget ran out. |
| loop.cut | a tool call cut mid-object, never retried | {"tool":"create_task","args":{"title":"Adapt was filed as "produced no report". |
| result.silent-truncation | a result shortened without saying so | A list cut at a byte offset made the bot deny a repo that was past the cut. |
| history.prefix-edited | an earlier turn changed between two requests | Claude Fable 5.1 binds thinking blocks to the conversation prefix; edit it and the API rejects every later block. |
| history.system-changed | the system prompt rewritten mid-conversation | The same rejection, and the cache restarts from byte zero. |
| wire.tool-result-missing | a tool_use never answered | The API rejects it; a harness that bounces a call must still answer the block. |
| wire.forced-tool-choice | tool_choice: any on a model that 400s | Carried over from an older integration, invisible in review. |
| cache.volatile-system | a timestamp in the system prompt | Every request misses the cache; the bill notices before you do. |
| leak.secret | an API key in a message | A tool dumped the environment into the model's context, the provider's logs, and the tape. |
Plus answer.empty, answer.cjk-leak, claim.no-evidence, loop.thrash,
loop.unknown-tool, loop.empty-args, result.error-hidden,
result.oversized, history.tools-changed, history.assistant-rewritten,
history.thinking-dropped, wire.parallel-split, wire.refusal,
wire.max-tokens, wire.thinking-config, cache.tool-order,
cache.no-breakpoint, cache.never-read.
The predicates behind the answer checks are exported (looksLikePromisedWork,
looksLikeDeflection, findUngroundedArtifactClaim, ...) so your loop can
bounce a reply in-run with the same definition the tape check uses
afterwards. One definition, two layers, and they can never disagree about what
a promise is. The guarded example loop is sixty lines on top of the naive one
and uses exactly those.
Replay, in a test
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { parseTape, player, recorder, runChecks, failing } from "looptape";
test("the promise tape no longer ships a promise", async () => {
const tape = parseTape(await readFile("tapes/01-promise-then-nothing.jsonl", "utf8"));
const p = player(tape, { strict: false }); // the model, replaced by the tape
const rec = recorder(); // the replay is itself a tape
await myLoop({ llm: p.llm, tools: p.tools(catalog), message: "/items", recorder: rec });
assert.deepEqual(failing(runChecks(rec.run())), []);
});The player has three faces over one cursor: player.llm(input) for a
text-protocol loop, player.messages.create(params) for code written against
the Anthropic SDK, player.chat.completions.create(params) for the OpenAI SDK.
A wire tape recorded from one dialect is served in the other, converted. Tools
answer from the tape too (player.tool(name), player.tools(catalog)), by
name and in order, or fall through to your live tools with { live: true }.
When the harness asks for a completion the tape never recorded, the player
throws TapeExhausted. That is a divergence, and a test should fail on it
rather than improvise a model. strict: false returns an empty completion
instead, which is the right mode for a guard the original harness never had:
the guard bounces, the tape has no reply to the bounce, and the harness has to
end honestly on its own. If it cannot, that is the finding.
Three ways to record
| | change | you get |
|---|---|---|
| proxy | one environment variable | every wire check, every answer check |
| wrap the client | two lines | the same, without a process |
| record the loop | wrapLlm, wrapTool, note, answer | everything, including the one number the proxy cannot see: raw size vs. what the model was fed |
const rec = recorder({ title: "nightly digest", model: "claude-fable-5-1" });
const client = rec.wrapAnthropic(new Anthropic());
// ... your loop ...
await rec.save("tapes/recorded/digest.jsonl"); // secrets redacted on the way outDetails, the session header, and the replay entry point: docs/recording.md.
Claude Fable 5.1, specifically
Four of the wire checks exist because the newest models moved the contract under harnesses that worked fine last year:
- Append-only history. A thinking block's signature records the
conversation prefix that produced it. Edit, reorder, or drop an earlier turn,
rebuild the system prompt, or change the tool set mid-conversation, and every
later block is invalid.
history.prefix-edited,history.system-changed,history.tools-changedandhistory.assistant-rewrittenread consecutive requests off the tape and name the first message that changed. A movedcache_controlmarker is not an edit; a compaction into one summary message is reported as info, not error. - Forced tool choice is gone.
tool_choice: any/toolis a 400.wire.forced-tool-choiceis an error on Fable and Mythos ids, info elsewhere. - Thinking configuration.
budget_tokenson Opus 4.7+ / Sonnet 5 / Fable, ortype: "disabled"on Fable, is a 400.wire.thinking-config. - Refusals are HTTP 200.
stop_reason: "refusal"with empty or partial content.wire.refusalreports the category so you can see whether your fallback configuration ever engaged.
None of the checks call a model. All of them run on a tape from any provider.
The seed corpus
tapes/ ships 24 tapes: 15 reconstructed from real incidents in a production
control plane between July and September 2026, and 9 synthetic controls and
wire shapes. Each carries a title, an origin line, a provenance, and expect:
the error-level checks it must trip, exactly. looptape check tapes fails the
moment a tape drifts in either direction, which is how the corpus stays a
regression suite instead of a museum.
$ looptape stats tapes
24 tapes, 46 steps, 23 tool calls
providers: text 16, anthropic 7, openai 1
provenance: reconstructed 15, synthetic 9Read them. They are short, they are real, and most of them are one sentence long where it matters.
A recorded tape is a production log: the whole conversation, every tool result. Redaction is by shape and cannot know what a customer's name looks like. Read SECURITY.md before you commit one.
In CI
- run: npx looptape check tapes --fail-on warn
- run: npx looptape check tapes/recorded --no-expect --skip cache.*--json for machines. Directories are walked recursively. Exit 1 on any
error or on a broken expect list.
What it is not
- Not an eval framework. Evals call live models and score outputs; they measure the model. Looptape never calls a model; it measures your harness against a conversation that already happened. Use both.
- Not a benchmark. A tape is one run, not a distribution.
- Not a judge. Every check is a deterministic predicate over the tape. No check asks a model whether another model lied.
Why this exists
Code is now cheap. A working agent loop is an afternoon. What is not cheap is the recording of the afternoon it went wrong: the exact sentence, the exact truncated list, the exact edited turn. Those recordings are the only thing that turns "the agent was weird on Tuesday" into a test that fails, and they cannot be generated, only kept.
Looptape was extracted from Foundation, a control plane that runs a fleet of autonomous agents on a mix of frontier and free-tier models, where every check here was first a morning message from a founder asking why the green report was wrong. The loop that grew those guards is 1,800 lines; the guards are the part that generalizes.
Glossary
| word | meaning here | |---|---| | harness (or loop) | your code around the model: call, read, run tools, feed back, repeat. The thing looptape tests. | | tape | one recorded session as a JSONL file: what the model said, what ran, what it was shown, how it ended | | wire level | a tape of raw API requests and responses, from the proxy or a wrapped client | | loop level | a tape a harness wrote about itself: completions, calls, results with sizes, notes, the answer | | player | the tape standing in for the model, so the harness runs for real without one | | recorder | what writes a tape, from a harness, an SDK client, or the proxy | | check | a deterministic predicate over a tape; error, warn or info; each with the incident it came from | | expect | a tape's contract: the error-level checks it must trip, exactly | | provenance | whether a tape is a byte-exact capture, a reconstruction of a real incident, or synthetic |
Layout
bin/looptape.js the CLI entry point
src/tape.js the format: parse, validate, serialize, redact
src/normalize.js one run shape from a wire tape or a loop tape, both API dialects
src/record.js the recorder: wrap an LLM function, a tool, an Anthropic or OpenAI client
src/proxy.js the recording HTTP proxy; src/sse.js folds streams back into messages
src/player.js the model, replaced by the tape
src/guards.js the predicates (what a promise, an offer, a deflection looks like)
src/checks/ honesty.js (answer.*, claim.*), loop.js (loop.*, result.*), wire.js (history.*, wire.*, cache.*, leak.*)
src/diff.js the first divergence between two runs
src/report.js findings for terminals and machines
tapes/ the seed corpus; scripts/seed-tapes.mjs regenerates it
examples/ naive-loop.js, guarded-loop.js, replay-both.js
docs/ format.md, checks.md (generated), recording.md, logo.md
types/index.d.ts the public surface, typedLibrary
Everything the CLI does is a function:
| | |
|---|---|
| parseTape(text) / serializeTape(tape) / redactTape(tape) | the format |
| normalize(tape) | { steps, calls, results, notes, answer, requests, tools, ... } |
| recorder(header) | .wrapLlm, .wrapTool, .wrapAnthropic, .wrapOpenAI, .note, .answer, .save |
| startProxy({ port, dir, upstream }) | the proxy, in-process |
| player(tape, { strict }) | .llm, .messages.create, .chat.completions.create, .tool, .tools |
| runChecks(tape, opts) / failing(findings) / CHECKS | the checks |
| diffRuns(a, b) | the first divergence |
| looksLikePromisedWork(text) and friends | the predicates, for bouncing in-run |
Roadmap
- A Gemini dialect for the proxy and the player.
- Tapes as GitHub Actions artifacts: record in CI, attach on failure.
- A
looptape watchmode that checks each tape as the proxy closes it. - More seed tapes. Yours, ideally. See CONTRIBUTING.md.
License
MIT © 2026 Chris Zemmel
