Instructions and Memory: Configuration That Persists

agents
reliability

A context controller can preserve only what it can identify. Chapter 6 made that pressure visible; this chapter decides which rules belong in the durable system prompt and which facts belong in cross-session memory. The package already exposes typed Config, TOML loading, prompt assembly, and a memory tool. We will exercise those APIs offline, make their precedence observable, and mark the boundaries where the current implementation is deliberately smaller than the full course design.

The guiding distinction is simple: instructions constrain behavior, while memory supplies facts. Treating either as an unlabelled blob makes shadowing, prompt injection, and stale facts difficult to diagnose.

Configuration layers

A layered configuration needs a declared order. In this package, the effective order is system TOML, then project TOML, then an AGENT.MD fallback for developer instructions. The project file is <cwd>/.ai-agent/config.toml, and a project value replaces a system value at the same key. Nested tables such as [model] are merged field by field; lists are values and are replaced rather than concatenated.

The next cell patches only the system-path lookup so the experiment cannot read a real user configuration. The project directory and both configuration files are temporary, making the precedence result reproducible.

from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch

from agent_harness.config import load_config

with TemporaryDirectory() as raw_dir:
    root07 = Path(raw_dir)
    system_path07 = root07 / "system.toml"
    system_path07.write_text(
        'approval = "auto"\nmax_turns = 12\n'
        '[model]\nname = "system-model"\ntemperature = 0.2\n',
        encoding="utf-8",
    )

    project07 = root07 / "repo"
    (project07 / ".ai-agent").mkdir(parents=True)
    (project07 / ".ai-agent" / "config.toml").write_text(
        'user_instructions = "Explain uncertainty."\nmax_turns = 4\n'
        '[model]\nname = "project-model"\n',
        encoding="utf-8",
    )
    (project07 / "AGENT.MD").write_text(
        "Project rule: run pytest before reporting success.\n",
        encoding="utf-8",
    )

    with patch("agent_harness.config.get_system_config_path", return_value=system_path07):
        loaded07 = load_config(project07)

    print("model:", loaded07.model_name)
    print("temperature inherited:", loaded07.temperature)
    print("approval:", loaded07.approval.value)
    print("max_turns overridden:", loaded07.max_turns)
    print("developer source:", loaded07.developer_instructions.strip())
    print("user instruction:", loaded07.user_instructions)
    assert loaded07.model_name == "project-model"
    assert loaded07.temperature == 0.2
    assert loaded07.max_turns == 4
    assert loaded07.developer_instructions.startswith("Project rule")

<1> isolates the global system location while leaving the public load_config path intact. The output shows a deep merge: the project replaces model.name and max_turns, while model.temperature and approval come from the lower layer. The AGENT.MD text is used because neither TOML layer supplied developer_instructions.

This is provenance worth recording even though Config currently returns only final values. A production loader should attach an origin to every effective field, because “the model is too hot” is actionable only when the operator can answer which layer set its temperature.

Configuration conflicts

Deep merge is not the same as permissive merge. If a lower layer says context_window = 200000 and a project overrides it with a string, silently coercing or dropping the value hides a configuration failure. Pydantic validation is the package’s current boundary for rejecting that conflict.

from agent_harness.config import ConfigError

with TemporaryDirectory() as raw_dir:
    root07b = Path(raw_dir)
    system_path07b = root07b / "system.toml"
    system_path07b.write_text(
        '[model]\ncontext_window = 200000\n',
        encoding="utf-8",
    )
    project07b = root07b / "repo"
    (project07b / ".ai-agent").mkdir(parents=True)
    (project07b / ".ai-agent" / "config.toml").write_text(
        '[model]\ncontext_window = "large"\n',
        encoding="utf-8",
    )

    with patch("agent_harness.config.get_system_config_path", return_value=system_path07b):
        try:
            load_config(project07b)
        except ConfigError as error07:
            print("error type:", type(error07).__name__)
            print("validation surfaced:", "Invalid configuration" in str(error07))
        else:
            raise AssertionError("a string context window must be rejected")

The failed load is preferable to a run with an accidental context budget. The same principle applies to hooks, allowlists, and memory schemas: reject ambiguity at load time, before a model can turn it into an external action. Note that invalid TOML files are logged and skipped by load_config; a syntactically valid file with a type error reaches the explicit ConfigError boundary shown here.

Instruction hierarchy

build_system_prompt does not merge prose semantically. It places labeled sections in a fixed order: identity, environment, tool guidance when tools exist, security guidance, developer instructions, user instructions, and operational guidance. This placement is an interface contract between configuration and the model. It is not a proof of obedience, and it must not be confused with a permission check.

from agent_harness.config import Config
from agent_harness.prompts import build_system_prompt
from agent_harness.tools.base import ToolRegistry

prompt_config07 = Config(
    cwd=Path.cwd(),
    developer_instructions="Developer rule: run pytest before reporting success.",
    user_instructions="User request: explain remaining uncertainty.",
)
prompt07 = build_system_prompt(prompt_config07, ToolRegistry(prompt_config07).get_tools())
markers07 = [
    "# Identity",
    "# Environment",
    "# Security Guidelines",
    "# Project Instructions",
    "# User Instructions",
    "# Operational Guidelines",
]
ordered_markers07 = [
    marker for _, marker in sorted((prompt07.index(marker), marker) for marker in markers07)
]
print("ordered sections:", ordered_markers07)
print("developer text present:", "Developer rule" in prompt07)
print("user text present:", "User request" in prompt07)
assert ordered_markers07.index("# Project Instructions") < ordered_markers07.index("# User Instructions")
assert prompt07.index("# Security Guidelines") < prompt07.index("# Project Instructions")

The labels make shadowing inspectable. A user request appears after the project section, but the model still receives both as text; prompt order alone does not enforce that project rules win a conflict. If a rule is safety-critical, enforce it in the permission layer from Chapter 8, not only in prose. If it is a workflow preference, measure compliance under conflicting and paraphrased requests rather than treating section order as evidence.

Instruction discovery

The course plan calls for a nearest-wins walk from the working directory toward its parents, with a size cap and cycle-safe path handling. The current public helper get_agent_md_path intentionally performs the smaller operation: it checks only the exact directory. Showing that distinction prevents a notebook experiment from silently promising a package guarantee that is not present yet.

from agent_harness.config import get_agent_md_path


def nearest_agent_md07(cwd: Path, max_chars: int = 1_000) -> tuple[Path, str] | None:
    """Teaching adapter for a bounded nearest-wins instruction walk."""
    resolved07 = cwd.resolve()
    for directory07 in (resolved07, *resolved07.parents):
        candidate07 = directory07 / "AGENT.MD"
        if candidate07.is_file():
            text07 = candidate07.read_text(encoding="utf-8")
            return candidate07, text07[:max_chars]
    return None


with TemporaryDirectory() as raw_dir:
    root07c = Path(raw_dir)
    nested07 = root07c / "src" / "package"
    nested07.mkdir(parents=True)
    (root07c / "AGENT.MD").write_text("root rule\n", encoding="utf-8")

    print("library exact-directory lookup:", get_agent_md_path(nested07))
    root_match07 = nearest_agent_md07(nested07)
    print("bounded parent walk before nested file:", root_match07[1].strip())

    (nested07 / "AGENT.MD").write_text("nested rule\n", encoding="utf-8")
    nested_match07 = nearest_agent_md07(nested07)
    print("nearest rule after nested file:", nested_match07[1].strip())
    print("size cap:", nearest_agent_md07(nested07, max_chars=6)[1])
    assert get_agent_md_path(nested07) == nested07 / "AGENT.MD"
    assert root_match07[1].strip() == "root rule"
    assert nested_match07[1].strip() == "nested rule"

<1> resolves once before walking parent pointers, so symlink aliases cannot create a loop in this adapter. The output first demonstrates the package’s exact-directory helper, then the bounded nearest-wins behavior required by the larger design. The helper is intentionally notebook-local: until the library exposes the walk, load_config should be described as cwd-local rather than as a recursive instruction loader.

The size cap is a safety boundary. An instruction file that consumes the whole system prompt can evict tool guidance and recent history, turning “more instructions” into a context-management failure. A complete implementation would also report each loaded file and its origin.

Memory tools

The memory API uses the same Tool and ToolResult contract as file and shell tools. Its actions are set, get, delete, list, and clear. This is a deliberately small persistent key-value store, useful for facts such as a repository’s test command or a user-approved convention. It is not a semantic index, and it should not become a place to hide secrets or unchecked model claims.

The package default points at a user-level ~/.cda/memory.json. For a deterministic notebook experiment, override _store_path in a tiny test subclass so all writes stay in a temporary project directory.

import asyncio

from agent_harness.tools.base import ToolInvocation
from agent_harness.tools.memory import MemoryTool


class ProjectMemory07(MemoryTool):
    def __init__(self, config: Config, path: Path):
        super().__init__(config)
        self.path07 = path

    def _store_path(self) -> Path:
        return self.path07


async def memory_round_trip07(memory07: ProjectMemory07, cwd07: Path):
    stored07 = await memory07.execute(
        ToolInvocation(
            {"action": "set", "key": "test_command", "value": "pytest -q"},
            cwd07,
        )
    )
    listed07 = await memory07.execute(ToolInvocation({"action": "list"}, cwd07))
    recalled07 = await memory07.execute(
        ToolInvocation({"action": "get", "key": "test_command"}, cwd07)
    )
    return stored07, listed07, recalled07


with TemporaryDirectory() as raw_dir:
    project_memory_dir07 = Path(raw_dir)
    store_path07 = project_memory_dir07 / "memory.json"
    memory07 = ProjectMemory07(Config(cwd=project_memory_dir07), store_path07)
    stored07, listed07, recalled07 = asyncio.run(
        memory_round_trip07(memory07, project_memory_dir07)
    )
    for result07 in (stored07, listed07, recalled07):
        print(result07.success, ":", result07.output)

    new_process_memory07 = ProjectMemory07(
        Config(cwd=project_memory_dir07),
        store_path07,
    )
    across_session07 = asyncio.run(
        new_process_memory07.execute(
            ToolInvocation({"action": "get", "key": "test_command"}, project_memory_dir07)
        )
    )
    print("new instance recalls:", across_session07.output)
    assert recalled07.output == "pytest -q"
    assert across_session07.output == "pytest -q"

The new ProjectMemory07 instance reads the same file and recovers the fact without adding another conversation message. That is the useful pressure trade: durable facts can keep transient context small, but stale or malicious facts now survive longer than one turn. In a complete harness, memory writes need approval or policy checks, values need provenance and size limits, and retrieval should be explicit in the trajectory.

The current library’s store is key-value rather than per-project semantic search. The subclass is a safe test seam, not a replacement for the package default. It makes the persistence behavior testable without touching a developer’s real memory file.

Memory registration

A special memory subsystem is easy to forget in the loop. Registering it as a normal Tool keeps validation, invocation, and event recording on the same path as every other capability. The next check uses a temporary-backed memory tool and the public ToolRegistry.invoke method.

from agent_harness.tools.base import ToolRegistry

with TemporaryDirectory() as raw_dir:
    registry_dir07 = Path(raw_dir)
    registry_config07 = Config(cwd=registry_dir07)
    registry07 = ToolRegistry(registry_config07)
    registry_memory07 = ProjectMemory07(
        registry_config07,
        registry_dir07 / "memory.json",
    )
    registry07.register(registry_memory07)

    set_result07 = asyncio.run(
        registry07.invoke(
            "memory",
            {"action": "set", "key": "language", "value": "Python"},
            registry_dir07,
        )
    )
    get_result07 = asyncio.run(
        registry07.invoke(
            "memory",
            {"action": "get", "key": "language"},
            registry_dir07,
        )
    )
    print("registered kind:", registry07.get("memory").kind.value)
    print("set:", set_result07.output)
    print("get:", get_result07.output)
    assert set_result07.success and get_result07.output == "Python"

The registry preserves the same validation and error-as-result boundary introduced in Chapter 3. Memory is therefore observable as a tool call, rather than an unlogged mutation of hidden agent state. That matters when Chapter 9 later records lifecycle events and when the capstone attributes a failure to an instruction, a memory fact, or the model.

Instruction placement

A rule can be present in the prompt and still fail behaviorally. A deterministic offline check cannot measure a model’s compliance, but it can measure the transport layer that a later model experiment depends on: whether a rule appears, which labeled section contains it, and whether a conflict has an explicit owner. This is the lowest-cost part of the proposed placement experiment.

from agent_harness.prompts import build_system_prompt

placements07 = {
    "project": Config(
        cwd=Path.cwd(),
        developer_instructions="Required rule: use pytest and report its result.",
    ),
    "user": Config(
        cwd=Path.cwd(),
        user_instructions="Required rule: use pytest and report its result.",
    ),
    "both": Config(
        cwd=Path.cwd(),
        developer_instructions="Required rule: use pytest and report its result.",
        user_instructions="Required rule: use pytest and report its result.",
    ),
}
rule07 = "Required rule: use pytest and report its result."
for placement07, config07 in placements07.items():
    prompt07b = build_system_prompt(config07, [])
    present07 = rule07 in prompt07b
    print(
        placement07,
        "present=",
        present07,
        "occurrences=",
        prompt07b.count(rule07),
        "after security=",
        prompt07b.index(rule07) > prompt07b.index("# Security Guidelines"),
    )
    assert present07

This small rubric deliberately stops short of calling presence “compliance.” The model-facing experiment should add a paraphrase sweep, a conflict matrix, and a held-out task check. The offline result still catches assembly regressions: a refactor that drops the developer section or moves instructions before the security section fails before an expensive model run.

The reliability connection is specification error. An instruction file is a specification, its placement is part of the specification, and the loader’s fallback rules are part of it too. Provenance and explicit conflict ownership make failures attributable instead of mysterious.

Memory lifecycle

Before a fact enters memory, ask who wrote it, how long it remains valid, and whether a future task can safely act on it. Before an instruction enters the system prompt, ask which layer owns it and what can override it. The current APIs provide the basic typed and persistent mechanisms; the experiments expose the work still needed for origin tracking, recursive instruction discovery, semantic retrieval, and expiry.

These boundaries are intentional teaching targets. Do not solve a provenance problem by increasing the context window, and do not solve a permission problem by adding a stronger sentence to AGENT.MD. Chapter 8 moves from declared rules to enforceable boundaries.

Back to top