Study guides / CCDV-F / Domain 3

Agents & Workflows · Lesson 4 of 5

3.4 - Session State and Memory Across Turns

Manage what persists across a long-running agent session, and know when to start clean instead of carrying everything forward.

Because the API is stateless (Lesson 1.1), all session memory is something your application manages: the growing message list you resend in full on every call. For a long agent session, that list needs active management - not just accumulation - or it eventually runs into cost, latency, and the model's context window limits. Every user/assistant turn adds two messages; every tool round trip adds a tool_use block and a tool_result block on top of that. Tool results, especially raw JSON dumps, log excerpts, or full file contents, are very often the largest single contributor to history size - much larger than the actual conversational turns around them.

Pruning tool results deliberately

Once a tool result has been read and acted on, later turns often don't need the full raw payload still sitting in history - they need whatever conclusion was drawn from it. Replacing an old, superseded tool_result block's content with a short placeholder ("[full file contents omitted after use]") while leaving the rest of the conversation intact is a deliberate, targeted form of pruning: it's not the same as blindly truncating the oldest N messages, which risks cutting something that's still relevant regardless of age. The Claude Developer Platform also offers this as a built-in feature - context editing that can automatically clear aging tool interactions, and a memory tool for persisting durable facts to storage outside the conversation so they survive even when the message history itself is trimmed. The underlying principle is the same whether you hand-roll it or configure the platform feature: decide deliberately what's still relevant, rather than letting everything accumulate by default.

Fresh start, full carry-forward, or a compact summary

There are three legitimate options, not two. Full carry-forward - keep appending to the same history - is correct for a task that's genuinely continuing. A fresh session is usually better than carefully pruning an old one down when the new request is unrelated to what came before: it avoids paying token cost for irrelevant history and avoids the model being distracted by stale context that has nothing to do with the current ask. A deliberately-written compact summary injected into a fresh session is the middle option - when some continuity genuinely matters (the agent needs to remember what it already tried, what conclusions were reached, what's still open) but the full raw transcript doesn't need to survive. Writing that summary is an active step, not a shortcut to skip: a good one states outcomes and open items in a few sentences, not a compressed retelling of the whole transcript.

Common exam distractor

Blind truncation - keeping only the last N messages, or the last K tokens' worth - is not the same thing as a deliberate summary, and the exam treats it as a lesser, riskier substitute. Truncation can cut something upstream that's still load-bearing (an early instruction, a fact established turns ago) with no judgment applied to what's kept. A summary is a judgment call about what still matters; truncation is a judgment-free cut by position.

Isolation applies across sessions too

The isolation principle from Lesson 3.2 isn't just about subagents - it applies to any fresh session too. A brand-new session has no memory of a prior one unless something is explicitly reinjected into it. If a follow-up task depends on the agent remembering a prior decision, that fact has to be written into the new session's opening context (via a summary, a stored memory-tool entry, or an explicit user-supplied recap) - it will not simply be recalled from thin air just because the same user is continuing the same overall workflow.

Practical example: a long-running incident-response agent

An on-call agent investigates a production incident over 40 tool calls across two hours - querying logs, checking dashboards, reading recent deploys, running diagnostic commands. Each of those tool results might run to several hundred or a few thousand tokens. Left unmanaged, the history for that single incident balloons into tens of thousands of tokens, most of it diagnostic output that was relevant for the two or three turns immediately after it arrived and irrelevant afterward - nobody needs the full raw output of a log query from 90 minutes ago once its conclusion ("no errors in that window") has already been drawn and acted on.

Every one of those input tokens is resent, and billed, on every subsequent call in the session - prompt caching can reduce the marginal cost of resending an unchanged prefix, but it doesn't make an ever-growing prefix free, and it does nothing about the model having to read past a wall of stale diagnostic output to find what's currently relevant. Pruning superseded tool results as the investigation moves on, and eventually writing a compact incident summary once the issue is resolved (what broke, what fixed it, what to watch for) rather than preserving the full 40-call transcript indefinitely, keeps both the cost and the model's effective attention where they belong.

Key concept

The choice isn't just "keep everything" vs. "lose everything." A compact, deliberately-written summary injected into a fresh session is a third option that preserves what matters without the token cost of the full history - and it's different in kind from blind truncation, which applies no judgment at all.

Exam traps

Practice question

A user finishes one unrelated task with an agent and immediately starts asking about a completely different topic in the same long-running session, which already has substantial accumulated history. What's the best handling?

  • A Keep appending to the same history regardless of topic, since more context is always better.

    Irrelevant prior-task history adds cost and potential distraction with no benefit to the new, unrelated task.

  • B Start a fresh session for the new topic, optionally carrying forward a compact summary only if something from before is actually relevant. Correct

    This avoids paying for and being distracted by irrelevant history, while still preserving genuinely relevant context via a deliberate summary rather than the raw log.

  • C Truncate the history to just the last message and continue in the same session.

    Blind truncation can cut relevant context arbitrarily and still isn't as clean as a deliberate fresh start for a genuinely unrelated topic.

  • D Switch to a larger context-window model instead of managing the history at all.

    A bigger window delays the problem rather than addressing the actual issue of irrelevant accumulated context.

Build exercise: Prune tool results, write a session summary, and verify a fresh-start handoff

Intermediate · 35 minutes

You'll practice:

  1. Run an agentic loop through 4-5 tool-call turns against a tool that returns a large payload (e.g. a stub returning a 2,000-word fake log file), and log the approximate token count of the full message history after each turn.

    You need to see history growth happen before pruning it means anything - this makes concrete which part of the history is actually driving size.

    You should see: A history size that grows sharply after each tool call, with the tool_result blocks visibly responsible for most of the growth compared to the surrounding user/assistant turns.

    Hints
    1. What's a quick way to estimate token count without an exact tokenizer call for every message?
    2. Anthropic's SDK exposes a token counting endpoint; alternatively, approximate with characters / 4 as a rough token estimate for a quick before/after comparison.
    3. async function approxTokens(messages) {
        const text = JSON.stringify(messages);
        return Math.round(text.length / 4);
      }
      for (const turn of turns) {
        // ...execute tool call, append to messages...
        console.log(`After turn: ~${await approxTokens(messages)} tokens`);
      }
  2. Implement a pruning function that, once a tool result is more than 2 turns old, replaces its content with a short placeholder string while leaving everything else in history untouched.

    This is deliberate, targeted pruning rather than blind truncation - the exam distinguishes the two, and the difference only becomes clear once you've written both.

    You should see: Older tool_result blocks replaced with something like "[tool result omitted, superseded by later turns]" while recent tool_result blocks and all user/assistant text remain fully intact.

    Hints
    1. How do you identify which message index a given tool_result belongs to, so you know how "old" it is relative to the current turn?
    2. Track the turn index each tool_result was added at. When building the outgoing request, replace the content field of any tool_result block older than your cutoff with a placeholder, without removing the message itself (removing it would break the tool_use/tool_result pairing).
    3. function pruneOldToolResults(messages, currentTurn, maxAge = 2) {
        return messages.map((m, i) => {
          if (m.role !== "user" || !Array.isArray(m.content)) return m;
          return {
            ...m,
            content: m.content.map(block =>
              block.type === "tool_result" && (currentTurn - block._addedAtTurn) > maxAge
                ? { ...block, content: "[tool result omitted, superseded by later turns]" }
                : block
            )
          };
        });
      }
  3. Take the full, unpruned 4-5 turn history and write a 3-5 sentence summary capturing only what a follow-up task would actually need to know - outcomes reached and anything still open, not a blow-by-blow retelling.

    Writing the summary yourself, rather than dumping the raw history or truncating it, is the actual skill being tested - a good summary is a judgment call, not a mechanical cut.

    You should see: A summary a small fraction of the original history's token count, phrased around conclusions and open items rather than restating each turn.

    Hints
    1. What would a colleague need to know to pick up this task cold, in the fewest possible sentences?
    2. Ask Claude itself to produce the summary from the full history, explicitly instructing it to state outcomes and open items only, not a turn-by-turn recap.
    3. const summaryReq = await client.messages.create({
        model: "claude-sonnet-5",
        max_tokens: 200,
        messages: [...messages, { role: "user", content: "Summarise this session in 3-5 sentences for someone picking up the task fresh. State conclusions reached and anything still open. Do not narrate turn by turn." }]
      });
      const summary = summaryReq.content.find(b => b.type === "text")?.text;
  4. Start a genuinely fresh session (empty messages array, new system prompt if needed) with only that summary injected as the opening context, then ask a realistic follow-up question that depends on something established in the original session - verify it answers correctly.

    This proves the summary actually carries what matters, and demonstrates the isolation principle directly: nothing from the old session exists in the new one except what you explicitly injected.

    You should see: A correct answer to the follow-up question using only the injected summary as prior context - not the full original history.

    Hints
    1. What message role and content should the summary be injected as, at the start of the new session?
    2. Inject it as a user (or system) message at the very start of the new messages array, framed as prior context, before the actual new user question.
    3. const freshMessages = [
        { role: "user", content: `Prior session context: ${summary}` },
        { role: "assistant", content: "Understood, I have that context." },
        { role: "user", content: followUpQuestion }
      ];
      const res = await client.messages.create({ model: "claude-sonnet-5", max_tokens: 500, messages: freshMessages });
  5. Now ask a completely unrelated question in the same fresh session used in the previous step (no injected summary this time) and confirm the model isn't distracted by leftover context from a topic swap - then contrast that with reusing the original, unpruned, on-topic history for a genuinely related follow-up.

    This closes the loop on the three-way decision: fresh-with-summary for related continuity, fresh-with-nothing for a genuinely new topic, and full carry-forward only when the task is actually still the same one.

    You should see: A clean, undistracted answer to the unrelated question from the empty fresh session, versus a correctly context-aware answer from the full original history when the follow-up is actually related.

    Hints
    1. Which of the three options (fresh-empty, fresh-with-summary, full carry-forward) should each of these two follow-ups use, and why?
    2. An unrelated question gets a genuinely empty fresh session - no summary, since nothing from before is relevant. A related follow-up on the same task reuses the original (possibly pruned) history directly, since it's still the same continuing task.
    3. // Unrelated topic: fresh, empty
      const unrelated = await client.messages.create({ model: "claude-sonnet-5", max_tokens: 300, messages: [{ role: "user", content: unrelatedQuestion }] });
      
      // Related topic: reuse original (pruned) history
      const related = await client.messages.create({ model: "claude-sonnet-5", max_tokens: 300, messages: [...prunedMessages, { role: "user", content: relatedFollowUp }] });

Sources