← Back to DevBytes

Context Window Management for Long-Horizon Agents

Context Window Management for Long-Horizon Agents

Long-horizon agents — systems that operate over many steps, hours, or even days — face a fundamental constraint: the context window of the underlying language model is finite. Whether you're building a coding agent that refactors an entire repository, a research assistant that browses dozens of sources, or a customer-support bot that maintains a multi-turn conversation, the agent will eventually accumulate more context than the model can ingest in a single call. Context window management is the discipline of deciding what stays, what gets compressed, what gets evicted, and what gets retrieved on demand so the agent remains coherent and effective across long horizons.

What Is Context Window Management?

At its core, context window management is the set of strategies and mechanisms an agent uses to keep its prompt within the model's token limit while preserving the information necessary to complete its task. A modern agent's context typically contains several categories of content:

Each of these grows at different rates. Tool observations, in particular, can explode in size — a single ls -R on a large repo or a verbose API response can consume thousands of tokens in one step. Without active management, the agent hits the token ceiling, degrades in reasoning quality, or fails outright with a context-length error.

Why It Matters

The naive approach — "just include everything" — fails for three reasons. First, there is a hard limit: every model has a maximum context length, and exceeding it produces an error or silent truncation. Second, even within the limit, models suffer from attention dilution: as context grows, the model's ability to focus on the most relevant information degrades, a phenomenon related to the "lost in the middle" effect. Third, cost and latency scale with input tokens. An agent that re-sends a 100K-token transcript on every step of a 200-step task is burning orders of magnitude more compute and money than necessary.

Effective context management directly improves three metrics that matter to developers: task success rate (the agent retains the right information to act correctly), cost per task (fewer redundant tokens), and latency (smaller prompts process faster). For production agents, these are often the difference between a viable product and an unsustainable one.

Core Strategies

There is no single technique that solves context management. Production systems combine several complementary strategies, each addressing a different failure mode.

1. Summarization and Compaction

The most common technique is to periodically summarize older portions of the context into a compact representation. When the context approaches a threshold, the agent (or a separate "compactor" call) takes the oldest N messages, produces a summary, and replaces them with that summary plus the most recent messages.

import tiktoken

def count_tokens(messages, model="gpt-4o"):
    enc = tiktoken.encoding_for_model(model)
    total = 0
    for m in messages:
        total += len(enc.encode(m["content"]))
    return total

def compact_history(messages, max_tokens, summarizer, keep_recent=6):
    """
    If total tokens exceed max_tokens, summarize everything except
    the most recent `keep_recent` messages into a single summary block.
    """
    if count_tokens(messages) <= max_tokens:
        return messages

    to_summarize = messages[:-keep_recent]
    recent = messages[-keep_recent:]

    summary_text = summarizer(to_summarize)

    return [
        {"role": "system", "content": f"Summary of earlier conversation:\n{summary_text}"},
        *recent,
    ]

The summarizer can be a smaller, cheaper model since summarization is a well-understood task. The key design decision is keep_recent: too few and you lose actionable detail; too many and compaction never triggers. A common heuristic is to keep enough recent turns to cover the current sub-task plus one or two prior steps for continuity.

2. Hierarchical Summarization

For very long horizons, a single flat summary becomes lossy. Hierarchical summarization maintains summaries at multiple levels of granularity: a high-level summary of the entire task, mid-level summaries of each phase, and the raw recent context. When the agent needs detail about a past phase, it can expand that mid-level summary back into its fuller form.

class HierarchicalMemory:
    def __init__(self):
        self.global_summary = ""
        self.phase_summaries = []   # list of (phase_id, summary, raw_messages)
        self.recent = []            # current phase raw messages

    def add_message(self, msg, compactor, phase_token_limit=4000):
        self.recent.append(msg)
        if count_tokens(self.recent) > phase_token_limit:
            summary = compactor(self.recent)
            self.phase_summaries.append({
                "phase_id": len(self.phase_summaries),
                "summary": summary,
                "raw": self.recent,
            })
            self.recent = []
            # Rebuild global summary from phase summaries
            self.global_summary = compactor(
                [{"role": "system", "content": p["summary"]}
                 for p in self.phase_summaries]
            )

    def build_prompt(self):
        parts = []
        if self.global_summary:
            parts.append({"role": "system",
                          "content": f"Task summary so far: {self.global_summary}"})
        for p in self.phase_summaries[-3:]:  # last 3 phase summaries
            parts.append({"role": "system",
                          "content": f"Phase {p['phase_id']}: {p['summary']}"})
        parts.extend(self.recent)
        return parts

This approach mirrors how humans handle long projects: you remember the overall arc, you remember what each major phase accomplished, and you keep detailed notes only for what you're actively working on.

3. Selective Retention and Eviction

Not all context is equally valuable. A tool call that returned a 5,000-token stack trace may be critical for the next step but irrelevant ten steps later. Selective retention applies explicit policies about what to keep based on message type and age.

def selective_prune(messages, max_tokens=8000):
    """
    Prune low-value content while preserving high-value items.
    """
    pruned = []
    budget = max_tokens

    # First pass: mark items for retention based on type and recency
    for i, msg in enumerate(reversed(messages)):
        age = i  # 0 = most recent
        content = msg.get("content", "")

        # Always keep the most recent 4 messages
        if age < 4:
            pruned.insert(0, msg)
            budget -= count_tokens([msg])
            continue

        # Keep tool results only if recent or explicitly marked important
        if msg.get("role") == "tool" and age > 8:
            pruned.insert(0, {"role": "tool",
                              "content": "[tool output evicted]",
                              "tool_call_id": msg.get("tool_call_id")})
            continue

        # Truncate very long messages to a snippet
        msg_tokens = count_tokens([msg])
        if msg_tokens > 1000:
            snippet = content[:1500] + "\n...[truncated]..."
            new_msg = {**msg, "content": snippet}
            pruned.insert(0, new_msg)
            budget -= count_tokens([new_msg])
        else:
            pruned.insert(0, msg)
            budget -= msg_tokens

        if budget <= 0:
            break

    return pruned

Notice the placeholder for evicted tool outputs. This preserves the structural integrity of the conversation (the assistant's message references a tool call ID, so the corresponding tool response must exist) while discarding the bulky content. Some frameworks also support "soft eviction," where the full content is moved to an external store and a reference is left in context.

4. External Memory with Retrieval

For agents that operate over hours or days, the most scalable approach is to offload context to an external store — a vector database, a key-value store, or even a simple file — and retrieve only what's needed for the current step. This decouples the agent's effective memory from the model's context window entirely.

import json
from pathlib import Path

class ExternalMemory:
    def __init__(self, store_path="agent_memory.jsonl"):
        self.store_path = Path(store_path)
        self.store_path.touch(exist_ok=True)

    def write(self, entry):
        """Append an entry with metadata for later retrieval."""
        with open(self.store_path, "a") as f:
            f.write(json.dumps(entry) + "\n")

    def search(self, query, embed_fn, top_k=5):
        """Simple keyword/semantic search over stored entries."""
        results = []
        with open(self.store_path) as f:
            for line in f:
                entry = json.loads(line)
                score = self._relevance(query, entry, embed_fn)
                results.append((score, entry))
        results.sort(key=lambda x: -x[0])
        return [e for _, e in results[:top_k]]

    def _relevance(self, query, entry, embed_fn):
        # In production, use cosine similarity over embeddings
        query_terms = set(query.lower().split())
        entry_terms = set(entry["content"].lower().split())
        return len(query_terms & entry_terms)

# Usage in an agent loop
memory = ExternalMemory()

def agent_step(user_input, llm, embed_fn):
    # Retrieve relevant past context
    relevant = memory.search(user_input, embed_fn, top_k=3)
    context_block = "\n---\n".join(r["content"] for r in relevant)

    prompt = [
        {"role": "system", "content": "You are a helpful agent."},
        {"role": "system", "content": f"Relevant past context:\n{context_block}"},
        {"role": "user", "content": user_input},
    ]

    response = llm(prompt)

    # Store this interaction for future retrieval
    memory.write({
        "content": f"User: {user_input}\nAssistant: {response}",
        "timestamp": "2024-01-01T00:00:00Z",
        "tags": ["conversation"],
    })

    return response

The retrieval approach shines when the agent's task spans many independent sub-tasks. A coding agent working through a backlog of issues doesn't need every prior issue's details in context — it needs the current issue, the relevant files, and perhaps a summary of related prior work. Retrieval makes that possible.

5. Structured Working Memory

Rather than dumping raw conversation history into the prompt, many advanced agents maintain a structured working-memory document that the agent itself updates. This might be a TODO list, a plan, a set of key facts, or a running log of decisions. The agent reads and writes this document each step, and only the document (not the full history) goes into the prompt.

WORKING_MEMORY_TEMPLATE = """# Current Task
{task}

# Plan
{plan}

# Key Facts Discovered
{facts}

# Completed Steps
{completed}

# Current Step
{current_step}

# Open Questions
{questions}
"""

class WorkingMemory:
    def __init__(self, task):
        self.task = task
        self.plan = []
        self.facts = []
        self.completed = []
        self.current_step = ""
        self.questions = []

    def render(self):
        return WORKING_MEMORY_TEMPLATE.format(
            task=self.task,
            plan="\n".join(f"- {p}" for p in self.plan),
            facts="\n".join(f"- {f}" for f in self.facts),
            completed="\n".join(f"- [x] {c}" for c in self.completed),
            current_step=self.current_step,
            questions="\n".join(f"- {q}" for q in self.questions),
        )

    def update_from_response(self, llm_response):
        """Parse the agent's output to update working memory fields."""
        # In practice, use structured output / function calling
        if "FACT:" in llm_response:
            for line in llm_response.split("\n"):
                if line.startswith("FACT:"):
                    self.facts.append(line[5:].strip())
        if "DONE:" in llm_response:
            for line in llm_response.split("\n"):
                if line.startswith("DONE:"):
                    self.completed.append(line[5:].strip())

This pattern is powerful because the agent controls its own memory footprint. It decides what's worth recording as a fact, what to mark complete, and what to keep as an open question. The prompt stays small and focused regardless of how many steps the agent has taken.

Putting It Together: A Complete Agent Loop

A production long-horizon agent typically combines several of these techniques. Here's a simplified but complete agent loop that uses working memory, selective pruning, and periodic compaction together:

class LongHorizonAgent:
    def __init__(self, llm, summarizer, max_context_tokens=12000):
        self.llm = llm
        self.summarizer = summarizer
        self.max_context_tokens = max_context_tokens
        self.working_memory = None
        self.history = []
        self.summary = ""

    def run(self, task, max_steps=50):
        self.working_memory = WorkingMemory(task)
        self.working_memory.plan = self._make_initial_plan(task)

        for step in range(max_steps):
            # Build prompt from working memory + recent history
            prompt = self._build_prompt()

            # Check context budget before calling the model
            if count_tokens(prompt) > self.max_context_tokens:
                self._compact()

            response = self.llm(prompt)
            self.history.append({"role": "assistant", "content": response})

            # Update working memory from the response
            self.working_memory.update_from_response(response)

            # Check for task completion
            if "TASK_COMPLETE" in response:
                return response

        return "Max steps reached without completion."

    def _build_prompt(self):
        prompt = [
            {"role": "system",
             "content": "You are a long-horizon task agent. "
                        "Use the working memory below. "
                        "Output TASK_COMPLETE when done."},
            {"role": "system",
             "content": self.working_memory.render()},
        ]

        if self.summary:
            prompt.append({"role": "system",
                           "content": f"Prior context summary: {self.summary}"})

        # Include only the last few raw exchanges for continuity
        recent = self.history[-4:]
        prompt.extend(recent)
        return prompt

    def _compact(self):
        """Summarize older history to free context budget."""
        if len(self.history) <= 4:
            return
        older = self.history[:-4]
        new_summary = self.summarizer(older)
        self.summary = f"{self.summary}\n{new_summary}".strip()
        self.history = self.history[-4:]

    def _make_initial_plan(self, task):
        plan_response = self.llm([
            {"role": "system", "content": "Break this task into steps."},
            {"role": "user", "content": task},
        ])
        return [line.strip("- ") for line in plan_response.split("\n")
                if line.strip().startswith("-")]

This architecture keeps the prompt bounded regardless of how many steps the agent takes. The working memory provides structured, agent-controlled context; the summary preserves the arc of the conversation; and the recent history gives the model enough raw detail to maintain coherence in the current step.

Best Practices

Conclusion

Context window management is not an optimization you bolt on after building an agent — it is a core architectural concern that shapes how the agent is designed from the first step. The most robust long-horizon agents combine structured working memory for agent-controlled focus, summarization and hierarchical compaction for preserving the task arc, selective eviction for discarding low-value content, and external retrieval for accessing the full history on demand. By treating context as a managed resource rather than an unlimited buffer, you build agents that remain coherent, cost-effective, and capable across tasks that span hundreds of steps — the kind of tasks where agents deliver their greatest value.

— Ad —

Google AdSense will appear here after approval

← Back to all articles