Files
troupe-standup/wizard-work/null/agentloop/README-agentloop.md
T

5.8 KiB

agentloop — a budgeted agent tool-loop with a hash-chained transcript

Lane: Codeforge. Stdlib only, Python 3.8+. No third-party imports anywhere.

python3 check.py          # the gate; exit code IS the verdict
python3 demo.py           # three end-to-end runs, writes demo-*.jsonl

What it is

Three moving parts, each small enough to read in one sitting:

file role
agentloop/tools.py Tool registry + a JSON-Schema-subset argument validator
agentloop/loop.py the budgeted loop: model turn -> tool calls -> tool results -> repeat
agentloop/transcript.py append-only JSONL log whose records form a SHA-256 hash chain

A model is any callable (messages, manifest) -> ModelReply. The loop never touches a clock or a random source, so a transcript is a pure function of (task, model, tools, budgets) — run the same loop twice and the transcripts are byte-identical. That determinism is what makes the hash chain a usable integrity check instead of decoration.

The transcript is the point

Every record carries the hash of the record before it:

{"hash":"cc7b32...","kind":"tool","payload":{...},"prev":"1f08fd...","seq":2}

So the file is a chain, and verification breaks at the first affected record and names it. A single file digest cannot do that — it says "something changed", not "record 3 was rewritten".

verify_records reports, per record index: hash_mismatch, seq_gap, prev_mismatch, missing_fields, not_an_object, unserialisable_payload. verify_file additionally reports bad_json (with the line number) and unreadable, and returns the record count.

What the chain does NOT prove — stated plainly

A hash chain detects modification and reordering of the records that are present. It cannot detect that records are missing from the end: delete the last line and the remaining chain still verifies, because every surviving record still points at a real predecessor. Completeness needs an anchor outside the transcript — a commit SHA, or a digest stored elsewhere. verify_file returns the count so a caller holding an external expectation can compare it. test_truncation_is_detected_only_against_an_external_count pins this limit rather than hiding it.

Stop reasons and their precedence

reason meaning
no_tool_calls the assistant asked for no tools — the ordinary end of a run
sentinel the assistant's own text contained stop_sentinel
step_budget checked before each turn, so steps never exceeds max_steps
token_budget cumulative estimate exceeded max_tokens; the over-budget turn is recorded, then the loop stops without running its tool calls
model_error the model callable raised, or returned a non-ModelReply

Within one turn the order is: record the turn, then sentinel, then no_tool_calls, then token_budget. A model that says DONE and asks for a tool in the same turn stops on the sentinel and never runs the tool.

Token accounting is max(1, len(text) // 4) — a heuristic, not a tokenizer. It bounds the loop, which is the property that matters; it does not claim to measure a real model's bill.

Tool failures are data, not crashes

An unknown tool name, a schema violation, and a tool that raises are all turned into a tool record with ok: false and an error string, and the loop continues. demo-recover.jsonl shows all three in one run:

seq=2 multiply ok=False error="no tool named 'multiply' (have: add, echo, fail, greet)"
seq=4 add      ok=False error="invalid arguments: $.add.a: expected type integer, got str"
seq=6 fail     ok=False error="tool 'fail' raised RuntimeError: the tool is broken on purpose"
seq=8 add      ok=True  value={'sum': 42}

A tool that returns a non-JSON-serialisable value is refused the same way: a result that cannot be recorded is not a result, and the transcript must always stay writable.

Evidence

Measured on this host, 2026-10-25, Python 3.14.7:

claim status proof
the gate passes measured python3 check.py -> 3 legs ok, Ran 55 tests, exit 0
the chain catches a payload rewrite at the right record measured suite TestTamperDetection (11 cases) + check.py tamper leg
the chain catches a rewrite and recomputed hash measured test_forged_record_with_a_recomputed_hash_still_breaks_the_chain — the next record's prev breaks
the chain catches a reorder even after renumbering measured test_reorder_plus_renumber_is_caught_as_a_prev_mismatch
the chain cannot detect a missing tail measured (limit, not a fault) test_truncation_is_detected_only_against_an_external_count
identical runs give byte-identical transcripts measured test_identical_runs_produce_byte_identical_transcripts
step budget caps the loop and equals the assistant record count measured test_step_budget_caps_the_loop_and_matches_the_record_count
token budget stops before running tools measured test_token_budget_stops_the_loop_before_running_tools
three demo transcripts verify from disk measured demo-answer.jsonl 5, demo-recover.jsonl 11, demo-budget.jsonl 9 records, all ok=True
the gate FAILS when the verifier is broken measured negative control: sabotaged copy -> 9 suite failures + tamper leg a mutated transcript VERIFIED, exit 1

Negative control

A gate that cannot be shown to fail is not evidence. check.py's tamper leg is independent of the suite — it re-derives its expectation from the file's own bytes, so a bug in the test fixtures cannot hide there. The sabotage run confirmed both legs fail together when verify_records is made to accept everything.

Replay

python3 check.py     # 0 = every leg passed, 1 = graded fault, 2 = could not grade
python3 demo.py      # 0 = every demo claim held