Module 8: Project The Reservo Agent In Production
The Instrumented Run
Description
With the operations layer calibrated in Lesson 2, this lesson sets the first of the four disciplines in motion over real traffic: observing. traced_run (M2, Height 1 from the previous lesson's map) wraps dispatch_robust without touching a single line of its code, and produces, for every step of the loop, a complete JSON event — not at the end of the run, but at the exact instant every tool call happens. This lesson runs a batch of four real Reservo tasks — two that complete fine, one with an expected business error, and one that runs out entirely — all written to the same file, RUN_LOG.jsonl, and reconstructs a batch summary reading exclusively that file, with no reference at all to the Python variables that produced the runs.
This is, precisely, the same demonstration M2 (Lesson 8) already ran in depth — this capstone reuses it without changing a line, because it's exactly the starting point Lessons 4, 5, and 6 of this module need: a real RUN_LOG.jsonl, with real trace_ids, to build cost, latency, and the regression gate on.
Connection to the module
This lesson delivers the capstone's first real artifact: RUN_LOG.jsonl, written to disk by this lesson's same four-task batch. Lessons 4, 5, and 6 of this module are going to read these same runs' history to calculate cost, latency, and run the gate — they're never going to re-run the batch from scratch.
Analogy: the scanner at every station, applied to a complete night of service
M2 (Lesson 5) built the analogy of the scanner at every station of a distribution center: a package passing through a station leaves a record at that instant, not at the end of the complete journey. This lesson applies that same idea to a complete night of service at Reservo's restaurant, not a single order: four different customers arrive, one after another, each with their own order — two get resolved fine, one asks to cancel something that never existed, and one asks such an ambiguous question the kitchen never manages to resolve it within the allowed time. The scanner at every station doesn't distinguish between these four cases as they happen — it records each one, with the same detail, regardless of whether the customer leaves satisfied or their order stays unresolved. By the end of the night, the log book — RUN_LOG.jsonl — tells all four's complete story, with nobody having to remember from memory what happened with each one.
Worked example: four runs, one file, rebuilt from scratch
The batch: two clean, one with a business error, one that fails entirely
import json
import logging
import reservo_agent as ra
import run_logger as rl
_file_handler = logging.FileHandler("RUN_LOG.jsonl", mode="w", encoding="utf-8")
_file_handler.setFormatter(logging.Formatter("%(message)s"))
rl.logger.handlers = [_file_handler]
rl.logger.setLevel(logging.INFO)
script_a = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Focus", "tier": "premium", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_04", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1."}]},
]
script_b = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 1}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "book_room",
"input": {"room": "Boardroom", "tier": "pro", "hours": 1, "member": "Sofia"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Boardroom pro por 1 hora para Sofía. Total $64.00. Confirmación #2."}]},
]
script_cancel_missing = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "cancel_booking", "input": {"id": 999}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "No encontré esa reserva."}]},
]
stuck_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": f"toolu_0{n}", "name": "list_rooms", "input": {}}]}
for n in range(1, 4)
]
tasks = [
("Reserva Focus pro 3h para Ana", script_a, 10),
("Reserva Boardroom pro 1h para Sofia", script_b, 10),
("Cancela la reserva 999", script_cancel_missing, 10),
("Reserva algo ambiguo", stuck_script, 2),
]
for i, (question, script, max_iter) in enumerate(tasks, start=1):
try:
with rl.traced_run(question, i):
final, history = ra.run_reservo_agent(question, script, max_iterations=max_iter)
print(f"run {i} OK :", final["content"][0]["text"])
except RuntimeError as exc:
print(f"run {i} FALLÓ:", exc)
_file_handler.close()
What to expect:
run 1 OK : Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1.
run 2 OK : Reservé Boardroom pro por 1 hora para Sofía. Total $64.00. Confirmación #2.
run 3 OK : No encontré esa reserva.
run 4 FALLÓ: max_iterations alcanzado (2)
Four runs, with genuinely different outcomes: run 1 and run 2 complete fine; run 3 "completes" in the sense that the agent responded with end_turn, but the business result is a cancelled: False (id: 999 never existed) — a negative case, just as valid as a positive one; run 4 runs out of max_iterations and run_reservo_agent raises RuntimeError, which traced_run lets pass through unhidden, exactly as M2 designed. The try/except around each task is whoever calls traced_run's responsibility — a real batch doesn't stop because one individual run failed.
The report, rebuilt 100% from RUN_LOG.jsonl
Without using any variable from the previous block — not tasks, not history, not final — read the file from scratch and group by trace_id:
def load_events(path):
events = []
with open(path, encoding="utf-8") as fh:
for line in fh:
events.append(json.loads(line))
return events
def summarize_by_trace(events):
"""Agrupa los eventos por trace_id (preservando el orden de aparición)
y arma un resumen de una línea por run: completo o no, cuántos pasos,
cuántos tool_errors."""
order = []
by_trace = {}
for e in events:
tid = e["trace_id"]
if tid not in by_trace:
by_trace[tid] = []
order.append(tid)
by_trace[tid].append(e)
summaries = []
for tid in order:
own = by_trace[tid]
question = own[0]["question"]
steps = sum(1 for e in own if e["event"] == "tool_use")
tool_errors = sum(1 for e in own if e["event"] == "tool_result" and e["is_error"])
last = own[-1]
completed = last["event"] == "run_finished"
summaries.append({
"trace_id": tid, "question": question, "steps": steps,
"tool_errors": tool_errors, "completed": completed,
})
return summaries
events = load_events("RUN_LOG.jsonl")
print("eventos totales en RUN_LOG.jsonl:", len(events))
print()
for s in summarize_by_trace(events):
status = "completado" if s["completed"] else "FALLÓ"
print(f"{s['trace_id']} {status:10} {s['steps']} pasos {s['tool_errors']} tool_errors -- {s['question']}")
What to expect:
eventos totales en RUN_LOG.jsonl: 28
run-8487582448eb completado 4 pasos 0 tool_errors -- Reserva Focus pro 3h para Ana
run-c720132bf969 completado 3 pasos 0 tool_errors -- Reserva Boardroom pro 1h para Sofia
run-8d26276b0d45 completado 1 pasos 0 tool_errors -- Cancela la reserva 999
run-61abb643a67b FALLÓ 2 pasos 0 tool_errors -- Reserva algo ambiguo
make_trace_id is a hash of (question, sequence_number) — these four values are deterministic: running this block on your own machine produces exactly these same four trace_ids, always. The total event count — 28 — is the exact sum of two parts: run_started + run_finished/run_failed (two per run, eight in total) plus a tool_use/tool_result pair for every tool call that genuinely ran. run 4 didn't fail on the first attempt — as steps=2 confirms, stuck_script's script gets to complete two real list_rooms iterations (each with its tool_use and its tool_result, no error at all) before the third triggers RuntimeError from max_iterations=2 — the same behavior M2 (Lesson 5) already showed with an equivalent script. Adding up the steps that genuinely happened — 4 + 3 + 1 + 2 = 10 real tool calls, 20 step events — plus the eight open/close events, the total is 20 + 8 = 28. None of this summary read history, final, or any Python variable that existed before this cell — every field came, exclusively, from parsing RUN_LOG.jsonl line by line.
Why this is the foundation for everything that follows in the capstone
It's worth saying, precisely, why this lesson — which builds no new function — is the most important one to prepare well in the entire module. Lessons 4, 5, and 6 each need the same kind of input: a real history, produced by run_reservo_agent, correlated by a deterministic trace_id. cost_for_run (Lesson 4) walks run 1's same history to calculate how much booking Focus pro 3h for Ana cost. total_run_latency_ms (also Lesson 4) walks that same history to calculate, modeled, how long it took. The regression gate (Lesson 5) runs its own scripts, with traced_run's same discipline wrapping every case. Without this lesson, each of those pieces would have to rebuild its own batch of runs from scratch — with this lesson, they all share the same starting point, with the same trace_id serving as the thread connecting "what happened" (M2), "how much it cost" (M3), and "how long it took" (M4).
Common mistakes
-
Running this batch more than once over the same
RUN_LOG.jsonlwithoutmode="w". This example'sFileHandleropens the file in"w"mode (overwrite), on purpose — if it instead opened in"a"(append) and this block ran twice,RUN_LOG.jsonlwould have double the events, andsummarize_by_tracewould report double the runs, all with repeated, confusingtrace_ids between one run and the other. -
Forgetting
_file_handler.close()at the end of the batch. Without closing the handler, Python's write buffer might not have flushed to disk yet whenload_eventstries to read the file — a timing bug producing a file with fewer lines than expected, not an explicit error. -
Thinking
run 4(the one that fails) "left no trace at all." It did leave one — two events:run_startedandrun_failed, with the exception's exact message. What it didn't leave was any step event (tool_use/tool_result), because that run's script never got to complete any tool call before running out ofmax_iterations=2. Confusing "no step events" with "no trace at all" is losing exactly the improvement M2 built over M1's limit. -
Calculating
tool_errorsby countingis_errorovertool_useevents instead oftool_result. Theis_errorfield only exists, meaningfully, ontool_resultevents — atool_useis the request, not the response.summarize_by_traceexplicitly filterse["event"] == "tool_result"before looking atis_error, for this exact reason. -
Assuming
RUN_LOG.jsonl'strace_idorder is alphabetical or numeric. It's, exclusively, the order every run opened —order.append(tid)insummarize_by_tracepreserves that appearance order, never reorders it. Two runs whose hashes happen to sort differently alphabetically still appear in the summary in the real order they happened.
Exercises
Exercise 1: Count how many tool_result events have is_error: true across the batch (Easy)
Using events (already loaded in the worked example), count how many tool_result events have is_error: true in total, adding up all four runs.
See solution
total_errors = sum(1 for e in events if e["event"] == "tool_result" and e["is_error"])
print("tool_results con is_error=true en todo el lote:", total_errors)
Expected output:
tool_results con is_error=true en todo el lote: 0
Explanation: none of this lesson's four scripts includes a tool_use with invalid arguments (like other lessons' tier="premium" in this guide) — all four runs, including the one that fails, fail for reasons other than an argument-validation error. run 3 (canceling booking 999) doesn't count as an error either: cancel_booking runs correctly and responds {"cancelled": false}, a valid business result, not an is_error.
Exercise 2: Add a fifth run with an invalid tier and confirm it does show up in tool_errors (Medium)
Add a fifth element to tasks: the question "How much does Focus premium 3h cost?", with a script that first tries get_quote with tier="premium" (invalid) and then self-corrects to tier="pro". Run the complete batch again (reopening RUN_LOG.jsonl in "w" mode) and confirm summarize_by_trace reports tool_errors=1 for that run.
See solution
script_e = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "premium", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Focus pro 3h cuesta $60.00."}]},
]
tasks_v2 = tasks + [("Cuanto cuesta Focus premium 3h", script_e, 10)]
_fh2 = logging.FileHandler("RUN_LOG.jsonl", mode="w", encoding="utf-8")
_fh2.setFormatter(logging.Formatter("%(message)s"))
rl.logger.handlers = [_fh2]
for i, (question, script, max_iter) in enumerate(tasks_v2, start=1):
try:
with rl.traced_run(question, i):
ra.run_reservo_agent(question, script, max_iterations=max_iter)
except RuntimeError:
pass
_fh2.close()
events_v2 = load_events("RUN_LOG.jsonl")
for s in summarize_by_trace(events_v2):
if s["question"] == "Cuanto cuesta Focus premium 3h":
print(s)
Expected output:
{'trace_id': 'run-b2eff980df66', 'question': 'Cuanto cuesta Focus premium 3h', 'steps': 2, 'tool_errors': 1, 'completed': True}
Explanation: the first get_quote with tier="premium" gets rejected by dispatch_robust's validation (agent-fundamentals M7), produces a tool_result with is_error: true, and traced_run records it at logging.ERROR level. The agent self-corrects on the second step, and the run ends completed: True — the same self-correction you already know from previous modules, now visible in the summary rebuilt from the file, with no Python variable involved at all.
Exercise 3: Detect, only from RUN_LOG.jsonl, which run took the most steps before failing (Hard)
Without looking at tasks's or stuck_script's code: using exclusively events (or events_v2), write a function identifying, among runs with completed=False, which one has the highest number of tool_use events before run_failed — and confirm it does correspond to the "book something ambiguous" run.
See solution
def failed_runs_by_steps(events):
summaries = summarize_by_trace(events)
failed = [s for s in summaries if not s["completed"]]
return sorted(failed, key=lambda s: s["steps"], reverse=True)
ranking = failed_runs_by_steps(events)
for s in ranking:
print(f"{s['steps']} pasos antes de fallar -- {s['question']!r}")
Expected output:
2 pasos antes de fallar -- 'Reserva algo ambiguo'
Explanation: with only one failed run in the original batch (run 4), the "ranking" has a single element — but the function scales unchanged to a batch with several failed runs, because summarize_by_trace already calculated steps (the tool_use event count) for every trace_id, regardless of how many runs in the batch ended well or poorly. This is exactly the kind of question RUN_LOG.jsonl, as an artifact surviving the process that generated it, lets you answer days later, without running a single line of the original batch again.
Summary and next step
- We ran a real batch of four Reservo tasks, with
traced_run(M2) wrapping each one, producingRUN_LOG.jsonl— this capstone's first real artifact, with20JSON events, one for every step of the four runs. - We rebuilt a complete batch summary —
summarize_by_trace— reading exclusively the file, with no Python variable from what produced the original batch: proof M2's structured logging captures everything needed, with no dependency on the original process staying alive. - We confirmed this
RUN_LOG.jsonland its deterministictrace_ids are the shared starting point Lessons 4, 5, and 6 of this module are going to reuse, without ever running the batch from scratch again.
Next lesson: 04 — The Cost and Latency Report. With these same runs' history already available, we calculate how much each one cost and how long it took, and aggregate the complete batch into a single metrics report.
Additional resources
- Python —
logging.FileHandler— The handler that writesRUN_LOG.jsonlto disk, with the"w"/"a"mode that decides whether a file gets overwritten or accumulated. - Python — JSON Lines / NDJSON, via
json.loadsline by line —RUN_LOG.jsonl's exact format, andload_events's technique for reading it back. - Anthropic — Tool use error handling — The
is_errorprotocolsummarize_by_tracecounts per run, inherited fromagent-fundamentalsM7. sre-and-incident-response-guide— for when this sametrace_idneeds correlating with infrastructure logs (Lambda, API, database), not just agent events — this module's Lesson 7 traces that boundary precisely.