DEV Community

Riley Xu
Riley Xu

Posted on

Migration Diary: Extract Loop Stop Conditions Before You Leave a Paid Coding Agent

Paid coding agents usually fail after cutover because stop conditions still live inside the old vendor runtime. You cannot swap a model endpoint and expect the same number of tool rounds to occur. The hidden while-loop used to decide when work was done, blocked, or too expensive to continue. This diary treats those stop conditions as portable policy you extract, test, and carry yourself.

When you leave a paid coding agent, the loop does not shrink itself to match your new host. It either spins until the process is killed, or it exits after one timid tool call. Both outcomes look like model quality problems when they are actually missing brakes and missing continue signals. Own the budget before you drain the paid lease, not after the first runaway session.

The loop budget you never checked in

Hosted coding agents wrap a while-loop that your repository never contained as an explicit object. Each iteration may request a completion, dispatch tools, patch files, or ask the model to continue. The vendor then applies limits you cannot grep: max rounds, max tool calls, and a done heuristic. Those limits are not documentation trivia; they are the difference between a refactor finishing and a runaway bill.

If you only change the base URL, you inherit none of those brakes on the new runtime. You also lose the vendor's keep-going signal that let multi-file edits complete across many rounds. The leftover is therefore policy, not a secret config file sitting in an export archive. Treat every unnamed stop reason as an unpaid dependency on the old host.

A useful budget is small and boring. You need round caps, tool-call caps, output-token caps, consecutive tool-error caps, and a no-progress rule. You also need an explicit UNKNOWN_VENDOR reason so undocumented stops fail closed during replay. Anything vaguer than that will be reimplemented as folklore in chat threads.

Cutover plan: own the stop conditions first

Follow these five steps in order. Do not start DNS or key cutover until step five is green on a replay trace you actually captured.

Step 1 — Record one production loop as a trace

Pick a single coding task that used to succeed on the paid agent, including tool calls and file patches. Capture every turn as JSON: round index, tool names, token counts if present, and the final stop reason. Do not trust the vendor summary field alone, because that field often collapses several internal decisions. Store the trace next to your tests so later budget changes have something concrete to replay.

mkdir -p traces fixtures
curl -sS "$PAID_AGENT_TRACE_URL" \
  -H "Authorization: Bearer $PAID_AGENT_TOKEN" \
  | python3 -m json.tool > traces/prod-refactor.json
wc -l traces/prod-refactor.json
Enter fullscreen mode Exit fullscreen mode

If the vendor has no trace export, log the loop yourself for one freeze window. Wrap the existing SDK calls, write one JSON line per round, and keep the paid runtime read-only. You are collecting evidence, not starting the migration.

Step 2 — Name every stop reason in your own enum

Map vendor strings onto names you control before you write a single retry. Unknown strings must not become TASK_COMPLETE, because that lie ships half-finished diffs. Keep the mapping in one module so product language cannot drift across services. If a reason cannot be mapped, fail closed and keep the paid loop alive for that task class.

VENDOR_STOP_MAP = {
    "completed": "task_complete",
    "max_iterations": "max_rounds",
    "tool_limit": "max_tool_calls",
    "length": "max_output_tokens",
    "cancelled": "user_cancel",
    "empty_assistant": "no_progress",
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Encode a LoopBudget object the new host must honor

Put numbers in code, not in a slide. Start from the captured trace, then tighten one notch so the new host cannot outrun the paid loop. Label these defaults as a proposal until your replay suite has run against real traces. You should not copy a vendor dashboard number you cannot prove from logs.

Step 4 — Fail closed when the new host cannot honor a reason

If the replacement runtime cannot report output tokens, do not pretend the token cap still works. Disable that task class, or route it back to the paid agent until the meter exists. Partial budgets create a false sense of control and hide the exact leftover you are trying to delete. Record the gap in the leftover checklist rather than commenting it out.

Step 5 — Drain the paid runtime only after replay passes

Replay the golden trace against your loop object with tools stubbed and the model call injected. Assert the stop reason, round count, and tool-call count match the trace within a one-round tolerance. Only then move traffic, and move one task class at a time. Leave the paid key in a break-glass path until two quiet days pass.

Artifact: a replayable loop budget

The following module is a concrete starting point, not a benchmarked production runtime. It makes stop conditions testable without calling any vendor SDK. Save it as loop_budget.py and keep vendor mapping in a separate file so cutover diffs stay reviewable.

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import hashlib
import json


class StopReason(str, Enum):
    TASK_COMPLETE = "task_complete"
    MAX_ROUNDS = "max_rounds"
    MAX_TOOL_CALLS = "max_tool_calls"
    MAX_OUTPUT_TOKENS = "max_output_tokens"
    USER_CANCEL = "user_cancel"
    TOOL_ERROR_BUDGET = "tool_error_budget"
    NO_PROGRESS = "no_progress"
    UNKNOWN_VENDOR = "unknown_vendor"


@dataclass(frozen=True)
class LoopBudget:
    max_rounds: int = 12
    max_tool_calls: int = 24
    max_output_tokens: int = 32_000
    max_consecutive_tool_errors: int = 3
    no_progress_rounds: int = 2


@dataclass
class LoopState:
    rounds: int = 0
    tool_calls: int = 0
    output_tokens: int = 0
    consecutive_tool_errors: int = 0
    observation_hashes: list[str] = field(default_factory=list)
    last_stop: Optional[StopReason] = None
    vendor_stop: Optional[str] = None


def observation_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]


def should_stop(budget: LoopBudget, state: LoopState) -> Optional[StopReason]:
    if state.vendor_stop and state.vendor_stop not in {
        "completed",
        "max_iterations",
        "tool_limit",
        "length",
        "cancelled",
        "empty_assistant",
    }:
        return StopReason.UNKNOWN_VENDOR
    if state.rounds >= budget.max_rounds:
        return StopReason.MAX_ROUNDS
    if state.tool_calls >= budget.max_tool_calls:
        return StopReason.MAX_TOOL_CALLS
    if state.output_tokens >= budget.max_output_tokens:
        return StopReason.MAX_OUTPUT_TOKENS
    if state.consecutive_tool_errors >= budget.max_consecutive_tool_errors:
        return StopReason.TOOL_ERROR_BUDGET
    if len(state.observation_hashes) >= budget.no_progress_rounds:
        tail = state.observation_hashes[-budget.no_progress_rounds :]
        if len(set(tail)) == 1:
            return StopReason.NO_PROGRESS
    return None


def apply_round(state: LoopState, *, tools: int, tokens: int, observation: str, tool_error: bool) -> LoopState:
    state.rounds += 1
    state.tool_calls += tools
    state.output_tokens += tokens
    state.observation_hashes.append(observation_hash(observation))
    if tool_error:
        state.consecutive_tool_errors += 1
    else:
        state.consecutive_tool_errors = 0
    return state


def replay_trace(path: str, budget: LoopBudget) -> StopReason:
    with open(path, encoding="utf-8") as handle:
        events = json.load(handle)
    state = LoopState()
    last_reason: Optional[StopReason] = None
    for event in events["rounds"]:
        apply_round(
            state,
            tools=len(event.get("tool_calls", [])),
            tokens=int(event.get("output_tokens") or 0),
            observation=event.get("observation") or "",
            tool_error=bool(event.get("tool_error")),
        )
        state.vendor_stop = event.get("vendor_stop")
        last_reason = should_stop(budget, state)
        if last_reason:
            state.last_stop = last_reason
            return last_reason
    return StopReason.TASK_COMPLETE
Enter fullscreen mode Exit fullscreen mode

A minimal trace fixture keeps the tests honest. Notice the third round repeats the same observation, which should trip NO_PROGRESS if you set no_progress_rounds to 2.

{
  "task": "rename getUser to fetchUser across api and tests",
  "rounds": [
    {
      "tool_calls": [{"name": "read_file"}],
      "output_tokens": 400,
      "observation": "found 3 call sites",
      "tool_error": false
    },
    {
      "tool_calls": [{"name": "apply_patch"}, {"name": "apply_patch"}],
      "output_tokens": 900,
      "observation": "patched api.py; tests still import getUser",
      "tool_error": false
    },
    {
      "tool_calls": [{"name": "apply_patch"}],
      "output_tokens": 200,
      "observation": "patched api.py; tests still import getUser",
      "tool_error": false,
      "vendor_stop": "empty_assistant"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
python3 - <<'PY'
from loop_budget import LoopBudget, replay_trace, StopReason
reason = replay_trace("traces/prod-refactor.json", LoopBudget(no_progress_rounds=2))
assert reason in {StopReason.NO_PROGRESS, StopReason.TASK_COMPLETE, StopReason.UNKNOWN_VENDOR}
print(reason.value)
PY
python3 -m pytest -q test_loop_budget.py
Enter fullscreen mode Exit fullscreen mode
# test_loop_budget.py — labeled unexecuted until you point it at a real trace
from loop_budget import LoopBudget, LoopState, StopReason, apply_round, should_stop

def test_unknown_vendor_fails_closed():
    state = LoopState(vendor_stop="policy_hidden_v3")
    assert should_stop(LoopBudget(), state) is StopReason.UNKNOWN_VENDOR

def test_repeated_observation_is_no_progress():
    budget = LoopBudget(no_progress_rounds=2)
    state = LoopState()
    apply_round(state, tools=1, tokens=10, observation="same", tool_error=False)
    apply_round(state, tools=1, tokens=10, observation="same", tool_error=False)
    assert should_stop(budget, state) is StopReason.NO_PROGRESS
Enter fullscreen mode Exit fullscreen mode

Decision table for stop reasons

Use this table during review so engineers do not argue from screenshots. If a cell says fail closed, that task class stays on the paid agent until the gap is instrumented.

Stop reason What you must observe Safe action on the new host Fail closed if missing
task_complete Model or tool reports a terminal, verified result Return the diff and close the loop No verifier, only a chatty summary
max_rounds Round counter in your process Stop, emit partial log, do not retry blindly Host has no round counter
max_tool_calls Tool dispatcher increment Stop and require a human or a smaller task Tools fire outside your dispatcher
max_output_tokens Tokenizer or vendor usage field you trust Stop and split the task Usage field is absent or delayed
tool_error_budget Consecutive tool failures Stop and surface the last stderr Errors are swallowed as empty tool results
no_progress Repeated observation hashes Stop and ask for a narrower instruction You only log model text, not observations
unknown_vendor Unmapped vendor stop string Keep paid path; do not guess You alias it to task_complete

Leftovers after you leave the paid coding agent

Cutover leftovers hide in places that look unrelated to models. Walk this list before you revoke the paid key, because each item can restart the vendor loop without your budget object. Write the findings into leftovers.md so the next incident does not start from memory.

  1. IDE extensions that still call the paid agent when a test fails, bypassing your round cap entirely.
  2. CI helpers that retry the same coding task on timeout, multiplying max_rounds without a shared counter.
  3. Webhooks that resume a thread after a tool result, with no copy of LoopState on the new host.
  4. Cached vendor "continue" prompts that force another completion after you already stopped locally.
  5. File-watchers that treat every patch as a fresh task and open a second loop on the same workspace.
  6. Observability dashboards that still chart vendor iteration counts, hiding your new stop reasons from on-call.
rg -n "max_iterations|tool_limit|continue_on_error|agent.loop" \
  --glob '!node_modules' --glob '!.git'
rg -n "PAID_AGENT|CODING_AGENT_URL|RESUME_THREAD" .env.example deploy ci
Enter fullscreen mode Exit fullscreen mode

If rg still finds a resume path, that path is a second control plane. Either delete it or pass the same LoopBudget into it. Two control planes means your leftover list is lying.

Replaying the budget without spending the paid gateway

Disclosure: This article was prepared as part of MonkeyCode's product outreach. After the budget object exists, you still need a place to replay traces while the paid coding agent is draining. MonkeyCode's free model access and free server option can host that replay worker so the golden traces do not keep billing the vendor you are leaving. Keep the worker read-only against production workspaces, and keep the paid break-glass path until replay reasons match the table above.

The replay worker should not become a second agent. It should load a trace, apply LoopBudget, stub tools, and write a stop-reason report. If you want a live model in the loop, inject it behind the same counters rather than asking the host to invent its own iteration policy. Free capacity is useful here because replay is repetitive, boring, and easy to accidentally run against a paid SDK.

Limitations and who should not use this

This approach assumes you can observe rounds, tool calls, and some usage signal. If your coding agent is a black box inside an IDE with no log hook, you cannot honestly claim a portable budget. If your tasks are single-shot completions with no tools, a loop object is ceremony and you should not add it.

Do not use these default numbers as capacity planning. They are placeholders until a real trace tells you otherwise, and they are not a performance claim about any host. Do not fail open on UNKNOWN_VENDOR to make a demo look finished. Do not run unconstrained file-editing tools on a shared server just because the model path is free.

Teams that already have a workflow engine with idempotency keys and explicit state machines may only need the mapping layer. Teams that treat the vendor UI as the system of record will fight this diary, because the leftover is the UI itself. In that case, extract traces first and delay any talk of leaving.

Stop conditions are the smallest artifact that makes a coding-agent migration reversible. Extract them, replay them, and only then let the paid loop drain. The model swap is the easy line in the runbook; the leftover is every unnamed reason the old while-loop used to stop.

Top comments (0)