null: agentloop -- budgeted agent tool-loop with a hash-chained JSONL transcript; 55 tests + independent tamper leg, negative control held

This commit is contained in:
null committed 2026-10-25 22:24:28 +00:00
1 parent b9f3fb1ad1
commit b23e9f1126
13 files changed
+1868

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# agentloop gate -- one command, and the exit code IS the verdict.
#
# 0 every leg passed
# 1 a graded fault (a leg reported one)
# 2 could not grade (no python3, the package will not import, ...)
#
# Resolves its own directory, so the copy in a clone grades that clone.
#
# The last line is `exit "$rc"`, not a constant: a gate whose exit code is a
# literal cannot fail, and a gate that cannot fail is not evidence.
set -u
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
py=""
if command -v python3 >/dev/null 2>&1; then
py="python3"
fi
if [ -z "$py" ]; then
echo "COULD NOT GRADE: no python3 on PATH"
rc=2
else
"$py" "$here/check.py"
rc=$?
fi
exit "$rc"
@@ -0,0 +1,117 @@
# 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
```
@@ -0,0 +1,75 @@
"""agentloop -- a dependency-free agentic tool-use loop with a hash-chained transcript.
Lane: Codeforge. Stdlib only, Python 3.8+. No third-party imports anywhere.
The three moving parts:
tools.py a Tool registry and a JSON-Schema-subset argument validator
loop.py the budgeted agent loop (model -> tool calls -> tool results)
transcript.py an append-only JSONL log whose records form a SHA-256 hash chain
The transcript is the point. An agent run is evidence, and evidence that
cannot be checked is just a log: every record carries the hash of the record
before it, so a rewrite, a dropped record or a swap breaks the chain at the
first affected record and names it. See README-agentloop.md for what the
chain does and does not prove.
"""
from .transcript import (
GENESIS,
KIND_ASSISTANT,
KIND_FINAL,
KIND_TOOL,
KIND_USER,
Transcript,
TranscriptError,
canonical_json,
verify_file,
verify_records,
)
from .tools import (
Tool,
ToolCall,
ToolError,
ToolNotFound,
ToolRegistry,
ToolResult,
ValidationError,
validate,
)
from .loop import (
AgentLoop,
LoopResult,
ModelReply,
StopReason,
estimate_tokens,
)
__version__ = "1.0.0"
__all__ = [
"GENESIS",
"KIND_ASSISTANT",
"KIND_FINAL",
"KIND_TOOL",
"KIND_USER",
"Transcript",
"TranscriptError",
"canonical_json",
"verify_file",
"verify_records",
"Tool",
"ToolCall",
"ToolError",
"ToolNotFound",
"ToolRegistry",
"ToolResult",
"ValidationError",
"validate",
"AgentLoop",
"LoopResult",
"ModelReply",
"StopReason",
"estimate_tokens",
"__version__",
]
@@ -0,0 +1,218 @@
"""The budgeted agent loop: model turn -> tool calls -> tool results -> repeat.
Determinism
-----------
The loop takes no clock and no randomness. A transcript is therefore a pure
function of ``(task, model, tools, budgets)``: run the same loop twice and the
two transcripts are byte-identical. Tests/test_agentloop.py asserts exactly
that, which is what makes the hash chain a usable integrity check rather than
decoration.
Stop reasons, and their precedence
-----------------------------------
step_budget checked BEFORE each model turn, so ``steps`` never exceeds
``max_steps`` and always equals the number of assistant records.
sentinel the assistant's own text contains ``stop_sentinel``.
no_tool_calls the assistant asked for no tools -- the ordinary end of a run.
token_budget cumulative estimated tokens exceeded ``max_tokens``; the
over-budget turn is recorded, then the loop stops WITHOUT
executing 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; a model that
spends its last token on tool calls stops on the budget and never runs them.
Token accounting is a 4-chars-per-token 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.
"""
from __future__ import annotations
import json
from .tools import ToolCall, ToolError, ToolRegistry, ToolResult, ValidationError
from .transcript import KIND_ASSISTANT, KIND_FINAL, KIND_TOOL, KIND_USER, Transcript, canonical_json
__all__ = ["StopReason", "ModelReply", "LoopResult", "AgentLoop", "estimate_tokens"]
class StopReason(object):
NO_TOOL_CALLS = "no_tool_calls"
SENTINEL = "sentinel"
STEP_BUDGET = "step_budget"
TOKEN_BUDGET = "token_budget"
MODEL_ERROR = "model_error"
class ModelReply(object):
"""What a model returns for one turn."""
__slots__ = ("text", "tool_calls")
def __init__(self, text="", tool_calls=None):
self.text = text or ""
self.tool_calls = list(tool_calls) if tool_calls else []
def __repr__(self):
return "ModelReply(%r, %r)" % (self.text, self.tool_calls)
class LoopResult(object):
"""The outcome of a run: messages, the budget accounting, and the log."""
__slots__ = ("messages", "steps", "tokens", "stop_reason", "transcript", "model_error")
def __init__(self, messages, steps, tokens, stop_reason, transcript, model_error=None):
self.messages = messages
self.steps = steps
self.tokens = tokens
self.stop_reason = stop_reason
self.transcript = transcript
self.model_error = model_error
@property
def ok(self):
return self.stop_reason in (StopReason.NO_TOOL_CALLS, StopReason.SENTINEL)
def tool_results(self):
"""The ToolResult records, in order, as recorded in the transcript."""
out = []
for record in self.transcript.records:
if record["kind"] == KIND_TOOL:
p = record["payload"]
out.append(ToolResult(p["name"], p["ok"], p["value"], p["error"]))
return out
def __repr__(self):
return "LoopResult(stop=%r, steps=%d, tokens=%d)" % (
self.stop_reason,
self.steps,
self.tokens,
)
def estimate_tokens(text):
"""4 chars/token, floor 1 for non-empty text. A heuristic, documented."""
if not text:
return 0
return max(1, len(text) // 4)
class AgentLoop(object):
def __init__(self, registry, max_steps=8, max_tokens=4000, stop_sentinel=None):
if not isinstance(registry, ToolRegistry):
raise TypeError("registry must be a ToolRegistry, got %s" % type(registry).__name__)
if not isinstance(max_steps, int) or isinstance(max_steps, bool) or max_steps < 1:
raise ValueError("max_steps must be a positive integer")
if not isinstance(max_tokens, int) or isinstance(max_tokens, bool) or max_tokens < 1:
raise ValueError("max_tokens must be a positive integer")
self.registry = registry
self.max_steps = max_steps
self.max_tokens = max_tokens
self.stop_sentinel = stop_sentinel
# -- one tool call ---------------------------------------------------
def _execute(self, call):
try:
tool = self.registry.get(call.name)
except ToolError as exc:
return ToolResult(call.name, False, None, str(exc))
try:
value = tool(call.arguments)
except ValidationError as exc:
return ToolResult(call.name, False, None, "invalid arguments: %s" % exc)
except ToolError as exc:
return ToolResult(call.name, False, None, str(exc))
except Exception as exc: # defensive: a transcript must stay writable
return ToolResult(
call.name, False, None, "tool raised %s: %s" % (type(exc).__name__, exc)
)
# A value that cannot be serialised cannot enter the transcript, and a
# result that cannot be recorded is not a result.
try:
canonical_json(value)
except (TypeError, ValueError) as exc:
return ToolResult(
call.name, False, None, "tool returned a non-serialisable value: %s" % exc
)
return ToolResult(call.name, True, value)
# -- the loop --------------------------------------------------------
def run(self, task, model, transcript=None):
if not callable(model):
raise TypeError("model must be callable")
tr = transcript if transcript is not None else Transcript()
tr.append(KIND_USER, {"text": task})
messages = [{"role": "user", "content": task}]
steps = 0
tokens = 0
stop_reason = None
model_error = None
while True:
if steps >= self.max_steps:
stop_reason = StopReason.STEP_BUDGET
break
try:
reply = model(messages, self.registry.manifest())
except Exception as exc:
stop_reason = StopReason.MODEL_ERROR
model_error = "model raised %s: %s" % (type(exc).__name__, exc)
break
if not isinstance(reply, ModelReply):
stop_reason = StopReason.MODEL_ERROR
model_error = "model returned %s, expected ModelReply" % type(reply).__name__
break
steps += 1
tokens += estimate_tokens(reply.text)
calls = [
c if isinstance(c, ToolCall) else ToolCall(**c) for c in (reply.tool_calls or [])
]
recorded_calls = [c.as_dict() for c in calls]
tr.append(KIND_ASSISTANT, {"text": reply.text, "tool_calls": recorded_calls})
messages.append(
{"role": "assistant", "content": reply.text, "tool_calls": recorded_calls}
)
if self.stop_sentinel and self.stop_sentinel in reply.text:
stop_reason = StopReason.SENTINEL
break
if not calls:
stop_reason = StopReason.NO_TOOL_CALLS
break
if tokens > self.max_tokens:
stop_reason = StopReason.TOKEN_BUDGET
break
for call in calls:
result = self._execute(call)
tr.append(KIND_TOOL, result.as_dict())
content = json.dumps(result.value) if result.ok else result.error
messages.append(
{
"role": "tool",
"content": content,
"name": call.name,
"ok": result.ok,
}
)
tr.append(
KIND_FINAL,
{
"stop_reason": stop_reason,
"steps": steps,
"tokens": tokens,
"model_error": model_error,
},
)
return LoopResult(messages, steps, tokens, stop_reason, tr, model_error)
@@ -0,0 +1,260 @@
"""Tool registry, and a JSON-Schema-subset validator for tool arguments.
The validator covers the subset a tool loop actually needs: ``type`` (including
the integer/number distinction, where ``1`` is a valid integer and ``True`` is
not), ``enum``, ``required``, ``additionalProperties``, ``items``, ``properties``
and the numeric/string bounds. It is deliberately not a full JSON Schema
implementation -- ``allOf``/``$ref``/``format`` are absent, and claiming they
were supported would be a lie a caller eventually finds out.
``bool`` is checked before ``int`` because ``bool`` is a subclass of ``int`` in
Python: without that ordering ``True`` would validate as the integer ``1``.
"""
from __future__ import annotations
import re
__all__ = [
"ToolError",
"ToolNotFound",
"ValidationError",
"ToolCall",
"ToolResult",
"Tool",
"ToolRegistry",
"validate",
]
class ToolError(Exception):
"""Base class for every tool failure the loop can report."""
class ToolNotFound(ToolError):
"""The model named a tool that is not registered."""
class ValidationError(ToolError):
"""The model's arguments do not satisfy the tool's schema."""
def _is_type(instance, name):
if name == "object":
return isinstance(instance, dict)
if name == "array":
return isinstance(instance, list)
if name == "string":
return isinstance(instance, str)
if name == "boolean":
return isinstance(instance, bool)
if name == "null":
return instance is None
if name == "integer":
return isinstance(instance, int) and not isinstance(instance, bool)
if name == "number":
return isinstance(instance, (int, float)) and not isinstance(instance, bool)
# An unknown type name is a schema bug, not a caller bug: fail loudly.
raise ValidationError("unknown type %r in schema" % (name,))
def validate(instance, schema, path="$"):
"""Raise ``ValidationError`` unless ``instance`` satisfies ``schema``."""
if schema is True or schema == {}:
return
if schema is False:
raise ValidationError("%s: no value is allowed here" % path)
if not isinstance(schema, dict):
raise ValidationError(
"%s: schema must be an object, got %s" % (path, type(schema).__name__)
)
if "enum" in schema:
if instance not in schema["enum"]:
raise ValidationError(
"%s: %r is not one of %r" % (path, instance, schema["enum"])
)
return
if "type" in schema:
declared = schema["type"]
names = declared if isinstance(declared, list) else [declared]
if not any(_is_type(instance, n) for n in names):
raise ValidationError(
"%s: expected type %s, got %s" % (path, declared, type(instance).__name__)
)
if isinstance(instance, str):
if "minLength" in schema and len(instance) < schema["minLength"]:
raise ValidationError(
"%s: shorter than minLength %d" % (path, schema["minLength"])
)
if "maxLength" in schema and len(instance) > schema["maxLength"]:
raise ValidationError(
"%s: longer than maxLength %d" % (path, schema["maxLength"])
)
if "pattern" in schema and re.search(schema["pattern"], instance) is None:
raise ValidationError(
"%s: %r does not match pattern %r" % (path, instance, schema["pattern"])
)
if isinstance(instance, bool):
return
if isinstance(instance, (int, float)):
if "minimum" in schema and instance < schema["minimum"]:
raise ValidationError("%s: %r is below minimum %r" % (path, instance, schema["minimum"]))
if "maximum" in schema and instance > schema["maximum"]:
raise ValidationError("%s: %r is above maximum %r" % (path, instance, schema["maximum"]))
if isinstance(instance, list):
if "minItems" in schema and len(instance) < schema["minItems"]:
raise ValidationError(
"%s: %d items is below minItems %d" % (path, len(instance), schema["minItems"])
)
if "maxItems" in schema and len(instance) > schema["maxItems"]:
raise ValidationError(
"%s: %d items is above maxItems %d" % (path, len(instance), schema["maxItems"])
)
if "items" in schema:
for i, item in enumerate(instance):
validate(item, schema["items"], "%s[%d]" % (path, i))
if isinstance(instance, dict):
for required in schema.get("required", []):
if required not in instance:
raise ValidationError("%s: missing required property %r" % (path, required))
properties = schema.get("properties", {})
for key, value in instance.items():
if key in properties:
validate(value, properties[key], "%s.%s" % (path, key))
elif schema.get("additionalProperties") is False:
raise ValidationError("%s: unexpected property %r" % (path, key))
class ToolCall(object):
"""One tool invocation requested by the model."""
__slots__ = ("name", "arguments")
def __init__(self, name, arguments=None):
self.name = name
self.arguments = dict(arguments) if arguments else {}
def __repr__(self):
return "ToolCall(%r, %r)" % (self.name, self.arguments)
def __eq__(self, other):
return (
isinstance(other, ToolCall)
and self.name == other.name
and self.arguments == other.arguments
)
def as_dict(self):
return {"name": self.name, "arguments": self.arguments}
class ToolResult(object):
"""The outcome of one tool invocation, as recorded in the transcript."""
__slots__ = ("name", "ok", "value", "error")
def __init__(self, name, ok, value=None, error=None):
self.name = name
self.ok = bool(ok)
self.value = value
self.error = error
def __repr__(self):
if self.ok:
return "ToolResult(%r, ok=True, value=%r)" % (self.name, self.value)
return "ToolResult(%r, ok=False, error=%r)" % (self.name, self.error)
def __eq__(self, other):
return (
isinstance(other, ToolResult)
and self.name == other.name
and self.ok == other.ok
and self.value == other.value
and self.error == other.error
)
def as_dict(self):
return {"name": self.name, "ok": self.ok, "value": self.value, "error": self.error}
class Tool(object):
"""A named, described, schema-checked callable."""
def __init__(self, name, description, parameters, fn):
if not isinstance(name, str) or not name:
raise ValueError("tool name must be a non-empty string")
if not callable(fn):
raise ValueError("tool %r: fn must be callable" % name)
if not isinstance(parameters, dict):
raise ValueError("tool %r: parameters must be a JSON-schema object" % name)
self.name = name
self.description = description or ""
self.parameters = parameters
self.fn = fn
def __call__(self, arguments=None):
arguments = dict(arguments) if arguments else {}
validate(arguments, self.parameters, "$.%s" % self.name)
try:
return self.fn(**arguments)
except ToolError:
raise
except Exception as exc:
raise ToolError(
"tool %r raised %s: %s" % (self.name, type(exc).__name__, exc)
)
def manifest(self):
return {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
def __repr__(self):
return "Tool(%r)" % self.name
class ToolRegistry(object):
"""Name -> Tool, with duplicate rejection and a model-facing manifest."""
def __init__(self, tools=()):
self._tools = {}
for tool in tools:
self.add(tool)
def add(self, tool):
if not isinstance(tool, Tool):
raise TypeError("expected Tool, got %s" % type(tool).__name__)
if tool.name in self._tools:
raise ValueError("duplicate tool name %r" % tool.name)
self._tools[tool.name] = tool
return tool
def get(self, name):
try:
return self._tools[name]
except KeyError:
raise ToolNotFound("no tool named %r (have: %s)" % (name, ", ".join(sorted(self._tools))))
def has(self, name):
return name in self._tools
def names(self):
return sorted(self._tools)
def manifest(self):
return [self._tools[n].manifest() for n in sorted(self._tools)]
def __len__(self):
return len(self._tools)
def __contains__(self, name):
return name in self._tools
@@ -0,0 +1,230 @@
"""Append-only JSONL transcript whose records form a SHA-256 hash chain.
An agent transcript is evidence, and evidence that cannot be checked is just a
log. Every record carries the hash of the record before it, so the file is a
chain: rewrite one payload, drop one record, or swap two, 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".
WHAT THIS DOES NOT PROVE -- stated plainly, because a gate that overclaims is
worse than no gate.
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 therefore also returns the record count, so a caller holding an
external count can compare it.
"""
from __future__ import annotations
import hashlib
import json
import os
GENESIS = "0" * 64
KIND_USER = "user"
KIND_ASSISTANT = "assistant"
KIND_TOOL = "tool"
KIND_FINAL = "final"
_KINDS = (KIND_USER, KIND_ASSISTANT, KIND_TOOL, KIND_FINAL)
_FIELDS = ("seq", "kind", "payload", "prev", "hash")
class TranscriptError(Exception):
"""Raised when a transcript is malformed, or a record is rejected."""
def canonical_json(obj):
"""Serialise deterministically: sorted keys, no incidental whitespace.
Two runs of the same loop must produce byte-identical transcripts, so the
encoding has to be a function of the data and nothing else.
"""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _record_hash(seq, kind, payload, prev):
body = canonical_json({"seq": seq, "kind": kind, "payload": payload, "prev": prev})
return hashlib.sha256(body.encode("utf-8")).hexdigest()
class Transcript(object):
"""An in-memory record list that can mirror itself to a JSONL file."""
def __init__(self, path=None):
self.path = path
self.records = []
self._prev = GENESIS
if path and os.path.exists(path):
self.load(path)
# -- writing ---------------------------------------------------------
def append(self, kind, payload):
if kind not in _KINDS:
raise TranscriptError(
"unknown record kind %r (expected one of: %s)" % (kind, ", ".join(_KINDS))
)
if not isinstance(payload, (dict, list)):
raise TranscriptError(
"payload must be a dict or list, got %s" % type(payload).__name__
)
# Reject a payload that cannot be written back out: a transcript that
# cannot be serialised is not a transcript.
try:
canonical_json(payload)
except (TypeError, ValueError) as exc:
raise TranscriptError("payload is not JSON-serialisable: %s" % exc)
seq = len(self.records)
digest = _record_hash(seq, kind, payload, self._prev)
record = {
"seq": seq,
"kind": kind,
"payload": payload,
"prev": self._prev,
"hash": digest,
}
self.records.append(record)
self._prev = digest
if self.path:
self._append_line(record)
return record
def _append_line(self, record):
with open(self.path, "a", encoding="utf-8") as fh:
fh.write(canonical_json(record) + "\n")
# -- reading ---------------------------------------------------------
def load(self, path=None):
path = path or self.path
if not path:
raise TranscriptError("no path given")
with open(path, "rb") as fh:
raw = fh.read()
records = []
for lineno, line in enumerate(raw.decode("utf-8", "replace").splitlines(), 1):
if not line.strip():
continue
try:
records.append(json.loads(line))
except ValueError as exc:
raise TranscriptError("line %d is not JSON: %s" % (lineno, exc))
ok, violations = verify_records(records)
if not ok:
raise TranscriptError("transcript failed verification: %s" % (violations[0],))
self.records = records
self._prev = records[-1]["hash"] if records else GENESIS
self.path = path
return self
def save(self, path=None):
path = path or self.path
if not path:
raise TranscriptError("no path given")
data = self.bytes()
tmp = path + ".tmp"
with open(tmp, "wb") as fh:
fh.write(data)
os.replace(tmp, path)
self.path = path
return path
def bytes(self):
return "".join(canonical_json(r) + "\n" for r in self.records).encode("utf-8")
def __len__(self):
return len(self.records)
def verify_records(records):
"""Return ``(ok, violations)``.
``violations`` is a list of dicts, each naming the record index that broke
the chain and the reason. The scan stops at the FIRST break: past a
broken link every later record's ``prev`` is wrong too, so continuing would
report one fault as many. The first break is the one to fix.
"""
violations = []
prev = GENESIS
for i, raw in enumerate(records):
if not isinstance(raw, dict):
violations.append(
{"seq": i, "reason": "not_an_object", "detail": type(raw).__name__}
)
break
missing = [f for f in _FIELDS if f not in raw]
if missing:
violations.append({"seq": i, "reason": "missing_fields", "detail": missing})
break
if raw["seq"] != i:
violations.append({"seq": i, "reason": "seq_gap", "detail": raw["seq"]})
break
if raw["prev"] != prev:
violations.append(
{
"seq": i,
"reason": "prev_mismatch",
"detail": raw["prev"],
"expected": prev,
}
)
break
try:
expected = _record_hash(raw["seq"], raw["kind"], raw["payload"], raw["prev"])
except (TypeError, ValueError) as exc:
violations.append({"seq": i, "reason": "unserialisable_payload", "detail": str(exc)})
break
if expected != raw["hash"]:
violations.append(
{
"seq": i,
"reason": "hash_mismatch",
"detail": raw["hash"],
"expected": expected,
}
)
break
prev = raw["hash"]
return (not violations), violations
def verify_file(path):
"""Verify a transcript file on disk. Returns ``(ok, violations, count)``.
``count`` is the number of records read. Compare it against an external
expectation to detect truncation -- the chain alone cannot, see module doc.
"""
try:
with open(path, "rb") as fh:
raw = fh.read()
except OSError as exc:
return False, [{"seq": None, "reason": "unreadable", "detail": str(exc)}], 0
records = []
for lineno, line in enumerate(raw.decode("utf-8", "replace").splitlines(), 1):
if not line.strip():
continue
try:
records.append(json.loads(line))
except ValueError as exc:
return (
False,
[
{
"seq": len(records),
"reason": "bad_json",
"line": lineno,
"detail": str(exc),
}
],
len(records),
)
ok, violations = verify_records(records)
return ok, violations, len(records)
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""null lane gate for the agentloop artifact -- one command, exit code IS the verdict.
python3 check.py
0 every leg passed
1 a graded fault (a leg reported one)
2 COULD NOT GRADE (python3 missing, the package will not import, ...)
Legs:
1. the package imports and exposes the documented API;
2. the unittest suite passes (chain integrity, tamper detection, argument
validation, loop budgets, determinism);
3. an INDEPENDENT tamper probe: build a transcript, mutate one payload byte on
disk, and require that verify_file names record 2 with reason hash_mismatch.
This leg does not reuse the suite's helpers -- it re-derives the expectation
from the file's own bytes, so a bug in the test fixtures cannot hide here.
This file resolves its own directory, so the copy in a clone grades that clone.
"""
import os
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
PY = sys.executable or "python3"
def leg_import():
probe = os.path.join(HERE, "_probe_import.py")
with open(probe, "w", encoding="utf-8") as fh:
fh.write(
"import sys; sys.path.insert(0, %r)\n"
"import agentloop\n"
"missing = [n for n in ('Transcript','verify_file','Tool','ToolRegistry',"
"'AgentLoop','ModelReply','StopReason','ToolCall') if not hasattr(agentloop, n)]\n"
"sys.exit(3 if missing else 0)\n" % (os.path.join(HERE, ".."),)
)
try:
rc = subprocess.call([PY, probe])
finally:
os.unlink(probe)
if rc == 3:
return 1, "import leg: documented API is missing"
if rc != 0:
return 2, "import leg: probe exited %d" % rc
return 0, "import leg: agentloop imports, documented API present"
def leg_suite():
env = dict(os.environ)
env["PYTHONPATH"] = os.path.join(HERE, "..") + os.pathsep + env.get("PYTHONPATH", "")
proc = subprocess.Popen(
[PY, "-m", "unittest", "discover", "-s", "tests", "-t", HERE, "-q"],
cwd=HERE, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
out = proc.communicate()[0].decode("utf-8", "replace")
tail = [l for l in out.strip().splitlines() if l.strip()][-6:]
if proc.returncode != 0:
return 1, "suite leg: FAILED\n" + "\n".join(tail)
ran = [l for l in out.splitlines() if l.startswith("Ran ")]
return 0, "suite leg: PASS (%s)" % (ran[0] if ran else "no summary line")
def leg_tamper():
"""Independent probe: mutate a transcript on disk, require the right verdict."""
sys.path.insert(0, os.path.join(HERE, ".."))
try:
from agentloop import Transcript, canonical_json
from agentloop.transcript import KIND_ASSISTANT, KIND_FINAL, KIND_TOOL, KIND_USER
except Exception as exc:
return 2, "tamper leg: could not import agentloop (%s)" % exc
tr = Transcript()
tr.append(KIND_USER, {"text": "add 2 and 3"})
tr.append(KIND_ASSISTANT, {"text": "calling add", "tool_calls": []})
tr.append(KIND_TOOL, {"name": "add", "ok": True, "value": 5})
tr.append(KIND_FINAL, {"stop_reason": "no_tool_calls", "steps": 1, "tokens": 4})
fd, path = tempfile.mkstemp(suffix=".jsonl")
os.close(fd)
try:
with open(path, "wb") as fh:
fh.write(tr.bytes())
# Read the file's own bytes, flip one character of record 2's payload,
# and write it back. Nothing here uses the suite's helpers.
with open(path, "rb") as fh:
raw = fh.read()
target = raw.index(b'"value":5')
flipped = raw[:target] + b'"value":9' + raw[target + len(b'"value":5'):]
self_check = flipped != raw
with open(path, "wb") as fh:
fh.write(flipped)
# Re-verify from disk, using only verify_file.
from agentloop import verify_file
ok, violations, count = verify_file(path)
finally:
os.unlink(path)
if not self_check:
return 2, "tamper leg: the mutation did not change the file"
if ok:
return 1, "tamper leg: a mutated transcript VERIFIED -- the chain is decorative"
if not violations:
return 1, "tamper leg: failed but named no violation"
v = violations[0]
if v.get("seq") != 2 or v.get("reason") != "hash_mismatch":
return 1, "tamper leg: wrong verdict -- got %r" % (v,)
if count != 4:
return 1, "tamper leg: expected 4 records, counted %d" % count
return 0, "tamper leg: mutation caught at seq 2 (hash_mismatch), 4 records intact"
LEGS = (leg_import, leg_suite, leg_tamper)
def main():
if not PY:
print("COULD NOT GRADE: no python3 interpreter")
return 2
rc = 0
print("agentloop gate -- %s" % HERE)
for leg in LEGS:
try:
code, message = leg()
except Exception as exc:
code, message = 2, "%s leg: raised %s: %s" % (leg.__name__, type(exc).__name__, exc)
print(" [%s] %s" % ("ok" if code == 0 else ("FAIL" if code == 1 else "N/A"), message))
if code == 1:
rc = 1
elif code == 2 and rc == 0:
rc = 2
if rc == 0:
print("VERDICT: every leg passed")
elif rc == 1:
print("VERDICT: graded fault")
else:
print("VERDICT: could not grade")
return rc
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,5 @@
{"hash":"5902d5e29878dc70da3b6794b74dd8479c3cea8951fa8e8ad39ec849b0c0e12b","kind":"user","payload":{"text":"What is 2 + 3?"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":0}
{"hash":"1f08fd31fcf3136f44905485ffdd15b77eb3970f9048a4d3d2f37b44f61e7617","kind":"assistant","payload":{"text":"Let me add those.","tool_calls":[{"arguments":{"a":2,"b":3},"name":"add"}]},"prev":"5902d5e29878dc70da3b6794b74dd8479c3cea8951fa8e8ad39ec849b0c0e12b","seq":1}
{"hash":"cc7b32293589a003b6bc75757c6b161744934de244299befd6f44b2d0950bda1","kind":"tool","payload":{"error":null,"name":"add","ok":true,"value":{"sum":5}},"prev":"1f08fd31fcf3136f44905485ffdd15b77eb3970f9048a4d3d2f37b44f61e7617","seq":2}
{"hash":"b783e9591ef27741504c3a66889b65b04b21c1b00e60f1caef9394b586cfb0a4","kind":"assistant","payload":{"text":"2 + 3 = 5.","tool_calls":[]},"prev":"cc7b32293589a003b6bc75757c6b161744934de244299befd6f44b2d0950bda1","seq":3}
{"hash":"93405bb51db5518e1078f639f60949bf08361901d54be2edeb3286db8ed2c59c","kind":"final","payload":{"model_error":null,"steps":2,"stop_reason":"no_tool_calls","tokens":6},"prev":"b783e9591ef27741504c3a66889b65b04b21c1b00e60f1caef9394b586cfb0a4","seq":4}
@@ -0,0 +1,9 @@
{"hash":"9f2b12e6cea41d14dbd01bfa202219d2275bfe726637149d2552875f88139e38","kind":"user","payload":{"text":"Keep going forever."},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":0}
{"hash":"b9809a74113fe58c8ebd7fb896025c5a5b9cdd3834d7ecff77ea2efba6d3ba3e","kind":"assistant","payload":{"text":"still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) still going (step 1) ","tool_calls":[{"arguments":{"text":"tick"},"name":"echo"}]},"prev":"9f2b12e6cea41d14dbd01bfa202219d2275bfe726637149d2552875f88139e38","seq":1}
{"hash":"8d9127924a7da47463acf8e3e2af264d9346824eca0547195f86f8f9d9298ff7","kind":"tool","payload":{"error":null,"name":"echo","ok":true,"value":"tick"},"prev":"b9809a74113fe58c8ebd7fb896025c5a5b9cdd3834d7ecff77ea2efba6d3ba3e","seq":2}
{"hash":"ad178c6060058adb2ce55126b628886d196a86729f374d89083c2c37c6f52e6a","kind":"assistant","payload":{"text":"still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) still going (step 2) ","tool_calls":[{"arguments":{"text":"tick"},"name":"echo"}]},"prev":"8d9127924a7da47463acf8e3e2af264d9346824eca0547195f86f8f9d9298ff7","seq":3}
{"hash":"a538a65f72d12b8a9d723996b7dff0ffe8a74a621da3a89bffaf1f29de1a8973","kind":"tool","payload":{"error":null,"name":"echo","ok":true,"value":"tick"},"prev":"ad178c6060058adb2ce55126b628886d196a86729f374d89083c2c37c6f52e6a","seq":4}
{"hash":"6963d68e4cc1182d47d115ecce90ee9fd809d97cb7121b80073b0db6f2641253","kind":"assistant","payload":{"text":"still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) still going (step 3) ","tool_calls":[{"arguments":{"text":"tick"},"name":"echo"}]},"prev":"a538a65f72d12b8a9d723996b7dff0ffe8a74a621da3a89bffaf1f29de1a8973","seq":5}
{"hash":"9cd3e5fe7c6630157085ae34750b16fa145d96727ff1cf3a59c035bfce3a022a","kind":"tool","payload":{"error":null,"name":"echo","ok":true,"value":"tick"},"prev":"6963d68e4cc1182d47d115ecce90ee9fd809d97cb7121b80073b0db6f2641253","seq":6}
{"hash":"2f3e40832cc8d2eeb3cb45e7703e69395865419e21fe01e3bb60a68047fbdd3a","kind":"assistant","payload":{"text":"still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) still going (step 4) ","tool_calls":[{"arguments":{"text":"tick"},"name":"echo"}]},"prev":"9cd3e5fe7c6630157085ae34750b16fa145d96727ff1cf3a59c035bfce3a022a","seq":7}
{"hash":"eeae79182ffb73b527092b95d57b3ac162cc70969ea2afb2e613b281636d50ec","kind":"final","payload":{"model_error":null,"steps":4,"stop_reason":"token_budget","tokens":252},"prev":"2f3e40832cc8d2eeb3cb45e7703e69395865419e21fe01e3bb60a68047fbdd3a","seq":8}
@@ -0,0 +1,11 @@
{"hash":"d23b04b27ffcbd916ebcf850a097cfcd2132497171511d309ffaacd01d7c3150","kind":"user","payload":{"text":"Add 20 and 22, tolerating my mistakes."},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":0}
{"hash":"d27a02a27764ea253597c8c68c4e86750e3b6e0b592f60eefb65e70466e990c6","kind":"assistant","payload":{"text":"First try a name I made up.","tool_calls":[{"arguments":{"a":1},"name":"multiply"}]},"prev":"d23b04b27ffcbd916ebcf850a097cfcd2132497171511d309ffaacd01d7c3150","seq":1}
{"hash":"1d030d3a1120706c6e2b11783feda547ce2cfb8d50ea90175e7836139d0481f5","kind":"tool","payload":{"error":"no tool named 'multiply' (have: add, echo, fail, greet)","name":"multiply","ok":false,"value":null},"prev":"d27a02a27764ea253597c8c68c4e86750e3b6e0b592f60eefb65e70466e990c6","seq":2}
{"hash":"62bc277459dec0852538b75ebfceafabff7671d957ded874adb350ee20846fbf","kind":"assistant","payload":{"text":"Now with the wrong argument type.","tool_calls":[{"arguments":{"a":"2","b":3},"name":"add"}]},"prev":"1d030d3a1120706c6e2b11783feda547ce2cfb8d50ea90175e7836139d0481f5","seq":3}
{"hash":"c18a871a8e6ee7c0d8aff95e7dd8c9aaacb3ab9d533b8715e73a8a4f19a1c5ff","kind":"tool","payload":{"error":"invalid arguments: $.add.a: expected type integer, got str","name":"add","ok":false,"value":null},"prev":"62bc277459dec0852538b75ebfceafabff7671d957ded874adb350ee20846fbf","seq":4}
{"hash":"49fc20d82513ec36e60c2b15eb7e8476d13899ea6de4019245a10799c362603c","kind":"assistant","payload":{"text":"Now one that raises.","tool_calls":[{"arguments":{},"name":"fail"}]},"prev":"c18a871a8e6ee7c0d8aff95e7dd8c9aaacb3ab9d533b8715e73a8a4f19a1c5ff","seq":5}
{"hash":"c173467c4892c61190cd92bfe3751f49d107c30fca2041a358eb86259a85b917","kind":"tool","payload":{"error":"tool 'fail' raised RuntimeError: the tool is broken on purpose","name":"fail","ok":false,"value":null},"prev":"49fc20d82513ec36e60c2b15eb7e8476d13899ea6de4019245a10799c362603c","seq":6}
{"hash":"ac2001b3c4a39433bfa80fd767eb5b33f494450e556f0816c16a4d78d75ba7de","kind":"assistant","payload":{"text":"Now correctly.","tool_calls":[{"arguments":{"a":20,"b":22},"name":"add"}]},"prev":"c173467c4892c61190cd92bfe3751f49d107c30fca2041a358eb86259a85b917","seq":7}
{"hash":"9a7693f9c98cf8661c1152417110f310d95e8f4e13636a8f46fe1ed68a76e100","kind":"tool","payload":{"error":null,"name":"add","ok":true,"value":{"sum":42}},"prev":"ac2001b3c4a39433bfa80fd767eb5b33f494450e556f0816c16a4d78d75ba7de","seq":8}
{"hash":"c987f959f3890b98e242846c442382832b299668188c300d577cdb89fb6498c2","kind":"assistant","payload":{"text":"20 + 22 = 42. DONE","tool_calls":[]},"prev":"9a7693f9c98cf8661c1152417110f310d95e8f4e13636a8f46fe1ed68a76e100","seq":9}
{"hash":"bd7a28422b47ae5a17a27d8688e790e2decb0edd75149f0d74abd70863788d82","kind":"final","payload":{"model_error":null,"steps":5,"stop_reason":"sentinel","tokens":26},"prev":"c987f959f3890b98e242846c442382832b299668188c300d577cdb89fb6498c2","seq":10}
+159
View File
@@ -0,0 +1,159 @@
"""End-to-end demo: three scripted models over one toolset.
These are not the test suite -- tests/ is. They are the readable proof that the
engine does something: a model that answers, a model that misuses its tools and
recovers, and a model that never stops and is cut off by the budget.
python3 demo.py # runs all three, writes demo-*.jsonl, prints a summary
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, ".."))
from agentloop import AgentLoop, ModelReply, StopReason, Tool, ToolCall, ToolRegistry # noqa: E402
from agentloop.transcript import verify_file # noqa: E402
def make_registry():
def add(a, b):
return {"sum": a + b}
def greet(name, excited=False):
return "hello %s%s" % (name, "!" if excited else ".")
def fail():
raise RuntimeError("the tool is broken on purpose")
def echo(text):
return text
return ToolRegistry(
[
Tool("add", "Add two integers.", {
"type": "object",
"properties": {
"a": {"type": "integer"},
"b": {"type": "integer"},
},
"required": ["a", "b"],
"additionalProperties": False,
}, add),
Tool("greet", "Greet someone by name.", {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1},
"excited": {"type": "boolean"},
},
"required": ["name"],
}, greet),
Tool("fail", "Always raises, to show a tool error in the transcript.", {
"type": "object", "properties": {}, "additionalProperties": False,
}, fail),
Tool("echo", "Return the text unchanged.", {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
}, echo),
]
)
# -- the three models -----------------------------------------------------
def answering_model(messages, manifest):
"""Adds 2 and 3, then answers without asking for anything else."""
if not any(m.get("name") == "add" for m in messages):
return ModelReply("Let me add those.", [ToolCall("add", {"a": 2, "b": 3})])
return ModelReply("2 + 3 = 5.")
def recovering_model(messages, manifest):
"""First turn: a tool that does not exist, then a bad argument, then a
crashing tool, then a valid call. Every failure is reported to the model as
a tool message and the loop continues."""
n = sum(1 for m in messages if m["role"] == "tool")
if n == 0:
return ModelReply("First try a name I made up.", [ToolCall("multiply", {"a": 1})])
if n == 1:
return ModelReply("Now with the wrong argument type.", [ToolCall("add", {"a": "2", "b": 3})])
if n == 2:
return ModelReply("Now one that raises.", [ToolCall("fail", {})])
if n == 3:
return ModelReply("Now correctly.", [ToolCall("add", {"a": 20, "b": 22})])
return ModelReply("20 + 22 = 42. DONE", [])
def runaway_model(messages, manifest):
"""Never stops on its own. Only the budget stops it."""
i = sum(1 for m in messages if m["role"] == "assistant") + 1
return ModelReply("still going (step %d) " % i * 12, [ToolCall("echo", {"text": "tick"})])
# -- runner ---------------------------------------------------------------
def main():
out_dir = HERE
runs = []
loop = AgentLoop(make_registry(), max_steps=6)
result = loop.run("What is 2 + 3?", answering_model)
path = os.path.join(out_dir, "demo-answer.jsonl")
result.transcript.save(path)
runs.append(("answering", result, path))
loop = AgentLoop(make_registry(), max_steps=6, stop_sentinel="DONE")
result = loop.run("Add 20 and 22, tolerating my mistakes.", recovering_model)
path = os.path.join(out_dir, "demo-recover.jsonl")
result.transcript.save(path)
runs.append(("recovering", result, path))
loop = AgentLoop(make_registry(), max_steps=4, max_tokens=200)
result = loop.run("Keep going forever.", runaway_model)
path = os.path.join(out_dir, "demo-budget.jsonl")
result.transcript.save(path)
runs.append(("runaway", result, path))
print("demo runs")
for name, result, path in runs:
ok, violations, count = verify_file(path)
print(
" %-11s stop=%-13s steps=%d tokens=%-4d records=%d chain=%s"
% (name, result.stop_reason, result.steps, result.tokens, count, "OK" if ok else violations)
)
errors = sum(1 for r in runs[1][1].tool_results() if not r.ok)
print(" recovering tool failures surfaced to the model: %d" % errors)
print(" runaway stopped by budget, not by the model: %s"
% (runs[2][1].stop_reason in (StopReason.STEP_BUDGET, StopReason.TOKEN_BUDGET)))
print(" transcripts written next to demo.py")
# Exit 0 only if every claim above actually held.
if not all(verify_file(p)[0] for _, _, p in runs):
print("FAIL: a transcript failed chain verification")
return 1
if runs[0][1].stop_reason != StopReason.NO_TOOL_CALLS:
print("FAIL: the answering model did not stop on no_tool_calls")
return 1
if runs[1][1].stop_reason != StopReason.SENTINEL:
print("FAIL: the recovering model did not stop on the sentinel")
return 1
if errors != 3:
print("FAIL: expected 3 surfaced tool failures, saw %d" % errors)
return 1
if runs[2][1].stop_reason not in (StopReason.STEP_BUDGET, StopReason.TOKEN_BUDGET):
print("FAIL: the runaway model was not cut off by a budget")
return 1
print("ALL DEMO CLAIMS HELD")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1 @@
"""Test package for agentloop."""
@@ -0,0 +1,605 @@
"""Test suite for agentloop.
Run it directly (``python3 -m unittest discover -s tests`` from the artifact
root) or through check.py, which also runs an independent tamper probe.
The tamper tests are the teeth: a verifier that cannot be shown to fail is not
evidence. Every mutation below is one a real editor or a buggy writer could
make, and each is asserted to be caught AND to be caught at the right record.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from agentloop import ( # noqa: E402
AgentLoop,
ModelReply,
StopReason,
Tool,
ToolCall,
ToolRegistry,
ToolResult,
Transcript,
TranscriptError,
ValidationError,
canonical_json,
estimate_tokens,
validate,
verify_file,
verify_records,
)
from agentloop.transcript import GENESIS, KIND_ASSISTANT, KIND_FINAL, KIND_TOOL, KIND_USER # noqa: E402
def add(a, b):
return a + b
def make_registry():
return ToolRegistry(
[
Tool("add", "Add two integers.", {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
"additionalProperties": False,
}, add),
Tool("shout", "Uppercase a string.", {
"type": "object",
"properties": {"text": {"type": "string", "minLength": 1}},
"required": ["text"],
}, lambda text: text.upper()),
]
)
def write_tmp(records, raw_lines=None):
fd, path = tempfile.mkstemp(suffix=".jsonl")
os.close(fd)
with open(path, "wb") as fh:
if raw_lines is not None:
fh.write(("\n".join(raw_lines) + "\n").encode("utf-8"))
else:
fh.write("".join(canonical_json(r) + "\n" for r in records).encode("utf-8"))
return path
# --------------------------------------------------------------------------
# transcript: the chain
# --------------------------------------------------------------------------
class TestTranscriptChain(unittest.TestCase):
def test_chain_links_each_record_to_its_predecessor(self):
tr = Transcript()
tr.append(KIND_USER, {"text": "hi"})
tr.append(KIND_ASSISTANT, {"text": "yo"})
tr.append(KIND_TOOL, {"name": "add", "ok": True, "value": 5})
self.assertEqual([r["seq"] for r in tr.records], [0, 1, 2])
self.assertEqual(tr.records[0]["prev"], GENESIS)
for earlier, later in zip(tr.records, tr.records[1:]):
self.assertEqual(later["prev"], earlier["hash"])
def test_genesis_hash_is_the_all_zero_constant(self):
tr = Transcript()
tr.append(KIND_USER, {"text": "x"})
self.assertEqual(tr.records[0]["prev"], "0" * 64)
def test_bytes_are_deterministic_and_reloadable(self):
def build():
tr = Transcript()
tr.append(KIND_USER, {"text": "same"})
tr.append(KIND_ASSISTANT, {"text": "same", "tool_calls": []})
return tr
self.assertEqual(build().bytes(), build().bytes())
path = write_tmp(build().records)
try:
self.assertEqual(Transcript(path).bytes(), build().bytes())
finally:
os.unlink(path)
def test_save_then_load_round_trips(self):
tr = Transcript()
tr.append(KIND_USER, {"text": "round trip"})
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "t.jsonl")
tr.save(path)
self.assertEqual(Transcript(path).records, tr.records)
self.assertEqual(verify_file(path)[:2], (True, []))
def test_rejects_unknown_kind_and_bad_payload(self):
tr = Transcript()
with self.assertRaises(TranscriptError):
tr.append("system", {"text": "nope"})
with self.assertRaises(TranscriptError):
tr.append(KIND_USER, "not a dict")
with self.assertRaises(TranscriptError):
tr.append(KIND_USER, {"bad": object()})
def test_load_raises_on_bad_json_with_the_line_number(self):
path = write_tmp(None, raw_lines=['{"seq": 0}', "{not json"])
try:
with self.assertRaises(TranscriptError) as ctx:
Transcript(path)
self.assertIn("line 2", str(ctx.exception))
finally:
os.unlink(path)
class TestTamperDetection(unittest.TestCase):
"""The teeth. Each mutation must be caught, and at the named record."""
def setUp(self):
tr = Transcript()
tr.append(KIND_USER, {"text": "add 2 and 3"})
tr.append(KIND_ASSISTANT, {"text": "calling add", "tool_calls": []})
tr.append(KIND_TOOL, {"name": "add", "ok": True, "value": 5})
tr.append(KIND_FINAL, {"stop_reason": "no_tool_calls", "steps": 1, "tokens": 4})
self.records = tr.records
def test_clean_chain_verifies(self):
ok, violations = verify_records(self.records)
self.assertTrue(ok, violations)
self.assertEqual(violations, [])
def test_payload_rewrite_is_caught_at_that_record(self):
tampered = json.loads(json.dumps(self.records))
tampered[2]["payload"]["value"] = 999
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["seq"], 2)
self.assertEqual(violations[0]["reason"], "hash_mismatch")
def test_single_character_edit_in_payload_is_caught(self):
tampered = json.loads(json.dumps(self.records))
tampered[0]["payload"]["text"] = "add 2 and 4"
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["seq"], 0)
self.assertEqual(violations[0]["reason"], "hash_mismatch")
def test_dropped_middle_record_is_caught_as_a_seq_gap(self):
tampered = json.loads(json.dumps(self.records))
del tampered[1]
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["seq"], 1)
self.assertEqual(violations[0]["reason"], "seq_gap")
def test_swapped_records_break_the_chain_at_the_first_swapped_index(self):
"""A raw swap moves record 2's seq into slot 1, so the seq check fires
first. The chain breaks and names index 1 -- which is the fault."""
tampered = json.loads(json.dumps(self.records))
tampered[1], tampered[2] = tampered[2], tampered[1]
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["seq"], 1)
self.assertEqual(violations[0]["reason"], "seq_gap")
def test_reorder_plus_renumber_is_caught_as_a_prev_mismatch(self):
"""The harder attack: swap two records AND renumber them so the seq
check passes. Only the prev link can catch this, and it does."""
tampered = json.loads(json.dumps(self.records))
tampered[1], tampered[2] = tampered[2], tampered[1]
tampered[1]["seq"], tampered[2]["seq"] = 1, 2
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["seq"], 1)
self.assertEqual(violations[0]["reason"], "prev_mismatch")
def test_forged_record_with_a_recomputed_hash_still_breaks_the_chain(self):
"""An attacker who rewrites a payload AND recomputes that record's hash
is still caught, because the NEXT record still points at the old hash."""
from agentloop.transcript import _record_hash
tampered = json.loads(json.dumps(self.records))
tampered[2]["payload"]["value"] = 999
tampered[2]["hash"] = _record_hash(
tampered[2]["seq"], tampered[2]["kind"], tampered[2]["payload"], tampered[2]["prev"]
)
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["seq"], 3)
self.assertEqual(violations[0]["reason"], "prev_mismatch")
def test_hash_field_replaced_by_a_random_hex_is_caught(self):
tampered = json.loads(json.dumps(self.records))
tampered[1]["hash"] = "f" * 64
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["seq"], 1)
self.assertEqual(violations[0]["reason"], "hash_mismatch")
def test_missing_field_is_reported_by_name(self):
tampered = json.loads(json.dumps(self.records))
del tampered[0]["prev"]
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["reason"], "missing_fields")
self.assertEqual(violations[0]["detail"], ["prev"])
def test_non_object_record_is_caught(self):
tampered = json.loads(json.dumps(self.records))
tampered[1] = "i am not a record"
ok, violations = verify_records(tampered)
self.assertFalse(ok)
self.assertEqual(violations[0]["reason"], "not_an_object")
def test_bad_json_line_is_reported_with_its_line_number(self):
path = write_tmp(None, raw_lines=[canonical_json(self.records[0]), "{oops"])
try:
ok, violations, count = verify_file(path)
self.assertFalse(ok)
self.assertEqual(violations[0]["reason"], "bad_json")
self.assertEqual(violations[0]["line"], 2)
self.assertEqual(count, 1)
finally:
os.unlink(path)
def test_truncation_is_detected_only_against_an_external_count(self):
"""The honest limit: the chain alone cannot see a missing tail."""
full = write_tmp(self.records)
cut = write_tmp(self.records[:2])
try:
ok_full, _, n_full = verify_file(full)
ok_cut, _, n_cut = verify_file(cut)
self.assertTrue(ok_full)
self.assertTrue(ok_cut) # chain intact -- this is the documented limit
self.assertLess(n_cut, n_full)
finally:
os.unlink(full)
os.unlink(cut)
def test_verify_file_reports_an_unreadable_path(self):
ok, violations, count = verify_file("/nonexistent/nope.jsonl")
self.assertFalse(ok)
self.assertEqual(violations[0]["reason"], "unreadable")
self.assertEqual(count, 0)
# --------------------------------------------------------------------------
# tool arguments
# --------------------------------------------------------------------------
class TestArgumentValidation(unittest.TestCase):
def test_valid_arguments_pass(self):
validate({"a": 2, "b": 3}, {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
})
def test_missing_required_property(self):
with self.assertRaises(ValidationError) as ctx:
validate({"a": 2}, {"type": "object", "properties": {"a": {"type": "integer"}},
"required": ["a", "b"]})
self.assertIn("b", str(ctx.exception))
def test_wrong_type(self):
with self.assertRaises(ValidationError):
validate({"a": "2", "b": 3}, {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
})
def test_bool_is_not_an_integer(self):
"""bool subclasses int; without the explicit guard True == 1."""
with self.assertRaises(ValidationError):
validate({"a": True, "b": 3}, {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
})
def test_float_is_a_number_but_not_an_integer(self):
validate(1.5, {"type": "number"})
with self.assertRaises(ValidationError):
validate(1.5, {"type": "integer"})
def test_additional_properties_false_rejects_extras(self):
with self.assertRaises(ValidationError) as ctx:
validate({"a": 1, "b": 2, "c": 3}, {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"additionalProperties": False,
})
self.assertIn("c", str(ctx.exception))
def test_enum(self):
validate("red", {"enum": ["red", "green"]})
with self.assertRaises(ValidationError):
validate("blue", {"enum": ["red", "green"]})
def test_array_items_are_validated_recursively(self):
schema = {"type": "array", "items": {"type": "integer"}, "minItems": 1}
validate([1, 2, 3], schema)
with self.assertRaises(ValidationError) as ctx:
validate([1, "two"], schema)
self.assertIn("[1]", str(ctx.exception))
def test_min_items_and_max_items(self):
with self.assertRaises(ValidationError):
validate([], {"type": "array", "minItems": 1})
with self.assertRaises(ValidationError):
validate([1, 2], {"type": "array", "maxItems": 1})
def test_string_bounds_and_pattern(self):
with self.assertRaises(ValidationError):
validate("", {"type": "string", "minLength": 1})
with self.assertRaises(ValidationError):
validate("toolong", {"type": "string", "maxLength": 3})
with self.assertRaises(ValidationError):
validate("abc", {"type": "string", "pattern": "^[0-9]+$"})
validate("123", {"type": "string", "pattern": "^[0-9]+$"})
def test_numeric_bounds(self):
validate(5, {"type": "integer", "minimum": 5, "maximum": 5})
with self.assertRaises(ValidationError):
validate(4, {"type": "integer", "minimum": 5})
with self.assertRaises(ValidationError):
validate(6, {"type": "integer", "maximum": 5})
def test_nested_object_properties_are_checked(self):
schema = {
"type": "object",
"properties": {"outer": {"type": "object", "properties": {"n": {"type": "integer"}},
"required": ["n"]}},
}
validate({"outer": {"n": 1}}, schema)
with self.assertRaises(ValidationError) as ctx:
validate({"outer": {"n": "x"}}, schema)
self.assertIn("outer.n", str(ctx.exception))
def test_unknown_type_in_schema_is_a_loud_error(self):
with self.assertRaises(ValidationError):
validate(1, {"type": "flobble"})
def test_tool_call_validates_before_running(self):
tool = Tool("add", "add", {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
}, add)
self.assertEqual(tool({"a": 2, "b": 3}), 5)
with self.assertRaises(ValidationError):
tool({"a": 2})
def test_tool_wraps_an_unexpected_exception_in_toolerror(self):
def boom():
raise RuntimeError("kaboom")
tool = Tool("boom", "booms", {"type": "object", "properties": {}}, boom)
with self.assertRaises(Exception) as ctx:
tool({})
self.assertIn("kaboom", str(ctx.exception))
def test_registry_rejects_duplicate_names(self):
reg = ToolRegistry([Tool("add", "", {"type": "object", "properties": {}}, add)])
with self.assertRaises(ValueError):
reg.add(Tool("add", "", {"type": "object", "properties": {}}, add))
def test_registry_manifest_is_sorted_and_complete(self):
reg = make_registry()
names = [m["name"] for m in reg.manifest()]
self.assertEqual(names, ["add", "shout"])
for m in reg.manifest():
self.assertEqual(sorted(m), ["description", "name", "parameters"])
# --------------------------------------------------------------------------
# the loop
# --------------------------------------------------------------------------
class TestLoop(unittest.TestCase):
def test_completes_when_the_model_stops_asking_for_tools(self):
def model(messages, manifest):
return ModelReply("done", [])
result = AgentLoop(make_registry()).run("hi", model)
self.assertEqual(result.stop_reason, StopReason.NO_TOOL_CALLS)
self.assertTrue(result.ok)
self.assertEqual(result.steps, 1)
def test_executes_tool_calls_in_order_and_records_them(self):
calls = [ToolCall("add", {"a": 2, "b": 3}), ToolCall("shout", {"text": "hi"})]
seen = []
def model(messages, manifest):
if not seen:
seen.append(1)
return ModelReply("working", calls)
return ModelReply("2+3=5 and HI", [])
result = AgentLoop(make_registry()).run("hi", model)
results = result.tool_results()
self.assertEqual([r.name for r in results], ["add", "shout"])
self.assertEqual([r.value for r in results], [5, "HI"])
self.assertTrue(all(r.ok for r in results))
def test_unknown_tool_is_surfaced_as_an_error_and_the_loop_continues(self):
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("try", [ToolCall("nope", {})])
return ModelReply("recovered", [])
result = AgentLoop(make_registry()).run("hi", model)
self.assertEqual(result.stop_reason, StopReason.NO_TOOL_CALLS)
(only,) = result.tool_results()
self.assertFalse(only.ok)
self.assertIn("no tool named 'nope'", only.error)
def test_invalid_arguments_are_surfaced_as_an_error(self):
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("try", [ToolCall("add", {"a": 1})])
return ModelReply("ok", [])
result = AgentLoop(make_registry()).run("hi", model)
(only,) = result.tool_results()
self.assertFalse(only.ok)
self.assertIn("invalid arguments", only.error)
self.assertIn("b", only.error)
def test_a_tool_that_raises_does_not_kill_the_loop(self):
reg = ToolRegistry([Tool("boom", "booms", {"type": "object", "properties": {}},
lambda: (_ for _ in ()).throw(RuntimeError("kaboom")))])
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("go", [ToolCall("boom", {})])
return ModelReply("survived", [])
result = AgentLoop(reg).run("hi", model)
self.assertEqual(result.stop_reason, StopReason.NO_TOOL_CALLS)
self.assertFalse(result.tool_results()[0].ok)
def test_non_serialisable_return_value_is_refused(self):
reg = ToolRegistry([Tool("obj", "returns an object", {"type": "object", "properties": {}},
lambda: object())])
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("go", [ToolCall("obj", {})])
return ModelReply("done", [])
result = AgentLoop(reg).run("hi", model)
(only,) = result.tool_results()
self.assertFalse(only.ok)
self.assertIn("non-serialisable", only.error)
# and the transcript is still writable
self.assertTrue(result.transcript.bytes())
def test_step_budget_caps_the_loop_and_matches_the_record_count(self):
def runaway(messages, manifest):
return ModelReply("more", [ToolCall("shout", {"text": "x"})])
result = AgentLoop(make_registry(), max_steps=4).run("hi", runaway)
self.assertEqual(result.stop_reason, StopReason.STEP_BUDGET)
self.assertEqual(result.steps, 4)
self.assertFalse(result.ok)
assistant_records = [r for r in result.transcript.records if r["kind"] == KIND_ASSISTANT]
self.assertEqual(len(assistant_records), 4)
self.assertEqual(result.steps, len(assistant_records))
def test_token_budget_stops_the_loop_before_running_tools(self):
def chatty(messages, manifest):
return ModelReply("x" * 400, [ToolCall("shout", {"text": "x"})])
result = AgentLoop(make_registry(), max_steps=50, max_tokens=20).run("hi", chatty)
self.assertEqual(result.stop_reason, StopReason.TOKEN_BUDGET)
self.assertEqual(result.tool_results(), [])
self.assertGreater(result.tokens, 20)
def test_sentinel_stops_the_loop(self):
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("first", [ToolCall("add", {"a": 1, "b": 1})])
return ModelReply("the answer is 2 -- DONE", [])
result = AgentLoop(make_registry(), stop_sentinel="DONE").run("hi", model)
self.assertEqual(result.stop_reason, StopReason.SENTINEL)
self.assertTrue(result.ok)
def test_sentinel_wins_over_a_tool_call_in_the_same_turn(self):
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("first", [ToolCall("add", {"a": 1, "b": 1})])
return ModelReply("done DONE", [ToolCall("add", {"a": 9, "b": 9})])
result = AgentLoop(make_registry(), stop_sentinel="DONE").run("hi", model)
self.assertEqual(result.stop_reason, StopReason.SENTINEL)
self.assertEqual(len(result.tool_results()), 1)
def test_model_exception_becomes_a_model_error_stop(self):
def model(messages, manifest):
raise RuntimeError("the model is down")
result = AgentLoop(make_registry()).run("hi", model)
self.assertEqual(result.stop_reason, StopReason.MODEL_ERROR)
self.assertIn("the model is down", result.model_error)
self.assertFalse(result.ok)
def test_model_returning_the_wrong_type_becomes_a_model_error(self):
result = AgentLoop(make_registry()).run("hi", lambda m, f: "not a reply")
self.assertEqual(result.stop_reason, StopReason.MODEL_ERROR)
def test_a_model_that_raises_mid_run_still_leaves_a_valid_transcript(self):
state = {"n": 0}
def model(messages, manifest):
state["n"] += 1
if state["n"] == 1:
return ModelReply("go", [ToolCall("add", {"a": 1, "b": 2})])
raise RuntimeError("died on turn 2")
result = AgentLoop(make_registry()).run("hi", model)
self.assertEqual(result.stop_reason, StopReason.MODEL_ERROR)
self.assertTrue(verify_records(result.transcript.records)[0])
self.assertEqual(result.transcript.records[-1]["kind"], KIND_FINAL)
def test_transcript_record_order_is_user_assistant_tool_final(self):
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("go", [ToolCall("add", {"a": 1, "b": 1})])
return ModelReply("done", [])
result = AgentLoop(make_registry()).run("hi", model)
kinds = [r["kind"] for r in result.transcript.records]
self.assertEqual(kinds, [KIND_USER, KIND_ASSISTANT, KIND_TOOL, KIND_ASSISTANT, KIND_FINAL])
def test_identical_runs_produce_byte_identical_transcripts(self):
def model(messages, manifest):
if len(messages) == 1:
return ModelReply("go", [ToolCall("add", {"a": 40, "b": 2})])
return ModelReply("42", [])
a = AgentLoop(make_registry()).run("hi", model)
b = AgentLoop(make_registry()).run("hi", model)
self.assertEqual(a.transcript.bytes(), b.transcript.bytes())
def test_a_supplied_transcript_is_appended_to_not_replaced(self):
existing = Transcript()
existing.append(KIND_USER, {"text": "an earlier session"})
result = AgentLoop(make_registry()).run("hi", lambda m, f: ModelReply("done", []),
transcript=existing)
# the supplied record, then user + assistant + final = 4
self.assertEqual(len(result.transcript), 4)
self.assertEqual(result.transcript.records[0]["payload"]["text"], "an earlier session")
self.assertEqual([r["kind"] for r in result.transcript.records][0], KIND_USER)
self.assertTrue(verify_records(result.transcript.records)[0])
def test_budgets_must_be_positive_integers(self):
with self.assertRaises(ValueError):
AgentLoop(make_registry(), max_steps=0)
with self.assertRaises(ValueError):
AgentLoop(make_registry(), max_steps=True)
with self.assertRaises(ValueError):
AgentLoop(make_registry(), max_tokens=0)
def test_registry_must_be_a_toolregistry(self):
with self.assertRaises(TypeError):
AgentLoop({"add": add})
def test_estimate_tokens_is_a_documented_floor(self):
self.assertEqual(estimate_tokens(""), 0)
self.assertEqual(estimate_tokens("ab"), 1) # floor 1, not 0
self.assertEqual(estimate_tokens("abcd"), 1)
self.assertEqual(estimate_tokens("abcde"), 1)
self.assertEqual(estimate_tokens("a" * 40), 10)
if __name__ == "__main__":
unittest.main(verbosity=2)