From chatbot to agent: Tools and the loop

A chatbot returns text. An agent takes action. The line between them is exactly one piece of code: a while-loop that asks the model, executes whatever tools it asks for, feeds the results back, and asks again. That loop is the agent.

04-tools-agent-loop/bot.py
python
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "run_command",
            "description": "Run a shell command and return stdout + stderr.",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {"type": "string", "description": "The shell command to execute"}
                },
                "required": ["command"],
            },
        },
    },
    # ... read_file, write_file, web_search follow the same shape
]

OpenAI / OpenRouter tool format. Each tool is type=function, with a name, description, and JSON-schema parameters. The model reads these and decides when to call them.

The model only knows about tools by name. Execution is on our side: a plain dispatch function with if/elif branches. Boring, deliberate, easy to read.

04-tools-agent-loop/bot.py
python
def execute_tool(name: str, args: dict) -> str:
    """Dispatch a tool call to the appropriate handler."""
    if name == "run_command":
        try:
            result = subprocess.run(
                args["command"], shell=True, capture_output=True, text=True, timeout=30,
            )
            return (result.stdout + result.stderr)[:10000] or "Command completed with no output."
        except subprocess.TimeoutExpired:
            return "Error: Command timed out after 30 seconds."
        except Exception as e:
            return f"Error: {e}"

    elif name == "read_file":
        try:
            return Path(args["path"]).read_text()[:10000]
        except Exception as e:
            return f"Error reading file: {e}"

    # ... write_file, web_search
    return f"Unknown tool: {name}"

execute_tool maps a tool name and args dict to a string result. Each branch is independent. Adding a new tool means one new branch.

One more helper before the loop. Earlier we persisted sessions by appending each message as it happened. Tool calls break that pattern: an assistant message with tool_calls must stay paired with its role=tool results, and a crash mid-turn would leave an orphaned half in the file. So persistence flips to save_session, which rewrites the whole file once the turn is complete.

04-tools-agent-loop/bot.py
python
def save_session(user_id: str, messages: list[dict]):
    """Overwrite the session file with all messages."""
    path = SESSIONS_DIR / f"{user_id}.jsonl"
    with open(path, "w") as f:
        for msg in messages:
            f.write(json.dumps(msg) + "\n")

save_session overwrites the JSONL file with the full history. Simple, and it guarantees tool_call ids and their results land together.

Now the loop. The same idea as a chatbot, plus one extra step: if the model came back with tool_calls, run them, feed the results back, and keep going.

04-tools-agent-loop/bot.py
python
def run_agent_turn(user_id: str, user_text: str) -> str:
    history = load_session(user_id)
    history.append({"role": "user", "content": user_text})

    while True:
        messages = [{"role": "system", "content": SOUL}] + history
        response = client.chat.completions.create(
            model=MODEL, max_tokens=4096, tools=TOOLS, messages=messages,
        )
        msg = response.choices[0].message

        assistant_msg = {"role": "assistant", "content": msg.content or ""}
        if msg.tool_calls:
            assistant_msg["tool_calls"] = [
                {"id": tc.id, "type": "function",
                 "function": {"name": tc.function.name, "arguments": tc.function.arguments}}
                for tc in msg.tool_calls
            ]
        history.append(assistant_msg)

        if not msg.tool_calls:
            save_session(user_id, history)
            return msg.content or "Done."

        for tc in msg.tool_calls:
            args = json.loads(tc.function.arguments) if tc.function.arguments else {}
            result = execute_tool(tc.function.name, args)
            history.append({"role": "tool", "tool_call_id": tc.id, "content": result})

The agent loop. Read it slowly. msg.tool_calls drives the branching. When that list is empty, we have the final answer.

The agent loop, one full turn

The yield point is the tool calls. The model says "run these", we run them, the model decides what to do with the output.

Because the model generates it token by token. It is literally text the model emitted. We call json.loads to parse it. If the model emits malformed JSON, you catch it and either retry or pass back an error to the next turn.

Quiz: Quiz

Loading practiceโ€ฆ