Capability Extensions
Extend what an agent can do through capability packages, external evidence, persistent state, and standard protocols.
How do we add Skills, retrieval, memory, and external protocol capabilities to an agent?Concept calibration
Memory
- What it is
- A mechanism for storing, retrieving, and using state across steps or sessions, with explicit write, read, and forgetting policies.
- What it is not
- It is not merely compressed chat history, and it should not put every old item back into context.
Context
- What it is
- The information visible to a model call, including instructions, conversation, tool schemas and results, retrieved evidence, and current state.
- What it is not
- It is not merely chat history, nor everything the model can remember permanently.
Observability
- What it is
- Use structured logs, traces, metrics, and version data to explain what happened during a system run.
- What it is not
- It is not just terminal output; logs without correlation IDs, state transitions, and privacy boundaries are hard to diagnose.
State, Sessions, Memory & Context Compression
Design session state, checkpoints, short- and long-term memory, compression, and replay.
Python 3.12 · pytest · Pydantic · SQLite · deterministic MockConcept calibration\n\nPut the concepts used in this module back inside their engineering boundaries before you work with the code.\n\n\n\n### Memory memory\n\n- What it is: A mechanism for storing, retrieving, and using state across steps or sessions, with explicit write, read, conflict, retention, and forgetting policies.\n- What it is not: It is not merely a history summary, and it should not put every old record into each context; stored data becomes useful memory only when retrieved and applied correctly.\n- Relationship to adjacent concepts: State represents current progress, checkpoints preserve consistent snapshots, memory reuses information across boundaries, and context carries only the selected portion.\n- Where it lives in HeatStack Forge: Forge stores task state, user approvals, and longer-lived preferences separately, retrieves by version and scope, and supports pause, resume, and replay.\n- Typical misuse and correction: A common misuse writes every model output automatically. Set write thresholds, provenance, expiry, and conflict policy, then test that stale data cannot override current facts.\n\n### Context context\n\n- What it is: The information visible to one model call, including instructions, conversation excerpts, tool schemas and results, retrieved evidence, current state, and output constraints.\n- What it is not: It is not merely chat history or every item in a database; only information selected for the current call belongs to its context.\n- Relationship to adjacent concepts: RAG selects external evidence, memory preserves cross-step state, and tool schemas describe actions. All can supply context, but their responsibilities differ.\n- Where it lives in HeatStack Forge: Forge assembles context by task, permission, budget, and source priority while recording provenance and version for each item.\n- Typical misuse and correction: A common misuse equates more tokens with better understanding. Use selection, truncation, citation, and sensitive-data filters, then verify them on fixed fixtures.\n\n### Observability observability\n\n- What it is: Structured logs, traces, metrics, state transitions, and version information that explain a run and support failure diagnosis and version comparison.\n- What it is not: It is not merely terminal output or unlimited input recording; logs without correlation IDs, state transitions, or privacy boundaries are hard to diagnose and may leak data.\n- Relationship to adjacent concepts: The harness emits runtime events, the agent loop and tool calls form traces, evaluation aggregates metrics, and memory and recovery depend on versioned records.\n- Where it lives in HeatStack Forge: Forge creates an operation_id for each task and records stage, tool, latency, cost, error class, state change, and evidence path while filtering sensitive data.\n- Typical misuse and correction: A common misuse adds logs only after failure. Define events and correlation fields with the contract, then use fault injection to prove the timeline can be reconstructed.\n\n
Distinguish current state from historical facts and deterministically rebuild RunState from immutable events
Start with a concrete problem
How to rebuild current state from an event sequence?
You are building a long-running task execution system that must record key facts during a run, such as run creation, step start, and permission revocation. These facts are immutable once they occur, but the current state (e.g., whether the run is still active, what the current step is, whether writing is allowed) changes as events are applied.
If you directly mutate a state object, you cannot audit how the state evolved or recover from a crash. Therefore, you need to treat events as an immutable history and rebuild state deterministically by replaying them.
In this stage, you will implement the core of event sourcing: define event and state models, and write an apply_event function that updates state for each event. You must also ensure the event sequence is contiguous and that no events are accepted after the run reaches a terminal state.
Make a prediction
Before implementing reduce_events, predict: if the event sequence has a gap (e.g., sequence 1 followed directly by sequence 3), how should the system handle it?
- Ignore the gap and continue applying subsequent events
- Raise an exception because the state cannot be reliably rebuilt
- Automatically fill in the missing events
- Log a warning but continue execution
Reasoning guide: The correct answer is to raise an exception. The event sequence must be contiguous because each event depends on the state produced by the previous event. A gap indicates incomplete history, so the system must refuse to rebuild rather than guess or ignore.
What it is
State is a projection of current progress, events are historical facts
State (RunState) represents the latest information about a run at a given moment, such as run status, current step, and write permission. It is derived from the event sequence, not stored independently.
Events (RunEvent) are immutable historical records, each containing a sequence number, type, and payload. Once created, events cannot be modified, so they can be safely replayed.
The apply_event function applies events one by one to the state, producing the current state. This process is deterministic: given the same event sequence, the same state is always produced.
What it is not
State is not history, and events are not mutable objects
State is not a list of all events; it contains only the information needed for the current moment. Historical events may be kept for audit, but state is a condensed projection.
Events are not mutable; once created, they cannot be changed. If an error is discovered, you cannot modify an old event; instead, you append a new event to correct it.
State cannot be read directly from history; it must be rebuilt by replaying events to ensure consistency and auditability.
How it relates to neighboring concepts
Events drive state transitions
Each event corresponds to a state transition. For example, RUN_CREATED creates the initial state, STEP_STARTED updates the current step, and WRITE_PERMISSION_REVOKED sets write_allowed to False.
The event sequence must be contiguous because each event builds on the state produced by the previous event. A gap means the intermediate state is unknown, so the sequence must be rejected.
After the run reaches a terminal state (COMPLETED or FAILED), no new events can be accepted because the state is final; applying further events would cause inconsistency.
Boundary decision for this stage: When the event sequence is not contiguous or the run is terminal, an exception must be raised rather than attempting to repair or ignore the problem. This ensures reliable and auditable state reconstruction.
Distinguish a session from an individual run and explicitly manage start, finish, and concurrency boundaries
Start with a concrete problem
Why do we need to distinguish sessions from runs?
In the previous stage, we built state and event models, but we still lack the ability to manage multiple runs. Now, a single user session may contain several runs, such as a retry after a failure or parallel processing of multiple tasks. If every run uses the same run_id, histories become mixed, and we cannot tell which run produced which events.
This stage requires implementing a Session container that can start multiple runs, finish runs, and guarantee that run_id is unique within a session. The test test_session_tracks_multiple_runs_without_merging_them verifies that the session correctly tracks multiple runs without merging them; the test test_duplicate_run_id_is_rejected verifies that a duplicate run_id is rejected.
Make a prediction
When implementing start_run, what should happen if an already existing run_id is passed?
- Silently ignore and do not add a new run
- Raise ValueError because run_id must be unique
- Overwrite the old run record
- Automatically generate a new run_id
Reasoning guide: The correct answer is to raise ValueError. The test test_duplicate_run_id_is_rejected explicitly requires that a duplicate run_id be rejected, and the error message must contain ‘unique’. Silently ignoring or overwriting would cause history loss or confusion, and automatically generating a new ID would violate the caller’s intent.
What it is
A session is a container for runs
Session is an immutable data structure containing a session_id and a tuple of runs. Each run is a RunRecord with a run_id and a status. The start_run function adds a new RunRecord to the session and returns a new Session instance. The finish_run function changes the status of a specified run to a terminal state (completed, failed, or cancelled).
The session lifecycle is explicitly managed by these functions: create session, start run, finish run. Run IDs must be unique within a session, which is guaranteed by a check in start_run.
What it is not
A session is not a single run
A session is not one concrete execution but a container for multiple executions. A run is a single execution with explicit state transitions. Confusing the two leads to state management chaos, such as being unable to distinguish events from different runs.
A session is also not equivalent to chat history or context; it only stores structural information about runs, not the actual event content.
How it relates to neighboring concepts
Relationship among session, runs, and state
Session contains multiple RunRecords, each with a run_id and status. start_run and finish_run are functions that operate on Session and return new Session instances, preserving immutability. The active_run_ids property filters runs with status ‘active’ and returns their IDs.
This design allows session state to be safely passed and shared without accidental modification.
Boundary decision for this stage: In start_run, we must check whether the run_id already exists; if it does, raise ValueError. This is the boundary for run ID uniqueness within a session.
Persist a minimal versioned checkpoint with integrity verification and reject corrupt or unsupported snapshots
Start with a concrete problem
Saved checkpoints may be tampered with or version-incompatible
During long-running tasks, we need to save the current state to pause, resume, or replay. If a checkpoint file is accidentally modified (e.g., disk error or deliberate tampering), recovery may load an incorrect state, causing subsequent decisions to be based on untrusted data.
Additionally, as the system evolves, the checkpoint structure may change. Loading an old or unknown version may crash due to missing fields or type mismatches, or silently produce wrong results.
This stage requires implementing a minimal checkpoint module with a version number and SHA-256 checksum, verifying both on load and rejecting corrupt or unsupported snapshots.
Make a prediction
When implementing checkpoint loading, which approach most reliably prevents loading tampered state?
- Only check that the JSON file can be parsed into a CheckpointEnvelope model.
- Recompute the SHA-256 checksum of the state on load and compare it with the stored checksum.
- Trust file system permissions, assuming only authorized users can modify the file.
- Only check that schema_version equals the current version on load.
Reasoning guide: The correct option is to recompute the checksum and compare. Parsing JSON or checking the version alone cannot detect content tampering; file system permissions cannot prevent all accidental modifications; version checking only handles structural compatibility, not data integrity.
What it is
Checkpoint Contract
A checkpoint is an immutable envelope (CheckpointEnvelope) containing schema_version, state, and checksum_sha256. schema_version identifies the structure version, state is the current run state, and checksum_sha256 is the SHA-256 hash of the canonical JSON representation of schema_version and state.
On save, we compute the checksum and atomically write the file; on load, we first verify that schema_version is supported, then recompute the checksum and compare it with the stored value, raising ValueError on any mismatch.
What it is not
What a Checkpoint Is Not
A checkpoint is not a simple serialization of state; it must include an integrity proof. It is also not a substitute for long-term memory or session history; it only saves a snapshot of state at a point in time.
A checkpoint does not decide when to save or restore, nor does it handle concurrent write conflicts; those policies are determined by upper-level session management.
How it relates to neighboring concepts
Relationships with Other Concepts
A checkpoint is closely related to state (RunState): it encapsulates the state and provides persistence. Unlike context, a checkpoint does not automatically enter the model context; only after explicit loading and selection of part of the state does it become part of the context.
In terms of observability, the checkpoint’s version and checksum provide an audit trail, helping diagnose recovery failures.
Boundary decision for this stage: When loading a checkpoint, both schema_version and checksum_sha256 must be verified; any failure should reject recovery rather than attempt partial loading or silent repair.
Recover from a checkpoint plus tail events and prove that the result matches a full event replay
Start with a concrete problem
How can we prove that state recovered from a checkpoint matches a full event replay exactly?
During long-running tasks, the system periodically saves checkpoints to support pause and resume. When a task is interrupted, we want to restore the run state by loading a checkpoint and replaying the events that occurred after it (the tail events).
However, simply loading a checkpoint and applying tail events does not guarantee correct recovery. If the tail events are incomplete (for example, one event is missing), the recovered state may differ from the state obtained by replaying all events from the beginning.
In this stage, you will implement a verification function, verify_recovery_equivalent, that compares the state recovered from checkpoint plus tail events with the state from a full event replay, and raises RecoveryMismatch if they differ.
Make a prediction
Before implementing recovery equivalence verification, predict: what happens if a tail event is omitted during recovery from a checkpoint?
- The recovered state may differ from full replay, but the system cannot automatically detect it
- The recovered state will always match full replay because the checkpoint contains all necessary information
- The system will raise an exception because the checkpoint checksum detects the missing event
- The recovered state may differ, but it can be manually checked via logs
Reasoning guide: The correct answer is the first option. A checkpoint only stores a snapshot of state at a certain point; it cannot know which events should occur afterwards. If tail events are incomplete, the recovered state will miss the effects of those events, causing a mismatch with full replay. Therefore, an explicit comparison is needed to detect this inconsistency.
What it is
What recovery equivalence verification is
Recovery equivalence verification is a safety mechanism that compares the results of two computation paths to ensure correct recovery: path one loads state from a checkpoint and applies tail events; path two replays the full event history from the beginning.
If the states from both paths are exactly equal, it proves that the checkpoint and tail events are sufficient to reconstruct the full state; if they differ, it indicates missing or out-of-order tail events, and an exception must be raised.
What it is not
What recovery equivalence verification is not
It is not checkpoint checksum verification. A checksum only ensures the checkpoint file has not been tampered with; it cannot guarantee tail event completeness.
It is not a superficial state comparison; it must actually perform a full replay because state may contain complex objects and surface-level field checks cannot determine deep consistency.
How it relates to neighboring concepts
Relationship with other concepts
A checkpoint saves a state snapshot at a certain moment and serves as the starting point for recovery.
Event replay builds state by applying events in sequence and represents the complete path.
Recovery equivalence verification combines both: the checkpoint provides acceleration, tail events provide increments, and full replay provides the baseline.
Boundary decision for this stage: Recovery is considered equivalent if and only if the state recovered from checkpoint plus tail events is exactly equal to the state from full event replay; otherwise, a RecoveryMismatch exception must be raised to prevent incorrect state from propagating.
Separate recoverable runtime state from retrievable long-term memory with scope, expiry, and deletion semantics
Start with a concrete problem
Separate long-term memory from runtime state and ensure memory is retrievable only within correct scope and validity period
In the previous stage, we implemented recoverable runtime state but no long-term memory. Now we need to add forge/memory.py, define MemoryScope (RUN, SESSION, USER) and MemoryRecord, and implement MemoryStore methods put, query, delete, and purge_expired.
Key constraints: queries must filter by scope and subject and exclude expired memories; deletion must be explicit and idempotent. Tests test_memory_is_queried_only_inside_its_scope_and_subject, test_expired_memory_is_hidden_and_can_be_purged, and test_deletion_is_explicit_and_idempotent will verify these behaviors.
The current starting code has no forge/memory.py, so running the tests will raise ModuleNotFoundError. You need to create that file from scratch and modify forge/init.py to export the relevant symbols.
Make a prediction
When implementing MemoryStore.query, what happens if you filter only by scope and subject_id without checking expires_at?
- Expired memories will still be returned, causing later decisions to use stale information.
- The query will raise an exception because the SQL statement is incomplete.
- Expired memories will be automatically deleted by the database, so they won’t be returned.
- The query results will be sorted by creation time but will not include expired memories.
Reasoning guide: The correct option is the first one. If the query does not check expires_at, then even if a memory is expired, as long as scope and subject_id match, it will be returned. This causes expired memories to contaminate the current context and affect decisions. The test test_expired_memory_is_hidden_and_can_be_purged specifically verifies this.
What it is
Memory boundary model
Memory is a data record with a scope and a lifetime. The scope (RUN, SESSION, USER) defines the visibility range of the memory: RUN is visible only within a single run, SESSION within a session, and USER across sessions long-term.
Each memory has created_at and optional expires_at. When querying, the system filters out expired memories based on the current time. Deletion is performed by memory_id precisely, and repeated deletion does not raise an error; it returns False to indicate no record was deleted.
What it is not
What memory boundaries are not
Memory is not a simple compressed chat transcript, nor does it put all historical information into context. It must define write, read, conflict, retention, and forgetting policies.
Memory is not the same as runtime state. Runtime state (such as current step, retry count) is volatile, while memory is persistent and can be reused across runs.
How it relates to neighboring concepts
Relationship between memory and other concepts
Memory and context: Context is the information visible to one model call, and memory is one possible source of context. Only memories that are queried and placed into context affect the current call.
Memory and state: State represents current progress, while memory preserves information across steps or sessions. State can be restored via checkpoints, while memory manages its lifecycle through scope and expiry policies.
Boundary decision for this stage: When implementing MemoryStore.query, you must filter by scope, subject_id, and expires_at simultaneously to ensure only currently valid memories within the requested scope are returned.
Compress ordinary conversation while preserving instructions, permission decisions, and commitments verbatim with fidelity checks
Start with a concrete problem
How can we compress context without losing critical information?
In long tasks, the context window is limited, so ordinary messages must be compressed to save space. However, compression must not affect critical information: instructions, permission decisions, and commitments must be preserved verbatim, otherwise the system may perform unauthorized actions.
Currently, forge/context.py has not been created, and tests fail on import. You need to implement ContextPacket, CompressedContext, compress_context, and verify_critical_fidelity, ensuring that the compressed critical fields are exactly identical to the source, and any modification triggers a fidelity check failure.
Make a prediction
When implementing verify_critical_fidelity, what should happen if the compressed permission_decisions differ from the source?
- Silently accept, because compression may change permissions
- Raise ValueError, because permission decisions must be preserved verbatim
- Log a warning but continue
- Automatically restore the source permissions
Reasoning guide: The correct option is to raise ValueError. Permission decisions are critical context; any change indicates a security risk and must fail immediately. Silently accepting or auto-restoring could mask tampering.
What it is
Context compression fidelity model
Context compression summarizes ordinary messages into a short text while copying critical fields (instructions, permission decisions, commitments) verbatim into the compressed result.
Critical fields are bound by a SHA-256 fingerprint. Verification compares each field and checks the fingerprint to ensure no critical information was lost or tampered with during compression.
What it is not
Not indiscriminate summarization
Compression is not summarization of all content. Critical fields cannot be summarized because summarization may change semantics, e.g., turning “write_access=revoked” into “write access discussed”.
Compression is also not simple truncation. Ordinary messages may be truncated, but critical fields must remain complete, otherwise subsequent steps cannot rely on this information.
How it relates to neighboring concepts
Component relationships
ContextPacket is the source data containing all fields. compress_context generates CompressedContext, where critical fields are copied directly and ordinary messages are summarized.
verify_critical_fidelity receives the source and compressed result, compares critical fields and verifies the fingerprint. If any critical field mismatches or the fingerprint is inconsistent, it raises ValueError.
Boundary decision for this stage: Critical fields (instructions, permission decisions, commitments) must be preserved verbatim and fingerprinted; ordinary messages may be compressed. During verification, any difference in critical fields or fingerprint mismatch must cause failure.
Replay a run from an auditable event journal and propagate memory deletion to dependent derived-summary caches
Start with a concrete problem
After deleting memory, derived summary caches still expose deleted content
In the previous stage, we implemented context compression, but the system still lacks an auditable event journal and a safe memory deletion mechanism. Currently, the file forge/replay.py does not exist, so tests fail at import time (ModuleNotFoundError: No module named ‘forge.replay’).
This stage requires implementing two core mechanisms: first, a hash-chained event journal that ensures the log is auditable and tamper-evident; second, when deleting memory, explicitly propagate the deletion to all derived summary caches that depend on that memory, preventing caches from continuing to expose deleted sensitive information.
Specifically, the test test_deletion_propagates_to_derived_summary_cache creates a user memory ‘private-note’ and generates two derived summaries: summary-1 references private-note, summary-2 does not. After calling delete_memory_everywhere, it expects summary-1 to be invalidated (invalidated == 1) while summary-2 remains unchanged.
The current failure fixture (fixtures/failure.json) shows that delete_memory_everywhere returns immediately after deleting the memory from the primary store without calling DerivedSummaryCache.invalidate_for_memory, causing the test to fail.
Make a prediction
When implementing delete_memory_everywhere, what happens if you only delete the memory from the primary store and do not handle the derived summary cache?
- The derived summary cache updates automatically; no extra action is needed.
- The derived summary cache still retains the deleted memory’s content, leading to data leakage.
- The system throws an exception because the cache is inconsistent with the primary store.
- The deletion fails because there are references in the cache.
Reasoning guide: The correct answer is the second option. The derived summary cache is stored independently and does not automatically detect changes in the primary store. Without explicit invalidation, the cache continues to expose the deleted memory content, violating data deletion integrity.
What it is
Relationship between event journal and derived summary cache
The event journal (EventJournal) is an append-only, hash-chained audit record. Each record contains the hash of the previous record (previous_hash) and the hash of the current event (record_hash). Any tampering causes a hash mismatch and is detected.
The derived summary cache (DerivedSummaryCache) stores summaries generated from multiple memories and records the list of source memory IDs (source_memory_ids) for each summary. When deleting a memory, you must find and delete all summaries that reference that memory; otherwise, the cache retains stale content.
The delete_memory_everywhere function coordinates the deletion: first delete the memory from the primary store, then call summary_cache.invalidate_for_memory(memory_id) to invalidate all related summaries and return the invalidation count.
What it is not
Common misconceptions
The event journal is not a plain text file; it uses a hash chain to guarantee integrity, and any modification breaks the chain.
The derived summary cache does not automatically synchronize with the primary store; explicit invalidation is required.
Deleting memory is not a single-point operation; it must propagate to all dependent derived data.
How it relates to neighboring concepts
Component collaboration
EventJournal uses RunEvent and reduce_events to replay state, ensuring that the state recovered from the journal matches the original state.
DerivedSummaryCache establishes dependencies between summaries and memories via the source_memory_ids field; the invalidate_for_memory method uses this field to find and delete related summaries.
delete_memory_everywhere connects MemoryStore and DerivedSummaryCache to implement cross-store deletion propagation.
Boundary decision for this stage: When deleting memory, you must handle both the primary store and all derived caches; deleting only from the primary store while ignoring caches leads to data inconsistency and potential leakage.
Complete the chapter
Pause, resume, and replay long tasks while controlling context growth.
Local lab self-check
- Not started
- 2Reading
- 3Lab downloaded
- 4Test result read
- 5Local check passed
verification.json is parsed only in this browser and is never uploaded. This checks the lab contract; it is not server certification or third-party endorsement.
Concept calibration and one-week review
Three questions check the chapter's boundaries, delivery evidence, and recovery path. Answers stay in this browser.