Skip to content

agents/context: cache the system prompt by default and restore the fluent withContext API #2772

Open

Description

@mattzcarey

ContextBlocks from agents/context replaced Session.create(this).withContext(...).withCachedPrompt() when sessions moved into a Lifecycle capability (#2196). The new API is harder to use on a plain Durable Object, and it doesn't cache the system prompt unless you know to pass a positional argument.

I hit this while rewriting the Agent Memory get started guide (cloudflare/cloudflare-docs#34730) for a plain DurableObject with Lifecycle. I ended up dropping agents/context from the guide and using a plain AI SDK tool, because ContextBlocks took more explaining than the feature it added.

Before

session = Session.create(this)
  .withContext("soul", { provider: { get: async () => "You are helpful." } })
  .withContext("memory", { description: "Learned facts", maxTokens: 1100 })
  .withCachedPrompt();

const system = await this.session.freezeSystemPrompt();
const tools = await this.session.tools();
  • Blocks were declared by name, one call each.
  • A block without a provider was backed by the agent's SQLite.
  • .withCachedPrompt() said what it did: persist the frozen prompt.
  • Compaction refreshed the prompt for you.

Now

readonly sessions = new Sessions();
readonly lifecycle = Lifecycle.install(this).use(this.sessions);
readonly session = this.sessions.session();

readonly context = new ContextBlocks(
  [
    { label: "soul", provider: { get: async () => "You are helpful." } },
    { label: "memory", description: "Learned facts", maxTokens: 1100 },
  ],
  new AgentContextProvider(this.lifecycle, "_system_prompt"), // the cache
  (label) => new AgentContextProvider(this.lifecycle, label), // blocks without a provider
);

What's wrong with it:

  1. The prompt isn't cached by default. Without the second argument, freezeSystemPrompt() keeps the prompt in memory only. After an eviction it renders again, and any block whose content changed since (a searchable block's entry count, a writable memory block) produces a different prompt and busts the provider's prefix cache. That's the thing withCachedPrompt() existed to prevent, and nothing warns you that it's off.
  2. Caching is a positional argument with a magic label. new AgentContextProvider(this.lifecycle, "_system_prompt") doesn't say "cache my prompt". It works because lifecycle.sql happens to match the SqlProvider shape. Think hard-codes the same _system_prompt label internally.
  3. Blocks without a provider need a third positional factory. Before, withContext("memory", { maxTokens }) was backed by SQLite automatically. Now it silently has no provider unless you pass defaultProvider.
  4. It isn't a capability. Sessions, Scheduler, and WebSockets attach with .use(). ContextBlocks is a loose object that reaches into Lifecycle storage, so it gets no startup hook, no lifecycle.ready(), and a different construction style from everything around it.
  5. Compaction no longer refreshes the prompt. Session used to call refreshSystemPrompt() after compacting. Think does that itself now, but a plain Durable Object using Sessions and ContextBlocks has to wire it by hand, and the docs don't say so.
  6. Tool names are implicit. A provider with search() becomes a search_context tool that never appears in user code, yet the system prompt has to name it.

Proposal

  • Make ContextBlocks (or a Context capability) cache the frozen prompt in the object's SQLite by default when installed on a Lifecycle, with an opt-out. No magic label in user code.
  • Bring back the fluent shape:
    readonly context = new Context()
      .withBlock("soul", { provider: { get: async () => "You are helpful." } })
      .withBlock("memory", { description: "Learned facts", maxTokens: 1100 });
    readonly lifecycle = Lifecycle.install(this).use(this.sessions).use(this.context);
    Blocks without a provider are SQLite-backed, as before.
  • Install it with .use() so it gets storage, ready(), and startup like other capabilities.
  • Offer a one-line link to a session so compaction refreshes the prompt, for example context.refreshOn(session), or document the manual wiring.
  • Let users name the generated tools, or at least export the names as constants, so prompts don't hard-code search_context.

agents/context is experimental, so this is the time to change it.

Activity

  1. github-actions commented on Oct 29, 2026

    @github-actions
    Contributor

    Thanks for the detailed write-up. I triaged this against main.

    Verified: in packages/agents/src/context/blocks.ts, ContextBlocks only persists the frozen prompt when a promptStore is passed (freezeSystemPrompt). Blocks with no provider get no storage unless defaultProvider is passed. Without a store, an eviction re-renders the prompt and can bust the prefix cache.

    Recommendation: make it a capability installed with .use(). It would cache the prompt in Lifecycle SQLite by default, with an opt-out, and back provider-less blocks with SQLite. Keep the existing block config shape and add a documented compaction refresh hook. Skip the fluent withBlock rebuild and tool renaming for now: they add API surface for little gain.

    Next: a maintainer decides. Adding gardener-fix will have this built.

  2. mattzcarey commented on Oct 29, 2026

    @mattzcarey
    MemberAuthor

    #2773 takes a different route from the one in this issue. Context stays separate from Sessions, but it's bound to a session, and the transcript records what the model was sent:

    • The first turn's system prompt is stored on that turn's user message. Later requests open with the same prompt, cached by default, with nothing to opt into and no magic label.
    • A remind block that changed is delivered after the user message of the turn that noticed it, and stored there. It's sent once and doesn't move.
    • Blocks without a provider are stored in SQLite (storage option), as before.
    • ThinkHarness takes a context option, and Think's context runs on the same class.
    • SET_CONTEXT_TOOL and SEARCH_CONTEXT_TOOL are exported from agents/context.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions