DEV Community

Jordan Huang
Jordan Huang

Posted on

I Asked Once. The Loop Did Not.

You typed one prompt. The agent answered with confidence. How many hidden calls sat between those two moments?

I keep hearing the same claim in agent threads. People count user turns. They do not count tool rounds. Chat UIs sell bubbles. Traces sell fan-out. Which number do you quote in standup?

This FAQ names five myths I still see in traces. Then I give you a counter you can run in one sitting. No vendor scoreboard. Just a file you can attach to a PR.

Why this FAQ exists

Agent demos look like chat. Production traces look like trees. Did your last "simple fix" hide six tool calls and two retries?

I wrote this for developers wiring tools to a remote model. Not for people training weights. Not for people chasing leaderboard screenshots.

Free model access makes retries cheap to ignore. Cheap to ignore is not cheap to debug. That gap is the whole article.

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

I point this workflow at MonkeyCode when I need free model access and a free server as a scratch runtime. The product is not the test. The round count is the test.

Myth 1: One user prompt equals one model call

Wrong. One prompt is a ticket. The agent is a clerk with a phone.

Typical loop, labeled as a sketch, not a captured production trace:

  1. User sends a task.
  2. Model requests read_file.
  3. Tool returns text.
  4. Model requests grep.
  5. Tool returns matches.
  6. Model writes a patch.
  7. Model summarizes for the user.

You saw two bubbles. The server saw five completions. Maybe more if a tool timed out. Maybe more if the model restated the plan.

Corrected mental model: count completion events, not chat rows. If your UI cannot show that number, your UI is not a trace.

Myth 2: A 200 from a tool means the answer was right

Status codes are transport. They are not semantics. Have you logged the arguments, not just the code?

read_file can return 200 and the wrong path. run_tests can return 200 and skip the suite. repo_grep can return 200 and search a stale checkout. The next prompt will treat that payload as truth.

I treat every tool payload as hostile input to the next round. Even if I wrote the tool. Especially if I wrote the tool.

Corrected mental model: assert on args and body, not on HTTP status. A green tool call can still poison the plan.

Myth 3: The system prompt is the architecture

A long system prompt is a memo. Architecture is what the loop may touch. Can the agent run rm? Can it hit prod credentials? Can it call one tool until the process dies?

Your prompt cannot answer those questions. An allowlist can. A max-round cap can. A working directory jail can. If you skip those, you are not moving fast. You are hoping.

Corrected mental model: policy lives in code. Prompts comment on policy. Comments do not enforce anything.

Myth 4: Streaming tokens mean the plan is finished

Tokens are speech. Plans are state machines. The model can narrate "I will run tests" and never call run_tests. The UI still looks busy. Looks busy is not done.

Streaming also hides retries. A chunked answer can follow a failed tool round you never rendered. Did your client print finish_reason? Or only the pretty text?

Corrected mental model: done means a terminal event you defined. Examples: empty tool_calls, an explicit task_complete, or max_rounds hit. Pick one. Write it down. Fail closed.

Myth 5: Silence means the session held state for you

No. Silence means you did not persist a trace. A free remote box is a compute slot. It is not a lab notebook.

Kill the process and the "memory" is gone unless you wrote a file. This is not a localhost story. This is trace durability. Did you write JSONL? Or did you trust the tab?

Corrected mental model: if it is not in your log file, it did not happen. Screenshots of bubbles are not evidence.

Artifact: a one-file call counter

Do not trust my adjectives. Count. The script below is a proposed tracer. I labeled it as an example. Point it at any OpenAI-compatible chat endpoint.

It prints user turns, completion calls, tool calls, and bytes. It does not grade quality. It grades fan-out. Stub tools on purpose so the first run cannot mutate your repo.

#!/usr/bin/env python3
"""call_counter.py — example tracer, not a benchmark."""
from __future__ import annotations

import json
import os
import sys
import urllib.request
from dataclasses import dataclass, field

@dataclass
class Trace:
    user_turns: int = 0
    completions: int = 0
    tool_calls: int = 0
    bytes_in: int = 0
    bytes_out: int = 0
    events: list = field(default_factory=list)

    def row(self) -> str:
        ratio = self.completions / max(self.user_turns, 1)
        return (
            f"user_turns={self.user_turns} "
            f"completions={self.completions} "
            f"tool_calls={self.tool_calls} "
            f"bytes_in={self.bytes_in} "
            f"bytes_out={self.bytes_out} "
            f"completions_per_turn={ratio:.2f}"
        )

def post_chat(url: str, key: str, payload: dict, trace: Trace) -> dict:
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=body,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {key}",
        },
        method="POST",
    )
    trace.completions += 1
    trace.bytes_out += len(body)
    with urllib.request.urlopen(req, timeout=60) as resp:
        raw = resp.read()
    trace.bytes_in += len(raw)
    data = json.loads(raw.decode("utf-8"))
    message = data["choices"][0]["message"]
    calls = message.get("tool_calls") or []
    trace.tool_calls += len(calls)
    trace.events.append(
        {
            "completion": trace.completions,
            "finish": data["choices"][0].get("finish_reason"),
            "tool_names": [c["function"]["name"] for c in calls],
        }
    )
    return message

def fake_tool(name: str, arguments: str) -> str:
    # Stub only. Replace with real tools in your repo.
    return json.dumps({"ok": True, "tool": name, "echo": arguments[:200]})

def run_loop(task: str, max_rounds: int = 8) -> Trace:
    url = os.environ["MODEL_URL"]
    key = os.environ.get("MODEL_KEY", "")
    tools = [
        {
            "type": "function",
            "function": {
                "name": "repo_grep",
                "description": "Search the workspace. Stubbed in this example.",
                "parameters": {
                    "type": "object",
                    "properties": {"pattern": {"type": "string"}},
                    "required": ["pattern"],
                },
            },
        }
    ]
    messages = [
        {
            "role": "system",
            "content": "Call tools. Do not claim success without a tool result.",
        },
        {"role": "user", "content": task},
    ]
    trace = Trace(user_turns=1)
    for _ in range(max_rounds):
        msg = post_chat(
            url,
            key,
            {
                "messages": messages,
                "tools": tools,
                "tool_choice": "auto",
            },
            trace,
        )
        messages.append(msg)
        calls = msg.get("tool_calls") or []
        if not calls:
            break
        for call in calls:
            result = fake_tool(
                call["function"]["name"],
                call["function"].get("arguments") or "",
            )
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": call["id"],
                    "content": result,
                }
            )
    print(trace.row())
    print(json.dumps(trace.events, indent=2))
    return trace

if __name__ == "__main__":
    task = sys.argv[1] if len(sys.argv) > 1 else "Find TODO comments."
    run_loop(task)
Enter fullscreen mode Exit fullscreen mode

Run it like this:

export MODEL_URL="https://YOUR_COMPATIBLE_ENDPOINT/v1/chat/completions"
export MODEL_KEY="YOUR_KEY"
python3 call_counter.py "List the failing tests. Then stop."
Enter fullscreen mode Exit fullscreen mode

Read completions_per_turn. If it is 1.00, you did not use tools. If it is 6.00 after one sentence, your UI lied about the work. Write the JSON events next to the patch. That file is the review artifact. The chat bubble is not.

Need a second user turn? Call run_loop again and increment user_turns yourself. The point is the ratio, not a framework.

Decision table

Claim you hear What to measure Pass if Fail if
"I only asked once" user_turns vs completions You report both numbers You report only turns
"The tool worked" tool args plus body schema Args match an allowlist You only checked status 200
"It finished" terminal event Explicit stop reason The stream just ended
"It remembered" file on disk JSONL exists after exit State lived in the session only
"I can rerun forever" bytes and rounds You set a cap per task The loop has no brake

Print that table in the PR template if you must. One row beats a paragraph of vibes.

A twenty-minute check

This is a workflow, not a scoreboard. I am not claiming product latency, quotas, or hardware here.

  1. Pick one task with a known stop condition.
  2. Cap max_rounds at a small number. Eight is plenty.
  3. Run the tracer against your endpoint.
  4. Diff completions against the bubbles in the UI.
  5. Open one tool payload. Check the path. Check the assumed cwd.
  6. Save events.json. Attach it to the PR.

Watch for this failure mode. The summary bubble arrives while an earlier round still shows finish_reason: tool_calls. The UI looks complete. The loop is not. That is Myth 4 in the wild.

Another failure: tool_calls is empty, but the text claims a file was edited. That is speech without labor. Reject it.

Where a free model and a free server fit

You need a place to burn bad loops. Paid tokens make people skip the count. That skip is the bug.

MonkeyCode's free model access and free server option are useful here as a scratch runtime. Point MODEL_URL at the workspace you already use. Keep the tracer local. Keep the JSONL local. Do not copy secrets onto the remote box to "make the demo nicer."

I do not claim quotas, GPUs, model names, or latency numbers. Those change. The counting method does not. If the remote box dies, your myth still dies with the log you saved. If you saved nothing, you learned nothing.

Limitations

This tracer does not prove the patch is correct. It proves the loop was visible. Visibility is necessary. It is not sufficient.

It stubs tools. Real tools have side effects. Side effects need extra spans, file diffs, and a rollback plan. Do not promote the stub to prod.

It assumes one chat-completions URL. Some stacks split tool execution onto another worker. Count those hops too, or your ratio is fiction.

It uses a sixty-second timeout. Slow tools look like failures. They might just be slow. Raise the timeout only after you log the hang.

It will not catch prompt injection in tool output. That is a different test. It will not catch a model that lies about tests it never ran, unless you stop stubbing run_tests.

Who should not use this

Do not use this as a production gateway. It is a flashlight. Flashlights are not load balancers.

Do not use this if you have no stop condition. You will watch a loop spin and call it research.

Do not use this to grade a model vendor. Fan-out is a harness property too. A chatty tool schema inflates the count. A silent schema hides work.

Do not use this if your policy forbids sending repo snippets to a remote model. The count does not override that policy. Skip the remote box. Run nothing.

Skip it if you only generate one-shot completions with no tools. Your ratio will always be one. The FAQ will waste your morning.

Corrected model, one line

Prompts are tickets. Completions are labor. Tools are forklifts. Logs are the only memory.

Ask the next demo one question. "Show me the round count." If they show a chat screenshot, they showed a myth.

If you already have a MonkeyCode scratch workspace, run the tracer there once and paste completions_per_turn into the PR. That is the whole ask.

Top comments (0)