1. What you will build
You will build the runtime around an AI agent, not a language model call. A deterministic planner classifies a Python problem, chooses a reviewed troubleshooting playbook, records a trace, and returns an answer.
The planner is deliberately predictable so you can inspect and test every action without credentials or network access. Later, you can replace only decide() with a model call while keeping the tools, validation, budget, trace, and tests.
You are done when the example prints three traced actions, returns the network troubleshooting playbook, and all three unit tests pass.
Prerequisites
- Python 3.12 or newer.
- A terminal and an empty working directory.
- No package install, model account, or API key.
2. Download the two files
Create a directory, then download the exact example and tests published with this guide:
mkdir flypython-agent-loop
cd flypython-agent-loop
curl -O https://flypython.com/examples/agent-loop/agent_loop.py
curl -O https://flypython.com/examples/agent-loop/test_agent_loop.pyDownload agent_loop.py · Download test_agent_loop.py
3. Read the complete agent loop
The runtime exposes only two narrow tools. It validates the tool name and arguments before execution, appends every action to a trace, and raises an error when the step budget is exhausted.
"""A deterministic troubleshooting agent with bounded, testable tools."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
PLAYBOOKS = {
"network": "Check the timeout, retry transient failures with backoff, and log the final status code.",
"dependency": "Recreate the environment, pin the dependency, and record the failing version.",
"general": "Reproduce the issue with the smallest input, capture the error, and add a regression test.",
}
@dataclass(frozen=True)
class Action:
name: str
arguments: dict[str, str] = field(default_factory=dict)
@dataclass
class Task:
request: str
observations: list[str] = field(default_factory=list)
trace: list[str] = field(default_factory=list)
answer: str | None = None
def classify_issue(text: str) -> str:
lowered = text.lower()
if any(word in lowered for word in ("timeout", "network", "api")):
return "network"
if any(word in lowered for word in ("import", "package", "dependency")):
return "dependency"
return "general"
def lookup_playbook(category: str) -> str:
if category not in PLAYBOOKS:
raise ValueError(f"Unknown category: {category}")
return PLAYBOOKS[category]
TOOLS: dict[str, Callable[..., str]] = {
"classify_issue": classify_issue,
"lookup_playbook": lookup_playbook,
}
EXPECTED_ARGUMENTS = {
"classify_issue": {"text"},
"lookup_playbook": {"category"},
}
def decide(task: Task) -> Action:
"""Stand in for a model so every action stays free and deterministic."""
if len(task.observations) == 0:
return Action("classify_issue", {"text": task.request})
if len(task.observations) == 1:
return Action("lookup_playbook", {"category": task.observations[0]})
return Action("finish", {"answer": task.observations[-1]})
def execute(action: Action) -> str:
tool = TOOLS.get(action.name)
if tool is None:
raise ValueError(f"Unknown tool: {action.name}")
if set(action.arguments) != EXPECTED_ARGUMENTS[action.name]:
raise ValueError(f"Invalid arguments for {action.name}")
return tool(**action.arguments)
def run_agent(
task: Task,
planner: Callable[[Task], Action] = decide,
max_steps: int = 3,
) -> Task:
"""Validate, execute, observe, trace, and stop within a fixed budget."""
if max_steps < 1:
raise ValueError("max_steps must be at least 1")
for step in range(1, max_steps + 1):
action = planner(task)
task.trace.append(f"{step}. {action.name}")
if action.name == "finish":
if set(action.arguments) != {"answer"}:
raise ValueError("Invalid finish action")
task.answer = action.arguments["answer"]
return task
task.observations.append(execute(action))
raise RuntimeError(f"Agent exceeded the {max_steps}-step budget")
if __name__ == "__main__":
result = run_agent(Task("My API request times out"))
print("\n".join(result.trace))
print(f"\nAnswer: {result.answer}")
Why this counts as the useful part of an agent
A model can propose an action, but your application still owns the allowed tools, argument validation, execution, observations, budget, completion state, and audit trail. Those boundaries remain important regardless of the provider or framework.
4. Run it and inspect the output
python3 agent_loop.pyExpected output:
1. classify_issue
2. lookup_playbook
3. finish
Answer: Check the timeout, retry transient failures with backoff, and log the final status code.Do not stop at “it ran.” The trace is the evidence: the planner classified the request, retrieved one bounded playbook, and finished explicitly in three steps.
5. Verify the success and failure paths
The test file checks the successful network path, the hard step limit, and rejection of a tool that is not on the allowlist.
"""Tests for the FlyPython deterministic troubleshooting agent."""
import unittest
from agent_loop import Action, PLAYBOOKS, Task, run_agent
class AgentLoopTests(unittest.TestCase):
def test_network_issue_returns_playbook(self) -> None:
task = run_agent(Task("My API request times out"))
self.assertEqual(task.observations[0], "network")
self.assertEqual(task.answer, PLAYBOOKS["network"])
self.assertEqual(
task.trace,
["1. classify_issue", "2. lookup_playbook", "3. finish"],
)
def test_step_budget_stops_the_loop(self) -> None:
with self.assertRaisesRegex(RuntimeError, "1-step budget"):
run_agent(Task("An import fails"), max_steps=1)
def test_unknown_tool_is_rejected(self) -> None:
def unsafe_planner(_task: Task) -> Action:
return Action("delete_files")
with self.assertRaisesRegex(ValueError, "Unknown tool"):
run_agent(Task("Delete everything"), planner=unsafe_planner)
if __name__ == "__main__":
unittest.main()
Run it from the same directory:
python3 -m unittest -vYou should see three passing tests and an OK result.
6. Understand the failure paths
- The planner requests an unknown tool. The runtime rejects it before anything executes.
- The planner sends unexpected arguments. The runtime compares the exact argument names with the tool contract.
- The loop does not finish in time. The step budget raises an error instead of allowing an unbounded run.
- The tool itself has side effects. This example avoids them; real tools should be idempotent and require review before irreversible, financial, or public actions.
7. Replace the planner with a model later
When you are ready, replace decide(task) with a provider call that returns the same Action shape. Keep the tool allowlist, argument validation, trace, step budget, and tests outside the model.
Use a framework when you need provider adapters, typed output parsing, tracing, handoffs, state graphs, or durable execution. The underlying boundaries should remain visible:
- OpenAI Agents SDK for tools, handoffs, guardrails, and tracing.
- Pydantic AI for typed inputs, structured outputs, and model portability.
- LangGraph for explicit state, branching, pausing, and durable execution.
- Model Context Protocol for reusable tool and data-source integration boundaries.
8. Verification and sources
The exact files shown above were run with Python 3.12.13 using only the standard library. The suite verifies the successful path, the step limit, and rejection of an unknown tool. Verification command: python3 -m unittest discover -s public/examples/agent-loop -p 'test_*.py'.
This guide uses the Python dataclasses documentation and Python unittest documentation as primary language references. Framework descriptions link directly to their maintained documentation.
Your next step
Change the example request, add one narrow read-only tool, and write its failure test before connecting a model. Then continue to the curated AI-building resources →