Episodes

Episodes record what a run did, so later runs can recall recent history. They come from an opt-in recorder for settled agent runs, or from agent-authored episodic records written with the generated remember tool.

Episodic records append. A later event never reconciles with or supersedes an earlier one, even when their data looks similar. Recall can filter episodes by an event-time window, and Distillation can later compact old episodes or derive reflections from them.

Enabling the run recorderCopy link to section: Enabling the run recorder

The recorder is disabled by default. Enable it for agent routes that have a resolved memory context:

b4.config.ts
export default {
  memory: {
    episodes: {
      enabled: true,
      ttlMs: 30 * 24 * 60 * 60 * 1000,
      cap: 500,
      includeFailedRuns: true,
    },
  },
} satisfies import("@b4run/core").B4Config

The TTL defaults to 30 days, the cap to 500 episodes per namespace, and failed runs are included.

The recorder does not embed episodes. embed currently resolves to false. Setting it to true logs one warning, and the record still lands without an embedding.

The recorder shares the long-term write switch. memory.writes: "off" makes the recorder a no-op even when memory.episodes.enabled is true. Use off when the whole route must stay recall-only.

What gets recordedCopy link to section: What gets recorded

One completed run produces one active episodic record:

json
{
  "kind": "episodic",
  "content": "run ok: summarize invoice 8821 (2 tools, 1.4s)",
  "data": {
    "input": "summarize invoice 8821",
    "outcome": "ok",
    "toolsUsed": ["readFile", "lookupInvoice"],
    "durationMs": 1400,
    "threadId": "thread-123",
    "runId": "run-456"
  },
  "source": { "type": "run", "id": "run-456" },
  "effectiveAt": "2026-08-10T18:00:00.000Z"
}

Let's quickly review:

  • The source id is runId if present, then threadId, then the start instant.
  • input is length-bounded, and toolsUsed lists each tool name once.
  • outcome is ok or error.
  • Successful episodes take tool names from the final route state. Note, failed episode records currently use toolsUsed: [] because no final state is available, even if tools ran before the error.
  • effectiveAt is the run's start time. createdAt and updatedAt are when the recorder wrote the record. Expiry counts from the start time.

Recorder errors never fail the user's run. The first failure is logged, and later failures in that process are muted. If you depend on episode capture, monitor the store.

Settled, failed, and parked runsCopy link to section: Settled, failed, and parked runs

  • A settled successful run records outcome: "ok".
  • A settled thrown run records outcome: "error" when includeFailedRuns is true.
  • A parked interrupt is not completed until a later resume settles or fails. The parked turn records nothing, so a half-finished run never appears as history.

This matters for human-in-the-loop permissions. An interrupt event means the route paused, and it may still fail or finish later.

RetentionCopy link to section: Retention

After writing an episode, the recorder calls prune for the namespace. Pruning removes rows whose expiresAt is at or before the fixed now. Then it removes the oldest episodic rows beyond the cap, ordered by effectiveAt (falling back to createdAt) with id as a tie-break.

Retention is lazy. It runs on a write or an explicit prune, and there's no background timer. If stale rows must go away without new traffic, run b4 memory prune on your own schedule.

Deleting a thread leaves its episodes in place. Plan tenant erasure and retention around the memory namespace rather than the thread lifecycle.

Time-windowed recallCopy link to section: Time-windowed recall

ts
await recall({
  kind: "episodic",
  query: "deployment failure",
  since: "-24h",
  until: "2026-08-11T00:00:00.000Z",
})

The generated tool resolves relative expressions against one request timestamp. since is inclusive and until is exclusive, both compared with effectiveAt (falling back to createdAt). Query-less windowed recall orders by that event time. Adding a query adds keyword ranking, recency, and confidence.

GovernanceCopy link to section: Governance

Recorded episodes are active as soon as they're written, and they skip candidate review. So limit who can run a route, derive tenant namespaces from verified application identity, and keep secrets out of model input the recorder might store.

Agent-authored episodes follow memory.writes. candidate holds them for review. auto and ask activate them without a supersede prompt. To govern the two sources differently, choose whether to enable the recorder and how to expose remember.

Agent-authored episodesCopy link to section: Agent-authored episodes

Declare kind: "episodic" in the route's memory.ts when the agent should write domain events itself:

src/app/support/memory.ts
import { defineMemory } from "@b4run/sdk"
import { z } from "zod"
 
export default defineMemory({
  kind: "episodic",
  scope: ["workspace", "route"],
  schema: z.object({
    event: z.string(),
    ticketId: z.string(),
  }),
})

Agent-authored episodes use the remember call's write timestamp for effectiveAt. They append even when identity fields match. The recorder's TTL skips them, so apply retention yourself through the store or the CLI.

TestingCopy link to section: Testing

Test episode behavior with a fixed clock and a real store implementation:

  1. Absent config records nothing.
  2. Success and permitted failure create the expected source/data shape.
  3. A parked interrupt creates no episode before resume.
  4. Resume settlement creates one episode per run, not one per turn.
  5. Expiry and the 500-row default cap prune the correct oldest records.
  6. embed: true still produces a record without an embedding.
  7. memory.writes: "off" records nothing even when episodes are enabled.
  8. A failed run records an empty toolsUsed list.

Use an isolated namespace per test, and query by kind and time window rather than relying on wall-clock order.