← Table of Contents · Page 693 of 868

PROJECT FOUR - EVALUATION AND OBSERVABILITY

Part 2 of 9

FLYING WITH INSTRUMENTS A pilot flying through cloud does not steer by feel. The pilot reads instruments, because feelings are unreliable and the instruments tell the truth about speed, height, and direction. An agent without instruments is the same. It may seem to work, while quietly wasting money, looping, or failing on some kinds of input. Observability is the ability to understand what a system is doing inside by looking at the records it emits. Verification is the separate habit of testing whether the system does the right thing. This chapter adds both. Three words need defining. A span is the record of one timed piece of work, such as one model call or one tool call, with its start, end, input, output, and status. A trace is the collection of spans belonging to one task, which tells the story of that task from beginning to end, like a receipt listing every item in a shopping trip. A dashboard is a single view that summarizes the health of the system at a glance. Step 1: Tracing Our tracing module gives each task a short identifying code, called a trace identifier, and provides a span helper that times a block of work and writes one line to logs/traces.jsonl when the block ends. It records an error status if the work fails, and it always writes the record, even on failure, because failures are the records you most need. File: tracing.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: recording a timed span for every call """tracing.py - records a span (one timed step) for every model call and tool call.""" # Import the contextlib module to build a with-block helper import contextlib # Import the json module to read and write structured data import json # Import the os module to work with file paths import os # Import the time module to measure and record when events happen import time # Import the uuid module to generate unique identifying codes import uuid [END OF PYTHON CODE] from permission_gate import ROOT [START OF PYTHON CODE] # Create a text variable called TRACE_PATH pointing to the trace log file TRACE_PATH = os.path.join(ROOT, "logs", "traces.jsonl") # Create a text variable called RUNS_PATH pointing to the run-summary log file RUNS_PATH = os.path.join(ROOT, "logs", "runs.jsonl") # Create a dictionary variable called _current holding the trace id of the task currently running _current = {"trace_id": None} # Define a function that appends one record as a line of JSON to the given log file def append_line(path, record): # Create the containing folder if it does not already exist os.makedirs(os.path.dirname(path), exist_ok=True) # Open the log file in append mode as UTF-8 text with open(path, "a", encoding="utf-8") as f: # Write the record as one line of JSON, followed by a newline f.write(json.dumps(record) + "\n") # Define a function that starts a new trace and returns its short identifying code def start_trace(): # Generate a short, unique trace id and store it as the current trace _current["trace_id"] = uuid.uuid4().hex[:8] # Return the new trace id return _current["trace_id"] # Mark the following function as a context manager, usable in a with-block @contextlib.contextmanager # Define a function that times one piece of work and records it as a span when it finishes def span(kind, name, input_text=""): # Build the starting span record: which trace it belongs to, a new span id, its kind, name, input, and start time record = {"trace_id": _current["trace_id"], "span_id": uuid.uuid4().hex[:6], "kind": kind, "name": name, "input": input_text[:200], "output": "", "start": time.time()} # Attempt to run the work inside the with-block try: # Hand the record to the with-block so it can fill in the output before finishing yield record # If the work completed without error, mark the status as ok record["status"] = "ok" # If the work raised an error except Exception as problem: # Record the error status, truncated to 100 characters record["status"] = "error: " + str(problem)[:100] # Re-raise the error so the caller still sees it raise # Whether the work succeeded or failed, always record the span finally: # Record the end time record["end"] = time.time() # Calculate and record how long the work took, in milliseconds record["latency_ms"] = round((record["end"] - record["start"]) * 1000, 2) # Append this span record to the trace log file append_line(TRACE_PATH, record) # Define a function that appends one run summary to the run-summary log file def record_run(summary): append_line(RUNS_PATH, summary) [END OF PYTHON CODE] The helper uses a Python feature called a context manager, which is the with statement. The code inside the with block is the work being timed, and the helper takes care of starting and stopping the clock around it. Step 2: The Dashboard The dashboard reads a second file, logs/runs.jsonl, which holds one summary line per task, and prints the success rate, average cost, average steps, and average delay across the last twenty-five runs. It prints the cost in lab cost units, a made-up unit that stands for money, because this book does not claim any real price. It also contains a small detector that scans the traces for a warning sign: one tool called many times with identical input inside a single trace. File: dashboard.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: a text table of recent runs plus a repeated-span detector """dashboard.py - a text table of the last 25 runs, plus a repeated-span detector.""" # Import the json module to read structured data import json # Import the os module to work with file paths import os # Import the Counter class to count how often things repeat from collections import Counter [END OF PYTHON CODE] from tracing import RUNS_PATH, TRACE_PATH [START OF PYTHON CODE] # Create a number variable called COST_PER_1K_TOKENS, the made-up lab cost of one thousand tokens COST_PER_1K_TOKENS = 1.0 # Define a function that reads a JSON-lines file and returns a list of its parsed records def read_lines(path): # If the file does not exist yet, there is nothing to read if not os.path.exists(path): return [] # Open the file for reading as UTF-8 text with open(path, "r", encoding="utf-8") as f: # Parse and return every non-blank line as a JSON record return [json.loads(line) for line in f if line.strip()] # Define a function that prints a summary table of the most recent runs def show(last=25): # Read the run-summary log and keep only the most recent runs runs = read_lines(RUNS_PATH)[-last:] # If there are no runs yet, say so and stop if not runs: print("No runs recorded yet.") return # Collect the runs that finished successfully with a known match level ok = [r for r in runs if r["status"] == "done" and r["level"] != "unknown"] # Calculate the average lab cost per run, based on total tokens used cost = sum(r["tokens"] for r in runs) / 1000 * COST_PER_1K_TOKENS / len(runs) # Print how many runs are being shown print("Runs shown: %d" % len(runs)) # Print the success rate as a percentage, with a simple bar made of equals signs print("Success rate: %3.0f%% %s" % (100 * len(ok) / len(runs), "=" * len(ok))) # Print the average lab cost per run print("Average cost: %.2f lab units" % cost) # Print the average number of steps per run print("Average steps: %.1f" % (sum(r["steps"] for r in runs) / len(runs))) # Print the average latency per run, in milliseconds print("Average latency: %.1f ms" % (sum(r["latency_ms"] for r in runs) / len(runs))) # Print a header row for the detailed table print("%-28s %-12s %5s %7s" % ("resume", "status", "steps", "tokens")) # Print one row per run, showing its resume name, status, step count, and token count for r in runs: print("%-28s %-12s %5d %7d" % (r["resume"], r["status"], r["steps"], r["tokens"])) # Define a function that scans the trace log for tools called suspiciously many times with identical input def find_repeated_spans(minimum=4): # A docstring explaining that this pattern suggests a runaway loop """A trace where one identical span appears many times is a runaway-loop warning.""" # Count how many times each (trace, tool name, input) combination appears among tool spans counts = Counter((s["trace_id"], s["kind"], s["name"], s["input"]) for s in read_lines(TRACE_PATH) if s["kind"] == "tool") # Walk through every combination and its count for (trace_id, kind, name, _), n in counts.items(): # If a combination repeated at least the minimum number of times, print a warning if n >= minimum: print("WARNING trace %s: tool %s called %d times with identical input" % (trace_id, name, n)) # If this file is run directly (not imported), show the dashboard and check for repeated spans if __name__ == "__main__": show() find_repeated_spans() [END OF PYTHON CODE] Step 3: The Instrumented Loop Now the main loop wraps every model call and every tool call in a span. It also writes a summary line per task, and it contains the guard for repeated calls, which we will explain in a moment. Here is the complete new file. File: main_loop.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: Stage 4, recording spans and guarding against repeated calls """main_loop.py - Stage 4: the loop now records spans and guards against repeated calls.""" # Import the json module to read and write structured data import json # Import the os module to work with file paths and folders import os # Import the re module to search text with patterns import re # Import the shutil module to copy and remove whole folders import shutil # Import the sys module to read command-line arguments import sys # Import the time module to measure and record when events happen import time # Import the compaction function that shrinks the history when it grows too large from compaction import maybe_compact # Import the notes file path and the functions that build context, estimate tokens, and track progress from context_builder import (NOTES_PATH, append_note, build_context, estimate_tokens, load_done) # Import the pre- and post-execution hooks that guard every tool call from hooks import post_execution_hook, pre_execution_hook # Import the function that sends a conversation to the model and gets a reply from model_adapter import call_model # Import the shared root folder, the custom error type, and the safe folder-listing function from permission_gate import ROOT, GateDenied, safe_listdir # Import the functions that describe available tools and run a chosen tool from tool_registry import describe_tools, run_tool # Import the functions that record run summaries, time spans of work, and start new traces from tracing import record_run, span, start_trace # Create a number variable called MAX_STEPS, the most think/act/observe steps allowed per resume MAX_STEPS = 10 # Create a number variable called REPEAT_LIMIT, how many identical calls in a row trigger the guard REPEAT_LIMIT = 3 # Create a number variable called COMPACTION_THRESHOLD, the token count that triggers compaction COMPACTION_THRESHOLD = 6000 # Create a text variable called LOG_PATH pointing to the log file this run will write to LOG_PATH = os.path.join(ROOT, "logs", "run_log.jsonl") # Create a flag variable called USE_REPEAT_GUARD controlling whether the repeat guard is active USE_REPEAT_GUARD = True # Define a function that appends one structured event to the log file def log_event(kind, **data): # Create the logs folder if it does not already exist os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) # Build the base log record with the current time and the kind of event record = {"time": round(time.time(), 3), "event": kind} # Add in any extra named details passed to this function record.update(data) # Open the log file in append mode as UTF-8 text with open(LOG_PATH, "a", encoding="utf-8") as f: # Write the record as one line of JSON, followed by a newline f.write(json.dumps(record) + "\n") # Define a function that runs the full think/act/observe loop for one resume, with tracing and a repeat guard def review_one(resume_file, history, use_context=True, max_steps=MAX_STEPS): # Start a new trace for this resume's work trace_id = start_trace() # Record the start time so total latency can be measured began = time.time() # Build the starting task instruction naming the resume file to review task = "Review the resume at resumes/%s against the job posting and write a report." % resume_file # Add this task as the next user message in the shared history history.append({"role": "user", "content": task}) # Start the running totals: no tokens used yet, level unknown, status defaults to step limit tokens_used, level, status, steps = 0, "unknown", "step_limit", max_steps # Track the signature of the last call made, and how many times it has repeated in a row last_signature, repeats = None, 0 # Repeat the think/act/observe cycle up to the maximum number of steps for step in range(1, max_steps + 1): # Decide what the model actually sees: the trimmed context, or the full raw history sent = build_context(history) if use_context else history # Time the model call as its own span, recording its input and output with span("model", "call_model", json.dumps(sent)[-200:]) as s: reply = call_model(sent, describe_tools()) s["output"] = json.dumps(reply) # Add this step's rough token cost to the running total tokens_used += estimate_tokens(json.dumps(sent)) # Record the model's reply in the log log_event("model_reply", resume=resume_file, step=step, reply=reply) # If the model says the task is finished, record that and stop the loop if reply["type"] == "final": status, steps = "done", step break # Build a signature identifying this exact tool call, so repeats can be detected signature = json.dumps([reply["tool"], reply["args"]], sort_keys=True) # Count this as a repeat if it matches the last call's signature, otherwise reset the count to one repeats = repeats + 1 if signature == last_signature else 1 last_signature = signature # If the repeat guard is on and the same call has repeated too many times in a row if USE_REPEAT_GUARD and repeats >= REPEAT_LIMIT: # Record the repeat-guard event in the log log_event("repeat_guard", resume=resume_file, step=step, tool=reply["tool"]) # Add a system message telling the model to stop looping and write a report instead history.append({"role": "system", "content": "GUARD: the same call was repeated with no new information. " "Stop searching and write a report explaining the problem."}) # Reset the repeat tracking and skip straight to the next step last_signature, repeats = None, 0 continue # Otherwise, add the model's tool-call reply to the shared history history.append({"role": "assistant", "content": json.dumps(reply)}) # Time the tool call as its own span, recording its input and output with span("tool", reply["tool"], json.dumps(reply["args"], sort_keys=True)) as s: # Attempt to run the tool through both hooks try: args = pre_execution_hook(reply["tool"], reply["args"]) result = post_execution_hook(reply["tool"], run_tool(reply["tool"], args)) # If the permission gate refused the request, record that as the result except GateDenied as problem: result = "DENIED: " + str(problem) # If anything else went wrong, record that as the result except Exception as problem: result = "ERROR: " + str(problem) # Record the tool's output on the span, truncated to 200 characters s["output"] = result[:200] # If this step wrote the report, try to read the match level back out of its text if reply["tool"] == "write_report": found = re.search(r"Match level: (\w+)", reply["args"].get("text", "")) level = found.group(1) if found else level # Record the tool result in the log, truncated to 200 characters log_event("tool_result", resume=resume_file, step=step, tool=reply["tool"], result=result[:200]) # Add the tool result to the shared history so the model can see it next step history.append({"role": "tool", "content": result}) # Append one important decision line to the history so it survives any future compaction history.append({"role": "assistant", "important": True, "content": "DECISION: %s -> %s" % (resume_file, level)}) # Build this resume's full outcome, including its trace id and total latency outcome = {"trace_id": trace_id, "resume": resume_file, "status": status, "steps": steps, "tokens": tokens_used, "level": level, "latency_ms": round((time.time() - began) * 1000, 2)} # Record this outcome as a run summary record_run(outcome) # Return this resume's full outcome return outcome # Define a function that clears out reports, logs, and progress notes so a batch can start fresh def reset_state(): # Remove the reports and logs folders entirely, ignoring errors if they do not exist for folder in ("reports", "logs"): shutil.rmtree(os.path.join(ROOT, folder), ignore_errors=True) # Remove the progress notes file if it exists if os.path.exists(NOTES_PATH): os.remove(NOTES_PATH) # Define a function that reviews every not-yet-done resume in one batch, sharing one history def run_batch(use_context=True, stop_after=None): # Create the reports folder if it does not already exist os.makedirs(os.path.join(ROOT, "reports"), exist_ok=True) # Load which resumes are already finished, start an empty shared history and results list done, history, results = load_done(), [], [] # Walk through every resume file in the resumes folder for name in safe_listdir("resumes"): # Skip this resume if the notes file says it is already finished if name in done: print("skipping (already done):", name) continue # If a stop-after limit was given and reached, simulate a crash and stop the batch if stop_after is not None and len(results) >= stop_after: print("simulated stop: pretend the program crashed here") break # Review this resume, adding to the shared history outcome = review_one(name, history, use_context) # Record this resume as done in the notes file append_note(name, outcome["level"]) # Compact the shared history if it has grown past the threshold (only when using context) history = maybe_compact(history, COMPACTION_THRESHOLD) if use_context else history # Add this outcome to the results list results.append(outcome) # Print this outcome to the screen print(outcome) # Add up the estimated tokens used across every resume in this batch total = sum(r["tokens"] for r in results) # Print a summary line for the whole batch print("resumes processed: %d, estimated tokens sent: %d" % (len(results), total)) # Return the total estimated tokens used return total # If this file is run directly (not imported), read the command-line arguments and act on them if __name__ == "__main__": # Collect the command-line arguments, skipping the script name itself args = sys.argv[1:] # If the fresh flag was given, clear all state before running if "--fresh" in args: reset_state() # If the redo flag was given, re-run just the one named resume with a fresh history and stop if "--redo" in args: print(review_one(args[args.index("--redo") + 1], [])) sys.exit() # If a stop-after value was given, read the number right after that flag; otherwise there is no limit stop = int(args[args.index("--stop-after") + 1]) if "--stop-after" in args else None # Run one batch with the chosen stop-after limit run_batch(stop_after=stop) [END OF PYTHON CODE] Look for the three additions. The lines starting with with span wrap the model call and the tool call. The variable signature describes a call as its tool name plus its arguments, so two identical calls have identical signatures. And record_run writes the summary line that the dashboard reads. Planted Bug: The Runaway Loop A runaway loop is a loop that keeps doing the same useless thing until something outside stops it. It is like a person pressing an elevator button over and over, convinced that the button will eventually work. We now plant one on purpose. An empty or damaged file is an ordinary event in real life, and a careless agent may not know what to do about it. This script creates a completely empty resume named resume_empty.txt. File: make_empty_data.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: planting an empty resume to trigger a runaway loop """make_empty_data.py - adds the planted runaway-loop trigger: an empty resume file.""" # Import the os module to work with file paths and folders import os # Create a text variable called HERE holding the folder that contains this file HERE = os.path.dirname(os.path.abspath(__file__)) # Create this same empty file in both the sample_data folder and the resumes folder for folder in ("sample_data", "resumes"): # Create the destination folder if it does not already exist os.makedirs(os.path.join(HERE, folder), exist_ok=True) # Open the file for writing and immediately close it, leaving it completely empty open(os.path.join(HERE, folder, "resume_empty.txt"), "w", encoding="utf-8").close() # Print a confirmation message once the file has been written print("resume_empty.txt written.") [END OF PYTHON CODE] We also teach the mock model a bad habit. Add this block to the mock reply function in model_adapter.py, just before the line that checks whether the search has been done. When the resume has no skills line, the stand-in keeps searching the posting again and again, never writing a report. [START OF PYTHON CODE] # If the resume has no skills line at all (such as an empty file), this special handling kicks in if not any(l.lower().startswith("skills:") for l in resume.splitlines()): # If the report has already been written, the task is finished if "write_report" in done: return {"type": "final", "text": "Report saved."} # If the repeat guard has already fired once, write an honest report instead of searching again if any(m["role"] == "system" and m["content"].startswith("GUARD") for m in mine): stem = os.path.splitext(os.path.basename(path))[0] return {"type": "tool_call", "tool": "write_report", "args": { "name": stem, "text": "The resume file is empty or unreadable, so no review " "was possible. Match level: unreadable."}} # Otherwise, keep asking to search the job posting, which (without the guard) would loop forever return {"type": "tool_call", "tool": "search_job_posting", "args": {"query": "Required skills"}} [END OF PYTHON CODE] The part about GUARD is the repair path, and it only wakes up when the harness sends a message beginning with that word. Everything before it is the bug.
Advertisement
Read Aloud:
0:00 / 0:00