Extend the Harness: State, Checkpoints, Tracing, and Intervention
Hands-on: add structured state, checkpoint/resume with idempotency, tracing, and an approval gate
Learning Objectives
- Add structured state management that tools can read and write across turns
- Implement checkpoint/resume with idempotency to survive crashes
- Build a tracing system with structured JSON events for observability
- Create an approval gate for human-in-the-loop intervention
Overview
In Lesson 9, you built a minimal harness with four concerns: loop internals, tool dispatch, context assembly, and loop controls. That harness runs, but it's fragile. It has no memory between tool calls, it can't recover from crashes, and it offers no visibility into what it's doing.
In this lesson, you'll extend it with four production-critical features:
graph TD
subgraph Minimal["Minimal Harness (Lesson 9)"]
AL["Agent Loop"]
TR["Tool Registry"]
CA["Context Assembler"]
LC["Loop Controller"]
end
subgraph Extensions["Extensions (This Lesson)"]
STATE["1. Structured State"]
CKPT["2. Checkpoint/Resume"]
TRACE["3. Tracing"]
GATE["4. Approval Gate"]
end
AL --> STATE
AL --> CKPT
AL --> TRACE
AL --> GATE
style STATE fill:#c6f6d5,stroke:#38a169
style CKPT fill:#c6f6d5,stroke:#38a169
style TRACE fill:#c6f6d5,stroke:#38a169
style GATE fill:#c6f6d5,stroke:#38a169
style Minimal fill:#e9d5ff,stroke:#9f7aea
style Extensions fill:#d1fae5,stroke:#10b981
Each extension builds incrementally. By the end, you'll have a harness that implements 6 of the 8 concerns from Lesson 1.
Pedagogical strategy: Each section introduces one extension in isolation. The final section integrates all four into a complete harness. The case study at the end demonstrates all four working together in a real run with crash-and-resume.
Let's start.
Extension 1: Structured Agent State
Right now, tools are stateless functions. They receive inputs, return outputs, and forget everything. If web_search finds three sources, the next tool call has no way to access them.
Solution: Add an AgentState class — a serializable dict that tools can read and write. Pass it to every tool.
# harness_state.py
import json
from typing import Dict, Any, List
class AgentState:
"""Mutable state that tools can read and write.
Separate from message history. History is the conversation.
State is working memory.
"""
def __init__(self):
self.data: Dict[str, Any] = {}
def get(self, key: str, default=None):
"""Read a value from state."""
return self.data.get(key, default)
def set(self, key: str, value: Any):
"""Write a value to state."""
self.data[key] = value
def to_dict(self) -> dict:
"""Serialize state to a dict."""
return self.data.copy()
def from_dict(self, data: dict):
"""Restore state from a dict."""
self.data = data.copy()
# Modified Tool Registry with State Support
class ToolRegistry:
"""Tool registry that passes state to tool functions."""
def __init__(self):
self.tools: Dict[str, callable] = {}
self.schemas: Dict[str, dict] = {}
def register(self, name: str, fn: callable, schema: dict):
self.tools[name] = fn
self.schemas[name] = schema
def dispatch(self, name: str, input_data: dict, state: AgentState) -> Any:
"""Dispatch a tool call with state access."""
if name not in self.tools:
raise ValueError(f"Tool '{name}' not registered")
# Pass state as an additional argument
# Unpack dict as kwargs with state
tool_fn = self.tools[name]
return tool_fn(state=state, **input_data)
def get_tool_schemas(self) -> List[dict]:
return list(self.schemas.values())
# Updated web_search: stores findings in state
def web_search(state: AgentState, query: str) -> str:
"""Web search that stores results in state."""
# Simulate search results
results = []
if "agent" in query.lower() and "framework" in query.lower():
results = [
{"title": "LangGraph Overview", "url": "https://langchain.com/langgraph", "snippet": "LangGraph is a framework for building stateful agents."},
{"title": "AutoGPT", "url": "https://github.com/Significant-Gravitas/AutoGPT", "snippet": "AutoGPT is an autonomous agent framework."},
{"title": "Claude SDK", "url": "https://docs.anthropic.com/en/sdk", "snippet": "The Claude SDK includes a tool runner for agent loops."}
]
elif "population" in query.lower():
results = [
{"title": "France Population", "url": "https://example.com", "snippet": "France has ~67 million people."}
]
# Store results in state
existing_sources = state.get("search_results", [])
existing_sources.extend(results)
state.set("search_results", existing_sources)
# Return formatted results
return json.dumps({"results": results, "count": len(results)})
# Updated calculator: logs computation history in state
def calculator(state: AgentState, expression: str) -> str:
"""Calculator that logs computations in state."""
try:
result = eval(expression, {"__builtins__": {}}, {})
# Log to state
history = state.get("computation_history", [])
history.append({"expression": expression, "result": result})
state.set("computation_history", history)
return str(result)
except Exception as e:
return f"Error: {e}"
# Updated Harness with State
class Harness:
"""Harness with structured state management."""
def __init__(self, client, registry: ToolRegistry, max_turns: int = 10):
self.client = client
self.registry = registry
self.max_turns = max_turns
self.done_flag = False
self.state = AgentState() # Add state
def assemble_context(self, goal: str, history: List[dict]) -> List[dict]:
system_message = {
"role": "system",
"content": (
"You are a research assistant. Use web_search to find information. "
"Use calculator for math. Call done when finished."
)
}
user_message = {"role": "user", "content": goal}
return [system_message, user_message] + history
def run(self, goal: str) -> dict:
"""Run agent loop with state management."""
history = []
turn_count = 0
print(f"[Harness] Starting with goal: '{goal}'\n")
while not self.done_flag and turn_count < self.max_turns:
messages = self.assemble_context(goal, history)
response = self.client.create_message(messages, tools=self.registry.get_tool_schemas())
turn_count += 1
print(f"\n[Turn {turn_count}]")
assistant_message = {"role": "assistant", "content": []}
tool_results = []
for block in response["content"]:
if block["type"] == "text":
print(f" Assistant: {block['text']}")
assistant_message["content"].append(block)
elif block["type"] == "tool_use":
tool_name = block["name"]
tool_input = block["input"]
tool_id = block["id"]
print(f" Calling: {tool_name}({tool_input})")
# Dispatch with state
result = self.registry.dispatch(tool_name, tool_input, self.state)
print(f" Result: {result}")
assistant_message["content"].append(block)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_id,
"content": result
})
history.append(assistant_message)
if tool_results:
history.append({"role": "user", "content": tool_results})
# Print state after each turn
print(f" State: {json.dumps(self.state.to_dict(), indent=2)}")
return {
"turns": turn_count,
"state": self.state.to_dict(),
"history": history
}
What changed:
AgentStateclass: simple key-value store withget(),set(), and serialization methodsToolRegistry.dispatch(): now passesstateas the first argument to every toolweb_search: stores search results instate.data["search_results"]calculator: logs computation history instate.data["computation_history"]Harness: owns the state object and prints it after each turn
Why this matters: Tools can now share data. A web_search → summarize → send_email sequence can pass findings through state without encoding them in the conversation.
Run it: Search for "AI agent frameworks" and inspect the state. You'll see all three sources stored in search_results.
Extension 2: Checkpoint and Resume with Idempotency
What happens if the harness crashes mid-run? All progress is lost. In production, long-running agents (research tasks, deployments, data pipelines) must survive failures.
Solution: Add checkpointing. Serialize state + history + completed tool calls to disk. On resume, skip completed work and continue from the last checkpoint.
graph TD
START["Start"] --> CHECK{"Checkpoint<br/>exists?"}
CHECK -->|yes| LOAD["Load saved state"]
CHECK -->|no| INIT["Initialize fresh state"]
LOAD --> SKIP["Skip completed turns"]
SKIP --> RESUME["Resume from next turn"]
INIT --> EXEC["Execute turn"]
RESUME --> EXEC
EXEC --> SAVE["Save checkpoint"]
SAVE --> TERM{"Done?"}
TERM -->|no| EXEC
TERM -->|yes| END["Return result"]
style LOAD fill:#e3f2fd
style SAVE fill:#fff3e0
style SKIP fill:#c6f6d5
# harness_checkpoint.py
import json
import os
from pathlib import Path
from typing import Set
class CheckpointManager:
"""Manages checkpoint persistence and resume."""
def __init__(self, checkpoint_dir: str = ".checkpoints"):
self.checkpoint_dir = Path(checkpoint_dir)
self.checkpoint_dir.mkdir(exist_ok=True)
def save(self, run_id: str, turn: int, state: dict, history: list, completed_calls: Set[str]):
"""Save a checkpoint to disk."""
checkpoint_path = self.checkpoint_dir / f"{run_id}_turn{turn}.json"
data = {
"run_id": run_id,
"turn": turn,
"state": state,
"history": history,
"completed_calls": list(completed_calls)
}
with open(checkpoint_path, "w") as f:
json.dump(data, f, indent=2)
print(f"[Checkpoint] Saved to {checkpoint_path}")
def restore(self, run_id: str) -> dict:
"""Restore the latest checkpoint for a run."""
checkpoints = list(self.checkpoint_dir.glob(f"{run_id}_turn*.json"))
if not checkpoints:
raise FileNotFoundError(f"No checkpoints found for run_id '{run_id}'")
# Sort by turn number, take the latest
latest = max(checkpoints, key=lambda p: int(p.stem.split("turn")[1]))
with open(latest, "r") as f:
data = json.load(f)
print(f"[Checkpoint] Restored from {latest}")
return data
class HarnessWithCheckpoint:
"""Harness with checkpoint/resume and idempotency."""
def __init__(self, client, registry: ToolRegistry, run_id: str, max_turns: int = 10):
self.client = client
self.registry = registry
self.run_id = run_id
self.max_turns = max_turns
self.done_flag = False
self.state = AgentState()
self.checkpoint_manager = CheckpointManager()
self.completed_calls: Set[str] = set() # Track completed tool call IDs
def assemble_context(self, goal: str, history: List[dict]) -> List[dict]:
system_message = {
"role": "system",
"content": (
"You are a research assistant. Use web_search to find information. "
"Use calculator for math. Call done when finished."
)
}
user_message = {"role": "user", "content": goal}
return [system_message, user_message] + history
def checkpoint(self, turn: int, history: list):
"""Save a checkpoint after each turn."""
self.checkpoint_manager.save(
self.run_id,
turn,
self.state.to_dict(),
history,
self.completed_calls
)
def restore(self):
"""Restore from the latest checkpoint."""
data = self.checkpoint_manager.restore(self.run_id)
self.state.from_dict(data["state"])
self.completed_calls = set(data["completed_calls"])
return data["history"], data["turn"]
def run(self, goal: str, resume: bool = False) -> dict:
"""Run agent loop with checkpoint/resume."""
if resume:
print(f"[Harness] Resuming run '{self.run_id}'...")
history, last_turn = self.restore()
turn_count = last_turn
print(f"[Harness] Restored from turn {last_turn}. Completed calls: {len(self.completed_calls)}")
else:
print(f"[Harness] Starting new run '{self.run_id}'...")
history = []
turn_count = 0
while not self.done_flag and turn_count < self.max_turns:
messages = self.assemble_context(goal, history)
response = self.client.create_message(messages, tools=self.registry.get_tool_schemas())
turn_count += 1
print(f"\n[Turn {turn_count}]")
assistant_message = {"role": "assistant", "content": []}
tool_results = []
for block in response["content"]:
if block["type"] == "text":
print(f" Assistant: {block['text']}")
assistant_message["content"].append(block)
elif block["type"] == "tool_use":
tool_name = block["name"]
tool_input = block["input"]
tool_id = block["id"]
# Check idempotency: skip completed calls
if tool_id in self.completed_calls:
print(f" [Idempotent Skip] {tool_name}({tool_input}) already completed")
# Return cached result from history
# In production, you'd store results in the checkpoint
# For this lab, we'll just return a placeholder
result = "[cached result]"
else:
print(f" Calling: {tool_name}({tool_input})")
result = self.registry.dispatch(tool_name, tool_input, self.state)
print(f" Result: {result}")
# Mark as completed
self.completed_calls.add(tool_id)
assistant_message["content"].append(block)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_id,
"content": result
})
history.append(assistant_message)
if tool_results:
history.append({"role": "user", "content": tool_results})
# Checkpoint after each turn
self.checkpoint(turn_count, history)
print(f"\n[Harness] Run complete. Total turns: {turn_count}")
return {
"turns": turn_count,
"state": self.state.to_dict(),
"history": history
}
What changed:
CheckpointManager: saves/restores checkpoints as JSON files in.checkpoints/completed_calls: a set of tool call IDs that have been executedcheckpoint(): saves state + history + completed_calls after each turnrestore(): loads the latest checkpoint and rebuilds state- Idempotency: Before dispatching a tool, check if its ID is in
completed_calls. If yes, skip it and return a cached result.
Demo: crash and resume
# Run for 3 turns, then simulate a crash
harness = HarnessWithCheckpoint(client, registry, run_id="research_001", max_turns=10)
harness.run("Research AI agent frameworks", resume=False)
# Manually kill the process (Ctrl+C) after turn 3
# Resume from checkpoint
harness2 = HarnessWithCheckpoint(client, registry, run_id="research_001", max_turns=10)
harness2.run("Research AI agent frameworks", resume=True)
Expected behavior:
- First run: completes 3 turns, checkpoints after each
- Kill the process
- Second run: restores from turn 3, skips completed tool calls, continues from turn 4
Why this matters: Long-running agents (data pipelines, multi-hour research tasks, deployment workflows) can survive crashes, network failures, and rate limits without losing progress.
Extension 3: Tracing
Right now, the harness prints logs to stdout. You can see what's happening, but you can't analyze it. Questions like "which turn was most expensive?" or "how long did tool X take?" require structured data.
Solution: Add a TracingContext that emits structured JSON events for every model call, tool call, and state mutation. Write events to a .jsonl file (JSON Lines — one JSON object per line).
# harness_tracing.py
import json
import time
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, asdict
from datetime import datetime
@dataclass
class TraceEvent:
"""Structured trace event."""
run_id: str
turn: int
span_id: str
event_type: str # "model_call", "tool_call", "state_mutation"
timestamp: str
duration_ms: Optional[float] = None
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
tool_name: Optional[str] = None
tool_input: Optional[dict] = None
tool_result: Optional[str] = None
state_key: Optional[str] = None
state_value: Optional[Any] = None
class TracingContext:
"""Tracing context that emits structured JSON events."""
def __init__(self, run_id: str, trace_dir: str = ".traces"):
self.run_id = run_id
self.trace_dir = Path(trace_dir)
self.trace_dir.mkdir(exist_ok=True)
self.trace_file = self.trace_dir / f"{run_id}.jsonl"
self.span_counter = 0
def next_span_id(self) -> str:
"""Generate a unique span ID."""
self.span_counter += 1
return f"span_{self.span_counter}"
def emit(self, event: TraceEvent):
"""Write an event to the trace file."""
with open(self.trace_file, "a") as f:
f.write(json.dumps(asdict(event)) + "\n")
def model_call(self, turn: int, input_tokens: int, output_tokens: int, duration_ms: float):
"""Log a model API call."""
event = TraceEvent(
run_id=self.run_id,
turn=turn,
span_id=self.next_span_id(),
event_type="model_call",
timestamp=datetime.utcnow().isoformat(),
duration_ms=duration_ms,
input_tokens=input_tokens,
output_tokens=output_tokens
)
self.emit(event)
def tool_call(self, turn: int, tool_name: str, tool_input: dict, tool_result: str, duration_ms: float):
"""Log a tool call."""
event = TraceEvent(
run_id=self.run_id,
turn=turn,
span_id=self.next_span_id(),
event_type="tool_call",
timestamp=datetime.utcnow().isoformat(),
duration_ms=duration_ms,
tool_name=tool_name,
tool_input=tool_input,
tool_result=tool_result
)
self.emit(event)
def state_mutation(self, turn: int, key: str, value: Any):
"""Log a state mutation."""
event = TraceEvent(
run_id=self.run_id,
turn=turn,
span_id=self.next_span_id(),
event_type="state_mutation",
timestamp=datetime.utcnow().isoformat(),
state_key=key,
state_value=value
)
self.emit(event)
class HarnessWithTracing:
"""Harness with structured tracing."""
def __init__(self, client, registry: ToolRegistry, run_id: str, max_turns: int = 10):
self.client = client
self.registry = registry
self.run_id = run_id
self.max_turns = max_turns
self.done_flag = False
self.state = AgentState()
self.tracer = TracingContext(run_id)
def assemble_context(self, goal: str, history: List[dict]) -> List[dict]:
system_message = {
"role": "system",
"content": (
"You are a research assistant. Use web_search to find information. "
"Use calculator for math. Call done when finished."
)
}
user_message = {"role": "user", "content": goal}
return [system_message, user_message] + history
def run(self, goal: str) -> dict:
"""Run agent loop with tracing."""
history = []
turn_count = 0
print(f"[Harness] Starting run '{self.run_id}'. Traces → {self.tracer.trace_file}\n")
while not self.done_flag and turn_count < self.max_turns:
messages = self.assemble_context(goal, history)
# Time the model call
start = time.time()
response = self.client.create_message(messages, tools=self.registry.get_tool_schemas())
duration_ms = (time.time() - start) * 1000
# Extract usage
usage = response.get("usage", {})
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
turn_count += 1
# Log model call
self.tracer.model_call(turn_count, input_tokens, output_tokens, duration_ms)
print(f"\n[Turn {turn_count}] Model call: {input_tokens} in + {output_tokens} out = {duration_ms:.2f}ms")
assistant_message = {"role": "assistant", "content": []}
tool_results = []
for block in response["content"]:
if block["type"] == "text":
print(f" Assistant: {block['text']}")
assistant_message["content"].append(block)
elif block["type"] == "tool_use":
tool_name = block["name"]
tool_input = block["input"]
tool_id = block["id"]
print(f" Calling: {tool_name}({tool_input})")
# Time the tool call
start = time.time()
result = self.registry.dispatch(tool_name, tool_input, self.state)
duration_ms = (time.time() - start) * 1000
# Log tool call
self.tracer.tool_call(turn_count, tool_name, tool_input, result, duration_ms)
print(f" Result: {result} ({duration_ms:.2f}ms)")
assistant_message["content"].append(block)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_id,
"content": result
})
history.append(assistant_message)
if tool_results:
history.append({"role": "user", "content": tool_results})
print(f"\n[Harness] Run complete. Trace saved to {self.tracer.trace_file}")
return {
"turns": turn_count,
"state": self.state.to_dict(),
"history": history
}
What changed:
TraceEvent: a dataclass with all event fieldsTracingContext: emits events to a.jsonlfiletracer.model_call(): logs model API calls with token usage and durationtracer.tool_call(): logs tool dispatch with inputs, results, and durationHarnessWithTracing: wraps every model/tool call with timing and logging
Inspect the trace:
After a run, open .traces/research_001.jsonl:
{"run_id": "research_001", "turn": 1, "span_id": "span_1", "event_type": "model_call", "timestamp": "2026-07-10T14:32:01.123Z", "duration_ms": 234.5, "input_tokens": 120, "output_tokens": 45}
{"run_id": "research_001", "turn": 1, "span_id": "span_2", "event_type": "tool_call", "timestamp": "2026-07-10T14:32:01.400Z", "duration_ms": 12.3, "tool_name": "web_search", "tool_input": {"query": "AI agent frameworks"}, "tool_result": "{\"results\": [...]}"}
{"run_id": "research_001", "turn": 2, "span_id": "span_3", "event_type": "model_call", "timestamp": "2026-07-10T14:32:02.100Z", "duration_ms": 189.7, "input_tokens": 200, "output_tokens": 60}
Query the trace:
# Which turn was most expensive?
import json
with open(".traces/research_001.jsonl") as f:
events = [json.loads(line) for line in f]
model_calls = [e for e in events if e["event_type"] == "model_call"]
most_expensive = max(model_calls, key=lambda e: e["input_tokens"] + e["output_tokens"])
print(f"Most expensive turn: {most_expensive['turn']}, {most_expensive['input_tokens'] + most_expensive['output_tokens']} tokens")
Why this matters: Structured traces enable:
- Cost analysis (which turns/tools are expensive?)
- Performance debugging (which tools are slow?)
- Audit trails (what did the agent do and when?)
- Replay and visualization (timeline views, dependency graphs)
Production observability tools (LangSmith, AgentOps, Braintrust) are all trace collectors with query UIs.
Extension 4: Approval Gate
Some tools are dangerous: send_email, deploy_to_prod, delete_database, execute_sql. You don't want an agent calling these unsupervised.
Solution: Add an approval gate. Before dispatching a tool marked requires_approval: true, the harness:
- Checkpoints current state (so you can resume after approval)
- Prints the proposed action
- Waits for human input (y/n)
- Continues on approval, aborts on rejection
This is the simplest form of human-in-the-loop intervention.
# harness_approval.py
from typing import Dict
class ToolRegistry:
"""Tool registry with approval gate support."""
def __init__(self):
self.tools: Dict[str, callable] = {}
self.schemas: Dict[str, dict] = {}
self.requires_approval: Dict[str, bool] = {} # Track approval requirements
def register(self, name: str, fn: callable, schema: dict, requires_approval: bool = False):
"""Register a tool with optional approval gate."""
self.tools[name] = fn
self.schemas[name] = schema
self.requires_approval[name] = requires_approval
def needs_approval(self, name: str) -> bool:
"""Check if a tool requires approval."""
return self.requires_approval.get(name, False)
def dispatch(self, name: str, input_data: dict, state: AgentState) -> Any:
if name not in self.tools:
raise ValueError(f"Tool '{name}' not registered")
# Unpack dict as kwargs with state
tool_fn = self.tools[name]
return tool_fn(state=state, **input_data)
def get_tool_schemas(self) -> List[dict]:
return list(self.schemas.values())
def send_email(state: AgentState, to: str, subject: str, body: str) -> str:
"""Send an email. Marked as requires_approval."""
# In production, this would actually send an email
print(f"\n[EMAIL SENT]")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f" Body: {body}\n")
return f"Email sent to {to}"
class HarnessWithApproval:
"""Harness with approval gates for dangerous tools."""
def __init__(self, client, registry: ToolRegistry, run_id: str, max_turns: int = 10):
self.client = client
self.registry = registry
self.run_id = run_id
self.max_turns = max_turns
self.done_flag = False
self.state = AgentState()
self.checkpoint_manager = CheckpointManager()
def assemble_context(self, goal: str, history: List[dict]) -> List[dict]:
system_message = {
"role": "system",
"content": (
"You are a research assistant. Use web_search to find information. "
"Use send_email to deliver results. Call done when finished."
)
}
user_message = {"role": "user", "content": goal}
return [system_message, user_message] + history
def request_approval(self, tool_name: str, tool_input: dict) -> bool:
"""Request human approval for a tool call.
In production, this would:
- Emit an approval request to a queue
- Send a webhook to a human review UI
- Wait for async approval (not blocking the harness)
For this lab, we use a simple CLI prompt.
"""
print(f"\n{'='*60}")
print(f"[APPROVAL REQUIRED]")
print(f" Tool: {tool_name}")
print(f" Input: {json.dumps(tool_input, indent=2)}")
print(f"{'='*60}")
response = input("Approve this action? (y/n): ").strip().lower()
if response == "y":
print("[APPROVED] Continuing...\n")
return True
else:
print("[REJECTED] Aborting...\n")
return False
def run(self, goal: str) -> dict:
"""Run agent loop with approval gates."""
history = []
turn_count = 0
print(f"[Harness] Starting run '{self.run_id}' with approval gates enabled.\n")
while not self.done_flag and turn_count < self.max_turns:
messages = self.assemble_context(goal, history)
response = self.client.create_message(messages, tools=self.registry.get_tool_schemas())
turn_count += 1
print(f"\n[Turn {turn_count}]")
assistant_message = {"role": "assistant", "content": []}
tool_results = []
for block in response["content"]:
if block["type"] == "text":
print(f" Assistant: {block['text']}")
assistant_message["content"].append(block)
elif block["type"] == "tool_use":
tool_name = block["name"]
tool_input = block["input"]
tool_id = block["id"]
# Check if approval is required
if self.registry.needs_approval(tool_name):
# Checkpoint before approval gate
self.checkpoint_manager.save(
self.run_id,
turn_count,
self.state.to_dict(),
history,
set()
)
# Request approval
approved = self.request_approval(tool_name, tool_input)
if not approved:
# Abort the run
result = f"[REJECTED] Tool '{tool_name}' was not approved by human reviewer."
print(f" {result}")
self.done_flag = True
else:
# Continue with the call
print(f" Calling: {tool_name}({tool_input})")
result = self.registry.dispatch(tool_name, tool_input, self.state)
print(f" Result: {result}")
else:
# No approval needed
print(f" Calling: {tool_name}({tool_input})")
result = self.registry.dispatch(tool_name, tool_input, self.state)
print(f" Result: {result}")
assistant_message["content"].append(block)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_id,
"content": result
})
history.append(assistant_message)
if tool_results:
history.append({"role": "user", "content": tool_results})
print(f"\n[Harness] Run complete. Total turns: {turn_count}")
return {
"turns": turn_count,
"state": self.state.to_dict(),
"history": history
}
What changed:
ToolRegistry.requires_approval: a dict tracking which tools need approvalregister(..., requires_approval=True): mark a tool as dangerousrequest_approval(): checkpoint → print proposal → wait for y/n → return decision- Before dispatching a tool with
requires_approval=True, the harness callsrequest_approval()
Conceptual framing:
The approval gate is a pause → checkpoint → emit request → receive decision → resume pattern. The CLI input() is the simplest implementation. Production runtimes use:
- Approval queues (e.g., Redis, SQS) where the harness pauses and waits for an async decision
- Webhooks (e.g., POST to a Slack bot or custom UI) that notify humans and resume on callback
- UIs (e.g., a dashboard where humans review pending actions and click approve/reject)
The harness logic is the same: pause, checkpoint, wait for decision, resume or abort.
Demo:
# Register send_email with approval gate
registry.register("send_email", send_email, {
"name": "send_email",
"description": "Send an email",
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"}
},
"required": ["to", "subject", "body"]
}
}, requires_approval=True)
# Run the harness
harness = HarnessWithApproval(client, registry, run_id="email_001")
harness.run("Research AI frameworks and email me the summary")
Expected behavior:
- Agent calls
web_search(no approval needed) - Agent calls
send_email→ approval gate fires - Harness checkpoints, prints the email proposal, waits for input
- If you type
y, the email is sent and the run completes - If you type
n, the run aborts with a rejection message
Why this matters: Approval gates prevent autonomous agents from taking irreversible actions without oversight. This is critical for production systems where mistakes are expensive (money, reputation, compliance).
The Complete Extended Harness
Now let's integrate all four extensions into a single harness.
# harness_extended.py
import json
import time
from pathlib import Path
from typing import Dict, Any, List, Set, Optional
from dataclasses import dataclass, asdict
from datetime import datetime
# =====================
# State Management
# =====================
class AgentState:
"""Mutable state that tools can read and write."""
def __init__(self):
self.data: Dict[str, Any] = {}
def get(self, key: str, default=None):
return self.data.get(key, default)
def set(self, key: str, value: Any):
self.data[key] = value
def to_dict(self) -> dict:
return self.data.copy()
def from_dict(self, data: dict):
self.data = data.copy()
# =====================
# Checkpointing
# =====================
class CheckpointManager:
"""Manages checkpoint persistence and resume."""
def __init__(self, checkpoint_dir: str = ".checkpoints"):
self.checkpoint_dir = Path(checkpoint_dir)
self.checkpoint_dir.mkdir(exist_ok=True)
def save(self, run_id: str, turn: int, state: dict, history: list, completed_calls: Set[str]):
checkpoint_path = self.checkpoint_dir / f"{run_id}_turn{turn}.json"
data = {
"run_id": run_id,
"turn": turn,
"state": state,
"history": history,
"completed_calls": list(completed_calls)
}
with open(checkpoint_path, "w") as f:
json.dump(data, f, indent=2)
def restore(self, run_id: str) -> dict:
checkpoints = list(self.checkpoint_dir.glob(f"{run_id}_turn*.json"))
if not checkpoints:
raise FileNotFoundError(f"No checkpoints found for run_id '{run_id}'")
latest = max(checkpoints, key=lambda p: int(p.stem.split("turn")[1]))
with open(latest, "r") as f:
return json.load(f)
# =====================
# Tracing
# =====================
@dataclass
class TraceEvent:
run_id: str
turn: int
span_id: str
event_type: str
timestamp: str
duration_ms: Optional[float] = None
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
tool_name: Optional[str] = None
tool_input: Optional[dict] = None
tool_result: Optional[str] = None
class TracingContext:
"""Tracing context that emits structured JSON events."""
def __init__(self, run_id: str, trace_dir: str = ".traces"):
self.run_id = run_id
self.trace_dir = Path(trace_dir)
self.trace_dir.mkdir(exist_ok=True)
self.trace_file = self.trace_dir / f"{run_id}.jsonl"
self.span_counter = 0
def next_span_id(self) -> str:
self.span_counter += 1
return f"span_{self.span_counter}"
def emit(self, event: TraceEvent):
with open(self.trace_file, "a") as f:
f.write(json.dumps(asdict(event)) + "\n")
def model_call(self, turn: int, input_tokens: int, output_tokens: int, duration_ms: float):
event = TraceEvent(
run_id=self.run_id,
turn=turn,
span_id=self.next_span_id(),
event_type="model_call",
timestamp=datetime.utcnow().isoformat(),
duration_ms=duration_ms,
input_tokens=input_tokens,
output_tokens=output_tokens
)
self.emit(event)
def tool_call(self, turn: int, tool_name: str, tool_input: dict, tool_result: str, duration_ms: float):
event = TraceEvent(
run_id=self.run_id,
turn=turn,
span_id=self.next_span_id(),
event_type="tool_call",
timestamp=datetime.utcnow().isoformat(),
duration_ms=duration_ms,
tool_name=tool_name,
tool_input=tool_input,
tool_result=tool_result
)
self.emit(event)
# =====================
# Tool Registry
# =====================
class ToolRegistry:
"""Tool registry with approval gate support."""
def __init__(self):
self.tools: Dict[str, callable] = {}
self.schemas: Dict[str, dict] = {}
self.requires_approval: Dict[str, bool] = {}
def register(self, name: str, fn: callable, schema: dict, requires_approval: bool = False):
self.tools[name] = fn
self.schemas[name] = schema
self.requires_approval[name] = requires_approval
def needs_approval(self, name: str) -> bool:
return self.requires_approval.get(name, False)
def dispatch(self, name: str, input_data: dict, state: AgentState) -> Any:
if name not in self.tools:
raise ValueError(f"Tool '{name}' not registered")
# Unpack dict as kwargs with state
tool_fn = self.tools[name]
return tool_fn(state=state, **input_data)
def get_tool_schemas(self) -> List[dict]:
return list(self.schemas.values())
# =====================
# Complete Harness
# =====================
class ExtendedHarness:
"""Complete harness with state, checkpoints, tracing, and approval gates."""
def __init__(self, client, registry: ToolRegistry, run_id: str, max_turns: int = 10):
self.client = client
self.registry = registry
self.run_id = run_id
self.max_turns = max_turns
self.done_flag = False
# Extension 1: State
self.state = AgentState()
# Extension 2: Checkpointing
self.checkpoint_manager = CheckpointManager()
self.completed_calls: Set[str] = set()
# Extension 3: Tracing
self.tracer = TracingContext(run_id)
def assemble_context(self, goal: str, history: List[dict]) -> List[dict]:
system_message = {
"role": "system",
"content": (
"You are a research assistant. Use web_search to find information. "
"Use send_email to deliver results. Call done when finished."
)
}
user_message = {"role": "user", "content": goal}
return [system_message, user_message] + history
def request_approval(self, tool_name: str, tool_input: dict) -> bool:
"""Request human approval (Extension 4: Approval Gate)."""
print(f"\n{'='*60}")
print(f"[APPROVAL REQUIRED]")
print(f" Tool: {tool_name}")
print(f" Input: {json.dumps(tool_input, indent=2)}")
print(f"{'='*60}")
response = input("Approve this action? (y/n): ").strip().lower()
if response == "y":
print("[APPROVED] Continuing...\n")
return True
else:
print("[REJECTED] Aborting...\n")
return False
def checkpoint(self, turn: int, history: list):
"""Save checkpoint (Extension 2)."""
self.checkpoint_manager.save(
self.run_id,
turn,
self.state.to_dict(),
history,
self.completed_calls
)
def restore(self):
"""Restore from checkpoint (Extension 2)."""
data = self.checkpoint_manager.restore(self.run_id)
self.state.from_dict(data["state"])
self.completed_calls = set(data["completed_calls"])
return data["history"], data["turn"]
def run(self, goal: str, resume: bool = False) -> dict:
"""Run the agent loop with all extensions."""
if resume:
print(f"[Harness] Resuming run '{self.run_id}'...")
history, last_turn = self.restore()
turn_count = last_turn
else:
print(f"[Harness] Starting new run '{self.run_id}'...")
print(f" Checkpoints → {self.checkpoint_manager.checkpoint_dir}")
print(f" Traces → {self.tracer.trace_file}\n")
history = []
turn_count = 0
while not self.done_flag and turn_count < self.max_turns:
messages = self.assemble_context(goal, history)
# Time the model call (Extension 3: Tracing)
start = time.time()
response = self.client.create_message(messages, tools=self.registry.get_tool_schemas())
duration_ms = (time.time() - start) * 1000
usage = response.get("usage", {})
input_tokens = usage.get("input_tokens", 0)
output_tokens = usage.get("output_tokens", 0)
turn_count += 1
# Log model call (Extension 3)
self.tracer.model_call(turn_count, input_tokens, output_tokens, duration_ms)
print(f"\n[Turn {turn_count}] Model: {input_tokens} in + {output_tokens} out ({duration_ms:.2f}ms)")
assistant_message = {"role": "assistant", "content": []}
tool_results = []
for block in response["content"]:
if block["type"] == "text":
print(f" Assistant: {block['text']}")
assistant_message["content"].append(block)
elif block["type"] == "tool_use":
tool_name = block["name"]
tool_input = block["input"]
tool_id = block["id"]
# Check idempotency (Extension 2)
if tool_id in self.completed_calls:
print(f" [Skip] {tool_name} already completed")
result = "[cached]"
else:
# Check approval gate (Extension 4)
if self.registry.needs_approval(tool_name):
self.checkpoint(turn_count, history)
approved = self.request_approval(tool_name, tool_input)
if not approved:
result = f"[REJECTED] '{tool_name}' not approved."
self.done_flag = True
else:
start = time.time()
result = self.registry.dispatch(tool_name, tool_input, self.state)
duration_ms = (time.time() - start) * 1000
self.tracer.tool_call(turn_count, tool_name, tool_input, result, duration_ms)
else:
# Normal dispatch
print(f" Calling: {tool_name}({tool_input})")
start = time.time()
result = self.registry.dispatch(tool_name, tool_input, self.state)
duration_ms = (time.time() - start) * 1000
# Log tool call (Extension 3)
self.tracer.tool_call(turn_count, tool_name, tool_input, result, duration_ms)
print(f" Result: {result} ({duration_ms:.2f}ms)")
self.completed_calls.add(tool_id)
assistant_message["content"].append(block)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_id,
"content": result
})
history.append(assistant_message)
if tool_results:
history.append({"role": "user", "content": tool_results})
# Checkpoint after each turn (Extension 2)
self.checkpoint(turn_count, history)
print(f"\n[Harness] Run complete. Turns: {turn_count}")
print(f" State keys: {list(self.state.data.keys())}")
print(f" Trace: {self.tracer.trace_file}")
return {
"turns": turn_count,
"state": self.state.to_dict(),
"history": history
}
What you have now:
- Extension 1: Tools share state via
AgentState - Extension 2: Checkpoint after every turn, resume on crash, idempotent tool calls
- Extension 3: Structured
.jsonltraces for model/tool calls - Extension 4: Approval gates for dangerous tools
This harness implements 6 of the 8 concerns:
| Concern | Status | |---------|--------| | 1. Agent loop internals | ✅ | | 2. Tool dispatch | ✅ | | 3. Context assembly | ✅ | | 4. Control flow | ✅ | | 5. State management | ✅ (Extension 1) | | 6. Persistence | ✅ (Extension 2) | | 7. Human-in-the-loop | ✅ (Extension 4) | | 8. Observability | ✅ (Extension 3) |
You've built a production-ready agent harness in ~300 lines of code.
Full Case Study Run
Let's run the complete harness with a real research task that exercises all four extensions.
Goal: "Research the latest AI agent frameworks and email me a summary."
Expected flow:
- Agent calls
web_search("AI agent frameworks")→ stores results in state - Agent calls
web_search("LangGraph")→ appends to state - Agent calls
web_search("AutoGPT")→ appends to state - Agent calls
send_email(...)→ approval gate fires - Human approves → email sent → agent calls
done()
Now simulate a crash after step 2:
- Kill the process (Ctrl+C) after the second
web_search - Restart with
resume=True - Harness restores from checkpoint, skips the first two searches (idempotency)
- Continues with the third search → approval gate → done
Full code:
# case_study.py
from harness_extended import ExtendedHarness, ToolRegistry, AgentState
import json
# Tool implementations
def web_search(state: AgentState, query: str) -> str:
"""Simulated web search with realistic responses."""
results = []
if "langgraph" in query.lower():
results = [
{
"title": "LangGraph Documentation",
"url": "https://langchain.com/langgraph",
"snippet": "LangGraph is a framework for building stateful, multi-actor applications with LLMs. It provides state graphs, checkpointing, and human-in-the-loop features."
}
]
elif "autogpt" in query.lower():
results = [
{
"title": "AutoGPT on GitHub",
"url": "https://github.com/Significant-Gravitas/AutoGPT",
"snippet": "AutoGPT is an experimental autonomous agent that chains GPT-4 calls to accomplish tasks. It supports tool use and self-prompting."
}
]
elif "agent" in query.lower() and "framework" in query.lower():
results = [
{
"title": "Anthropic Claude SDK",
"url": "https://docs.anthropic.com/en/sdk",
"snippet": "The Claude SDK includes a tool runner for building agent loops with tool use and context assembly."
},
{
"title": "LangGraph Overview",
"url": "https://langchain.com/langgraph",
"snippet": "LangGraph is a framework for stateful agents with checkpointing and graph-based control flow."
},
{
"title": "CrewAI",
"url": "https://github.com/joaomdmoura/crewAI",
"snippet": "CrewAI is a framework for orchestrating role-playing autonomous agents."
}
]
# Store in state
existing = state.get("search_results", [])
existing.extend(results)
state.set("search_results", existing)
return json.dumps({"results": results, "count": len(results)})
def send_email(state: AgentState, to: str, subject: str, body: str) -> str:
"""Send an email (requires approval)."""
print(f"\n[EMAIL SENT]")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f" Body:\n{body}\n")
return f"Email sent to {to}"
def done(state: AgentState) -> str:
"""Signal completion."""
return "Task complete."
# Fake model client with realistic scripting
class FakeModelClient:
def __init__(self):
self.turn_count = 0
def create_message(self, messages, tools=None, max_tokens=1024):
self.turn_count += 1
# Turn 1: Search for frameworks
if self.turn_count == 1:
return {
"id": f"msg_{self.turn_count}",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "web_search",
"input": {"query": "AI agent frameworks"}
}
],
"usage": {"input_tokens": 120, "output_tokens": 30}
}
# Turn 2: Search for LangGraph
if self.turn_count == 2:
return {
"id": f"msg_{self.turn_count}",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_2",
"name": "web_search",
"input": {"query": "LangGraph documentation"}
}
],
"usage": {"input_tokens": 200, "output_tokens": 35}
}
# Turn 3: Search for AutoGPT
if self.turn_count == 3:
return {
"id": f"msg_{self.turn_count}",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_3",
"name": "web_search",
"input": {"query": "AutoGPT autonomous agent"}
}
],
"usage": {"input_tokens": 250, "output_tokens": 40}
}
# Turn 4: Send email
if self.turn_count == 4:
return {
"id": f"msg_{self.turn_count}",
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_4",
"name": "send_email",
"input": {
"to": "user@example.com",
"subject": "AI Agent Frameworks Research Summary",
"body": "I found three leading frameworks:\n\n1. Claude SDK — Anthropic's tool runner for agent loops\n2. LangGraph — Stateful agents with checkpointing\n3. AutoGPT — Autonomous agent with self-prompting\n\nSee attached sources for details."
}
}
],
"usage": {"input_tokens": 300, "output_tokens": 80}
}
# Turn 5: Done
return {
"id": f"msg_{self.turn_count}",
"role": "assistant",
"content": [
{"type": "text", "text": "Research complete."},
{
"type": "tool_use",
"id": "call_5",
"name": "done",
"input": {}
}
],
"usage": {"input_tokens": 320, "output_tokens": 20}
}
# Main
def main():
client = FakeModelClient()
registry = ToolRegistry()
# Register tools
registry.register("web_search", web_search, {
"name": "web_search",
"description": "Search the web",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
})
registry.register("send_email", send_email, {
"name": "send_email",
"description": "Send an email",
"input_schema": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"}
},
"required": ["to", "subject", "body"]
}
}, requires_approval=True) # Approval gate
harness_instance = ExtendedHarness(client, registry, run_id="research_001", max_turns=10)
def done_callback(state: AgentState) -> str:
harness_instance.done_flag = True
return "Task complete."
registry.register("done", done_callback, {
"name": "done",
"description": "Signal completion",
"input_schema": {"type": "object", "properties": {}}
})
# Run the harness
result = harness_instance.run("Research the latest AI agent frameworks and email me a summary")
print("\n" + "="*60)
print("FINAL RESULT:")
print(f" Turns: {result['turns']}")
print(f" Sources found: {len(result['state'].get('search_results', []))}")
print("="*60)
if __name__ == "__main__":
main()
Expected output:
[Harness] Starting new run 'research_001'...
Checkpoints → .checkpoints
Traces → .traces/research_001.jsonl
[Turn 1] Model: 120 in + 30 out (5.23ms)
Calling: web_search({'query': 'AI agent frameworks'})
Result: {"results": [...], "count": 3} (2.10ms)
[Turn 2] Model: 200 in + 35 out (4.87ms)
Calling: web_search({'query': 'LangGraph documentation'})
Result: {"results": [...], "count": 1} (1.95ms)
[Turn 3] Model: 250 in + 40 out (5.01ms)
Calling: web_search({'query': 'AutoGPT autonomous agent'})
Result: {"results": [...], "count": 1} (2.03ms)
[Turn 4] Model: 300 in + 80 out (6.12ms)
============================================================
[APPROVAL REQUIRED]
Tool: send_email
Input: {
"to": "user@example.com",
"subject": "AI Agent Frameworks Research Summary",
"body": "I found three leading frameworks:..."
}
============================================================
Approve this action? (y/n): y
[APPROVED] Continuing...
[EMAIL SENT]
To: user@example.com
Subject: AI Agent Frameworks Research Summary
Body:
I found three leading frameworks:
1. Claude SDK — Anthropic's tool runner for agent loops
2. LangGraph — Stateful agents with checkpointing
3. AutoGPT — Autonomous agent with self-prompting
See attached sources for details.
[Turn 5] Model: 320 in + 20 out (3.45ms)
Assistant: Research complete.
[Harness] Run complete. Turns: 5
State keys: ['search_results']
Trace: .traces/research_001.jsonl
============================================================
FINAL RESULT:
Turns: 5
Sources found: 5
============================================================
Now simulate a crash and resume:
- Modify the code to crash after turn 2 (add
raise Exception("Simulated crash")after turn 2) - Run again with
resume=True
# Simulate crash after turn 2
if turn_count == 2:
raise Exception("Simulated crash!")
# ... later, restart with resume=True
result = harness_instance.run("Research...", resume=True)
Expected behavior:
- Harness restores from checkpoint
- Turns 1-2 are skipped (idempotency)
- Turn 3 proceeds normally
- Approval gate fires on turn 4
- Run completes
Trace analysis:
$ cat .traces/research_001.jsonl | jq -r 'select(.event_type == "model_call") | "\(.turn): \(.input_tokens + .output_tokens) tokens"'
Output:
1: 150 tokens
2: 235 tokens
3: 290 tokens
4: 380 tokens
5: 340 tokens
You can now query: "Which turn was most expensive?" (Turn 4, 380 tokens)
What's Still Missing
Your harness now has 6 of the 8 concerns. Here's what production frameworks add:
| Concern | Your Harness | Production Frameworks | |---------|--------------|----------------------| | 1. Agent loop internals | ✅ Basic while loop | Async loops, parallel execution, sub-agent spawning | | 2. Tool dispatch | ✅ Registry + dispatch | Validation, retries, rate limiting, caching | | 3. Context assembly | ✅ System + history | Prompt caching, sliding windows, RAG integration | | 4. Control flow | ✅ Max turns, done signal | Graph-based routing, conditional branches, sub-goals | | 5. State management | ✅ Simple dict | Typed state, versioning, diffing, conflict resolution | | 6. Persistence | ✅ JSON checkpoints | Distributed storage (Postgres, Redis), transactional writes | | 7. Human-in-the-loop | ✅ CLI approval gate | Approval queues, webhooks, UIs, async resumption | | 8. Observability | ✅ JSONL traces | APM integration, span hierarchies, cost dashboards, replay UIs |
What LangGraph adds:
- State graphs: Define transitions between states with conditional edges
- Distributed checkpoints: Store state in Postgres for multi-node resume
- Subgraphs: Nest agents within agents
- Parallel execution: Run multiple branches concurrently
What Claude SDK adds:
- Streaming: Stream model responses token-by-token
- Prompt caching: Cache system prompts to reduce latency
- Message batching: Send multiple tool results in one API call
What AgentOps/LangSmith add:
- Span hierarchy: Nest tool calls under model calls for tree-view debugging
- Replay: Re-run a trace from any checkpoint
- Cost tracking: Aggregate token usage across runs
- A/B testing: Compare harness versions side-by-side
In Lesson 11, you'll see how production frameworks solve these concerns at scale.
Summary
You extended the minimal harness from Lesson 9 with four production-critical features:
- Structured state: Tools share data via
AgentState - Checkpoint/resume: Survive crashes with idempotent tool calls
- Tracing: Structured
.jsonlevents for observability - Approval gates: Human-in-the-loop for dangerous tools
Your harness now implements 6 of the 8 concerns. It's production-ready for:
- Research assistants (web search → summarize → email)
- Data pipelines (extract → transform → load with checkpoints)
- Deployment workflows (build → test → approve → deploy)
Critical insight: You've built the primitives that LangGraph, Claude SDK, and other frameworks abstract. When you see StateGraph.add_node() or client.beta.messages.tool_runner.run(), you now know exactly what they're doing under the hood.
What you shipped: A complete agent harness with state management, checkpointing, tracing, and approval gates. All four extensions integrate seamlessly. The case study demonstrates crash-and-resume with idempotency in a real research task.