← Table of Contents · Page 677 of 868

PROJECT TWO - ADDING CONTEXT MANAGEMENT

Part 4 of 11

WHAT CHANGES IN THE CODE Stage 1 gave every resume its own fresh conversation. Stage 2 gives the whole batch one shared history list, so that old messages really do pile up. That is the problem we then solve. Copy your Stage 1 folder, keep all existing files, and add or replace the files below. Step 1: More Sample Data Compaction only matters when there is something to compact, so we grow the sample data from five resumes to twenty-five. The new script writes resumes 06 through 25, mixing skill sets so that some are strong, some partial, and some no match. Two of them, numbers 12 and 21, are padded with forty invented projects each, making them unusually long, about the size of two to three pages. They are there to stress the budget, the way a heavy suitcase tests whether a luggage scale works. File: make_more_data.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: adding twenty more fictional resumes """make_more_data.py - adds resumes 06 to 25 (all fictional). Run make_sample_data.py first.""" # 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 a list variable called FIRST holding a pool of invented first names FIRST = ["Avery", "Blake", "Cameron", "Dakota", "Emerson", "Finley", "Harper", "Jamie", "Kendall", "Logan"] # Create a list variable called LAST holding a pool of invented last names LAST = ["Sampleperson", "Testcase", "Mockwell", "Fakename", "Examplesmith", "Placeholder", "Demoson", "Dummyfield"] # Create a list variable called SKILL_SETS holding a pool of different skill combinations SKILL_SETS = [ "Python, SQL, Airflow, Docker", "Python, SQL, Excel", "Docker, Kubernetes, Go", "Accounting, Budgeting, Excel", "Python, Airflow, Testing, Communication", "Graphic Design, Photography", "SQL, Tableau, Communication", "Python, SQL, Airflow, Docker, Spark, Testing", "Teaching, Curriculum Design, Communication", "Python, Docker, Testing", ] # Create a list variable called TITLES holding a pool of invented job titles TITLES = ["Data Engineer", "Analyst", "Developer", "Coordinator", "Designer", "Teacher", "Accountant"] # Create a set variable called LONG_ONES naming the two resume numbers that should be padded very long LONG_ONES = {12, 21} # Define a function that builds the full fictional resume text for one resume number def build(number): # Pick a first and last name for this number, cycling through the name pools name = "%s %s" % (FIRST[number % len(FIRST)], LAST[number % len(LAST)]) # Pick a job title for this number, cycling through the title pool title = TITLES[number % len(TITLES)] # Pick a skill set for this number, cycling through the skill-set pool skills = SKILL_SETS[number % len(SKILL_SETS)] # Build the base resume text from the name, title, skills, and a made-up years-of-experience figure text = "Name: %s\nTitle: %s\nSkills: %s\nExperience: %d years at made-up companies.\n" % ( name, title, skills, 1 + number % 7) # If this resume number is one of the two that should be padded long if number in LONG_ONES: # Add forty invented project lines to make this resume unusually long for i in range(1, 41): text += ("Project %d: Invented project in which this fictional person improved a made-up " "process for a made-up team, wrote invented documentation, and presented " "imaginary results to a pretend manager.\n" % i) # Return the finished resume text return text # Define the main function that writes resumes 06 through 25 to both the sample_data and resumes folders def main(): # Walk through every resume number from 6 up to (but not including) 26 for number in range(6, 26): # Build this resume's file name, zero-padded to two digits filename = "resume_%02d.txt" % number # Write this same resume text into 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 destination file for writing as UTF-8 text with open(os.path.join(HERE, folder, filename), "w", encoding="utf-8") as f: # Write this resume's built text into the file f.write(build(number)) # Print a confirmation message once every resume has been written print("Resumes 06 to 25 written.") # If this file is run directly (not imported), start the main function if __name__ == "__main__": main() [END OF PYTHON CODE] Run make_sample_data.py first and then make_more_data.py. Check that the resumes folder now holds twenty-five files. Step 2: The Context Builder The context builder is a function that takes the full history and returns only what the model needs right now. It follows three rules. Rule one is that everything belonging to the current task is always kept, because the model must see the resume it is reviewing and the tool results so far. Rule two is that any earlier message flagged as important is always kept. Rule three is that of all the other earlier messages, only the most recent five are kept. In our lab the important messages are the one-line decisions, such as DECISION: resume_03.txt to partial, which the loop writes after each resume. Those lines are tiny, but they are exactly the facts that must never be lost. The same file also holds the helper functions for the notes file. The notes file is called progress_notes.md, and the loop writes one line to it after each finished resume. When the program starts, it reads the file to learn which resumes are already done. File: context_builder.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: choosing what the model sees and keeping the notes file """context_builder.py - chooses what the model sees on each call, and keeps the notes file.""" # Import the json module to read and write structured data import json # Import the os module to work with file paths import os [END OF PYTHON CODE] from permission_gate import ROOT [START OF PYTHON CODE] # Create a text variable called NOTES_PATH pointing to the progress notes file NOTES_PATH = os.path.join(ROOT, "progress_notes.md") # Define a function that roughly estimates how many tokens a piece of text uses def estimate_tokens(text): # Estimate one token per four characters, but never fewer than one token return max(1, len(text) // 4) # Define a function that builds the trimmed-down context the model actually sees on each call def build_context(history, keep_recent=5): # A docstring explaining what this function keeps: the task, flagged messages, and recent history """Return the current task in full, every message flagged important, and only the most recent few of everything else.""" # Find the index of the most recent message sent by the user start = max(i for i, m in enumerate(history) if m["role"] == "user") # Collect the indexes of every message that came before that task message earlier = list(range(start)) # Collect the indexes of the earlier messages that are flagged as important important = [i for i in earlier if history[i].get("important")] # Collect the indexes of the earlier messages that are not flagged as important others = [i for i in earlier if not history[i].get("important")] # Combine the important messages, the most recent few ordinary messages, and everything from the task onward keep = sorted(set(important + others[-keep_recent:] + list(range(start, len(history))))) # Return the actual messages at those kept indexes, in order return [history[i] for i in keep] # Define a function that reads the notes file and returns which resumes are already finished def load_done(): # A docstring explaining what this function returns """Read progress_notes.md and return the set of resume files already finished.""" # Start with an empty set of finished resumes done = set() # Only try to read the file if it actually exists yet if os.path.exists(NOTES_PATH): # Open the notes file for reading as UTF-8 text with open(NOTES_PATH, "r", encoding="utf-8") as f: # Walk through the file one line at a time for line in f: # A line starting with "DONE " names a finished resume file if line.startswith("DONE "): done.add(line.split()[1]) # Return the set of finished resume file names return done # Define a function that appends one finished-resume note to the notes file def append_note(resume_file, level): # Open the notes file in append mode as UTF-8 text with open(NOTES_PATH, "a", encoding="utf-8") as f: # Write a line recording that this resume is done, and at what match level f.write("DONE %s level=%s\n" % (resume_file, level)) [END OF PYTHON CODE] Step 3: Compaction Compaction means replacing a big pile of old messages with a short summary. Our rule is that once the stored history grows beyond a configurable token threshold, the oldest half is replaced by one summary message. The risky part is deciding what the summary keeps. A careless summary could drop a match decision, and then the agent would silently forget a result. So our compaction function copies every important message into the summary word for word, and it never cuts through the middle of the newest task. It is a good rule to remember: summarize the chatter, never the decisions. File: compaction.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: squeezing old history into a short summary """compaction.py - squeezes old history into a short summary.""" # Import the token-estimating function from the context builder from context_builder import estimate_tokens # Import the json module to measure the size of the conversation import json # Define a function that compacts the history when it grows past a token threshold def maybe_compact(history, threshold): # A docstring explaining the compaction rule: summarize chatter, never decisions """If the stored history is too big, replace its oldest half with one summary. Every message flagged important (the DECISION lines) is copied into the summary word for word, so no match decision can be lost.""" # If the history is still within the token budget, leave it unchanged if estimate_tokens(json.dumps(history)) <= threshold: return history # Otherwise, find the midpoint to split the history into an older half and a recent half cut = len(history) // 2 old, recent = history[:cut], history[cut:] # Nudge the cut point earlier until the recent half starts cleanly on a user message while recent and recent[0]["role"] != "user" and cut > 0: cut -= 1 old, recent = history[:cut], history[cut:] # Collect the content of every important message from the older half, word for word kept_facts = [m["content"] for m in old if m.get("important")] # Build one summary message standing in for the whole older half summary = { "role": "system", "important": True, "content": "SUMMARY of %d older messages. Facts kept: %s" % (len(old), "; ".join(kept_facts) or "none"), } # Return the summary message followed by the untouched recent half return [summary] + recent [END OF PYTHON CODE] The stored history is compacted, while the builder separately trims what is sent on each call. You may wonder why we need both. The builder protects each individual call, and compaction protects the memory of the harness itself, so that the stored history does not grow without limit over a very long batch. Step 4: The Updated Main Loop Now we wire everything together. The loop keeps one history for the whole batch, appends a decision line after every resume, writes a note, and compacts when needed. It also offers four ways to run, which are listed after the code. File: main_loop.py [START OF PYTHON CODE] # Module docstring stating the purpose of this file: Stage 2, a shared history with context building """main_loop.py - Stage 2: the loop now keeps one shared history and uses a context builder.""" # Import the filecmp module to compare files for equality import filecmp # 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 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 # Create a number variable called MAX_STEPS, the most think/act/observe steps allowed per resume MAX_STEPS = 10 # 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") # 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, sharing the batch's history def review_one(resume_file, history, use_context=True, max_steps=MAX_STEPS): # 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 # 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 # Ask the model what to do next reply = call_model(sent, describe_tools()) # 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 # Otherwise, add the model's tool-call reply to the shared history history.append({"role": "assistant", "content": json.dumps(reply)}) # Attempt to run the requested tool with the requested arguments try: result = run_tool(reply["tool"], reply["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) # 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)}) # Return this resume's full outcome return {"resume": resume_file, "status": status, "steps": steps, "tokens": tokens_used, "level": level} # 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 compare flag was given, run the batch twice and compare token usage with and without context if "--compare" in args: # Clear all state so the first run starts clean reset_state() # Run the batch without context trimming, keeping track of tokens used before = run_batch(use_context=False) # Build a path to save a copy of this run's reports for later comparison saved_copy = os.path.join(ROOT, "reports_before") # Remove any old saved copy, ignoring errors if it does not exist shutil.rmtree(saved_copy, ignore_errors=True) # Copy the reports folder into the saved-copy location shutil.copytree(os.path.join(ROOT, "reports"), saved_copy) # Clear all state again so the second run also starts clean reset_state() # Run the batch again, this time with context trimming, keeping track of tokens used after = run_batch(use_context=True) # Count how many reports are byte-for-byte identical between the two runs same = sum(1 for n in os.listdir(saved_copy) if filecmp.cmp(os.path.join(saved_copy, n), os.path.join(ROOT, "reports", n), shallow=False)) # Print how many tokens were used before and after, and the percentage saved print("BEFORE %d tokens, AFTER %d tokens, saved %.0f%%" % (before, after, 100 * (before - after) / before)) # Print how many of the reports matched exactly between the two runs print("Reports identical before and after: %d of %d" % (same, len(os.listdir(saved_copy)))) # Otherwise, run a single ordinary batch else: # If the fresh flag was given, clear all state before running if "--fresh" in args: reset_state() # 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] Four ways to run the agent. The command python main_loop.py runs the batch and skips any resume already listed in the notes. The command python main_loop.py --fresh clears the reports, logs, and notes first. The command python main_loop.py --fresh --stop-after 10 stops after ten resumes to simulate a crash. The command python main_loop.py --compare runs the whole batch twice, first without the context builder and then with it, and compares the cost and the reports.
Advertisement
Read Aloud:
0:00 / 0:00