Flagship guide

Build a testable Python agent loop without an API key.

In 15–20 minutes, build a small troubleshooting agent that chooses bounded tools, records every action, and stops on a fixed budget.

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.

Completion check

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.py

Download 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.py

Expected 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 -v

You should see three passing tests and an OK result.

6. Understand the failure paths

  1. The planner requests an unknown tool. The runtime rejects it before anything executes.
  2. The planner sends unexpected arguments. The runtime compares the exact argument names with the tool contract.
  3. The loop does not finish in time. The step budget raises an error instead of allowing an unbounded run.
  4. 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:

8. Verification and sources

Verification record

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 →