# 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: ```json {"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 ```