Agentic AI

Agent Memory

Short-Term Memory

Every step, you assemble a prompt from more material than will fit. Making that allocation explicit is the whole of short-term memory design.

JrCodex·8 min read

Jr Codex Agentic AI Notes

Level: Intermediate Prerequisites: Chapter 1: Why Agents Need Memory Time to complete: ~25 minutes


Table of Contents

  1. The Context Budget
  2. Strategy 1 — Trimming
  3. Strategy 2 — Summarising
  4. Strategy 3 — The Scratchpad
  5. Compressing Tool Results
  6. Assembling the Context
  7. Summary & Next Steps

1. The Context Budget

Every step, you assemble a prompt from more material than will fit. Making that allocation explicit is the whole of short-term memory design.

The Allocation
─────────────────────────────────────────
  ┌─────────────── context window ───────────────┐
  │ system │ tools │ scratchpad │ history │ out  │
  │  ~5%   │  ~10% │    ~10%    │  ~55%   │ ~20% │
  └───────────────────────────────────────────────┘

  system + tools   FIXED. Same every step.
  scratchpad       SMALL, curated, always present.
  history          the ELASTIC part — what gets cut.
  out              RESERVED. Never let input eat it.
─────────────────────────────────────────
from dataclasses import dataclass
 
@dataclass
class ContextBudget:
    window: int = 128_000
    reserve_output: int = 4_000
    scratchpad_max: int = 6_000
 
    def history_allowance(self, fixed_tokens: int, scratchpad_tokens: int) -> int:
        return self.window - self.reserve_output - fixed_tokens - scratchpad_tokens
 
def fit(messages, allowance, enc, keep_recent=4):
    """Drop from the MIDDLE — never the newest, never the anchor."""
    if total(messages, enc) <= allowance:
        return messages, []
    head, tail = messages[:1], messages[-keep_recent:]      # first turn + latest steps
    middle, dropped = [], []
    for m in reversed(messages[1:-keep_recent]):            # newest-first through the middle
        if total(head + [m] + middle + tail, enc) <= allowance:
            middle.insert(0, m)
        else:
            dropped.insert(0, m)
    return head + middle + tail, dropped                    # `dropped` feeds summarisation

2. Strategy 1 — Trimming

The simplest strategy: keep the most recent N steps, discard the rest.

What It Gets Right and Wrong
─────────────────────────────────────────
  RIGHT   cheap, deterministic, no extra LLM call,
          and the recent steps are usually the
          relevant ones.

  WRONG   drops the ORIGINAL GOAL, which lives at
          the very start. An agent that trims its
          first message has forgotten what it is
          doing — the single worst failure available.
─────────────────────────────────────────
The Fix Is Structural
─────────────────────────────────────────
  Never trim positionally alone. PIN the messages
  that must survive:

    PINNED   system prompt, original goal, active
             plan, user constraints
    ELASTIC  intermediate steps and tool results

  Trim only from the elastic set. `fit()` above does
  this by keeping `head` and `tail` unconditionally.
─────────────────────────────────────────

3. Strategy 2 — Summarising

Replace dropped steps with a generated summary rather than discarding them outright.

COMPACT_PROMPT = """Summarise these agent steps for a colleague taking over mid-task.
 
Preserve EXACTLY:
  - every fact discovered, with its source tool
  - every decision made, and why
  - every tool that FAILED, and how it failed
  - every open question
 
Omit: reasoning that led nowhere, restatements of the goal.
Write as terse bullet points, not prose.
 
STEPS:
{steps}"""
 
def compact(client, dropped_messages, prior_summary=None):
    body = render(dropped_messages)
    if prior_summary:                                   # ROLL UP — do not stack summaries
        body = f"PREVIOUS SUMMARY:\n{prior_summary}\n\nNEW STEPS:\n{body}"
 
    return client.chat.completions.create(
        model="gpt-4o-mini",                            # a cheap model is fine here
        messages=[{"role": "user", "content": COMPACT_PROMPT.format(steps=body)}],
        temperature=0,
    ).choices[0].message.content
The Two Details That Matter
─────────────────────────────────────────
  "EVERY TOOL THAT FAILED, AND HOW"
    Without this line, summarisation erases failure
    history — and the agent cheerfully retries the
    tool that already failed three times. This is a
    real and common regression.

  ROLL UP, DO NOT STACK
    Summarising a summary compounds loss. Always
    fold new steps into the PREVIOUS summary to
    produce ONE current summary, rather than
    accumulating a chain of them.
─────────────────────────────────────────
The Trade
─────────────────────────────────────────
  Costs an extra LLM call and is LOSSY — each
  compaction can drop a detail permanently.

  Worth it when runs are long. Unnecessary below
  roughly ten steps, where trimming with pinning is
  sufficient.
─────────────────────────────────────────

4. Strategy 3 — The Scratchpad

The most effective technique in this chapter, and the least used. Maintain a small structured state object outside the message history.

from dataclasses import dataclass, field
 
@dataclass
class Scratchpad:
    goal: str
    facts: dict = field(default_factory=dict)          # what has been ESTABLISHED
    done: list = field(default_factory=list)           # completed sub-tasks
    todo: list = field(default_factory=list)           # remaining sub-tasks
    failed: dict = field(default_factory=dict)         # tool -> why it failed
    open_questions: list = field(default_factory=list)
 
    def render(self) -> str:
        parts = [f"GOAL: {self.goal}"]
        if self.facts:
            parts.append("ESTABLISHED:\n" + "\n".join(f"  {k}: {v}"
                                                      for k, v in self.facts.items()))
        if self.done:
            parts.append("DONE:\n" + "\n".join(f"  ✓ {d}" for d in self.done))
        if self.todo:
            parts.append("REMAINING:\n" + "\n".join(f"  · {t}" for t in self.todo))
        if self.failed:
            parts.append("DO NOT RETRY:\n" + "\n".join(f"  ✗ {k}: {v}"
                                                       for k, v in self.failed.items()))
        if self.open_questions:
            parts.append("OPEN:\n" + "\n".join(f"  ? {q}" for q in self.open_questions))
        return "\n\n".join(parts)
Why It Beats Both Other Strategies
─────────────────────────────────────────
  BOUNDED    it is a fixed-size summary of state,
             not a growing transcript. It does not
             need trimming at all.

  CURATED    only what matters is in it, because
             your code decides what goes in — not
             the accident of what happened recently.

  ALWAYS AT  render it at the END of the context
  THE END    every step, so the goal and state sit in
             the highest-attention position (Module 2,
             Chapter 1's fix for goal drift).

  EXPLICIT   "DO NOT RETRY" mechanically prevents the
  FAILURES   repeated-failure loop, rather than
             hoping the model notices.
─────────────────────────────────────────
def update_scratchpad(pad, call, result):
    """Maintain state in CODE — deterministic, and cheaper than asking the model."""
    if isinstance(result, dict) and "error" in result:
        pad.failed[f"{call.name}({call.args})"] = result.get("message", result["error"])
    else:
        pad.done.append(f"{call.name}{summarise_result(result)}")
        pad.facts.update(extract_facts(result))
    return pad

5. Compressing Tool Results

Module 2, Chapter 1 measured tool results at ~80% of agent context. This is where the budget is won or lost.

def compress(result, max_chars=2_000):
    """Reduce a tool result to what the model can actually use."""
    if isinstance(result, list):
        if len(result) > 10:
            head = json.dumps(result[:5], default=str)
            return (f"{head}\n...[{len(result)} items total, showing first 5. "
                    f"Refine your query or request a specific item.]")
        return json.dumps(result, default=str)
 
    text = result if isinstance(result, str) else json.dumps(result, default=str)
    if len(text) <= max_chars:
        return text
    return text[:max_chars] + f"\n...[truncated from {len(text)} chars]"
 
def compress_history(messages, current_step, keep_full_for=2):
    """OLD tool results get compressed harder than recent ones."""
    out = []
    for m in messages:
        age = current_step - m.get("step", current_step)
        if m["role"] == "tool" and age > keep_full_for:
            out.append({**m, "content": compress(m["content"], max_chars=300)})
        else:
            out.append(m)
    return out
The Age-Based Principle
─────────────────────────────────────────
  A tool result from the last step may need full
  detail — the agent is reasoning about it now.

  A result from eight steps ago needs only its
  CONCLUSION. Keeping 4,000 tokens of it is pure
  waste.

  Compressing by age recovers large amounts of
  budget with almost no loss of useful signal.
─────────────────────────────────────────

6. Assembling the Context

All of it, in the order that matters.

def build_context(system, tools, scratchpad, history, budget, enc):
    fixed = count(system, enc) + count(tools, enc)
    pad_text = scratchpad.render()
    pad_tokens = min(count(pad_text, enc), budget.scratchpad_max)
 
    allowance = budget.history_allowance(fixed, pad_tokens)
    kept, dropped = fit(compress_history(history, len(history)), allowance, enc)
 
    if dropped:
        summary = compact(client, dropped, scratchpad.summary)
        scratchpad.summary = summary                    # rolled up, not stacked
        kept = [{"role": "user", "content": f"[Earlier steps]\n{summary}"}] + kept
 
    return [
        {"role": "system", "content": system},          # 1. stable prefix — CACHEABLE
        *kept,                                          # 2. the elastic middle
        {"role": "user", "content": pad_text},          # 3. state, LAST = most attended
    ]
Why This Order
─────────────────────────────────────────
  STATIC FIRST   the system prompt and tool schemas
                 never change, so a stable prefix
                 gets prompt-cache hits (Gen AI
                 Notes, Module 2, Chapter 4).

  ELASTIC MIDDLE the part that varies, positioned
                 where lower attention costs least.

  STATE LAST     goal, progress and failures in the
                 highest-attention position.

  Cost and quality point the same way here — the
  ordering that caches best is also the ordering
  that reasons best.
─────────────────────────────────────────

7. Summary & Next Steps

Key Takeaways

  • Short-term memory is budget allocation: fixed prefix, curated scratchpad, elastic history, and a reserved output allowance that input must never consume.
  • Trimming must pin the goal and constraints — positional trimming alone deletes the first message, which is the task itself.
  • Summarisation must explicitly preserve failures and roll up rather than stack, or the agent retries tools that already failed.
  • A structured scratchpad rendered at the end of the context is the strongest technique: bounded, curated, and it prevents retry loops mechanically.

Concept Check

  1. Why is naive "keep the last N messages" trimming actively dangerous for an agent?
  2. What regression appears when a summarisation prompt omits the instruction to preserve tool failures?
  3. Why do cost optimisation and reasoning quality agree on the static-first, state-last ordering?

Next Chapter

Chapter 3: Long-Term Memory


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index