DEV Community

Taylor Wang
Taylor Wang

Posted on

48-Hour Field Notes: The Agent Wasn't Stuck. My Loop Guard Counted Turns, Not Tool Calls.

A small tool-calling assistant spent two days looking like it had a model problem. It did not. The model kept asking for the same file, my guard kept letting it, and at the end of the run I could not tell you how many times the tool had actually executed. Have you ever counted the wrong thing and then blamed the thing you were counting?

This is the write-up of what I tried, what broke, and what I would repeat. The artifact is a single file you can run with no API key, no network, and no provider account, because the bug lives in the client-side loop accounting, not in the model.

What I was actually building

I wanted a tiny assistant that could read files from a workspace and summarise them. One tool, read_file, one system prompt, one OpenAI-compatible chat endpoint. That is the smallest useful shape of an agent, and I assumed it was too small to get wrong.

The loop itself is standard: send messages, inspect tool_calls, execute each call, append a tool message with the matching tool_call_id, repeat until the model stops asking for tools. That shape is documented in the OpenAI function-calling guide linked below, and it is what every compatible endpoint implements.

I also wanted to run the long jobs somewhere that did not need a card on file. MonkeyCode advertises free model access and a free server option, and those two availability claims are operator-supplied rather than something I benchmarked here. I did not measure quotas, model lists, or hardware, so treat them as a starting point to verify, not as numbers from me.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

What broke in the first 48 hours

The symptom was boring and that is why it cost me a day. The run stopped, I read the log, and I had no idea what the guard had been protecting me from.

  • The guard reported turns=4, and I believed the budget was respected.
  • The tool had executed seven times, not four, because two assistant messages had requested two calls each.
  • Two of those seven executions were the same read, with the arguments JSON reordered by the model.
  • One execution used a default argument I never sent, so my string-based dedupe key missed it entirely.

So was the model looping, or was I? It was me. Three separate mistakes were hiding behind one misleading counter.

1. I counted turns, and one turn can contain N calls

tool_calls is an array. A single assistant message may request one call or five, and the transport does not care. My guard did turns += 1 once per message, so a max_turns=10 budget was really a budget of up to 10 messages, not 10 executions. The work was unbounded in the dimension I cared about.

2. My dedupe key was the raw arguments string

The arguments field on a tool call is a JSON string, not an object, and its key order comes from the model. {"path":"a.py","encoding":"utf-8"} and {"encoding":"utf-8","path":"a.py"} are the same call to your function and two different strings to your dictionary. I have been burned before by trusting a key that merely looked canonical, and I walked straight into it again.

3. Defaults made two argument strings hit the same real work

read_file(path, encoding="utf-8") treats {"path":"a.py"} and {"path":"a.py","encoding":"utf-8"} identically. String comparison sees two calls. The function sees one. If your dedupe runs before argument binding, your key is describing the wire format instead of the work.

The artifact: reproduce it in thirty seconds

Save this as loopbug.py. It contains a deterministic stub model, so nothing leaves your machine. Run python loopbug.py for the buggy guard and python loopbug.py --fixed for the repaired one.

#!/usr/bin/env python3
"""loopbug.py - reproduce a runaway tool loop with zero network calls."""
from __future__ import annotations

import argparse
import inspect
import json
import time
from dataclasses import dataclass

EXECUTIONS: list[tuple[str, str]] = []
MAX_EXECUTIONS = 6
DEADLINE_SECONDS = 2.0

# The model returns `arguments` as a JSON string, so key order is out of your hands.
SCRIPT: list[list[str]] = [
    ['{"path": "app/config.py"}'],
    ['{"path": "app/config.py", "encoding": "utf-8"}',
     '{"encoding": "utf-8", "path": "app/config.py"}'],
]


def read_file(path: str, encoding: str = "utf-8") -> str:
    """Stand-in for a real tool. It records every execution."""
    EXECUTIONS.append((path, encoding))
    return f"<contents of {path} as {encoding}>"


@dataclass
class StubModel:
    """Deterministic stand-in for an OpenAI-compatible chat endpoint."""

    turn: int = 0

    def chat(self, messages: list[dict]) -> dict:
        batch = SCRIPT[self.turn] if self.turn < len(SCRIPT) else SCRIPT[-1]
        self.turn += 1
        return {
            "choices": [{
                "finish_reason": "tool_calls",
                "message": {
                    "role": "assistant",
                    "content": None,
                    "tool_calls": [
                        {
                            "id": f"call_{self.turn}_{i}",
                            "type": "function",
                            "function": {"name": "read_file", "arguments": raw},
                        }
                        for i, raw in enumerate(batch)
                    ],
                },
            }]
        }


def canonical_signature(name: str, raw_arguments: str, tool_fn) -> str:
    """Bind the call to the real function, apply defaults, then serialise."""
    try:
        parsed = json.loads(raw_arguments)
    except json.JSONDecodeError:
        return f"{name}:<unparseable:{raw_arguments!r}>"
    bound = inspect.signature(tool_fn).bind(**parsed)
    bound.apply_defaults()
    return json.dumps(
        {"name": name, "args": dict(bound.arguments)},
        sort_keys=True,
        separators=(",", ":"),
    )
Enter fullscreen mode Exit fullscreen mode

The two drivers show the difference in one line each. The buggy one increments per assistant message; the fixed one increments per execution and carries a monotonic deadline.

def run_buggy(max_turns: int = 4) -> int:
    model = StubModel()
    messages = [{"role": "user", "content": "Read app/config.py."}]
    turns = 0
    for _ in range(max_turns):
        choice = model.chat(messages)["choices"][0]
        calls = choice["message"].get("tool_calls") or []
        if not calls:
            break
        turns += 1  # BUG: one increment per assistant message, not per call
        for call in calls:
            fn = call["function"]
            result = read_file(**json.loads(fn["arguments"]))
            messages.append(
                {"role": "tool", "tool_call_id": call["id"], "content": result}
            )
    return turns


def run_fixed(max_executions: int = MAX_EXECUTIONS,
              deadline_s: float = DEADLINE_SECONDS) -> int:
    model = StubModel()
    messages = [{"role": "user", "content": "Read app/config.py."}]
    cache: dict[str, str] = {}
    executions = 0
    deadline = time.monotonic() + deadline_s
    while executions < max_executions and time.monotonic() < deadline:
        choice = model.chat(messages)["choices"][0]
        calls = choice["message"].get("tool_calls") or []
        if not calls:
            break
        fresh_information = False
        for call in calls:
            fn = call["function"]
            signature = canonical_signature(fn["name"], fn["arguments"], read_file)
            if signature in cache:
                result = cache[signature] + "\n(identical call; result reused)"
            else:
                result = read_file(**json.loads(fn["arguments"]))
                cache[signature] = result
                executions += 1
                fresh_information = True
            messages.append(
                {"role": "tool", "tool_call_id": call["id"], "content": result}
            )
        if not fresh_information:
            break  # nothing new to learn: stop and hand back to a human
    return executions
Enter fullscreen mode Exit fullscreen mode

With that scripted input the counters are deterministic, so you can check my arithmetic without trusting me. The buggy driver reports a guard of 4 while the tool executes 7 times. The fixed driver stops one message earlier, executes the tool 2 times, and reuses the cached result for the reordered duplicate instead of re-reading the file.

Four rules I now apply to every tool loop

  1. Budget executions, not messages. The unit that costs you money, latency, and side effects is the tool call, so that is the unit the guard should count.
  2. Key on the bound call. inspect.signature(...).bind(...) plus apply_defaults() turns the wire format into the actual invocation, and sort_keys=True removes key-order noise.
  3. Keep a monotonic deadline next to the counter. time.monotonic() never steps backwards, so a deadline built on it cannot be moved by a clock adjustment mid-run.
  4. Stop when a turn produces no fresh information. If every call in a turn was served from cache, the loop is not converging, and a human should see the transcript.

Verify this before you blame the provider

Two transport details are worth checking against primary docs rather than folklore, because both can look like a broken model from the outside.

  • In requests, the timeout parameter is not a total download deadline. The library documents it as applying per socket operation, so a slow stream can look like a stall even though bytes are still arriving.
  • In httpx, timeouts are split by phase (connect, read, write, pool), and read is the wait for a chunk of data rather than for the whole body.
  • A response can also end with finish_reason of length rather than stop, and a truncated tool call may not be valid JSON at all. Parse arguments defensively and check the current behaviour of your specific endpoint in its own docs.

Those links are the authority here, not me. My harness uses a stub, so it proves the client-side accounting and says nothing about how any particular hosted model behaves under load.

Decision table

Case Dedupe by canonical signature? Hard budget on? Why
Read-only tools (file reads, SELECT, GET) Yes Yes Same call, same answer, within a single run
Side-effecting tools (email, POST, payments) No, use server-side idempotency keys Yes A client cache cannot unsend a message
Polling or job-status tools No Yes, plus a deadline Repetition is the point; caching would freeze the state
Single-shot prompting without tools Not applicable Not applicable There is no loop to guard

What I would repeat, and what I would change

I would repeat the stub-model approach, because it made the failure reproducible in a terminal instead of in a chat UI. I would also repeat logging the executions list and printing it at the end, since that one line is what exposed the seven-versus-four discrepancy.

What I would change is the order of work. I spent the first day reading model output and the second day reading my own guard. Next time the counter gets written first, with the stub, before a single token is requested from anywhere.

The harness runs against any OpenAI-compatible endpoint, including the free model access MonkeyCode advertises, and the debugging steps do not change if you swap the stub for a hosted model. If you take one thing from these field notes, take the counter: measure executions, not turns, and the mystery usually collapses in an afternoon.

Top comments (0)