160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""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())
|