·

This topic is a complete, worked implementation. You will build a context compression system for a multi-hour agentic coding session from scratch — starting with a simple agent loop and progressively adding each layer of the compression stack: incremental summarization, code skeleton injection, diff summarization, and a hierarchical project archive. By the end, you will have a production-grade context management system you can adapt for your own engineering, QA, or product workflows.

The scenario: you are running an agentic assistant that will help you implement a new feature over a 3-4 hour session, making changes across 10-15 files, running tests, and iterating on feedback. Without compression, this session would exhaust a 200K-token context window or produce degraded results. With compression, it runs smoothly to completion.


Environment Setup and Prerequisites

You will need:
- Python 3.11+
- The Anthropic Python SDK (pip install anthropic)
- A project repository (the examples use a Node.js/TypeScript project, but the patterns work for any language)
- An Anthropic API key set as ANTHROPIC_API_KEY

Install dependencies:

pip install anthropic tiktoken rich python-dotenv

The tiktoken library is used for token counting (it approximates Claude's tokenization well enough for budget tracking). The rich library provides clean terminal output for the session UI.

Create the project directory:

mkdir agent-session && cd agent-session
touch session_state.py context_manager.py agent.py compression.py main.py

Tip: Treat this implementation as a learning scaffold, not production code. The goal is to understand each compression layer by building it. In a real project, you would integrate these patterns into your existing agent framework (LangChain, LlamaIndex, ControlFlow, etc.) rather than maintaining a custom loop.


Step 1: Define the Session State Model

The session state is the central data structure. Everything — rolling summary, recent turns, token counts, project archive — lives here.

session_state.py:

from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import json
import os

@dataclass
class SessionState:
    # Session identity
    session_id: str
    project_name: str
    session_objective: str
    started_at: datetime = field(default_factory=datetime.now)

    # Tier 1: Hot context (recent turns)
    recent_turns: list[dict] = field(default_factory=list)

    # Tier 2: Warm context (rolling summary)
    rolling_summary: str = ""
    last_compression_turn: int = 0

    # Tier 3: Cold context (project archive)
    project_archive: str = ""

    # Token tracking
    total_input_tokens: int = 0
    total_output_tokens: int = 0
    turn_count: int = 0
    cost_usd: float = 0.0

    # Configuration
    chunk_window_size: int = 12
    model: str = "claude-sonnet-4-5"
    model_context_limit: int = 200_000
    rolling_summary_budget: int = 800   # tokens
    archive_budget: int = 2000           # tokens

    def to_dict(self) -> dict:
        return {
            "session_id": self.session_id,
            "project_name": self.project_name,
            "session_objective": self.session_objective,
            "rolling_summary": self.rolling_summary,
            "project_archive": self.project_archive,
            "turn_count": self.turn_count,
            "total_input_tokens": self.total_input_tokens,
            "total_output_tokens": self.total_output_tokens,
            "cost_usd": self.cost_usd,
        }

    def save(self, directory: str = ".agent") -> None:
        os.makedirs(directory, exist_ok=True)
        with open(f"{directory}/session_{self.session_id}.json", "w") as f:
            json.dump(self.to_dict(), f, indent=2, default=str)
        # Also save the archive separately (for version control)
        with open(f"{directory}/project-context.md", "w") as f:
            f.write(self.project_archive)

    @classmethod
    def load(cls, session_id: str, directory: str = ".agent") -> "SessionState":
        with open(f"{directory}/session_{session_id}.json") as f:
            data = json.load(f)
        state = cls(
            session_id=data["session_id"],
            project_name=data["project_name"],
            session_objective=data["session_objective"],
        )
        state.rolling_summary = data["rolling_summary"]
        state.project_archive = data["project_archive"]
        state.turn_count = data["turn_count"]
        state.total_input_tokens = data["total_input_tokens"]
        return state

Tip: Always save the session state after each turn, not just at session end. This makes the session crash-recoverable — if your process dies, you can resume from the last saved state without losing context.


Step 2: Implement the Compression Engine

The compression engine handles all three compression operations: incremental summarization (Tier 1 → Tier 2), distillation (Tier 2 → Tier 3), and code skeleton generation.

compression.py:

from anthropic import Anthropic
from session_state import SessionState

client = Anthropic()


MERGE_PROMPT = """You are updating a rolling technical session summary.

EXISTING ROLLING SUMMARY:
---
{existing_summary}
---

NEW TURNS (since last update):
---
{new_turns}
---

Update the summary. Rules:
- Preserve all decisions, constraints, and established patterns
- Update "Current State" to reflect the latest progress
- Add new decisions with one-line rationale
- Update "Do Not Revisit" for any rejected approaches
- For reversed decisions: show final state with "(revised)" note
- Remove resolved open questions (add resolution as a note)

FORMAT (use exactly this structure):
## Rolling Summary — Turn {turn_count}
**Objective:** [session goal]
**Decisions Made:** [bullet list]
**Current State:** [2-3 sentences]
**Work Completed:** [bullet list]
**Open Questions:** [bullet list with owners]
**Known Issues:** [bullet list]
**Do Not Revisit:** [bullet list]

HARD BUDGET: {budget} tokens maximum. Be precise, not verbose."""

def compress_tier1_to_tier2(state: SessionState) -> None:
    """Merge recent turns into rolling summary."""

    turns_text = format_turns_for_compression(state.recent_turns)

    prompt = MERGE_PROMPT.format(
        existing_summary=state.rolling_summary or "(First compression — no existing summary)",
        new_turns=turns_text,
        turn_count=state.turn_count,
        budget=state.rolling_summary_budget
    )

    response = client.messages.create(
        model=state.model,
        max_tokens=1200,
        messages=[{"role": "user", "content": prompt}]
    )

    state.rolling_summary = response.content[0].text
    state.last_compression_turn = state.turn_count
    # Keep last 2 turns for conversational continuity
    state.recent_turns = state.recent_turns[-2:]


DISTILLATION_PROMPT = """You are updating a project's long-term memory archive.

CURRENT PROJECT ARCHIVE:
---
{archive}
---

END-OF-SESSION SUMMARY:
---
{session_summary}
---

Update the archive. Elevate from the session summary:
- Any new permanent architectural decisions → "Architectural Decisions"
- Completed milestones → "Completed Milestones" (one line each)
- New known issues → "Known Issues"
- New established patterns → "Patterns & Standards"
- Rejected approaches → "Do Not Revisit"
- Updated project state → "Current State"

Do NOT include: tactical session details, intermediate debugging steps,
open questions still being explored, or anything session-specific.

Keep archive under {budget} tokens. Compress older items if needed.

FORMAT:
## Project Archive: {project_name}
*Updated: {date}*

### Project Identity
### Architectural Decisions
### Completed Milestones
### Current State
### Known Issues
### Constraints
### Patterns & Standards
### Do Not Revisit"""

def distill_tier2_to_tier3(state: SessionState) -> None:
    """Distill session summary into project archive. Run at session end."""
    from datetime import date

    if not state.rolling_summary:
        return

    prompt = DISTILLATION_PROMPT.format(
        archive=state.project_archive or "(New project — no existing archive)",
        session_summary=state.rolling_summary,
        budget=state.archive_budget,
        project_name=state.project_name,
        date=date.today().isoformat()
    )

    response = client.messages.create(
        model=state.model,
        max_tokens=2500,
        messages=[{"role": "user", "content": prompt}]
    )

    state.project_archive = response.content[0].text


SKELETON_PROMPT = """Produce a code skeleton for this file. Include:
- All class/interface/function/method signatures with types
- One-line behavior comment per method (not implementation detail)
- Constructor dependencies
- Key thrown exceptions or error conditions
- Non-obvious side effects (external calls, state mutations)
- Important constants or configuration values

Remove: implementation bodies, import statements (replace with a comment listing modules),
docstrings, inline comments, blank lines.

Target: under 25% of original length.
File: {filename}

{content}"""

def generate_skeleton(filename: str, content: str) -> str:
    """Generate a compact code skeleton for a source file."""
    response = client.messages.create(
        model="claude-haiku-4-5",  # Use faster/cheaper model for skeletons
        max_tokens=1000,
        messages=[{
            "role": "user",
            "content": SKELETON_PROMPT.format(filename=filename, content=content)
        }]
    )
    return response.content[0].text


DIFF_SUMMARY_PROMPT = """Summarize this git diff as a semantic change log.

For each changed file, produce:
- File path
- One sentence: what changed and why (behavior-level, not implementation-level)
- Any new dependencies (imports, injected services)
- Any interface/API changes (breaking marked explicitly)
- Any behavioral changes (altered error conditions, changed defaults)

Ignore: whitespace, comment-only changes, reformatting.
Format: one bullet per file, sub-bullets for items 2-4 if present.
Target: 1-2 sentences per changed file.

{diff}"""

def summarize_diff(diff_content: str) -> str:
    """Convert a raw git diff into a semantic change log."""
    # Truncate very large diffs
    if len(diff_content) > 40000:
        diff_content = diff_content[:40000] + "\n[DIFF TRUNCATED]"

    response = client.messages.create(
        model=state.model,
        max_tokens=800,
        messages=[{
            "role": "user",
            "content": DIFF_SUMMARY_PROMPT.format(diff=diff_content)
        }]
    )
    return response.content[0].text


def format_turns_for_compression(turns: list[dict]) -> str:
    result = []
    for turn in turns:
        role = turn["role"].upper()
        content = turn["content"]
        # Truncate very long individual turns (e.g., large tool outputs)
        if len(content) > 1500:
            content = content[:1500] + f"\n[...truncated, {len(turn['content'])} chars total]"
        result.append(f"{role}: {content}")
    return "\n\n".join(result)

def estimate_tokens(text: str) -> int:
    """Rough token estimate: ~4 chars per token for English/code."""
    return len(text) // 4

Tip: Use a faster, cheaper model (like claude-haiku-4-5) for mechanical compression tasks — skeleton generation, diff summarization, and even Tier 1 → Tier 2 compression. Reserve the full Sonnet model for the actual coding work. This can cut compression costs by 80-90% while maintaining quality, since compression is a well-defined extraction task that benefits less from reasoning depth.


Step 3: Build the Context Manager

The context manager assembles the agent's context for each turn and decides when to trigger compression.

context_manager.py:

from session_state import SessionState
from compression import compress_tier1_to_tier2, estimate_tokens

SYSTEM_PROMPT_TEMPLATE = """You are an expert software engineering assistant working on {project_name}.

{archive_section}

Current session objective: {objective}

Guidelines:
- Follow all architectural decisions in the project archive above
- When you complete a significant milestone, explicitly state "[MILESTONE COMPLETE]: [description]"
- When you make a decision, explicitly state "[DECISION]: [decision] because [brief rationale]"
- When you reject an approach, explicitly state "[DO NOT REVISIT]: [approach] — [reason]"
- Produce clean, production-quality code following the project's established patterns"""

def build_system_prompt(state: SessionState) -> str:
    archive_section = ""
    if state.project_archive:
        archive_section = f"PROJECT ARCHIVE (Long-term memory):\n{state.project_archive}\n"

    return SYSTEM_PROMPT_TEMPLATE.format(
        project_name=state.project_name,
        archive_section=archive_section,
        objective=state.session_objective
    )

def build_messages(state: SessionState) -> list[dict]:
    """Construct the messages array for the current turn."""
    messages = []

    # Inject Tier 2 (rolling summary) as first message pair
    if state.rolling_summary:
        messages.append({
            "role": "user",
            "content": f"[ROLLING SESSION SUMMARY]\n{state.rolling_summary}\n[END SUMMARY — continuing session]"
        })
        messages.append({
            "role": "assistant",
            "content": "Session context loaded. I have the current state and all decisions. Ready to continue."
        })

    # Add Tier 1 (recent turns)
    messages.extend(state.recent_turns)

    return messages

def should_compress(state: SessionState) -> bool:
    """Determine if incremental compression should trigger."""
    turns_since_last = state.turn_count - state.last_compression_turn

    # Estimate total context size
    recent_tokens = sum(estimate_tokens(t["content"]) for t in state.recent_turns)
    summary_tokens = estimate_tokens(state.rolling_summary)
    archive_tokens = estimate_tokens(state.project_archive)
    total_estimated = recent_tokens + summary_tokens + archive_tokens + 3000  # system prompt estimate

    return (
        turns_since_last >= state.chunk_window_size
        or total_estimated > state.model_context_limit * 0.55
    )

Tip: The [MILESTONE COMPLETE], [DECISION], and [DO NOT REVISIT] tags in the system prompt are critical. They turn the agent's natural language output into structured, parseable events. After each turn, scan the agent's response for these tags and log them separately. Your compression prompts can then explicitly reference these tagged items, dramatically improving compression quality.


Step 4: Implement the Main Agent Loop

agent.py:

from anthropic import Anthropic
from session_state import SessionState
from context_manager import build_system_prompt, build_messages, should_compress
from compression import compress_tier1_to_tier2
from rich.console import Console
from rich.panel import Panel
from rich.text import Text

client = Anthropic()
console = Console()

INPUT_TOKEN_COST = 3.00 / 1_000_000   # $3 per million
OUTPUT_TOKEN_COST = 15.00 / 1_000_000  # $15 per million

def run_turn(state: SessionState, user_message: str) -> str:
    """Execute one agent turn with full context management."""

    # Add user message to Tier 1
    state.recent_turns.append({"role": "user", "content": user_message})
    state.turn_count += 1

    # Build context
    system = build_system_prompt(state)
    messages = build_messages(state)

    # Log context stats
    console.print(f"[dim]Turn {state.turn_count} | "
                  f"Recent turns: {len(state.recent_turns)} | "
                  f"Summary: {'yes' if state.rolling_summary else 'no'} | "
                  f"Archive: {'yes' if state.project_archive else 'no'}[/dim]")

    # Call the model
    response = client.messages.create(
        model=state.model,
        max_tokens=4096,
        system=system,
        messages=messages
    )

    agent_reply = response.content[0].text

    # Track costs
    input_tokens = response.usage.input_tokens
    output_tokens = response.usage.output_tokens
    state.total_input_tokens += input_tokens
    state.total_output_tokens += output_tokens
    turn_cost = (input_tokens * INPUT_TOKEN_COST) + (output_tokens * OUTPUT_TOKEN_COST)
    state.cost_usd += turn_cost

    # Add response to Tier 1
    state.recent_turns.append({"role": "assistant", "content": agent_reply})

    # Display response
    console.print(Panel(agent_reply, title=f"Agent (Turn {state.turn_count})", 
                        border_style="blue"))
    console.print(f"[dim]Cost this turn: ${turn_cost:.4f} | "
                  f"Session total: ${state.cost_usd:.4f} | "
                  f"Input tokens: {input_tokens:,}[/dim]")

    # Trigger compression if needed
    if should_compress(state):
        console.print("[yellow]⚡ Triggering incremental compression...[/yellow]")
        compress_tier1_to_tier2(state)
        console.print(f"[green]✓ Context compressed. Summary: "
                      f"{len(state.rolling_summary.split())} words[/green]")

    # Save state after each turn
    state.save()

    return agent_reply

def extract_structured_events(text: str) -> list[dict]:
    """Extract tagged events from agent output for logging."""
    events = []
    for line in text.split('\n'):
        for tag in ['[DECISION]', '[MILESTONE COMPLETE]', '[DO NOT REVISIT]']:
            if tag in line:
                events.append({"type": tag, "content": line.strip()})
    return events

Tip: Print structured events (decisions, milestones) in a distinct color in the terminal output. When you review a session log, these highlighted events create an instant visual summary of what the agent accomplished. This is also a quick way to validate that your compression is capturing the right things — the events in the terminal log should all appear in the rolling summary.


Step 5: Putting It All Together — The Main Session Script

main.py:

import uuid
from datetime import datetime
from rich.console import Console
from rich.prompt import Prompt, Confirm
from session_state import SessionState
from agent import run_turn
from compression import distill_tier2_to_tier3
import os

console = Console()

def load_project_archive(project_name: str) -> str:
    """Load existing project archive if it exists."""
    archive_path = f".agent/project-context.md"
    if os.path.exists(archive_path):
        with open(archive_path) as f:
            return f.read()
    return ""

def start_new_session(project_name: str, objective: str) -> SessionState:
    session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
    state = SessionState(
        session_id=session_id,
        project_name=project_name,
        session_objective=objective,
    )
    state.project_archive = load_project_archive(project_name)

    if state.project_archive:
        console.print("[green]Project archive loaded from previous sessions.[/green]")
    else:
        console.print("[yellow]No project archive found. Starting fresh.[/yellow]")

    return state

def end_session(state: SessionState) -> None:
    """Run end-of-session protocol."""
    console.print("\n[bold]Running end-of-session protocol...[/bold]")

    # Final Tier 1 → Tier 2 compression
    if state.recent_turns:
        console.print("Compressing remaining turns...")
        from compression import compress_tier1_to_tier2
        compress_tier1_to_tier2(state)

    # Tier 2 → Tier 3 distillation
    if state.rolling_summary:
        console.print("Distilling session into project archive...")
        distill_tier2_to_tier3(state)

    # Save final state
    state.save()

    # Print session report
    console.print(f"\n[bold green]Session Complete[/bold green]")
    console.print(f"  Turns: {state.turn_count}")
    console.print(f"  Total input tokens: {state.total_input_tokens:,}")
    console.print(f"  Total output tokens: {state.total_output_tokens:,}")
    console.print(f"  Total cost: ${state.cost_usd:.4f}")
    console.print(f"  Archive updated: .agent/project-context.md")

def main():
    console.print("[bold blue]Agentic Coding Session with Context Compression[/bold blue]\n")

    project_name = Prompt.ask("Project name", default="my-project")
    objective = Prompt.ask("Session objective")

    state = start_new_session(project_name, objective)

    console.print(f"\n[bold]Session {state.session_id} started.[/bold]")
    console.print("Type 'exit' to end the session, 'status' to see context stats.\n")

    while True:
        user_input = Prompt.ask("\n[bold cyan]You[/bold cyan]")

        if user_input.lower() == 'exit':
            if Confirm.ask("End session and run archive distillation?"):
                end_session(state)
            break

        if user_input.lower() == 'status':
            console.print(f"Turns: {state.turn_count}")
            console.print(f"Recent turns in buffer: {len(state.recent_turns)}")
            console.print(f"Rolling summary: {'present' if state.rolling_summary else 'none'}")
            console.print(f"Project archive: {'present' if state.project_archive else 'none'}")
            console.print(f"Session cost so far: ${state.cost_usd:.4f}")
            continue

        run_turn(state, user_input)

if __name__ == "__main__":
    main()

Tip: Add a status command (shown above) that prints context statistics without advancing the session. Use it regularly during long sessions — especially before and after compression cycles — to develop intuition for how your context is growing and how effective each compression is. This habit, more than any other, makes you a better practitioner of context management.


Step 6: Running the Session and Validating Compression

Start a session:

python main.py

Sample session flow demonstrating compression in action:

Session objective: Implement JWT authentication for the user service

> You: Let's start. I need to add JWT auth to our Express API. We'll use jsonwebtoken 
  and store refresh tokens in Redis. No database for token storage.

> Agent: [DECISION]: Use jsonwebtoken library with Redis for refresh token storage 
  because it avoids DB writes on every auth check...

[Turn 12] Compressing context...
Summary: 340 words (was 2,800 words across 12 turns)

> You: status
Turns: 12
Recent turns in buffer: 2
Rolling summary: present (340 words)
Session cost so far: $0.0187

> You: Now add the token refresh endpoint. Remember we're not storing in the DB.
> Agent: [Based on rolling summary] Right, Redis only. Here's the refresh endpoint...
  [MILESTONE COMPLETE]: JWT auth middleware and token issuance complete

The compression at turn 12 reduced 2,800 words of context to 340 words — an 8.2:1 ratio — and the agent correctly recalled the "no DB storage" constraint from the rolling summary in turn 13.

Tip: At the end of your first real session with this system, review the project archive that was produced. Ask yourself: if a new engineer read only this archive, would they have the information they need to continue the project? If yes, your compression system is working. If no, identify what is missing and refine your distillation prompt. Iterate until the archive passes this "new engineer test."


Step 7: Extending the System — Code Skeleton Injection

Add automatic skeleton injection when the agent needs to reference existing files:

def inject_file_context(state: SessionState, file_path: str, 
                         full_content: bool = False) -> str:
    """Add a file to the agent's context, using skeleton by default."""
    with open(file_path) as f:
        content = f.read()

    if full_content or len(content) < 500:
        return f"File: {file_path}\n```\n{content}\n```"
    else:
        from compression import generate_skeleton
        skeleton = generate_skeleton(file_path, content)
        return f"File skeleton: {file_path}\n```\n{skeleton}\n```\n[Full file available on request]"

Usage in a session:

file_context = inject_file_context(state, "src/users/users.service.ts")
user_message = f"{file_context}\n\nPlease add email verification to the registration flow."
run_turn(state, user_message)

Tip: Build a file registry in your session state: a dictionary mapping file paths to their current compression status (full/skeleton) and last-modified timestamp. When the agent modifies a file, invalidate the skeleton and mark the file as "needs regeneration." This prevents the agent from referencing an outdated skeleton for a file it just changed.


Validation Checklist and Performance Benchmarks

Use this checklist to verify your implementation is working correctly:

Functional validation:
- [ ] Rolling summary is updated every chunk_window_size turns
- [ ] Summary token count stays within budget after each compression
- [ ] Decisions from turn 5 are referenced correctly at turn 50
- [ ] Session resumes correctly after a pause (restart the script, load the saved state)
- [ ] Project archive is updated at session end
- [ ] Code skeletons are < 25% of original file size

Performance benchmarks (for a 3-hour / 50-turn session):
- Total input tokens: < 150K (vs. ~500K+ without compression)
- Total cost: < $0.75 (vs. ~$3+ without compression)
- Rolling summary at end of session: < 800 tokens
- Project archive: < 2,000 tokens
- Decision recall accuracy: > 95% (manually verify)

Common issues and fixes:
- Summary grows each cycle: Your merge prompt is not enforcing the token budget. Add "HARD BUDGET: {n} tokens" and reduce max_tokens in the compression call.
- Agent forgets decisions from early session: Your rolling summary is not being injected as the first message pair. Check build_messages().
- Compression cycles too slow: Switch compression model to claude-haiku-4-5. Compression is a mechanical task.
- Archive distillation drops open questions: Explicitly add "Carry forward all unresolved open questions" to your distillation prompt.

Tip: Run your first 5 sessions manually, reviewing the rolling summary and project archive output at each compression cycle. Treat this as calibration time. Take notes on what the compression gets wrong — decisions that are missing, sections that are too verbose, items that should not have survived to the archive. These notes directly translate into prompt improvements that will pay dividends across every future session.


Summary

You have built a complete, three-tier context compression system for multi-hour agentic coding sessions. The system handles incremental compression (Tier 1 → Tier 2) automatically during the session, performs code skeleton injection to compress file context, supports diff summarization for change review, and runs session-boundary distillation (Tier 2 → Tier 3) to maintain a persistent project archive. The architecture is model-agnostic, language-agnostic, and role-adaptable — engineers, QA engineers, and product managers can all use the same framework with different prompt templates for their specific session types.

The key discipline: context compression is not an emergency response to a full context window. It is a proactive, engineered system that runs continuously, producing better-quality agent outputs at lower cost from the first turn to the last.