Skip to content

resource

Durable Agent Memory: Unit A Exercises

Study durable agent memory in Chapter 5. Unit A: build the mechanism, record your predictions and test a changed case in the student notebook.

Student edition · 90 minutes of dedicated work · 2026-09-09

Open in Colab Runs on Google Colab as it ships today, or on any local Python 3.12+ kernel.

This is one of two practical units for Chapter 5. Unit A constructs and connects the mechanism; Unit B investigates a controlled failure, repairs it and transfers the invariant. Each is a complete ninety-minute session, with its own setup and required conceptual introductions. Basic Python variables, conditions, loops, functions, lists and dictionaries are the starting knowledge. Libraries and specialized concepts used here are introduced below before the main task.

By the end you should be able to:

  1. Explain the chapter's mechanism using a prediction and an observed intermediate result.
  2. Build bounded retrieval from assistant_preferences. Select only this session and active revisions, rank case-insensitive word overlap, break ties by newest identity, and preserve provenance.
  3. Solve retrieve the newest eligible memory under a limit using changed inputs and an independent expectation.
  4. Retain your implementation, failed/corrected observations, causal explanation and limits.
MinutesDedicated activityEvidence you produce
0–5State the problem and make a predictionInitial prediction in your own words
5–25Foundations and library examplesValues, explanations, revised predictions
25–35Trace setup and the main interfaceInput → learner function → observation
35–60Construct and connectSource, visible checks and runtime evidence
60–80Implement and challenge the transfer taskFunction and a new counterexample
80–90Retrieve, explain and saveExit ticket and retained submission

Installation is preparation time. These are planning estimates, not measured completion times. Use the reference primers when a term is unfamiliar; in Unit B retrieve an explanation before re-reading it. Run All checks that the artifact executes. Unfinished student functions deliberately produce NEEDS_WORK. Keep your first attempt before opening answers.

This notebook belongs to the construction edition. Its supplied teaching runtime is embedded, so it can run without the textbook or another notebook. Where code uses REFERENCE_LESSON, that is the frozen runtime exercise identifier; the reader-facing chapter and saved unit identifiers use the current edition. Building against a supplied runtime is not proof that you have constructed all of its dependencies.

Run the self-contained setup

Open this notebook in Google Colab with the badge above, or use a local Python 3.12+ Jupyter kernel, with Pydantic 2. If needed, run %pip install "pydantic==2.13.4" once in a separate cell and restart the kernel. Package installation needs internet; the lesson itself needs no repository download, API key or prior notebook. The closing extension can use an OpenAI key from Colab's Secrets pane to put a live model behind the same tools; without a key it replays a recorded transcript and says so.

The collapsed cell contains 91 frozen teaching files. Base85 represents compressed bytes as text; zlib decompresses them; SHA-256 checks that the decoded files match this edition. These are supplied packaging operations, not learner algorithms. tempfile creates an isolated working copy; Path handles file locations; sys.path tells Python where the supplied modules live; a .pth file, the mechanism an editable install uses, tells reviewed local subprocesses the same. The code is available for inspection below and performs no package installation itself. The subsequent lesson teaches the libraries used by the mechanisms you will implement.

Run setup on every fresh kernel. It writes scratch runtime files separately from your retained practical-work/ch05-a folder. Rerunning setup restores the frozen support files and keeps your saved work. Restarting a kernel clears variables, not saved submission files. Source basis: Sovereign Agent 444c5f6. Some tasks use reviewed local subprocesses; they are not an OS sandbox.

Supplied offline setup and teaching files

The complete supplied setup cell is in the downloadable notebook. Run it there before attempting the cells below. This web edition omits only that compressed setup payload.

Commit to a prediction before the examples

python
prediction_notes = {
    "prediction": "Write the expected behavior before running the worked example.",
    "reason": "Name the input and rule behind that prediction.",
    "falsifier": "Name an observation that would prove the explanation wrong.",
    "revision": "After execution, explain what changed in your understanding.",
}

Reading the Python vocabulary used in this notebook

You need basic assignments, if, loops, functions, lists and dictionaries. The less familiar features used by the supplied code are introduced here. A library is reusable code that Python can import. The standard library ships with Python; Pydantic is an additional package. An import makes a name available, but does not mean that you have completed the exercise.

JSON is text for exchanging structured values. A Python dictionary is an in-memory object; the JSON representation is a string. Use json.dumps to encode and json.loads to decode. Decoding proves that text has valid JSON syntax, not that its fields match our business contract. Predict which of the following two decoded objects could describe a stock count.

python
import json

intro_data = {"sku": "MANGO", "count": 3}
intro_text = json.dumps(intro_data, sort_keys=True)
print(type(intro_data).__name__, type(intro_text).__name__, intro_text)
print(json.loads(intro_text))
print("Also valid JSON:", json.loads('["not", "a", "stock", "record"]'))
assert json.loads(intro_text) == intro_data

The first result is a dictionary; the second is a list. Before indexing a decoded object, check the shape that your function promises to accept. An exception interrupts the normal path. raise ValueError(...) refuses an invalid value; try/except lets a caller inspect that expected refusal. Catch the expected class, rather than turning every programming error into apparent success. finally runs cleanup even when an earlier operation raises.

An annotation, such as count: int, documents the expected type. It does not by itself enforce the type at runtime. A class defines a kind of object; an instance holds one object's data. @dataclass asks Python to generate routine construction and comparison methods from annotated fields. frozen=True prevents ordinary reassignment of the instance's fields; it does not make every object nested inside those fields immutable. A method is a function attached to a class; self refers to the instance receiving the call.

python
from dataclasses import dataclass


@dataclass(frozen=True)
class IntroObservation:
    operation: str
    count: int


intro_observation = IntroObservation("count-mango", 3)
print(intro_observation.operation, intro_observation.count)
assert intro_observation == IntroObservation("count-mango", 3)

A callback is a function passed to another function. This is how the classroom harness invokes your implementation. The argument candidate below is a function object; parentheses perform the call. Predict the two answers before execution, then trace the result to the callback.

python
def intro_apply(candidate, value):
    return {"input": value, "observed": candidate(value)}


def intro_double(value):
    return value * 2


print(intro_apply(intro_double, 3))
print(intro_apply(lambda value: value + 2, 3))
assert intro_apply(intro_double, 3)["observed"] == 6

lambda value: value + 2 is a small anonymous function. A closure is a function that retains access to values from its surrounding scope. It can bind a tool to a shop snapshot. A shallow copy duplicates only the outer container; copy.deepcopy also copies nested containers used in these fixtures. A set stores distinct values; required <= allowed asks whether every required item is allowed. frozenset is the corresponding immutable set. A tuple groups ordered values; (value,) is a one-item tuple, including the comma.

Paths and cleanup. Path represents a filesystem location. path / "file.json" constructs a child path; read_text and write_text read and write text. A context manager, used with with, manages entry and exit. A temporary-directory context removes its contents on exit. Save your submission outside temporary runtime directories. Reopening a file is different from reusing a Python variable: the former tests retained bytes, while the latter only tests this kernel.

python
from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as intro_folder:
    intro_path = Path(intro_folder) / "observation.json"
    intro_path.write_text(json.dumps(intro_data), encoding="utf-8")
    intro_reopened = json.loads(intro_path.read_text(encoding="utf-8"))
    assert intro_reopened == intro_data
    print("Read from a file:", intro_reopened)

Retrieval check: explain JSON versus a dictionary, annotation versus validation, class versus instance, and defining a callback versus invoking it. Change the callback above so an incorrect implementation visibly changes the observed output. This distinction will matter when grading your connected work. Reference: Python's JSON, dataclasses, and pathlib documentation.

Pydantic: turn an input dictionary into a checked object

Pydantic is an additional Python library for validating data. Its model is a class describing fields, not a neural network. Inherit from BaseModel, declare annotated fields, then call model_validate on incoming data. A field without a default is required. A field with a default can be omitted. The result is an instance whose values you read with dot notation.

python
from pydantic import BaseModel, ConfigDict, Field, ValidationError


class IntroCourseRequest(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    name: str = Field(min_length=1)
    quantity: int = Field(gt=0, le=1000)
    note: str = ""


intro_request = IntroCourseRequest.model_validate({"name": "mango", "quantity": 4})
print(intro_request.name, intro_request.quantity, repr(intro_request.note))
assert intro_request.note == ""

The annotation says the field's type. Field supplies constraints: gt=0 means greater than zero, le=1000 means at most 1000, and min_length=1 excludes an empty name. ConfigDict sets model-wide behavior. strict=True rejects conversions for this integer field, including "4", 4.0 and True; extra="forbid" rejects undeclared keys. Pydantic can otherwise convert some compatible inputs, so choose this boundary deliberately rather than assuming every accepted input arrived in the expected type.

Predict which rule refuses each payload. ValidationError reports a failed contract. Its errors() entries contain loc, the field location, and type, the failure category. Catching that expected exception lets the notebook inspect the failure and continue.

python
intro_bad_requests = [
    {"name": "mango", "quantity": "4"},
    {"name": "mango", "quantity": True},
    {"name": "", "quantity": 4},
    {"name": "mango", "quantity": 0},
    {"name": "mango", "quantity": 4, "approved": True},
    {"quantity": 4},
]
for intro_bad_request in intro_bad_requests:
    try:
        IntroCourseRequest.model_validate(intro_bad_request)
    except ValidationError as intro_error:
        print([(item["loc"], item["type"]) for item in intro_error.errors(include_input=False)])
    else:
        raise AssertionError("An invalid input crossed the declared contract")

Use model_dump() for a Python dictionary, model_dump_json() for JSON text, and model_validate_json() to parse and validate JSON. model_json_schema() describes the contract; it is neither an instance's current values nor an invocation of the business handler.

python
intro_serialized = intro_request.model_dump_json()
intro_schema = IntroCourseRequest.model_json_schema()
assert IntroCourseRequest.model_validate_json(intro_serialized) == intro_request
print("Actual values:", intro_request.model_dump())
print("Quantity contract:", intro_schema["properties"]["quantity"])
assert intro_schema["properties"]["quantity"]["exclusiveMinimum"] == 0

Four is valid input to this schema even if the shop needs six. Pydantic checks the declared shape and constraints; the handler still needs authoritative stock, price and permission. Ordinary assignments to an existing instance are not automatically revalidated unless configured for assignment validation. This lesson validates new input at the boundary and uses the resulting values. Explain these limits before relying on a model object in a transaction or tool call.

Chapter 2's full introduction expands this pattern with a separate data-repair checkpoint. This notebook contains the required pattern here so prior Pydantic experience is not needed. References: models, fields, and strict mode.

SQLite from the first row to an atomic change

A dictionary disappears when its process ends. A database can retain records so a later process can resume from evidence. SQLite is an embedded database: Python's sqlite3 library opens a local database file without starting a separate database server. SQL is the language used to define, select and change its records. A table has named columns and rows. A primary key identifies a row; a query asks for rows satisfying a condition.

Start with a deliberately small preference table. Read the SQL as instructions: create the table, insert one named value, then select the value for one session. ? is a parameter placeholder; the values are passed separately so they are data, not SQL instructions. fetchone() returns one row or None; it does not guarantee that a matching row exists.

python
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as intro_sql_folder:
    intro_db_path = Path(intro_sql_folder) / "example.sqlite"
    intro_db = sqlite3.connect(intro_db_path, autocommit=True)
    intro_db.execute("CREATE TABLE preference (id INTEGER PRIMARY KEY, session TEXT, value TEXT)")
    intro_db.execute("INSERT INTO preference (session,value) VALUES (?,?)", ("lucy", "09:00"))
    intro_row = intro_db.execute(
        "SELECT value FROM preference WHERE session=?", ("lucy",)
    ).fetchone()
    print("Matching row:", intro_row)
    assert intro_row == ("09:00",)
    assert (
        intro_db.execute(
            "SELECT value FROM preference WHERE session=?", ("another-session",)
        ).fetchone()
        is None
    )
    intro_db.close()
    intro_reopen = sqlite3.connect(intro_db_path, autocommit=True)
    assert intro_reopen.execute("SELECT count(*) FROM preference").fetchone()[0] == 1
    intro_reopen.close()
print("The row survived closing and reopening its connection.")

Index [0] selects the first column of a returned tuple. sqlite3.Row is an alternative row factory that also permits named-column access. dict(row) then produces an ordinary dictionary. The book's Database wrapper supplies that configuration and its schema; the wrapper is course code, while sqlite3 is the standard library. You will use the public connection and transaction methods explained at the exercise boundary, rather than needing to reconstruct the wrapper.

Now consider a budget. Moving five cents from reserved to spent requires two values to change together. A transaction makes a group of local changes commit together or roll back together. The example explicitly controls SQL transactions with autocommit=True and SQL statements. BEGIN IMMEDIATE starts a write transaction; COMMIT keeps its changes; ROLLBACK discards them. Predict the row after the deliberately raised exception. Catching an error alone would not undo the first update; the rollback is the operation that restores the prior state.

python
intro_ledger = sqlite3.connect(":memory:", autocommit=True)
intro_ledger.execute(
    "CREATE TABLE budget (id INTEGER PRIMARY KEY, reserved INTEGER, spent INTEGER)"
)
intro_ledger.execute("INSERT INTO budget VALUES (1,5,0)")
intro_ledger.execute("BEGIN IMMEDIATE")
try:
    intro_ledger.execute("UPDATE budget SET reserved=0 WHERE id=1")
    raise ValueError("injected failure before the matching spend update")
except ValueError:
    intro_ledger.execute("ROLLBACK")
assert intro_ledger.execute("SELECT reserved,spent FROM budget").fetchone() == (5, 0)
intro_ledger.execute("BEGIN IMMEDIATE")
intro_ledger.execute("UPDATE budget SET reserved=0,spent=5 WHERE id=1")
intro_ledger.execute("COMMIT")
print(
    "After a complete change:", intro_ledger.execute("SELECT reserved,spent FROM budget").fetchone()
)
intro_ledger.close()

The literal :memory: creates a temporary database inside this connection; it is useful for the small experiment, but the earlier file example establishes persistence. Neither example proves that a remote supplier rolls back when the local transaction rolls back. An external operation has its own state and evidence.

SQL you will meet later. UPDATE ... SET ... WHERE ... changes selected rows. AND combines conditions. ORDER BY makes an ordering explicit; absent that clause, do not rely on row order. count(*) counts rows; sum(amount) totals a column and can be NULL on an empty input; coalesce(sum(amount),0) uses zero for that empty aggregate. A UNIQUE constraint rejects duplicate identities. GROUP BY status computes one aggregate per status.

An invariant is a condition that must remain true across operations, such as nonnegative reserved money. A snapshot is a consistent view at one point; two separate reads can describe different moments unless their transaction contract binds them. In the book, with db.immediate() groups related writes. It is a course-defined context manager with the commit/rollback purpose you just observed. Do not assume that an arbitrary with connection has identical behavior under every SQLite autocommit setting.

Your prediction: two workers both read ten remaining cents outside a transaction and each approve seven. Why can both believe the next order fits? Explain what must be checked together with the write. Then change the example's initial reserved amount and repeat the failure. Reference: Python's SQLite tutorial and transaction control.

Memory means selecting retained evidence for a new request

Lucy says deliveries should arrive at nine, then corrects herself to ten. A useful assistant must retain the correction after a restart and avoid presenting both times as current guidance. Persistence means the record survives the process. Retrieval means choosing which retained records to include now. They are separate mechanisms: a database can preserve every revision while retrieval returns only the currently active revision for this session.

A session identifies the conversation or work context that owns a memory. Provenance records where a value came from. A revision is a new version of an earlier value. Here a correction creates current guidance while old evidence can remain available for audit. Forgetting excludes a value from future context; it does not erase already sent provider requests or backups.

Derive a retrieval rule on visible rows

Our small table has two sessions and two revisions. First filter by session and active status. Then rank the remaining rows for this query. Reversing those operations can waste a limited context budget on a foreign or superseded row. Predict the returned identities for Lucy.

python
intro_memories = [
    {"id": 1, "session": "lucy", "active": False, "name": "delivery", "value": "09:00"},
    {"id": 2, "session": "lucy", "active": True, "name": "delivery", "value": "10:00"},
    {"id": 3, "session": "another-shop", "active": True, "name": "delivery", "value": "06:00"},
    {"id": 4, "session": "lucy", "active": True, "name": "invoice", "value": "email"},
]
intro_eligible = [row for row in intro_memories if row["session"] == "lucy" and row["active"]]
print([(row["id"], row["value"]) for row in intro_eligible])
assert [row["id"] for row in intro_eligible] == [2, 4]

The database exercise performs the same filtering with a parameterized WHERE clause. The bounded result must retain identity, name, value, source and creation evidence. Returning only a friendly sentence would discard the provenance needed to investigate a bad answer.

Understand the deliberately simple relevance score

We lowercase using casefold, split into whitespace-separated words, and count the intersection with query words. A set intersection keeps words present in both sets. This lexical score is easy to inspect; it is not a semantic embedding or a claim that similar meanings always match. An embedding would represent text numerically for another similarity method; we do not need that additional model or library for this lesson's explicit rule.

python
intro_query_words = set("DELIVERY time".casefold().split())
intro_ranked = []
for intro_row in intro_eligible:
    intro_words = set((intro_row["name"] + " " + intro_row["value"]).casefold().split())
    intro_ranked.append((len(intro_query_words & intro_words), intro_row["id"], intro_row["value"]))
intro_ranked.sort(key=lambda item: (-item[0], -item[1]))
print(intro_ranked)
assert intro_ranked[0] == (1, 2, "10:00")

The negative signs turn ascending Python sorting into descending score and descending identity. Identity breaks ties deterministically in favor of the newer row. Slicing [:maximum] then limits the output. Validate the maximum first; accepting negative slicing would quietly turn an invalid requested budget into a different selection rule.

Follow the context all the way to the model seam

The course's Database supplies durable rows. preferences retrieves them. The context builder places selected values in the next request. The replay model records the actual messages it received. A successful insert proves only persistence; a successful query proves retrieval; the recorded message proves that the retrieved data was connected to the model input.

In Unit A you implement retrieval, close and reopen the database, then inspect the context. In Unit B a query loses its active=1 condition. The old value can reappear even if the new value ranks first. The repair must exclude stale guidance rather than merely move it lower in the list.

Before the main task: explain which records survive an empty query, why another session's matching word cannot grant eligibility, and what happens when two rows have the same score. For transfer, correct twice, forget one preference, and vary the retrieval limit. Keep actual row identities beside the final context text so a plausible-looking answer cannot conceal the wrong revision. Authoritative current stock still comes from the shop tool, not remembered prose.

Understand the supplied execution interface

The course runtime is provided so your implementation can be connected to real callers and storage. SourceTask(ROOT, chapter) makes a private copy. install(source) replaces only the declared function; visible() invokes the real chapter probe; save(path, result) retains a successful implementation and its evidence. load(path) checks the saved identities and hashes. inject_failure() changes the declared boundary; repair(fragment) replaces that broken fragment. close() removes the scratch copy after you retain evidence. These methods are supplied harness operations, not additional packages you must discover or install.

RuntimeLab provides the same copied-source failure experiment without the complete-function construction layer. Its run method records exit status, observations and the compared expectation. A subprocess log from an unfinished learner implementation is feedback about that implementation; it is not a successful connection. A syntax error in the notebook cell itself is a separate issue to fix. The task below names which interface it uses.

For direct-function units, the visible driver calls your callback without installing a source string. In either case, trace where your code is invoked. Supplied fixtures, database wrappers and replay models are labeled infrastructure; your own implementation and changed-case explanation are the evidence of learning.

Main practical: construct, connect and challenge

Design question: A correction must change the next model context?

Build bounded retrieval from assistant_preferences. Select only this session and active revisions, rank case-insensitive word overlap, break ties by newest identity, and preserve provenance.

You edit a complete function in a temporary copy of src/sovereign_agent/assistant_context.py. The real callers, database and tool boundaries remain connected. The self-contained setup above supplies the frozen development runtime. The notebook runs reviewed local subprocesses; it is not a security sandbox. No live account is required.

Predict before running

Remember, correct, close and reopen SQLite; invoke context and inspect the actual system message seen by the model.

Write the expected result and a falsifying observation before running. Include one legal action, one refusal, and the exact-empty case where the interface permits it. Explain the consequence for Lucy if your prediction is wrong.

python
import json
import os
import runpy
from pathlib import Path

ROOT = COURSE_ROOT

SourceTask = runpy.run_path(str(ROOT / "book/always_on/exercises/source_tasks_v1.py"))["SourceTask"]
REFERENCE_LESSON = 4
HANDOFF = Path("ch05-unit-a-handoff-v1.json")

Construct the complete mechanism

Implement preferences in the string below. Keep the named signature and existing helper interfaces. The starter is intentionally incomplete; its failed connection is reported separately from whether the notebook itself executed. A constant answer cannot stand in for the real mechanism.

Inspect the supplied caller around the function in src/sovereign_agent/assistant_context.py. Draw the data path from the observed output through this function to its actual input or query. Then write your implementation from the contract.

python
implementation_source = """
def preferences(
    db: Database, session: str, query: str = "", *, maximum: int = 20
) -> list[dict[str, Any]]:
    raise NotImplementedError("Construct this chapter mechanism")
"""

Hint 1: the design

Putting the new preference first does not remove contradictory old guidance.

Hint 2: the boundary

Inspect the parameters and the caller in src/sovereign_agent/assistant_context.py. Identify validation, durable state and the first externally observable effect. Preserve the existing surrounding helper contracts.

Hint 3: the structure

Use a parameterized query, set intersection for relevance, deterministic ties and a validated maximum.

Connect to the cumulative runtime

The following installs your complete function into the copied runtime and executes the chapter probe against it. It saves your implementation and the observed connection for Unit B only after that connection succeeds.

python
def connect_build(source):
    task = SourceTask(ROOT, REFERENCE_LESSON)
    try:
        task.install(source)
        result = task.visible()
        if result["status"] == "PASS":
            task.save(HANDOFF, result)
        return result
    finally:
        task.close()


build_result = connect_build(implementation_source)
print("CONNECTION", build_result["status"])
print("OBSERVATION", build_result["observation"])

Challenge and transfer

Correct twice, forget one key and introduce another session with the same preference name. Prove no stale or foreign value reaches context.

Use a fresh SourceTask, install your implementation and edit only its copied probe to run the changed input. Keep the expected result in your prediction notes, independent of your implementation. Call task.run("MY_TRANSFER", expected=your_expected) and close the task in finally. Retain both a valid and a refused case so rejecting everything cannot pass.

The instructor runs additional cases with different identities and boundaries against the real source. Passing the visible connection alone is not the transfer verdict. Do not put instructor solutions or holdouts into a student submission.

Save and explain

After success, submit ch04-unit-a-handoff-v1.json, your source, prediction notes and changed-input observations. Unit B checks the chapter, runtime hash and exact implementation hash and re-executes your code.

Explain which input or state caused the output, which observation would refute the explanation and what remains outside the guarantee: Forgetting future context is not secure erasure of backups or past provider requests.

python
exercise_report = {
    "unit": "ch05-a",
    "attempted": 1,
    "completed": int(build_result["status"] == "PASS"),
    "failed": int(build_result["status"] != "PASS"),
    "skipped": 0,
    "connection": build_result["status"],
    "handoff": "WRITTEN" if build_result["status"] == "PASS" else "NOT_READY",
}
print("EXERCISE_REPORT=" + json.dumps(exercise_report, sort_keys=True))

Changed-constraint construction: Retrieve the newest eligible memory under a limit

Allow twenty minutes. Spend three minutes predicting, ten implementing and tracing, five on a new case of your own, and two explaining the surviving limitation. This is dedicated work, not an invitation to run a supplied answer. Both units revisit the same invariant after different core experiences; in Unit B, attempt this task from memory before consulting Unit A.

Implement transfer_check(rows, session, limit). Each row has unique integer id, session and boolean active. Return eligible row IDs newest first, at most limit. Require an exact integer limit in 1..100; otherwise raise ValueError. Empty eligible input returns an empty list. Do not mutate rows. This deliberately isolates eligibility and tie order from lexical scoring.

Write your expected values before running the table. Keep one accepted case and one refusal. Your function is passed directly into the driver below. The driver copies inputs and checks they remain unchanged; it does not replace your implementation with the reference answer.

Hint 1: identify the authoritative inputs Name the source field for each output value. Which input changes while the rule remains the same?

Hint 2: choose the boundary cases Start with exact empty, exact equality and one value on each side of the boundary where valid. Do not add a special case for a visible product name or operation identity.

python
def transfer_check(rows, session, limit):
    raise NotImplementedError("Filter before ordering and limiting")
python
import copy
import json

TRANSFER_CASES = [
    (
        "current own row",
        [
            [
                {"id": 1, "session": "lucy", "active": False},
                {"id": 2, "session": "lucy", "active": True},
                {"id": 3, "session": "other", "active": True},
            ],
            "lucy",
            2,
        ],
        [2],
    ),
    (
        "newest first",
        [
            [
                {"id": 8, "session": "lucy", "active": True},
                {"id": 9, "session": "lucy", "active": True},
            ],
            "lucy",
            1,
        ],
        [9],
    ),
    ("empty", [[], "lucy", 3], []),
    ("zero limit", [[], "lucy", 0], {"raises": "ValueError"}),
]


def same_transfer_value(actual, expected):
    if type(actual) is not type(expected):
        return False
    if isinstance(expected, dict):
        return actual.keys() == expected.keys() and all(
            same_transfer_value(actual[key], value) for key, value in expected.items()
        )
    if isinstance(expected, list):
        return len(actual) == len(expected) and all(
            same_transfer_value(a, e) for a, e in zip(actual, expected, strict=True)
        )
    return actual == expected


def run_transfer(candidate, cases):
    observations = []
    for label, arguments, expected in cases:
        supplied = copy.deepcopy(arguments)
        before = copy.deepcopy(supplied)
        raised = None
        try:
            actual = candidate(*supplied)
        except NotImplementedError:
            raised = "NotImplementedError"
            actual = {"unfinished": True}
        except Exception as error:
            raised = type(error).__name__
            actual = {"raises": raised}
        expects_error = isinstance(expected, dict) and set(expected) == {"raises"}
        correct = (
            raised == expected["raises"]
            if expects_error
            else (raised is None and same_transfer_value(actual, expected))
        )
        passed = correct and same_transfer_value(supplied, before)
        observations.append(
            {"case": label, "expected": expected, "observed": actual, "passed": passed}
        )
        print("PASS" if passed else "NEEDS_WORK", label, "expected", expected, "observed", actual)
    return observations


transfer_observations = run_transfer(transfer_check, TRANSFER_CASES)
TRANSFER_PASSED = all(row["passed"] for row in transfer_observations)
print("TRANSFER_STATUS", "PASS" if TRANSFER_PASSED else "NEEDS_WORK")

Design a counterexample and retrieve the mechanism

Add one new case with an independently calculated expected outcome to TRANSFER_CASES and rerun the driver. Change one condition at a time. Then deliberately replace your candidate with a constant answer in a temporary copy and show a case that rejects it. Restore your implementation. Explain why that counterexample is stronger than repeating the original example with a new name.

Without viewing the worked example, write the invariant in words and trace one observed value back to its input. Identify which part is a local fixture result and which claim would need a live provider, host or external-system observation. Keep a first attempt even if you used a hint.

Save your evidence and explain the result

Fill the prediction notes and your explanation before saving. Include the exact observed value, the input or retained row that caused it, your code's invocation point, one failed hypothesis, and the strongest claim the evidence still cannot support. A completed code cell alone does not earn explanation credit. Do not label reference-start behavior as your own Unit A construction.

Keep this edited notebook, the Markdown if used for notes, saved handoff files, and the JSON record below. Your work folder survives scratch cleanup and can be reopened in a new kernel. An instructor can ask for an unseen case after the visible checks; keep your implementation general.

python
explanation_notes = {
    "causal_trace": "Explain the input, learner invocation and observed result.",
    "failed_hypothesis": "Describe a prediction the evidence changed.",
    "remaining_limit": "Name the guarantee not established by this experiment.",
}
course_submission = {
    "unit": "ch05-a",
    "planned_minutes": 90,
    "starting_evidence": globals().get("HANDOFF_ORIGIN", "INDEPENDENT_UNIT_A"),
    "prediction": prediction_notes,
    "explanation": explanation_notes,
    "core_report": exercise_report,
    "transfer": transfer_observations,
    "explanation_review": "HUMAN_REVIEW_REQUIRED",
}
submission_path = COURSE_WORK / "ch05-a-submission-v1.json"
submission_path.write_text(
    json.dumps(course_submission, indent=2, sort_keys=True), encoding="utf-8"
)
print("Saved evidence:", submission_path)
print(
    "COURSE_REPORT="
    + json.dumps(
        {
            "unit": "ch05-a",
            "transfer_passed": TRANSFER_PASSED,
            "starting_evidence": course_submission["starting_evidence"],
            "edition": "student",
        },
        sort_keys=True,
    )
)

Extension: let the model manage Lucy's memory

Everything above kept one rule fixed: you decided what the shop remembers, and your code wrote it. The remaining question in this chapter is what changes when the model decides. Lucy tells the assistant that mango sold out again on Saturday. Nobody calls remember. The model reads the message, judges whether that fact will still matter next week, and asks to store it.

The mechanism is the loop from Chapter 3, shipped in the embedded runtime as agent_loop.run_loop: send the conversation and the tool schemas, read back an answer or tool calls, run each call through the dispatcher, append the observation under its tool_call_id, ask again, and count every attempt against Limits. The model only ever proposes a write. memory_tools wires remember_fact and recall_facts to the same Dispatcher the book uses for every tool. Writing is marked consequential, so the dispatcher refuses it unless a before_write gate is attached, and that gate, MemoryWritePolicy, can refuse a write and records why. Reading is not consequential. That asymmetry is the whole design.

llm_lab supplies the model seam: ScriptedModel replays turns written in this notebook and runs everywhere; OpenAIModel sends the same messages to a hosted model when a key is present. The loop cannot tell them apart; the record always says which one ran.

Prediction: the next cell seeds one memory and prints the two tool contracts. Before running it, write which fields the model must supply to store a fact, and which shop rule would refuse a correctly shaped fact.

python
import json
from pathlib import Path

from sovereign_agent.agent_loop import Limits, run_loop
from sovereign_agent.database import Database
from sovereign_agent.llm_lab import ScriptedModel, describe, scripted_turn, tool_trace
from sovereign_agent.memory import remember
from sovereign_agent.memory_tools import toolbox
from sovereign_agent.model_turn import ToolCall

LLM_MEMORY_DB = COURSE_WORK / "ch05-llm-memory.sqlite"
SEED_MEMORY = "Deliveries from the dairy supplier arrive at ten on weekdays."


def fresh_memory_database(path):
    """Start from an empty shop memory so reruns and replays observe the same rows."""
    for stale in (path, Path(f"{path}-wal"), Path(f"{path}-shm"), path.with_suffix(".authority")):
        stale.unlink(missing_ok=True)
    return Database(path)


def seeded_toolbox(**policy):
    """A fresh memory holding only the seed row, behind the gated tools."""
    box = toolbox(fresh_memory_database(LLM_MEMORY_DB), actor_id="lucy", **policy)
    remember(box.db, "lucy-seed-001", SEED_MEMORY, importance=0.8)
    return box


memory_box = seeded_toolbox()
memory_dispatcher = memory_box.dispatcher()
for tool_schema in memory_dispatcher.schemas():
    function = tool_schema["function"]
    print(function["name"], "requires", function["parameters"]["required"])
    print("   ", function["description"])
print("Writes allowed per session:", memory_box.policy.max_writes)
print("Phrases the shop never stores:", memory_box.policy.banned_phrases)
print("Patterns the shop never stores:", memory_box.policy.banned_patterns)
MEMORY_MESSAGES = [
    {
        "role": "system",
        "content": (
            "You help Lucy run her ice cream shop. You have two tools: recall_facts to search "
            "what the shop already remembers, and remember_fact to store a durable fact. Store "
            "a fact only when it will still matter in a future conversation: a standing pattern, "
            "a supplier arrangement, a recurring problem. Search before you store. The shop may "
            "refuse a write; if it does, read the result and adapt rather than retrying the same "
            "thing. Finish with plain words about what you stored and why."
        ),
    },
    {
        "role": "user",
        "content": (
            "Mango sold out again on Saturday afternoon, so we should order extra on Fridays. "
            "Also remember the dairy account is paid by card, number ending 4421."
        ),
    },
]

The schema the model reads is the model_json_schema() you inspected earlier, wrapped in the {"type": "function", ...} envelope the API expects. required lists the fields without defaults; strict=True and extra="forbid" still apply, so a call with importance as the string "0.9" is refused before any handler runs, exactly like the bad IntroCourseRequest payloads above.

Hand-build three tool calls and dispatch them yourself, with no model involved. Predict each result: one valid write, one write that names a card number, and one with a string where a float belongs. Then read the memories table directly to see which rows SQLite actually holds. invoke_with_reason adds the shop rule that refused a write. A Pydantic refusal stays invalid_arguments, because the dispatcher never copies raw arguments into an error message.

python
manual_calls = [
    ToolCall(
        id="manual-1",
        name="remember_fact",
        arguments={
            "content": "Mango sells out most Saturday afternoons; order extra on Friday.",
            "importance": 0.7,
            "reason": "recurring pattern",
        },
    ),
    ToolCall(
        id="manual-2",
        name="remember_fact",
        arguments={
            "content": "Lucy's card number ends in 4421 for the dairy account.",
            "importance": 0.3,
            "reason": "billing detail",
        },
    ),
    ToolCall(
        id="manual-3",
        name="remember_fact",
        arguments={
            "content": "Pistachio is the slowest seller in winter.",
            "importance": "0.9",
            "reason": "seasonal pattern",
        },
    ),
]
manual_results = [memory_box.invoke_with_reason(memory_dispatcher, call) for call in manual_calls]
for call, result in zip(manual_calls, manual_results, strict=True):
    print(call.id, result)
assert manual_results[0] == {"ok": True, "value": {"stored": True, "memory_id": "lucy-llm-001"}}
assert manual_results[1]["error"] == "content_not_permitted"
assert manual_results[2] == {"ok": False, "error": "invalid_arguments"}
stored_rows = memory_box.db.connection.execute(
    "SELECT id, content, importance FROM memories ORDER BY id"
).fetchall()
for stored_row in stored_rows:
    print(dict(stored_row))
assert [row["id"] for row in stored_rows] == ["lucy-llm-001", "lucy-seed-001"]
memory_box.db.connection.close()

Run the loop against a recorded transcript

ScriptedModel replays what a model replied at each step. It is not a stand-in pretending to be live: the record says scripted, while the tool calls, the refusals and the SQLite rows are real. Read the script as a conversation. Turn one searches memory before storing anything. Turn two proposes two facts, one of which the shop refuses. Turn three adapts and stores the arrangement without the secret. Turn four asks for no tools, which ends the loop.

Inside the loop a refusal arrives as an ordinary failed observation, tool_failed, because Dispatcher.invoke never copies a raw error into the transcript. The policy's own ledger, memory_box.policy.refused, keeps the reason for you and for the classroom. The cell starts from a fresh shop memory holding only the seed row, so your manual write above is gone. Prediction: how many rows will memories hold after this run, and which turn produces the refusal?

python
RECORDED_TURNS = [
    scripted_turn(calls=[{"name": "recall_facts", "arguments": {"query": "mango saturday dairy"}}]),
    scripted_turn(
        calls=[
            {
                "name": "remember_fact",
                "arguments": {
                    "content": "Mango sells out most Saturday afternoons; order extra on Friday.",
                    "importance": 0.7,
                    "reason": "recurring stock pattern",
                },
            },
            {
                "name": "remember_fact",
                "arguments": {
                    "content": "The dairy account is paid by card number ending 4421.",
                    "importance": 0.3,
                    "reason": "billing arrangement",
                },
            },
        ]
    ),
    scripted_turn(
        calls=[
            {
                "name": "remember_fact",
                "arguments": {
                    "content": "The dairy account is paid by card; the details stay with Lucy.",
                    "importance": 0.3,
                    "reason": "billing arrangement without the secret",
                },
            }
        ]
    ),
    scripted_turn(
        "Stored the Saturday mango pattern and the dairy billing arrangement. The shop refused "
        "the card number, so I kept only that a card is used."
    ),
]
LOOP_LIMITS = Limits(model_calls=8, tool_calls=16, seconds=120, output_tokens=4096)

scripted_box = seeded_toolbox()
scripted_result = run_loop(
    ScriptedModel(RECORDED_TURNS), scripted_box.dispatcher(), MEMORY_MESSAGES, limits=LOOP_LIMITS
)
print(describe(scripted_result, "scripted"))
print("Refused by policy:", scripted_box.policy.refused)
scripted_trace = tool_trace(scripted_result)
assert scripted_result.status == "COMPLETED"
assert [step["ok"] for step in scripted_trace] == [True, True, False, True]
assert scripted_box.written == ["lucy-llm-001", "lucy-llm-002"]
assert [reason for _, reason in scripted_box.policy.refused] == ["content_not_permitted"]
scripted_rows = scripted_box.db.connection.execute(
    "SELECT id, content FROM memories ORDER BY id"
).fetchall()
for scripted_row in scripted_rows:
    print(dict(scripted_row))
assert len(scripted_rows) == 3
scripted_box.db.connection.close()

Run the same loop against a live model

The loop, the tools and the shop rules do not change. Only the model does. build_model looks for an OPENAI_API_KEY in Colab's Secrets pane (the key icon in the left sidebar: add a secret with that name and switch on notebook access for it), then in the process environment for a local kernel. Without a key it returns the scripted model again and the record says so. A missing key is a normal condition, never a silent substitution.

The live model is gpt-5.1 through the openai library, which Colab ships preinstalled; on a local kernel run %pip install openai once. One run costs a few thousand tokens. The model receives the same two schemas and the same system message, and the shop policy still decides every write. Compare the live record with the scripted one: what did the model search for first, what did it store, what did it decline to store on its own, and what did the shop refuse for it?

One more thing to watch. The policy bans the phrase card number, and a live model tends to paraphrase: "card ending 4421" carries the same secret in different words. A phrase list is weak against that; so MemoryWritePolicy also refuses a pattern, the word card followed by digits. Read both rules in the embedded memory_tools.py and decide which one the live run will trip.

Prediction: write down which facts from Lucy's message a careful assistant should keep. Then run the cell and compare that list with the model's judgment, or with the recorded transcript when the run is scripted; the printed source says which you are reading.

python
from sovereign_agent.llm_lab import DEFAULT_MODEL, build_model, resolve_api_key

chosen_model = build_model(RECORDED_TURNS, model=DEFAULT_MODEL)
print("Model source:", chosen_model.source, "| key found:", resolve_api_key() is not None)
live_box = seeded_toolbox()
live_result = run_loop(chosen_model, live_box.dispatcher(), MEMORY_MESSAGES, limits=LOOP_LIMITS)
print(describe(live_result, chosen_model.source))
print("Refused by policy:", live_box.policy.refused)
live_rows = live_box.db.connection.execute(
    "SELECT id, content, importance FROM memories ORDER BY id"
).fetchall()
for live_row in live_rows:
    print(dict(live_row))
assert live_result.status in {"COMPLETED", "MODEL_CALL_LIMIT", "TOOL_LIMIT", "MODEL_FAILED"}
# Whatever the model proposed, nothing the policy bans reached SQLite: re-run every rule.
for live_row in live_rows:
    assert live_box.policy.check(live_row["content"]) in {None, "duplicate_of_accepted_memory"}
assert len(live_rows) - 1 == len(live_box.written) <= live_box.policy.max_writes
live_box.db.connection.close()

Challenge: tighten the shop's rules and save the evidence

The policy is data you can change. seeded_toolbox(max_writes=1) lets one write through; banned_phrases=("card number", "delivery") refuses more. The next cell reruns the recorded transcript under a one-write budget. Predict before running: the card-number write is still refused, but is the reason still content_not_permitted? Read MemoryWritePolicy.check in the embedded memory_tools.py and note the order of its rules. With a live key, rerun the live cell under the same budget and explain what the model did after its second write was refused; a model that keeps retrying the same write is a finding about the model, not a bug in your policy.

Explain in your notes which part of this run is a local fixture result and which claim needs the live model: the scripted transcript proves the loop, the gate and the SQLite rows; only a live run proves the model's judgment. The saved record keeps the two sources separate.

python
tight_box = seeded_toolbox(max_writes=1)
tight_result = run_loop(
    ScriptedModel(RECORDED_TURNS), tight_box.dispatcher(), MEMORY_MESSAGES, limits=LOOP_LIMITS
)
print(describe(tight_result, "scripted"))
print("Refused by policy:", tight_box.policy.refused)
assert tight_box.written == ["lucy-llm-001"]
assert [reason for _, reason in tight_box.policy.refused] == [
    "write_budget_exhausted",
    "write_budget_exhausted",
]
tight_box.db.connection.close()

llm_lab_report = {
    "unit": "ch05-a",
    "scripted": {
        "status": scripted_result.status,
        "written": list(scripted_box.written),
        "refused": [reason for _, reason in scripted_box.policy.refused],
    },
    "session": {
        "source": chosen_model.source,
        "model": DEFAULT_MODEL if chosen_model.source == "live" else None,
        "status": live_result.status,
        "model_calls": live_result.model_calls,
        "written": list(live_box.written),
        "refused": [reason for _, reason in live_box.policy.refused],
        "answer": live_result.answer[:400],
    },
    "tightened": {"max_writes": 1, "refused": [reason for _, reason in tight_box.policy.refused]},
}
llm_report_path = COURSE_WORK / "ch05-a-llm-lab-report-v1.json"
llm_report_path.write_text(json.dumps(llm_lab_report, indent=2, sort_keys=True), encoding="utf-8")
print("Saved evidence:", llm_report_path)
print("LLM_LAB_REPORT=" + json.dumps(llm_lab_report["session"], sort_keys=True))

Keep building with Prof Rod

Found this material through a colleague, classroom or shared download? Get the complete book at profrod.ai/book and join the Prof Rod learner community. Bring one result, one question or one failure you learned from. Share this resource with another learner and keep its source links with it so they can find the full course and future updates.