Module 8: Project The Reservo Agent In Production
Assembling the Operations Layer
Description
Lesson 1 confirmed the seven reused files import together with no conflict. This lesson goes a step further: before instrumenting a single real run (that starts in Lesson 3), it confirms every piece of the operations layer still works on its own, exactly as in the module where it got built — and, with that confirmed, it maps out exactly where each piece connects to the Reservo agent, because they don't all connect at the same place.
This distinction matters more than it looks at first glance. traced_run (M2) connects by replacing dispatch_robust — the dispatch layer, one level above the real tool. The CircuitBreaker (M6) connects by replacing a specific tool's entry in TOOLS — one level lower, the real function. cost_for_run and latency_model (M3/M4) don't connect by replacing anything — they read history, the value run_reservo_agent already produced, after the run finished. Three different connection points, for three different ways of instrumenting the same system. This lesson makes them visible, one by one, before Lesson 3 puts them to work together over a real run.
Connection to the module
This lesson builds no new piece — it confirms, with a run check for every file, that M2-M7's nine reused pieces still produce exactly the output you already saw in their original module. It's the foundation the rest of the capstone rests on: if something here didn't match what you already know, it would be the signal something broke along the way, before Lesson 3 tries to build on that ground.
Analogy: checking every kitchen station before turning on the full service
Before the night's service opens, the head chef doesn't trust from memory that every station is ready — they walk the kitchen, one station at a time: they test that the grill's stopwatch reads zero, that the dessert station's scale correctly weighs a known reference weight, that the oven's thermal switch is in the right position, that the recipe file has the versions it's supposed to have. None of these checks cooks a dish — each one confirms, separately, that the instrument about to be used during service is calibrated. This lesson is exactly that walk, applied to Reservo's operations layer: before serving a real run with all four disciplines on at once, it confirms every instrument — M2's event clock, M3's cost scale, M4's modeled stopwatch, M5's case file, M6's switch, M7's version registry — is still calibrated exactly as it stood at the end of its own module.
Worked example: six checks, one per piece, without touching the agent yet
M2 — run_logger: make_trace_id is still deterministic
import run_logger as rl
t1 = rl.make_trace_id("Reserva Focus pro 3h para Ana", 1)
t1_again = rl.make_trace_id("Reserva Focus pro 3h para Ana", 1)
print("trace_id :", t1)
print("mismo trace_id otra vez:", t1_again)
print("son iguales :", t1 == t1_again)
What to expect:
trace_id : run-8487582448eb
mismo trace_id otra vez: run-8487582448eb
son iguales : True
The same trace_id as always — run-8487582448eb — the one you're going to see repeat in every lesson in this module using Ana's canonical question. Without this check, any later lesson citing that value would be citing something you might no longer be able to reproduce on your own machine.
M3 — cost_calculator: the fixed pricing is still the same
import cost_calculator as cc
print("pricing entrada (centavos/1M tok):", cc.INPUT_PRICE_CENTS_PER_MILLION_TOKENS)
print("pricing salida (centavos/1M tok):", cc.OUTPUT_PRICE_CENTS_PER_MILLION_TOKENS)
print("costo de 64 in / 56 out (Ana) :", cc.estimate_cost_cents(64, 56), "centavos")
What to expect:
pricing entrada (centavos/1M tok): 300
pricing salida (centavos/1M tok): 1500
costo de 64 in / 56 out (Ana) : 0 centavos
300/1500 are the constant fixed in this guide's DISEÑO — claude-sonnet-5, list price, cited in M3; 64/56 are Ana's run's estimated tokens, which this module's Lesson 4 is going to recalculate end to end. 0 cents is still the honest answer for a run of this size.
M4 — latency_model: TOOL_LATENCY_MS hasn't changed a single value
import latency_model as lm
for name, ms in sorted(lm.TOOL_LATENCY_MS.items(), key=lambda kv: kv[1]):
print(f" {name:15} {ms:4} ms")
print("percentil 50 de [25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210, 275]:",
lm.percentile(sorted([25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210, 275]), 50), "ms")
What to expect:
get_quote 25 ms
list_rooms 40 ms
cancel_booking 90 ms
book_room 120 ms
p50 = 90 ms
percentil 50 de [25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210, 275]: 90 ms
(the percentile print is shown abbreviated above; the real value is the same 90 ms M4, Lesson 6, calculated over the twelve-run batch). TOOL_LATENCY_MS still sits at exactly the four values M4 fixed: book_room (120 ms) is still, by a margin, the most expensive tool — the write, against list_rooms/get_quote's reads and the destructive cancel_booking tool.
M5 — harness: the CASE_SET still has five cases, with the same anchors
import harness as hn
for c in hn.CASE_SET:
print(f"{c['name']:38} tools={c['expected_tools']}")
What to expect:
quote_focus_pro_3h tools=['get_quote']
quote_focus_basic_3h tools=['get_quote']
book_focus_pro_3h_ana tools=['list_rooms', 'get_quote', 'book_room']
book_boardroom_pro_1h_sofia tools=['list_rooms', 'get_quote', 'book_room']
book_and_cancel_studio_basic_1h_diego tools=['book_room', 'cancel_booking']
The same five cases M5 fixed, in the same order, covering the four tools. None got regenerated, none changed its script — the CASE_SET's "fixed" property, confirmed again before genuinely running the gate in Lesson 5.
M6 — tool_circuit_breaker: the state machine starts CLOSED, and knows how to open
from tool_circuit_breaker import CircuitBreaker, CLOSED, OPEN
breaker = CircuitBreaker("book_room", failure_threshold=3, cooldown_calls=2)
print("estado inicial:", breaker.state)
for _ in range(3):
breaker.on_failure()
print("estado tras 3 fallos consecutivos:", breaker.state)
assert breaker.state == OPEN
print("assert OK -- el breaker abre exactamente en el umbral configurado")
What to expect:
estado inicial: CLOSED
estado tras 3 fallos consecutivos: OPEN
assert OK -- el breaker abre exactamente en el umbral configurado
failure_threshold=3 is still M6's threshold: three consecutive failures, with no success in between, and the breaker trips to OPEN. This module's Lesson 6 is going to run this same state machine over a tool genuinely failing, not over direct calls to on_failure() like this quick check.
M7 — prompt_registry: v1's and v2's hashes are still the same
from prompt_registry import PROMPT_REGISTRY
for version_id, av in PROMPT_REGISTRY.items():
print(f"{version_id}: hash={av.prompt_hash} tools={av.tools_version} nota={av.note!r}")
What to expect:
v1: hash=c5757b6d6264 tools=tools-v1 nota='version original, segura'
v2: hash=c364e85e5649 tools=tools-v1 nota='mas proactiva -- rompe quote_focus_pro_3h'
(each version's exact notes may vary in wording, but the hashes c5757b6d6264/c364e85e5649 are deterministic and don't change). hashlib.sha256 over the same prompt text always produces the same short hash — the property this module's Lesson 5 is going to use to unambiguously identify which version ran the gate.
The three connection points: where each piece hooks in
With the six checks confirmed, it's worth naming precisely something the previous lessons showed separately, but never side by side: this guide's operations layer connects to the Reservo agent at three different heights, not just one.
run_reservo_agent(question, model_script)
│
├─ HEIGHT 1 -- replaces rr.dispatch_robust (the entire dispatch of ONE tool call)
│ traced_run (M2): logs tool_use BEFORE and tool_result AFTER
│ ANY tool, regardless of which. A single patch, covers all 4 tools.
│
├─ HEIGHT 2 -- replaces rc.TOOLS["book_room"] (ONE tool's real function)
│ CircuitBreaker + call_with_breaker (M6): decides, before touching
│ that specific tool's real function, whether the call goes through or gets rejected.
│ One patch PER TOOL -- get_quote and list_rooms never get touched.
│
└─ HEIGHT 3 -- reads `history` AFTER run_reservo_agent has already returned
cost_for_run (M3), total_run_latency_ms (M4): they don't replace anything --
they walk the result already produced, with no intervention during
the run.
This distinction isn't an academic detail: it determines the order in which the pieces combine when they work together. If book_room's CircuitBreaker (Height 2) is OPEN and rejects a call, that call never gets to run — but traced_run (Height 1), which already wrapped dispatch_robust one level higher up, still sees the complete attempt go by: the tool_use, and then a tool_result with is_error: True when CircuitOpenError turns into the same kind of error as any other tool failure. Heights 1 and 3 never "know" a circuit breaker exists beneath them — each one does its job with the information reaching it, with no need for explicit coordination with the others. That independence is, precisely, what makes it possible to assemble four separately built disciplines without any of them needing to know the internal details of the other three.
Common mistakes
-
Thinking
traced_runand theCircuitBreakercompete for the same connection point. They don't compete — one replacesdispatch_robust(a dispatch level), the other replaces a specific tool's real function insideTOOLS(a lower level). The two monkeypatches coexist with no conflict at all because they target different attributes, in different modules. -
Running this lesson's M6 check (
breaker.on_failure()three times in a row) and thinking that "already tested" the circuit breaker. It didn't test it against a real tool — it only confirmed the state machine itself transitions at the right threshold. The real test, withbook_roomgenuinely failing andretry_with_backoffinvolved, is Lesson 6's complete content. -
Forgetting
cost_for_runandlatency_modelnever modify anything during the run. Unliketraced_runand theCircuitBreaker, neither of the two measurement pieces installs a patch — they readhistoryoncerun_reservo_agenthas already finished. If a run fails with aRuntimeErrorbefore returning, there's nohistoryfor either of these two pieces to read — the same limitrun_and_observe(M1) already showed. -
Assuming this lesson's six checks replace using each piece for real in its own module. They don't — they're a quick calibration check, not a repeat of M2-M7's content. If any of the six produced a value different from this lesson's, the right signal is to go back to the original module, not "fix it" here.
-
Loading
golden_cases.json(M5) with a relative path different from this capstone's working directory.harness.CASE_SETdepends ongolden_cases.jsonbeing in the same directory Python runs from — a path mistake produces aFileNotFoundErroreasy to confuse with a problem in the harness itself.
Exercises
Exercise 1: Confirm the four tools across the three registries (Easy)
Without running any run, confirm rc.TOOLS (agent-fundamentals), lm.TOOL_LATENCY_MS (M4), and the tools appearing in hn.CASE_SET (M5) name exactly the same set of four tools, with none extra or missing.
See solution
import reservo_contracts as rc
import latency_model as lm
import harness as hn
tools_contract = set(rc.TOOLS.keys())
tools_latency = set(lm.TOOL_LATENCY_MS.keys())
tools_cases = {t for c in hn.CASE_SET for t in c["expected_tools"]}
print("tools en reservo_contracts:", sorted(tools_contract))
print("tools en TOOL_LATENCY_MS :", sorted(tools_latency))
print("tools que aparecen en CASE_SET:", sorted(tools_cases))
print("contract == latency:", tools_contract == tools_latency)
print("latency es superset de cases:", tools_latency >= tools_cases)
Expected output:
tools en reservo_contracts: ['book_room', 'cancel_booking', 'get_quote', 'list_rooms']
tools en TOOL_LATENCY_MS : ['book_room', 'cancel_booking', 'get_quote', 'list_rooms']
tools que aparecen en CASE_SET: ['book_room', 'cancel_booking', 'get_quote', 'list_rooms']
contract == latency: True
latency es superset de cases: True
Explanation: all three sets match exactly. This is the property that makes it possible for total_run_latency_ms to never need its default value (.get(name, 0)) on a real Reservo tool — every tool the agent can call already has an entry in TOOL_LATENCY_MS, and the gate's CASE_SET exercises all four, none extra.
Exercise 2: Simulate Height 1 and Height 2 coexisting, without touching the agent yet (Medium)
Without running run_reservo_agent, write a two-level dict representing the idea of "two monkeypatches active at once": a "dispatch_robust_patched" key with True if rr.dispatch_robust is no longer the module's original function, and a "book_room_patched" key with True if rc.TOOLS["book_room"] is no longer the original function. Apply both patches (with simple test functions, no real logic) and confirm both can be active at the same time without either interfering with the other.
See solution
import reservo_robust as rr
import reservo_contracts as rc
original_dispatch = rr.dispatch_robust
original_book_room = rc.TOOLS["book_room"]
def fake_dispatch_robust(tool_use_block, max_retries=3, timeout=2.0):
return original_dispatch(tool_use_block, max_retries=max_retries, timeout=timeout)
def fake_book_room(room, tier, hours, member):
return original_book_room(room, tier, hours, member)
rr.dispatch_robust = fake_dispatch_robust
rc.TOOLS["book_room"] = fake_book_room
status = {
"dispatch_robust_patched": rr.dispatch_robust is not original_dispatch,
"book_room_patched": rc.TOOLS["book_room"] is not original_book_room,
}
print(status)
# Restaurar, como hace `finally` en traced_run -- nunca dejar un parche instalado "para siempre".
rr.dispatch_robust = original_dispatch
rc.TOOLS["book_room"] = original_book_room
print("restaurado:", rr.dispatch_robust is original_dispatch, rc.TOOLS["book_room"] is original_book_room)
Expected output:
{'dispatch_robust_patched': True, 'book_room_patched': True}
restaurado: True True
Explanation: the two patches coexist with no conflict at all because they target completely different attributes — one in the reservo_robust module, another in the rc.TOOLS dictionary. Restoring both at the end, explicitly, is the same discipline traced_run applies with its finally block: no patch in this guide stays installed beyond the scope it belongs to.
Exercise 3: Design the correct order if both Heights fail at once (Hard)
Imagine that, in the same run, book_room's CircuitBreaker is OPEN (Height 2) AND, additionally, you want that rejection recorded in RUN_LOG.jsonl (Height 1). Describe, in prose, the exact order the two pieces would have to act in for the breaker's rejection to show up as a tool_result event with is_error: True in the log — without writing the complete code, name which exception would have to turn into what, and at which point.
See solution
The right order is: the real book_room is wrapped by the CircuitBreaker (Height 2, closest to the tool); dispatch_robust is still wrapped by traced_run (Height 1, closest to the loop). When the breaker is OPEN, call_with_breaker raises CircuitOpenError before ever touching book_room's real function. That exception rises up to dispatch_robust — agent-fundamentals M7's same dispatch_robust, which already knows how to catch any real exception from a tool and turn it into a tool_result with is_error: True, without the run crashing. Since traced_run (M2) already replaced dispatch_robust with _make_traced_dispatch, and that wrapper logs the tool_result after the original dispatch_robust did its job, the final result is exactly what was intended: a log line with event: "tool_result", tool: "book_room", is_error: true, and CircuitOpenError's message as content — with neither piece needing to know, in advance, the other existed. CircuitOpenError never needs special handling inside dispatch_robust: to that code, it's a real tool exception, just like a ConnectionError or a KeyError — the same uniform error protocol agent-fundamentals M7 built from the start, now demonstrating its value in an integration that module never explicitly anticipated.
Summary and next step
- We confirmed, with a quick per-piece check, that M2-M7's six central functions/structures —
make_trace_id, the fixed pricing,TOOL_LATENCY_MS, theCASE_SET, theCircuitBreaker's state machine, andPROMPT_REGISTRY— still produce exactly what you already saw in their original module. - We traced the three real connection points between the operations layer and
run_reservo_agent: Height 1 (dispatch_robust, M2), Height 2 (a specific tool's real function, M6), and Height 3 (readinghistoryafter the run, M3/M4) — three different heights coexisting with no conflict. - We confirmed, with two monkeypatches active at once, that replacing
dispatch_robustand replacingTOOLS["book_room"]don't interfere with each other, and both restore cleanly when done.
Next lesson: 03 — The Instrumented Run. With the operations layer calibrated, we wrap run_reservo_agent for the first time with traced_run over a real batch of Reservo tasks — RUN_LOG.jsonl, written to disk, and read back with no Python variable in memory at all.
Additional resources
- Python — Modifying a module's attributes at runtime — The technical foundation of how
run_reservo_agentresolvesrr.dispatch_robustagain on every iteration, which makes Height 1 and Height 2 monkeypatching possible. - Python —
hashlib—hash_prompt's (M7) foundation, confirmed again in this lesson's check. - Python —
statistics—statistics.quantiles,percentile's (M4) foundation, confirmed again in this lesson's check. - Anthropic — Tool use error handling — The
is_errorprotocol that makes it possible forCircuitOpenError(Height 2) and any other real tool failure to travel the same path all the way to M2's log (Height 1), with no special case at all.