A 400-line monolithic agent is easy to write and hard to trust. Here's how I broke one into skills, sub-agents, and a sandbox — and measured that it got better.
StockPilot is an inventory-management agent for a mid-size outdoor-gear retailer. It works across three warehouses (East, West, Central) and a catalog of ~250 SKUs. Its job: watch stock levels, forecast demand, decide when and how much to reorder, pick the right supplier, place the purchase order, and tell the operations team what happened.
None of those steps is hard in isolation. The difficulty is that they interlock — a reorder decision needs a forecast, which needs sales history; a supplier choice needs price and lead time and reliability, plus a pile of supplier-specific quirks that live in someone's head, not in the catalog. The natural first instinct is to pour all of that knowledge into one agent's system prompt and give it every tool it might need. That instinct is exactly what gets you in trouble.
The first version — in agents/before/ — is a single agent with a ~400-line system prompt and 12 tools. It works, mostly. But three failure modes appear that anyone who has shipped an agent will recognize:
Decomposition means splitting the one-big-agent along its natural seams. There are four levers, and the decomposed StockPilot (agents/starter/) pulls all four:
Less prompt, fewer tools, better results — because each piece now does one thing and can be reasoned about on its own.
A skill is a piece of methodology written as markdown: "here is how you decide a reorder quantity," "here is how you pick a supplier, including the quirks that aren't in the catalog." StockPilot has five: reorder-policy, supplier-selection, forecasting, notify-templates, and weekly-report.
The coordinator does not carry all five in its prompt. It carries a one-line index of what each skill is for, and a single tool to load one on demand:
@tool
def load_skill(name: str) -> str:
"""Load a methodology skill on demand. Call this BEFORE doing the kind
of work the skill governs (reorder math, supplier choice, forecasting
approach, notification format, weekly report)."""
if name not in SKILL_INDEX:
return f"unknown skill '{name}'. Available: {', '.join(SKILL_INDEX)}"
return load_skill_content(name) # reads ./skills/<name>.md, or S3
Two things make this better than prose-in-a-prompt. First, cost and focus: methodology is only in the context window when the task actually needs it. Second, and more important for production, auditability. Each skill is a file. Put it in an S3 bucket with versioning enabled and you have a controlled document: a domain expert can review reorder-policy.md, approve a version, and you can answer "which version of the policy did the agent use on this date?" with a bucket version ID.
"The methodology is a versioned, signed-off artifact, and every use is logged" wins procurement reviews. Every load_skill call shows up in the trace, so you can prove the agent consulted the policy before acting. "It's somewhere in a 400-line prompt" does not.
The monolith had 12 tools. The decomposed coordinator has 8 direct tools — pure data reads and side-effecting actions — plus two higher-order tools (load_skill and forecaster). What got removed were tools that were really methodology in disguise.
| Concern | Monolith (12 tools) | Decomposed (8 tools) |
|---|---|---|
| Read inventory | get_stock_level, list_low_stock, get_metric (overlapping) | get_stock_level, list_low_stock only |
| Forecasting | forecast_demand wrapping an inline prose subagent | forecaster sub-agent (own context + sandbox) |
| Supplier choice | compare_supplier_quotes wrapping a prose subagent | get_supplier_catalog + supplier-selection skill |
| Writing messages | send_slack_alert wrapping a writing subagent | Coordinator writes inline, guided by notify-templates |
In Strands, a tool is just a typed Python function with a docstring; the framework generates the schema. So "fewer, clearer tools" is also "less code." Tool selection becomes more reliable because there are fewer near-duplicate options for the model to disambiguate.
The forecaster is the clearest case for isolation. Forecasting needs 90 days of daily sales history. If the coordinator does that work itself, all that history lands in its context window — for a task whose final output is a single number. That is the context-bloat failure mode, just relocated.
So the forecaster becomes its own Strands agent, exposed to the coordinator as a tool (the "agents-as-tools" pattern). It has its own context window and its own sandbox. The coordinator calls it with a SKU; it returns a compact structured result:
@tool
def forecaster(sku: str, context_note: str = "") -> str:
"""Delegate a 30-day demand forecast to the forecasting sub-agent.
Use this instead of reasoning over raw sales rows yourself."""
sub = Agent(
model=make_model(max_tokens=1500),
system_prompt=FORECASTER_SYSTEM, # "compute, don't reason in prose"
tools=[run_python], # its own sandbox tool
)
return str(sub(task)) # returns JSON: {forecast_qty, confidence, ...}
If a step has its own large, short-lived context that's irrelevant to the outer task, give it its own agent. Forecasting qualifies — 90 days of sales data is irrelevant after the forecast number arrives. Sending a Slack message does not — the isolation overhead would exceed the benefit.
The forecaster doesn't eyeball numbers; it writes a small pandas script and runs it. Running arbitrary model-generated code on your own host is how you end up with a bad evening, so it runs in a sandbox.
The production sandbox is Bedrock AgentCore Code Interpreter — a managed, serverless service. You call it from the SDK; AWS runs the code in an isolated microVM, server-side, per session. No cluster to operate — it works from a laptop, a Lambda, or a container.
def run_python(code: str, data_files: list[str]) -> str:
"""Execute code in a sandbox with the named CSVs available in CWD."""
if _use_agentcore(): # AGENTCORE_CODE_INTERPRETER=1
return _run_agentcore(code, data_files) # managed microVM
return _run_local(code, data_files) # local subprocess fallback
The seam is the point: for a customer who needs everything inside their own VPC, you swap the backend for a container job that reads data from S3, and the agent logic does not change. Only the execution boundary moves.
When AgentCore is enabled but the SDK or permission is missing, the forecaster returns a loud AGENTCORE_ERROR: rather than silently falling back to local. You should always know which boundary your code actually ran in.
Decomposition is a claim — "this is better." A claim needs a measurement. The repo ships a 12-task suite (evals/tasks.yaml) that runs the same prompts against either agent and grades the result. Nine are regression tasks (R1–R9), three are forecasting tasks (F1–F3). Each task names a grader — and the grader choice is where the real thinking is.
uv run evals --compareexact_match)"What's the current stock level for SKU-0042 at WH-EAST?" The canary task: one tool call, one number. If R1 fails, something is fundamentally broken. In a clean run it passes in two turns.
action_taken)"Place a purchase order for 200 units of SKU-0017 from the cheapest in-stock supplier." This grades a side effect, not text. Every PO is appended to a per-run JSONL sink; the grader checks that a PO record exists for the right SKU and quantity. This is how you test that an agent actually did the thing rather than just describing it convincingly.
numeric_tolerance)"Give me a reorder recommendation for SKU-0042." The hardest task: chains current stock, sales velocity, a forecast, the safety-stock formula, and MOQ rounding. No single correct integer exists — so the grader passes the agent if its number lands within ±30%. Tolerance graders are how you test judgment-shaped outputs without pretending they're exact.
# run the full suite, both agents, side by side
uv run evals --compare
Because the agent is "just" a Strands program calling Bedrock, the deployment surface is ordinary AWS — not a bespoke runtime:
bedrock:InvokeModel* · bedrock-agentcore:*CodeInterpreter* · s3:GetObject on the skills bucketThe audit story falls out of using native AWS: CloudTrail records every Bedrock and AgentCore call; S3 versioning records every skill change; and the per-run action sinks give you a structured record of every PO, ERP write, and notification the agent produced.
The decomposition has obvious extension points — each a clean addition rather than a rewrite:
The throughline: every one of these is a part you can add, version, test, and reason about on its own. A monolith grows by accretion into something no one fully understands. A decomposed system grows by composition into something you can still explain — to a teammate, to an auditor, to yourself six months later.
The full repository — both agents, five skills, the sandbox abstraction, the eval harness, and the S3 setup script — is on GitHub: github.com/laghao/stockpilot-bedrock. Runs on Amazon Bedrock with Claude Sonnet 4. MIT licensed.