Home / Articles

Context Engineering in Practice: A Claude Agent SDK Research Agent

Build a Python research agent with the Claude Agent SDK using write, select, compress and isolate patterns, then compare it against a naive baseline in measured runs.

Publisher: REACTAPP.TOP20 min read4633 words

This article is published in English.

Sources: Context Engineering in Practice: Building a Production AI Agent with the Claude Agent SDK

A research agent seldom stumbles on one hard prompt. What hurts is the pile-up. Over a single investigation, the context fills with instructions from the user, a plan, search queries, downloaded pages, tool schemas, source metadata, half-formed claims, reports from helpers and earlier turns. Leave all of that active and good evidence ends up fighting the agent's own backlog for attention.

Here the Write, Select, Compress and Isolate framework becomes a working Python architecture on the Claude Agent SDK. A single agent gets improved one decision at a time, and every boundary prompts the same question: what must the model see at this moment, and what can remain outside it? The goal is a research agent you can run, not a catalogue of SDK features.

The build proceeds in ten steps:

  1. Define what's being built.
  2. Establish a naive baseline.
  3. Write: move working state out of the conversation.
  4. Select: assemble a bounded working set.
  5. Compress: make long sessions recoverable.
  6. Isolate: delegate focused investigations.
  7. Isolate heavy tool outputs in the environment.
  8. Carry context across sessions.
  9. Assemble the context-engineered agent.
  10. Compare the two architectures.

What gets built

Given a question, the agent plans its research, searches primary sources, hands narrow investigations to helpers, logs evidence and finally writes a cited report. The example task: compare the main long-term memory approaches used in production AI agents and explain what each costs. It needs papers, implementation docs, repository evidence and citation checks, so it's broad enough to be a real test.

The lead agent keeps the plan and writes the final synthesis. Curated working state lives in files, retrieval caps the amount of knowledge coming in, and subagents pursue small questions inside their own contexts. None of this is built into the SDK. It supplies the model and the tool loop, and the application chooses where state sits and how much passes each boundary.

The first figure shows the starting system: one lead agent wired to tools, knowledge, a scratchpad and subagents. The remainder of the build changes how information moves across those connections.

Start by creating an environment and installing a pinned SDK version before running any module.

# terminal
python3 -m venv .venv
source .venv/bin/activate
python -m pip install claude-agent-sdk==0.2.139
export ANTHROPIC_API_KEY="your-api-key"

The layout keeps agent configuration apart from workspace operations, custom tools, hooks and measurement. That split matters because each context operation will alter one boundary without requiring a whole new application.

# code/
research_agent/
  config.py       # naive and engineered ClaudeAgentOptions
  hooks.py        # pre-compaction checkpoint
  metrics.py      # message-stream and context measurements
  runner.py       # repeated runs and comparison
  tools.py        # in-process MCP tools
  workspace.py    # scratchpad and bounded retrieval
knowledge/
  memory_approaches.json
tests/
CLAUDE.md

Context engineering starts with assigning ownership. The conversation holds the current decision, while files, tools, retrieval systems and subagents hold everything that doesn't need to be active at the moment.

The naive baseline

The smallest Agent SDK program hands the control loop to query(). Claude gets the prompt, decides whether a tool is needed, observes the result and repeats until it returns an answer or hits a configured limit. Because the SDK provides this loop, the application needn't write its own tool-call parsing and dispatch.

# research_agent/minimal.py
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query

QUESTION = "Compare approaches for long-term memory in production AI agents."

async def main() -> None:
    options = ClaudeAgentOptions(
        model="sonnet",
        allowed_tools=["WebSearch", "WebFetch"],
        permission_mode="dontAsk",
        max_turns=20,
    )
    async for message in query(prompt=QUESTION, options=options):
        if isinstance(message, ResultMessage):
            print(message.result or "")

asyncio.run(main())

That short program conceals something: query() runs the loop, but the application still decides what enters it. The runnable baseline in research_agent/config.py spells those choices out.

# research_agent/config.py
def naive_options(*, run_root, server, model, max_budget_usd):
    tools = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]
    return ClaudeAgentOptions(
        cwd=run_root,
        model=model,
        tools=tools,
        allowed_tools=[*tools, "mcp__research__*"],
        permission_mode="dontAsk",
        mcp_servers={"research": server},
        strict_mcp_config=True,
        setting_sources=[],
        system_prompt={
            "type": "preset",
            "preset": "claude_code",
            "append": NAIVE_PROMPT,
        },
        env={"ENABLE_TOOL_SEARCH": "false"},
        max_turns=20,
        max_budget_usd=max_budget_usd,
    )

In the baseline there's no Write tool, no Agent tool, no agents setting, no hook and no project instruction file. The lead has to complete the whole investigation inside one conversation. Setting ENABLE_TOOL_SEARCH=false also keeps MCP schemas from being deferred. The system prompt finishes the policy by telling the agent to call the whole-corpus tool, hold findings in the conversation, and neither write workspace notes nor delegate.

The whole-corpus tool is deliberately tiny, because the problem lies in what it returns and not in how it's implemented.

# research_agent/tools.py
@tool(
    "load_knowledge_corpus",
    "Return the entire local memory-research collection. Intended only for the naive baseline.",
    {},
    annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
)
async def load_knowledge(_: dict[str, Any]) -> dict[str, Any]:
    return _text_result(load_corpus(corpus_path))

When Claude calls it, the SDK emits the result as a UserMessage holding a ToolResultBlock. The "user" there is a protocol role and not another human message. That block joins the history passed to every later model step, so one web search, three fetched pages and the full corpus produce a repeating sequence like this.

UserMessage(question)
AssistantMessage(ToolUseBlock: load_knowledge_corpus)
UserMessage(ToolResultBlock: entire corpus)
AssistantMessage(ToolUseBlock: WebSearch + WebFetch)
UserMessage(ToolResultBlock: raw search and page content)
AssistantMessage(final report)

Nothing about that sequence is wrong in itself. It's naive because the application never turns raw results into curated external state and never removes them from the active trajectory.

The second figure shows the naive context collecting instructions, tool definitions, raw pages, findings and conversation history inside a single window. The growth comes from the application's policy and not from any defect in the SDK loop.

Tool search is on by default for most Agent SDK setups, with documented exceptions. A fair baseline therefore has to switch it off on purpose if the experiment means to preload the whole tool surface. As a control, the naive design is useful because it strips out every context-management decision that later steps will test.

Before spending time and tokens on a comparison, a smoke test sent one constrained request through the same configuration. Its captured output showed that authentication, routing and model resolution all worked.

# Captured output: python scripts/smoke_test.py
subtype=success
result=OPENROUTER_OK
models=claude-sonnet-5

The full baseline then ran three times, with results sitting beside the engineered trials in the final comparison. The first trial makes the accumulation concrete.

# Captured output: python scripts/show_naive_trial.py ../measurements/comparison.json
Naive trial 1
SDK result: success
Assistant steps: 4
Tool calls: 6
  WebFetch: 3
  WebSearch: 1
  mcp__research__load_knowledge_corpus: 1
  mcp__research__search_knowledge: 1
Final active context: 16,478 tokens
Final tool-result payload: 6,851 tokens
Cumulative tree input: 138,182 tokens
Estimated cost: $0.754

The last three values measure different things. The figure of 16,478 is the final active lead context, and 6,851 of those tokens came from tool results. The 138,182 is cumulative input over all four assistant steps, counting cache creation and cache reads, so it doesn't mean the agent held a 138,182-token window at one time. The distinction matters once subagents join the comparison.

A naive agent is only a fair control if conveniences such as tool search are explicitly turned off. Otherwise current SDK defaults can quietly make the baseline smarter than intended.

Write: scratchpad files

The first change adds a scratchpad backed by the filesystem. plan.md holds the question, the research dimensions, what's done and what remains. research_notes.md collects claims, evidence and contradictions, sources.json carries citation metadata, and open_questions.md makes loose ends visible. Bulky source material goes into an artifacts folder.

# research_agent/workspace.py
def initialize_workspace(root: Path, question: str) -> Path:
    workspace = root / "workspace"
    workspace.mkdir(parents=True, exist_ok=True)
    (workspace / "artifacts").mkdir(exist_ok=True)
    (workspace / "checkpoints").mkdir(exist_ok=True)

    for name, template in WORKSPACE_FILES.items():
        path = workspace / name
        if not path.exists():
            path.write_text(template.format(question=question), encoding="utf-8")
    return workspace

Because these files are durable, the agent can update them after each research step and read just the one it needs next. A crash or a compaction event leaves the curated plan and evidence intact, and a person can read the state directly without piecing it together from a transcript.

The third figure comes from an offline script that initializes the workspace and prints the exact files the engineered agent uses as its scratchpad.

A session serves another purpose. The session docs describe it as the store for prompts, tool calls, tool results and responses needed to carry on a conversation. The scratchpad instead holds the application's own curated research state. Resuming a session brings back a trajectory, whereas reading plan.md brings back a deliberately maintained view of the job.

That distinction prevents two common mistakes. If the transcript is the only database, recovery depends on an ever-longer history. If the scratchpad becomes a transcript dump, the same context problem is recreated on disk. So each file gets a narrow schema and holds conclusions, evidence and outstanding work, not copied conversations. Session history records how the agent got here, and the scratchpad keeps the small curated state needed to decide what happens next.

Select: a bounded working set

Outside state can outgrow what any single model call should take in. Selection fixes that by loading according to phase: planning reads plan.md, synthesis reads research_notes.md, and building citations reads sources.json. Tools and knowledge follow the same principle, since even their metadata can eat a meaningful part of the prompt.

On the tool side, the engineered setup enables tool search. Anthropic's documentation states that a catalog may contain as many as 10,000 tools, with up to five relevant ones returned per search unless changed. It covers MCP tools and custom SDK MCP tools, so a big catalog can stay outside the context until Claude requires a particular schema. With roughly ten tools or fewer, the docs say loading everything up front is often faster, which makes deferred loading a tool for scale rather than a guaranteed gain.

For knowledge, a custom search_knowledge tool sits on SQLite FTS5. The handler takes a query and a limited top_k and hands back cited passages instead of the full collection. Custom tools in the SDK run through an in-process MCP server, so the example stays self-contained yet crosses the same boundary an external service would.

# research_agent/tools.py
@tool(
    "search_knowledge",
    "Search the local memory-research collection and return only the most relevant cited passages.",
    {
        "type": "object",
        "properties": {
            "query": {"type": "string", "minLength": 1},
            "top_k": {"type": "integer", "minimum": 1, "maximum": 10},
        },
        "required": ["query", "top_k"],
        "additionalProperties": False,
    },
    annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
)
async def search_knowledge(args: dict[str, Any]) -> dict[str, Any]:
    try:
        return _text_result(
            search_corpus(corpus_path, args["query"], args["top_k"])
        )
    except (KeyError, TypeError, ValueError, sqlite3.Error) as error:
        return _error_result(error)

The prompt requests top_k=5 while the schema allows anything from one to ten. Policy and hard limit are kept apart this way: retrieval quality can be tuned later without altering the tool contract, and a stray request for thousands of records fails before it can flood the context.

The fourth figure shows file reads, tool search and knowledge retrieval as three ways of selecting different kinds of context, all ending in the same result: a bounded working set for the current step.

An offline test runs the real search code and confirms that five acts as a ceiling, that every returned item keeps its source URL, and that bad top_k values are rejected. It says nothing about semantic retrieval quality because the fixture is tiny. In production, that judgment belongs with the retriever, backed by judged queries and recall metrics, instead of being guessed from how the final prose reads. Pushing data out of the conversation frees room, yet selection determines whether the agent can still find the right five items when they become relevant.

Compress: recoverable long sessions

Selection governs what comes in, yet a long session keeps accumulating old exchanges. As the context limit approaches, the Agent SDK compacts the history on its own: older messages become a summary, and recent turns and important state are retained. A SystemMessage with subtype compact_boundary appears in the stream, which lets the measurement code count each time it happens.

A generic summary may drop precisely the facts a research job relies on. The project therefore puts research-specific retention rules into CLAUDE.md: preserve the original question, verified findings, open questions, contradictions, source URLs, major decisions and the present plan. The file steers what the summary favors, and the whole scratchpad doesn't have to ride along on every call.

Before any compaction, automatic or manual, a PreCompact hook saves a checkpoint. When the SDK transcript is available it gets copied, and metadata notes the trigger, the session, the working directory and where the transcript came from.

# research_agent/hooks.py

def build_precompact_hook(workspace: Path):
    async def archive_before_compaction(
        input_data: dict[str, Any],
        tool_use_id: str | None,
        context: Any,
    ) -> dict[str, Any]:
        del tool_use_id, context

        checkpoint_dir = workspace / "checkpoints"
        checkpoint_dir.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        session_id = input_data["session_id"]
        trigger = input_data["trigger"]
        stem = f"{timestamp}-{session_id}-{trigger}"
        transcript = Path(input_data["transcript_path"])

        metadata = {
            "session_id": session_id,
            "trigger": trigger,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "source_transcript": str(transcript),
            "custom_instructions": input_data.get("custom_instructions"),
            "archived": transcript.is_file(),
        }
        if transcript.is_file():
            shutil.copy2(transcript, checkpoint_dir / f"{stem}.jsonl")
        (checkpoint_dir / f"{stem}.json").write_text(
            json.dumps(metadata, indent=2) + "\n", encoding="utf-8"
        )
        return {}

    return archive_before_compaction

The fifth figure shows compaction shrinking accumulated conversation into a summary that respects the preservation rules, while the checkpoint and scratchpad stay as independent records in the environment.

Scratchpads and compaction answer different needs. The scratchpad is state the agent writes on purpose along the way, organized for later reading. Compaction is a lossy squeeze of accumulated chat that happens because the active history has grown too big. With both in place, a compacted session can fall back on sources.json or research_notes.md if the summary lost something.

An offline test fed the hook a temporary transcript and confirmed three things: the checkpoint appeared, the transcript was copied and the trigger metadata survived. In the six live comparison runs no compact_boundary ever showed up, since the biggest lead context seen was 34,309 tokens against a reported 200,000-token window. So the experiment confirms the hook works and tells nothing about how good live compaction is. The summary should carry decisions and unresolved work, and the scratchpad should hold the evidence in a re-readable form.

Isolate: focused subagents

Independent lines of inquiry are simpler to manage when they don't share one ever-growing trajectory. After the lead agent drafts the plan, three named subagents take paper analysis, repository evidence and citation checking. Each definition limits the task, the tools and the shape of the handoff.

# research_agent/config.py
"paper-researcher": AgentDefinition(
    description="Analyzes primary research papers for memory mechanisms and trade-offs.",
    prompt=(
        "Investigate only the assigned paper question. Use primary sources. "
        "Return at most five findings, each with a URL and an explicit limitation."
    ),
    tools=["WebSearch", "WebFetch", "mcp__research__search_knowledge"],
    model=model,
),

In code, subagents are declared with AgentDefinition. Per Anthropic's subagent docs, each starts in a fresh conversation with its own context, keeps its intermediate tool calls and results there, and sends only a final message back to the parent. The handoff is therefore a true context boundary, not just a convention inside the lead's existing window.

The sixth figure shows each specialist receiving a focused task and a fresh window. The lead sees the concise handoff and none of the searches, fetched pages or extraction steps that produced it.

Isolation costs something. Three subagents may rediscover the same sources, consume more tokens and settle on clashing terminology. To contain that, the prompts demand primary URLs, stated limitations and no more than five findings. The fact-checker only sees disputed claims and not the whole brief, which keeps its remit auditable and cuts the risk of it quietly rewriting the synthesis.

A parent has to be able to evaluate what comes back. Five lines without source identifiers are compact but unusable, and a full child transcript wipes out the boundary. The contract here returns findings, citations, version constraints and limitations, the fields the lead needs when weighing claims against each other. Hiding the child conversation isn't enough on its own, because isolation works only if the return carries evidence and uncertainty.

Isolate heavy tool outputs

Certain payloads ought to cross the tool boundary as references only. materialize_source writes a chosen document into workspace/artifacts/ and gives back its path, size in bytes and a brief preview. Later Claude can open a relevant range using file tools, so the whole document never lands in one tool result.

# research_agent/workspace.py (full function; use the Gist when publishing)
def materialize_source(corpus_path: Path, workspace: Path, source_id: str) -> dict:
    document = next(
        (item for item in load_corpus(corpus_path) if item["source_id"] == source_id),
        None,
    )
    if document is None:
        raise ValueError(f"unknown source_id: {source_id}")

    path = safe_artifact_path(workspace, f"{source_id}.txt")
    content = (
        f"Title: {document['title']}\n"
        f"URL: {document['url']}\n"
        f"Published: {document['published']}\n\n"
        f"{document['text']}\n"
    )
    path.write_text(content, encoding="utf-8")
    return {
        "path": str(path),
        "characters": len(content),
        "preview": content[:240],
    }

The SDK has a similar safety net for MCP results. Anthropic's MCP documentation flags output over 10,000 tokens and sets 25,000 tokens as the default ceiling; oversized text can be written to disk and swapped in context for a file reference. That default is a backstop. Returning paths and metadata deliberately gives the application a steady contract well before reaching the emergency limit.

The seventh figure comes from a deliberate failure test that tries to escape the artifact directory with ../outside.txt. The workspace rejects the path before anything is written.

Environment isolation creates a security boundary too. A model-controlled artifact name must never become an arbitrary filesystem path. safe_artifact_path accepts one conservative filename and checks that the resolved destination stays inside workspace/artifacts/. The observed failure looked like this.

# Captured output: python scripts/demonstrate_failure.py
Blocked artifact path: artifact name must use only letters, numbers, dots, underscores, or hyphens

The check makes no statement about operating-system sandboxing or multi-tenant isolation. It confirms one narrow rule: a tool argument can't use path traversal to write beyond the artifact directory. Real deployments still need process-level permissions and separate workspaces for each user or run. And swapping a big payload for a path only saves context when the path is constrained and the agent can pull a bounded slice later.

Carry context across sessions

Long jobs outlast processes as well as context windows. The SDK offers three options: continue the latest conversation, resume a particular session by ID, or fork a session into a branch. Continue is for one ongoing line of work, resume is explicit recovery, and fork fits a follow-up that should inherit earlier context without altering the original session's future.

# examples/session_modes.py
def session_options(session_id: str) -> dict[str, ClaudeAgentOptions]:
    return {
        "continue": ClaudeAgentOptions(continue_conversation=True),
        "resume": ClaudeAgentOptions(resume=session_id),
        "fork": ClaudeAgentOptions(resume=session_id, fork_session=True),
    }

Each option keeps conversational state: prompts, tool calls, results and responses. A fork is issued a fresh session ID while the original remains. That suits a request such as "Now compare these findings specifically for coding agents", since the new branch can build on the existing research trajectory.

The eighth figure contrasts the modes: continue extends the current trajectory, resume restores a chosen one, and fork starts a new branch from the same checkpoint.

Files on disk need their own handling. Branches created by a fork share a working directory unless the application gives them another, so both could overwrite plan.md or final_report.md. The runner dodges collisions by making a unique run root from a UTC timestamp and a random suffix, and a production fork ought to copy or version the scratchpad into a new run root before either branch writes. Keep in mind that a session ID names conversational history. It isn't a transaction ID for the outside systems the agent touched, so databases, object stores, search indexes and workspaces each need their own lineage and concurrency policy. The file layout keeps that visible and doesn't hide it behind resume behavior. A fork separates only the conversation, while external state stays shared until the workspace is versioned or split too.

Assembling the context-engineered agent

The finished setup combines all four operations and adds no further orchestration framework. Write is the structured workspace. Select is phase-based file reading, deferred tool schemas and bounded knowledge retrieval. Compress pairs SDK compaction with retention instructions and a checkpoint taken just before it. Isolate means scoped subagents and tool outputs stored in files.

# research_agent/config.py
tools = ["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch", "Agent"]
return ClaudeAgentOptions(
    cwd=run_root,
    model=model,
    tools=tools,
    allowed_tools=[*tools, "mcp__research__*"],
    permission_mode="dontAsk",
    mcp_servers={"research": server},
    strict_mcp_config=True,
    setting_sources=["project"],
    system_prompt={"type": "preset", "preset": "claude_code", "append": ENGINEERED_PROMPT},
    env={"ENABLE_TOOL_SEARCH": "true"},
    hooks={"PreCompact": [HookMatcher(hooks=[build_precompact_hook(workspace)])]},
    agents=research_subagents(model),
    max_turns=30,
    max_budget_usd=max_budget_usd,
)

Setting setting_sources=["project"] lets the run-specific CLAUDE.md join the configuration, and strict_mcp_config=True restricts the SDK to the server this application supplies. The permission_mode is dontAsk only because the example targets unattended, isolated runs, and it isn't a general permission recommendation. Advanced approval flows and deployment policy are left out of scope on purpose.

In the ninth figure, the completed architecture governs four boundaries: durable writes, selective reads, compaction of the conversation and isolated execution. The lead model receives only a focused active context.

The final runner relies on ClaudeSDKClient and skips the lighter query() helper. They share the same agent loop, but the client offers an explicit response boundary and a closing get_context_usage() call. Instrumentation drove that choice, and the architecture stays as it was.

The first engineered attempt, driven by prompts alone, revealed another production boundary. All three SDK runs finished, but just one produced final_report.md; two lead agents returned while delegated work was effectively unfinished. A clean SDK exit, then, doesn't mean the application's task is complete. The remedy was to make subagent calls explicitly synchronous and give the controller a completion contract of its own: it looks for a substantial report containing at least two URLs and allows a single focused repair query inside the same session.

# research_agent/runner.py
while True:
    async for message in client.receive_response():
        metrics.observe(message)

    report_path = workspace / "final_report.md"
    if mode == "naive" or _report_meets_contract(report_path):
        break
    if metrics.completion_retries >= MAX_COMPLETION_RETRIES:
        break

    metrics.completion_retries += 1
    await client.query(COMPLETION_REPAIR_PROMPT)

The failed prompt-only result stays in measurements/initial-prompt-only-comparison.json, since a controller should verify durable outcomes and not infer them from a model's final status.

The offline test suite handles everything that needs no model: both option sets, workspace creation, bounded retrieval, rejection of bad paths, compaction checkpoints and message accounting. This is what it reported in the stated environment.

# Captured output: python -m unittest discover -s tests -q
----------------------------------------------------------------------
Ran 12 tests in 0.048s

OK

At that point the framework is operational: every verb is tied to a boundary that has its own code, an owner for its state and an invariant that a test can check.

Comparing the two architectures

Claims about architecture should become numbers. RunMetrics observes the SDK message stream, removes duplicate assistant steps at the top level using message IDs, counts every tool call and each compaction boundary, tallies characters in tool results and asks for a final context snapshot. Cumulative model usage over the entire agent tree, subagents included, comes from ResultMessage.model_usage, while total_cost_usd is a local estimate and no billing statement.

# research_agent/metrics.py
if isinstance(message, ResultMessage):
    self.query_results += 1
    self.session_id = message.session_id
    self.result_subtype = message.subtype
    result = message.result or ""
    self.sdk_success = (
        message.subtype == "success"
        and bool(result.strip())
        and "not logged in" not in result.lower()
    )
    self.estimated_cost_usd = (
        (self.estimated_cost_usd or 0.0) + (message.total_cost_usd or 0.0)
    )
    self.result_usage = message.usage
    self.model_usage = self._merge_model_usage(self.model_usage, message.model_usage)
    self.total_tree_input_tokens = self._tree_input_tokens(self.model_usage)

if isinstance(message, SystemMessage) and message.subtype == "compact_boundary":
    self.compactions += 1

Every architecture was run three times, independently, on one shared question, with identical SDK, model alias, provider and local knowledge fixture. The per-run records and the derived means live in measurements/comparison.json, and a small display script regenerates the compact table from that file.

# terminal
python scripts/show_comparison.py ../measurements/comparison.json

The tenth figure charts three-run means and reveals a genuine trade-off. Isolation shrank the final tool-result payload that the lead saw, but the subagent tree consumed far more tokens and estimated cost. The captured output of the display script follows.

# Captured output: python scripts/show_comparison.py ../measurements/comparison.json
Measured comparison - 3 runs per architecture
Metric                               Naive      Engineered
Artifact success                       3/3             3/3
SDK success                            3/3             1/3
Mean tree input                    102,210         737,036
Mean peak context                   17,184          32,668
Final tool-result tokens             7,351           6,202
Mean subagents                           0               3
Mean estimated cost                 $0.663          $3.270
Compactions                              0               0

Success comes in two flavors. Artifact success means the architecture's output contract was met: for the naive agent a returned report citing at least two distinct URLs, for the engineered agent a durable final_report.md of 500 or more characters with two URLs. All six runs got there.

SDK success requires that the final ResultMessage subtype be success. Naive runs managed that three times out of three, engineered runs once out of three. Two engineered runs produced cited reports yet finished as error_max_budget_usd, and one of those used the controller's single repair query. Anthropic's budget example explains that spending is checked after an API call, so a run may complete its artifact and still pass the configured estimate before the terminal result arrives. Treat the cap as a guardrail, not as a signal that the application is done.

The numbers:

  • The lead received less. Lead-visible tool-result characters averaged 28,572 for the baseline and 23,428 once isolated, an 18.0% decline, and the tool-result share of the final context snapshot went from 7,351 to 6,202 tokens (15.6% lower). Exactly three subagents ran in every engineered trial, and their interim searches never entered the lead's trajectory.
  • Overall cost climbed steeply. Total input across the tree averaged 102,210 tokens at first and 737,036 afterward, a 621.1% increase. Peak lead-context input averaged 17,184 and then 32,668, which is 90.1% higher, and the SDK's cost estimate moved from $0.663 to $3.270, a 393.4% jump.
  • Tool activity grew too: about 30.3 calls across the tree, versus six in the baseline. For a bounded task like this, three specialists, their schemas and the handoffs between them cost more than the context they spared the lead.

Compaction was never exercised, since every run reported zero boundaries. That doesn't show preservation-aware compaction is ineffective. It shows this task never came near the 200,000-token window. The experiment also doesn't isolate the value of tool search, because the MCP catalog was small and deferred discovery had little room to repay its selection overhead.

What the results do and don't say

Nothing here amounts to a blanket case against context engineering. The data shows why the four operations should be used selectively. Writing files and bounded retrieval produce durable state that can be inspected. Isolation pays off when a child's trajectory would otherwise pollute a lead context that's already genuinely long, and it's never free. At this task size a single lead with a scratchpad may well be the better production design, unless gains in specialist quality justify the added spend.

What's left is a working artifact with a context architecture, a failure seen locally and a clear evaluation boundary. A sensible follow-up question concerns quality: is the material being written, selected, compressed and isolated actually the right material? Answering takes task-level evals for evidence coverage, contradiction handling, citation validity and recovery after compaction. On this task isolation trimmed the lead's tool-result payload yet multiplied tokens and cost for the whole tree, so context engineering has to be measured as a system and not presumed to be an optimization.