← Table of Contents · Page 702 of 868

PROJECT FIVE - MULTI-AGENT AND PRODUCTION HARDENING

Part 2 of 11

OUR DESIGN Our supervisor loops over the resume files. For each one it calls the matcher, a subagent that reads the resume, searches the posting, and returns a small structured result listing the candidate, the matched skills, the missing skills, and the level. Then it calls the writer, a subagent that receives only that small result and writes the final report. Notice what the writer never sees: the resume text, the posting, and the matcher's tool chatter. That is context isolation in action. Model routing means choosing which model tier handles which task. The matcher does a simple comparison, so it runs on a cheap, fast tier. The writer composes the final words, so it runs on a stronger tier. Our lab prices these tiers in made-up cost units, a cheap tier at 0.2 units per thousand tokens and a strong tier at 1.0, so that the arithmetic is clear without claiming any real price. Step 1: Routing and the Cost Ledger The routing file defines the two tiers, the batch budget, and a ledger that counts tokens per tier. File: routing.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: model tiers and the cost ledger, in made-up lab units """routing.py - model tiers and the cost ledger. Costs are made-up lab units, not real prices.""" # Import the os module to read environment variables import os # Create a dictionary variable called TIERS defining the cheap and strong model tiers and their costs TIERS = { "cheap": {"model": os.environ.get("LAB_CHEAP_MODEL", "small-model-name"), "cost_per_1k": 0.2}, "strong": {"model": os.environ.get("LAB_STRONG_MODEL", "large-model-name"), "cost_per_1k": 1.0}, } # Create a number variable called BATCH_BUDGET, the most lab cost units a whole batch may spend BATCH_BUDGET = 60.0 # Create a dictionary variable called ledger tracking how many tokens have been spent on each tier ledger = {"cheap": 0, "strong": 0} # Define a function that adds tokens spent to the running total for a given tier def charge(tier, tokens): ledger[tier] += tokens # Define a function that calculates the total lab cost across every tier spent so far def total_cost(): return sum(ledger[t] / 1000 * TIERS[t]["cost_per_1k"] for t in ledger) # Define a function that resets every tier's spent-token count back to zero def reset_ledger(): for tier in ledger: ledger[tier] = 0 [END OF PYTHON CODE] Step 2: One General Loop for Every Agent We now turn main_loop.py into a general runner that any subagent can use. Each call to run_agent starts with an empty history holding only the task, runs the think, act, and observe cycle, and returns an outcome. It keeps everything from the earlier stages: spans, the repeated-call guard, and the hooks. It adds the reliability features of this chapter, which deserve a careful explanation. Retries with exponential backoff. Some failures are temporary, like a phone line that is busy for a moment. A retry simply tries again. Backoff means waiting between tries, and exponential backoff means doubling the wait after each failure, here 0.2 seconds, then 0.4, then 0.8. The doubling is polite to a struggling service, because hammering it with instant retries makes its trouble worse. A maximum number of attempts, here four, ensures that hope has an end. The ToolUnavailable error. When all four attempts fail, the loop does not keep asking. It stops the subagent with the honest status tool_unavailable, and the supervisor reacts. This is a form of graceful failure, ending cleanly with a clear reason, rather than flailing. The maximum-iteration safeguard. Each subagent has its own step limit passed in by its caller, so neither can run forever. File: main_loop.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: Stage 5, one general loop any subagent can use """main_loop.py - Stage 5: one general loop that any subagent can use.""" # Import the json module to read and write structured data import json # Import the os module to work with file paths and environment variables import os # Import the time module to measure delays and record when events happen import time # Import the token-estimating function from context_builder import estimate_tokens # 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 and the custom permission error type from permission_gate import ROOT, GateDenied # Import the function that charges tokens spent against a model tier from routing import charge # 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 REPEAT_LIMIT, how many identical calls in a row trigger the guard REPEAT_LIMIT = 3 # Create a number variable called MAX_ATTEMPTS, the most times a failing tool call is retried MAX_ATTEMPTS = 4 # Create a number variable called BASE_DELAY, the starting wait time between retries, read from the environment BASE_DELAY = float(os.environ.get("LAB_BACKOFF_BASE", "0.2")) # 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") # Define a custom error type raised when a tool keeps failing even after every retry class ToolUnavailable(Exception): # A docstring explaining when this error is raised """A tool kept failing even after every retry.""" # 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 calls a tool, retrying with doubling waits if it fails with a connection error def call_with_retries(tool, args): # A docstring explaining the retry and backoff behavior """Try a tool up to MAX_ATTEMPTS times, waiting twice as long after each failure.""" # Try the tool call up to the maximum number of attempts for attempt in range(1, MAX_ATTEMPTS + 1): # Attempt to run the tool try: return run_tool(tool, args) # If the tool failed with a connection error except ConnectionError as problem: # Record this retry attempt in the log log_event("retry", tool=tool, attempt=attempt, problem=str(problem)) # If this was the last allowed attempt, give up and raise the tool-unavailable error if attempt == MAX_ATTEMPTS: raise ToolUnavailable("%s failed %d times in a row" % (tool, MAX_ATTEMPTS)) # Otherwise, wait before trying again, doubling the wait time after each failed attempt time.sleep(BASE_DELAY * 2 ** (attempt - 1)) # Define a function that runs one subagent on one task, starting from a fresh, empty history def run_agent(task, tier, max_steps, label): # A docstring explaining that this subagent sees only its own task, not any shared history """Run one subagent on one task. It starts with an empty history, so it sees only its task.""" # Start a new trace and record the start time trace_id, began = start_trace(), time.time() # Start the history with just this subagent's task as the first user message history = [{"role": "user", "content": task}] # Start the running totals: no tokens or steps yet, status defaults to step limit, no final text yet tokens, steps, status, final_text = 0, 0, "step_limit", "" # 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): # Record the current step number steps = step # Time the model call as its own span, labeled with the model tier being used with span("model", "call_model:" + tier, json.dumps(history)[-200:]) as s: reply = call_model(history, describe_tools(), tier) s["output"] = json.dumps(reply) # Estimate the token cost of this step and add it to both the running total and the tier ledger used = estimate_tokens(json.dumps(history)) tokens += used charge(tier, used) # If the model says the task is finished, record the final text and stop the loop if reply["type"] == "final": status, final_text = "done", reply["text"] 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 same call has repeated too many times in a row if repeats >= REPEAT_LIMIT: # Record the repeat-guard event in the log log_event("repeat_guard", agent=label, step=step, tool=reply["tool"]) # Add a system message telling the model to stop looping and report the problem history.append({"role": "system", "content": "GUARD: the same call was repeated with no new information. " "Stop and report 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 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 the hooks and the retry-with-backoff wrapper try: args = pre_execution_hook(reply["tool"], reply["args"]) result = post_execution_hook(reply["tool"], call_with_retries(reply["tool"], args)) # If the permission gate refused the request, record that as the result except GateDenied as problem: result = "DENIED: " + str(problem) # If the tool kept failing even after every retry, record that, mark the status, and stop except ToolUnavailable as problem: s["output"] = str(problem) status = "tool_unavailable" log_event("tool_unavailable", agent=label, tool=reply["tool"]) break # 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] # Add the tool result to the history so the model can see it next step history.append({"role": "tool", "content": result}) # Build this subagent's full outcome, including its trace id, tier, and total latency outcome = {"trace_id": trace_id, "resume": label, "status": status, "steps": steps, "tokens": tokens, "level": "n/a", "final_text": final_text, "latency_ms": round((time.time() - began) * 1000, 2)} # Record this outcome as a run summary, leaving out the full final text to keep the summary short record_run({k: v for k, v in outcome.items() if k != "final_text"}) # Return this subagent's full outcome return outcome [END OF PYTHON CODE] Step 3: The Two Subagents The matcher is short. It builds a task beginning with the word MATCH, runs the general loop on the cheap tier with a limit of eight steps, and turns the final text into a dictionary. File: matcher_agent.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: scoring one resume on the cheap model tier """matcher_agent.py - scores one resume against the posting. Runs on the cheap tier.""" # Import the json module to read and write structured data import json [END OF PYTHON CODE] from main_loop import run_agent [START OF PYTHON CODE] # Create a number variable called MATCHER_MAX_STEPS, the step limit for the matcher subagent MATCHER_MAX_STEPS = 8 # Define a function that runs the matcher subagent on one resume and returns its structured result def match(resume_file): # Build the matcher's task instruction, starting with the word MATCH task = "MATCH resumes/%s" % resume_file # Run the general subagent loop on the cheap tier with the matcher's step limit outcome = run_agent(task, "cheap", MATCHER_MAX_STEPS, "matcher:" + resume_file) # If the subagent did not finish successfully, return no data along with the outcome if outcome["status"] != "done": return None, outcome # Attempt to parse the subagent's final text as JSON try: data = json.loads(outcome["final_text"]) # If it was not valid JSON, return no data along with the outcome except ValueError: return None, outcome # Record which resume this result belongs to data["resume"] = resume_file # Return the parsed match data along with the outcome return data, outcome [END OF PYTHON CODE] The writer is shorter still, and the key line is the one that builds the slim dictionary. Only five named fields go through. File: writer_agent.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: turning the matcher's result into a report, on the strong tier """writer_agent.py - turns the matcher's small structured result into a report. Strong tier.""" # Import the json module to read and write structured data import json [END OF PYTHON CODE] from main_loop import run_agent [START OF PYTHON CODE] # Create a number variable called WRITER_MAX_STEPS, the step limit for the writer subagent WRITER_MAX_STEPS = 4 # Define a function that runs the writer subagent on the matcher's result and returns the written report def write(match_data): # Build a slim dictionary holding only the five fields the writer actually needs slim = {key: match_data[key] for key in ("resume", "candidate", "matched", "missing", "level")} # Build the writer's task instruction, starting with the word WRITE, followed by the slim data as JSON task = "WRITE " + json.dumps(slim) # Run the general subagent loop on the strong tier with the writer's step limit return run_agent(task, "strong", WRITER_MAX_STEPS, "writer:" + match_data["resume"]) [END OF PYTHON CODE] Step 4: The Supervisor The supervisor ties the pieces together. It skips resumes already listed in the notes, checks the budget before starting each new one, calls the matcher and then the writer, records progress, and keeps a list of failures rather than hiding them. If the matcher reports that a tool is unavailable, the supervisor stops the whole batch, because the same broken tool would fail again on the next resume and further tries would only waste time. File: supervisor.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: handing each resume to the matcher then the writer, watching the budget """supervisor.py - hands each resume to the matcher, then the writer, and watches the budget.""" # Import the os module to work with file paths and folders import os # Import the shutil module to remove whole folders import shutil # Import the sys module to read command-line arguments import sys # Import the notes file path and the functions that track and load progress from context_builder import NOTES_PATH, append_note, load_done # Import the function that runs the matcher subagent from matcher_agent import match # Import the shared root folder and the safe folder-listing function from permission_gate import ROOT, safe_listdir # Import the batch budget, the spending ledger, and the functions to reset and total it from routing import BATCH_BUDGET, ledger, reset_ledger, total_cost # Import the function that runs the writer subagent from writer_agent import write # Define a function that clears out reports, logs, progress notes, and the spending ledger 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) # Reset the spending ledger back to zero for every tier reset_ledger() # Define a function that reviews every not-yet-done resume, watching the budget and recording failures def run_batch(): # 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, and start counters for finished, failed, and total steps done, finished, failed, steps = load_done(), 0, [], 0 # 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: continue # If the budget has already been exceeded, stop the whole batch here if total_cost() > BATCH_BUDGET: print("BUDGET EXCEEDED: stopping before", name) failed.append((name, "budget")) break # Run the matcher subagent on this resume data, m_out = match(name) # Add the matcher's steps to the running total steps += m_out["steps"] # If the matcher did not produce usable data if data is None: # Record this resume as failed, with the matcher's status as the reason failed.append((name, m_out["status"])) print("matcher failed on", name, "->", m_out["status"]) # If the matcher failed because a tool was unavailable, stop the whole batch if m_out["status"] == "tool_unavailable": break # Otherwise, move on to the next resume continue # Run the writer subagent on the matcher's result w_out = write(data) # Add the writer's steps to the running total steps += w_out["steps"] # If the writer did not finish successfully, record this resume as failed and move on if w_out["status"] != "done": failed.append((name, "writer_" + w_out["status"])) continue # Record this resume as done, with its match level, in the notes file append_note(name, data["level"]) # Count this resume as finished finished += 1 # Print how many resumes finished and which ones failed print("finished: %d, failed: %s" % (finished, failed or "none")) # Print the total steps taken, the spending ledger by tier, and the total cost against the budget print("steps: %d, tokens by tier: %s, cost: %.2f lab units (budget %.0f)" % ( steps, ledger, total_cost(), BATCH_BUDGET)) # If this file is run directly (not imported), optionally reset state, then run one batch if __name__ == "__main__": # If the fresh flag was given, clear all state before running if "--fresh" in sys.argv: reset_state() # Run one batch run_batch() [END OF PYTHON CODE] Step 5: Updating the Remaining Files The mock model must now understand two kinds of task. A task beginning with MATCH asks for a structured result, and a task beginning with WRITE asks for a report. Replace model_adapter.py with the version below. Its comparison logic is the same as before, and it keeps the planted gullibility and the empty-file behavior from earlier chapters. File: model_adapter.py """model_adapter.py - the one place that talks to a model. LAB_MODEL_MODE=mock (default) uses a scripted stand-in that needs no account. Any other value sends the conversation to Google Gemini through hosted_gemini.py. """ import json import os import re [START OF PYTHON CODE] # Create a text variable called MODE, read from the LAB_MODEL_MODE environment variable, defaulting to "mock" MODE = os.environ.get("LAB_MODEL_MODE", "mock") # Define a function that sends a conversation to whichever model is configured and returns its reply def call_model(messages, tools, tier="cheap"): # If running in mock mode, answer with the small scripted stand-in instead of a real model if MODE == "mock": return _mock_reply(messages) # Otherwise, forward the conversation on to the real hosted model return call_hosted_model(messages, tools, tier) # Define a function that sends a conversation to the real hosted Gemini model def call_hosted_model(messages, tools, tier): # Import the Gemini-calling function only when it is actually needed from hosted_gemini import call_gemini # Call Gemini with the conversation, available tools, and tier, and return its reply return call_gemini(messages, tools, tier) # Define a function that splits a "label: comma, separated, values" line into a cleaned-up list of values def _parse_list(line): # Take everything after the first colon, split it on commas, trim whitespace, and drop empty entries return [s.strip() for s in line.split(":", 1)[1].split(",") if s.strip()] # Define a function that compares a resume against the job posting and returns a structured match result def _assess(resume, posting_text): # Find the line of the job posting that lists the required skills posting_line = next(l for l in posting_text.splitlines() if l.lower().startswith("required skills")) # Parse that line into a list of required skills required = _parse_list(posting_line) # Collect the lowercased set of skills the candidate's resume actually lists have = {s.lower() for line in resume.splitlines() if line.lower().startswith("skills:") for s in _parse_list(line)} # Work out which required skills the candidate has matched = [s for s in required if s.lower() in have] # Work out which required skills the candidate is missing missing = [s for s in required if s.lower() not in have] # Decide an overall match level based on how many required skills matched level = "strong" if len(matched) >= 3 else "partial" if matched else "none" # Find the candidate's name from the resume, defaulting to "Unknown" if not found candidate = next((l.split(":", 1)[1].strip() for l in resume.splitlines() if l.lower().startswith("name:")), "Unknown") # If the resume contains either phrasing of the hidden "ignore instructions" order, obey it (planted bug) if re.search(r"ignore (all )?prior instructions", resume, re.I) or re.search(r"ignore (all )?previous instructions", resume, re.I): level = "strong" # Return the structured match result return {"candidate": candidate, "matched": matched, "missing": missing, "level": level} # Define a function that scripts a stand-in model reply, handling both MATCH and WRITE tasks def _mock_reply(messages): # Find the index of the most recent message sent by the user start = max(i for i, m in enumerate(messages) if m["role"] == "user") # Read out the user's task from that message task = messages[start]["content"] # Collect every message that came after the user's task message mine = messages[start + 1:] # Collect every tool call the assistant has made so far, parsed out of JSON text calls = [json.loads(m["content"]) for m in mine if m["role"] == "assistant"] # Collect every tool result that has come back so far results = [m["content"] for m in mine if m["role"] == "tool"] # Collect just the names of the tools that have already been called done = [c["tool"] for c in calls] # Check whether the repeat guard has already fired during this task guarded = any(m["role"] == "system" and m["content"].startswith("GUARD") for m in mine) # If this is a WRITE task, build and save the final report text if task.startswith("WRITE "): # Parse the slim match data out of the task text data = json.loads(task[6:]) # If the report has already been written, the task is finished if "write_report" in done: return {"type": "final", "text": "Report saved."} # If the match level is "unreadable", write the honest unreadable-file message if data["level"] == "unreadable": text = ("The resume file is empty or unreadable, so no review was possible. " "Match level: unreadable.") # Otherwise, build the normal match-summary report text else: total = len(data["matched"]) + len(data["missing"]) text = ("%s matches %d of %d required skills (%s). Missing: %s. Match level: %s." % (data["candidate"], len(data["matched"]), total, ", ".join(data["matched"]) or "none", ", ".join(data["missing"]) or "none", data["level"])) # Build the report's file-name stem from the resume's file name stem = os.path.splitext(data["resume"])[0] # Ask to write the report using that stem as the report's name return {"type": "tool_call", "tool": "write_report", "args": {"name": stem, "text": text}} # Otherwise, this is a MATCH task: find the resume file path mentioned in the task text path = re.search(r"resumes/\S+\.txt", task).group(0) # If the resume has not been read yet, ask to read it first if "read_file" not in done: return {"type": "tool_call", "tool": "read_file", "args": {"path": path}} # Otherwise, the resume text is the first tool result received resume = results[0] # If reading the resume failed or was denied, return an empty final result if resume.startswith(("DENIED", "ERROR")): return {"type": "final", "text": "{}"} # If the resume has no skills line at all (such as an empty file) if not any(l.lower().startswith("skills:") for l in resume.splitlines()): # If the repeat guard has already fired, give up and report the file as unreadable if guarded: return {"type": "final", "text": json.dumps({ "candidate": "Unknown", "matched": [], "missing": [], "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"}} # If the job posting has not been searched yet, ask to search it for the required skills section if "search_job_posting" not in done: return {"type": "tool_call", "tool": "search_job_posting", "args": {"query": "Required skills"}} # Otherwise, assess the resume against the posting and return the structured result as the final answer return {"type": "final", "text": json.dumps(_assess(resume, results[1]))} [END OF PYTHON CODE] Add a failure switch so that you can simulate a broken tool. Insert these two lines in run_tool in tool_registry.py, just after the check that the arguments are a dictionary. [START OF PYTHON CODE] # If the environment names this exact tool as the one to simulate failing, raise a connection error on purpose if os.environ.get("LAB_FAIL_TOOL") == name: raise ConnectionError("simulated outage of " + name) [END OF PYTHON CODE] In dashboard.py change the success test so that it only requires the status done, because the new runner no longer records a match level in the run summary. The line becomes the one below. [START OF PYTHON CODE] # Count a run as successful based only on its status, since this architecture no longer stores a match level here ok = [r for r in runs if r["status"] == "done"] [END OF PYTHON CODE] Finally replace eval_runner.py so that the evaluation exercises the new architecture. File: eval_runner.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: running every eval item through matcher and writer, then scoring it """eval_runner.py - runs every eval item through matcher and writer, then scores it.""" # Import the json module to read structured data import json # Import the os module to work with file paths import os # Import the re module to search text with patterns import re # Import the function that runs the matcher subagent from matcher_agent import match # Import the shared root folder from permission_gate import ROOT # Import the function that runs the writer subagent from writer_agent import write # Define a function that runs the whole evaluation set through the matcher and writer and prints a scorecard def run_eval(): # Open and read the evaluation set file with open(os.path.join(ROOT, "eval_set.json"), "r", encoding="utf-8") as f: items = json.load(f) # Start a passed-count of zero passed = 0 # Walk through every evaluation item for item in items: # Work out the expected report file for this item's resume stem = os.path.splitext(item["input_file"])[0] report = os.path.join(ROOT, "reports", stem + ".txt") # Delete any old report first, so a leftover file cannot make this test look wrong if os.path.exists(report): os.remove(report) # Run the matcher subagent on this resume data, m_out = match(item["input_file"]) # Start the step count from the matcher's steps, and default the result to "missing" steps, got = m_out["steps"], "missing" # If the matcher produced usable data, run the writer too and read the result back if data is not None: # Add the writer's steps to the running total steps += write(data)["steps"] # If a report was written, read the match level back out of it if os.path.exists(report): with open(report, "r", encoding="utf-8") as f: found = re.search(r"Match level: (\w+)", f.read()) got = found.group(1) if found else "missing" # This item passes if the match level is exactly what was expected ok = got == item["golden_level"] # Add one to the passed count if this item passed passed += ok # Print this item's result as one formatted line print("%s %-26s expected=%-10s got=%-10s steps=%2d %s" % ( item["id"], item["input_file"], item["golden_level"], got, steps, "PASS" if ok else "FAIL")) # Print the overall pass rate across every item print("Pass rate: %d of %d (%.0f%%)" % (passed, len(items), 100 * passed / len(items))) # If this file is run directly (not imported), run the full evaluation if __name__ == "__main__": run_eval() [END OF PYTHON CODE]
Advertisement
Read Aloud:
0:00 / 0:00