DEV Community

Finley Zhu
Finley Zhu

Posted on

Workshop: Catch No-Op Agent Turns With a State Digest in 80 Minutes

Agent loops should terminate when tool results stop changing application state, not when the model still wants another call. This workshop treats an observed stall as a first-class failure mode rather than a retryable timeout. Students will instrument call fingerprints, count consecutive no-op turns, and exit with a structured reason code. The full sequence fits in eighty minutes with a rerunnable fixture and a failing control case.

A practical reading of this week's agent debate

Public developer threads this week keep asking whether typical agents are planners or retrying conditionals with extra ceremony. This workshop does not try to settle that argument, because the stall test does not depend on the label. If a loop can spend tokens without moving state, the wrapper around the tools is a secondary concern. A fail-closed stall bound remains a testable control whether the planner is a model or a scripted list.

Why stalls hide inside successful tool calls

Many classroom agents report success because each tool wrapper returned JSON without raising an exception. That signal is too weak for a planner that slightly mutates arguments and repeats the same tool. Instructors usually notice the waste only after a shared rate limit starts failing during a live lab. The missing check is whether application state actually changed between consecutive planner turns in the store.

A progress bound is not a latency timeout and is not a maximum step cap copied from tutorials. Timeouts fire when the network is slow, and step caps fire even when useful work continues. A stall counter fires only when tool fingerprints and a state digest repeat without a write. That distinction keeps long retrieval jobs alive while still stopping silent argument churn on no-op writes.

Workshop timing and outcomes

The intended audience is backend and ML-adjacent developers who already maintain a tool-calling agent loop. Prior labs on this account treated exit events, router scoring, source tags, and fan-out caps separately. This session isolates state-change detection so those other controls are not mixed into one patch.

Clock

  • 0–10 min: Freeze a tiny ticket store and a planner fixture that repeats equivalent updates on purpose.
  • 10–30 min: Canonicalize tool arguments, hash a domain snapshot, and print both fingerprints after every turn.
  • 30–50 min: Increment a stall counter on repeated no-op digests, then fail closed with STALL_NO_STATE_CHANGE.
  • 50–70 min: Rerun the looping control case and the progressing case, then fill the four-row decision table.
  • 70–80 min: Review snapshot pitfalls, list who should skip this bound, and decide whether a hosted planner swap is even useful.

Exit criteria

  • A rerunnable Python module named stall_lab.py that loops on purpose before the bound is applied.
  • Working call_fp and state_fp values that stay stable across JSON key reordering on the same arguments.
  • A fail-closed policy that returns STALL_NO_STATE_CHANGE before eight identical updates can finish.
  • A decision table that classmates can reuse in review without reading an entire tool-call transcript.

Prerequisites

Each student needs Python 3.11 or newer, a local shell, and permission to run scripts. No production credentials belong in this lab, because the planner is a fixture rather than a live client. If the group later swaps the fixture planner for a hosted model, keep the stall policy outside the adapter. The bound must not depend on prompt wording if the exercises are going to stay testable.

Exercise 1 (10 min): freeze a looping fixture

The fixture below is labeled workshop material and is not a captured production trace from any vendor. It models a planner that keeps calling update_ticket with equivalent payloads while the store stays unchanged. Students should save the file as stall_lab.py and run it before adding any fail-closed bound.

# Workshop fixture: local only. Not a vendor client and not production telemetry.
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from typing import Any, Callable


def canonical_json(value: Any) -> str:
    return json.dumps(value, sort_keys=True, separators=(",", ":"))


def sha256_hex(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


@dataclass
class TicketStore:
    status: str = "open"
    assignee: str = "unassigned"
    writes: int = 0

    def snapshot(self) -> dict[str, Any]:
        return {"status": self.status, "assignee": self.assignee}

    def update_ticket(self, status: str, assignee: str) -> dict[str, Any]:
        if self.status != status or self.assignee != assignee:
            self.status = status
            self.assignee = assignee
            self.writes += 1
        return {"ok": True, "status": self.status, "assignee": self.assignee}


TOOLS: dict[str, Callable[..., dict[str, Any]]] = {}


def looping_plan() -> list[dict[str, Any]]:
    same = {"status": "open", "assignee": "unassigned"}
    return [{"tool": "update_ticket", "args": same} for _ in range(8)]


def progress_plan() -> list[dict[str, Any]]:
    return [
        {"tool": "update_ticket", "args": {"status": "open", "assignee": "ada"}},
        {"tool": "update_ticket", "args": {"status": "pending", "assignee": "ada"}},
    ]


def run_naive_loop(store: TicketStore, plan: list[dict[str, Any]]) -> dict[str, Any]:
    last = None
    for step in plan:
        last = TOOLS[step["tool"]](store, **step["args"])
    return {"ok": True, "steps": len(plan), "last": last, "writes": store.writes}
Enter fullscreen mode Exit fullscreen mode

Smoke command

TOOLS["update_ticket"] = lambda store, **kwargs: store.update_ticket(**kwargs)

if __name__ == "__main__":
    report = run_naive_loop(TicketStore(), looping_plan())
    print(canonical_json(report))
Enter fullscreen mode Exit fullscreen mode
python stall_lab.py
Enter fullscreen mode Exit fullscreen mode

The smoke run should report eight steps, a true ok flag, and zero writes against the ticket store. That combination is the failure mode this workshop is designed to catch in the later exercises. If students already mutate the store on every call, they should restore the no-op path first.

Exercise 2 (20 min): fingerprint the call and the store

A fingerprint has two halves that must be hashed separately if debugging is going to stay honest. The call half covers tool name plus canonical arguments, which catches retries that only shuffle JSON keys. The state half covers a domain snapshot, which catches tools that wrap unchanged records in new JSON. Combining those halves into one digest too early hides which side of the turn was actually stable.

@dataclass
class TurnTrace:
    index: int
    call_fp: str
    state_fp: str
    combined: str
    wrote: bool


def fingerprint_turn(
    index: int,
    tool: str,
    args: dict[str, Any],
    store: TicketStore,
    writes_before: int,
) -> TurnTrace:
    call_fp = sha256_hex(canonical_json({"tool": tool, "args": args}))
    state_fp = sha256_hex(canonical_json(store.snapshot()))
    combined = sha256_hex(f"{call_fp}:{state_fp}")
    wrote = store.writes > writes_before
    return TurnTrace(index, call_fp, state_fp, combined, wrote)
Enter fullscreen mode Exit fullscreen mode

Students should print both hashes after each turn rather than logging only the combined digest value. Classroom debugging goes faster when the group can say the call matched and the state matched. Keep the snapshot function small and explicit, and do not serialize entire Python objects into the digest.

Gate before the next block

  • Reordered keys in args must produce the same call_fp after canonical_json runs.
  • A real assignee change must produce a new state_fp and a true wrote flag.
  • A no-op update must keep state_fp stable and leave wrote false for the stall counter.

Exercise 3 (20 min): fail closed on consecutive stalls

The policy is a small integer threshold, not a classifier score and not a model-generated confidence value. After each turn, increment stalls when the write flag is false and the combined fingerprint repeats. Reset the counter to zero whenever a turn records a real write against the local ticket store. When stalls reach max_stalls, stop immediately and return a structured reason instead of another planner step.

@dataclass
class LoopReport:
    ok: bool
    reason: str
    steps: int
    stalls: int
    writes: int
    traces: list[TurnTrace] = field(default_factory=list)


def run_bounded_loop(
    store: TicketStore,
    plan: list[dict[str, Any]],
    max_stalls: int = 2,
) -> LoopReport:
    traces: list[TurnTrace] = []
    stalls = 0
    prev_combined: str | None = None

    for index, step in enumerate(plan, start=1):
        writes_before = store.writes
        result = TOOLS[step["tool"]](store, **step["args"])
        trace = fingerprint_turn(index, step["tool"], step["args"], store, writes_before)
        traces.append(trace)

        stalled = (not trace.wrote) and (prev_combined == trace.combined)
        stalls = stalls + 1 if stalled else 0
        prev_combined = trace.combined

        if stalls >= max_stalls:
            return LoopReport(
                ok=False,
                reason="STALL_NO_STATE_CHANGE",
                steps=index,
                stalls=stalls,
                writes=store.writes,
                traces=traces,
            )
        _ = result

    return LoopReport(
        ok=True,
        reason="PLAN_EXHAUSTED",
        steps=len(plan),
        stalls=stalls,
        writes=store.writes,
        traces=traces,
    )
Enter fullscreen mode Exit fullscreen mode

Replace the naive main block with two runs: the looping plan and a plan that assigns a ticket. Students should treat max_stalls equal to two as a lab default rather than a portable production constant. Reviewers can later raise the threshold for systems that acknowledge writes only after a short delay.

Worked example students can rerun

The assertions below are lab checks against the fixture, not measurements from a hosted model. Append them to stall_lab.py after TOOLS is wired, then run the file again from a clean shell. The looping case should die early; the progress case should exhaust its two mutating steps.

def report_to_dict(report: LoopReport) -> dict[str, Any]:
    return {
        "ok": report.ok,
        "reason": report.reason,
        "steps": report.steps,
        "stalls": report.stalls,
        "writes": report.writes,
        "trace_count": len(report.traces),
    }


def run_lab_assertions() -> dict[str, Any]:
    TOOLS["update_ticket"] = lambda store, **kwargs: store.update_ticket(**kwargs)

    looping = run_bounded_loop(TicketStore(), looping_plan(), max_stalls=2)
    progress = run_bounded_loop(TicketStore(), progress_plan(), max_stalls=2)

    reordered = [
        {"tool": "update_ticket", "args": {"assignee": "unassigned", "status": "open"}},
        {"tool": "update_ticket", "args": {"status": "open", "assignee": "unassigned"}},
        {"tool": "update_ticket", "args": {"status": "open", "assignee": "unassigned"}},
    ]
    shuffled = run_bounded_loop(TicketStore(), reordered, max_stalls=2)

    assert looping.ok is False and looping.reason == "STALL_NO_STATE_CHANGE"
    assert looping.writes == 0 and looping.steps < 8
    assert progress.ok is True and progress.reason == "PLAN_EXHAUSTED"
    assert progress.writes == 2
    assert shuffled.ok is False and shuffled.reason == "STALL_NO_STATE_CHANGE"

    return {
        "looping": report_to_dict(looping),
        "progress": report_to_dict(progress),
        "shuffled_keys": report_to_dict(shuffled),
    }


if __name__ == "__main__":
    print(canonical_json(run_lab_assertions()))
Enter fullscreen mode Exit fullscreen mode
python stall_lab.py
Enter fullscreen mode Exit fullscreen mode

The looping case should end with ok false and reason STALL_NO_STATE_CHANGE before all eight steps run. The progress case should exhaust the short plan and report two writes against a fresh ticket store. If both cases return PLAN_EXHAUSTED, the stall predicate is not wired to the fingerprints yet.

Exercise 4 (20 min): decision table for review

Use the table during code review rather than arguing from unstructured model logs or chat transcripts. Each row is a lab observation from the fixture, not a vendor benchmark and not a latency study. Rerun stall_lab.py whenever someone changes snapshot so the hashes stay aligned with the domain fields.

Observation wrote Fingerprints Policy result Lab meaning
Eight identical update_ticket calls false combined repeats STALL_NO_STATE_CHANGE planner churn with no store movement
Status open to pending with a new assignee true state_fp changes stalls reset real progress, keep planning
Same arguments with reordered keys false call_fp stable counts as stall canonicalization is doing the job
Two mutating steps under a step cap of eight true never repeats PLAN_EXHAUSTED do not confuse a cap with a stall

Instructors can add a third plan that alternates two equivalent argument dicts with different key insertion order. That plan should still fail closed if canonical_json is used instead of hashing a raw dictionary string. If it does not fail, students probably hashed str(args) and should repair the canonicalization helper.

python -c "import stall_lab as s; s.TOOLS['update_ticket'] = lambda store, **kwargs: store.update_ticket(**kwargs); print(s.run_bounded_loop(s.TicketStore(), s.looping_plan()).reason)"
Enter fullscreen mode Exit fullscreen mode

Optional hosted planner after local green

Some groups replace the list-based planner with a hosted tool-calling model after the fixture tests are green. That swap is optional and should happen only after the stall assertions pass without any network calls. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option that can host this same classroom harness.

Keep the stall policy in process, next to the store, rather than burying it inside a prompt template. Prompts cannot be unit-tested against key order and no-op writes with the same local determinism. If a hosted model is used, log reason, steps, stalls, and writes exactly as the fixture already does. Do not write model names or quota numbers into the lab notes, because those claims drift and break assertions.

If the class needs a shared place to rerun the green fixture, that free server option is enough. The assignment remains the stall bound; hosting is optional and should not replace the local tests. Skip the hosted path entirely when students can already reproduce both rows of the decision table locally.

Limitations

This bound assumes the snapshot function sees every field the business actually cares about during the loop. If students omit assignee from snapshot, a real assignment looks like a stall and the loop dies early. The opposite error is worse, because volatile timestamps make every no-op look like genuine forward progress. Snapshot design is therefore a domain decision, not a hashing trick that can be copied between products.

The stall counter also assumes tools are synchronous and that writes is a trustworthy local signal. External queues, eventually consistent stores, and human approval steps can delay a visible state change past the threshold. In those systems, pair this lab with an explicit pending field in the snapshot instead of guessing. Raising max_stalls without evidence just hides the same no-op pattern behind a larger integer threshold.

Fingerprint equality is not a security boundary and does not prove that a tool call was authorized. It also does not replace result schema validation or memory gating covered in earlier independent workshops on this account. The only question it answers is whether the loop is still moving the store between turns.

Who should not use this approach

Do not install this fail-closed policy on agents whose useful work is side-effect free by design. Read-only research loops that reread the same document will look stalled even when the user is satisfied. Do not use the bound as a substitute for timeouts when the failure mode is a hung socket. Do not ship the fixture planner to production, because it exists only to make stalls cheap to demonstrate.

Teams without a defined state snapshot should not hash whatever JSON the tool happened to return. Returning a new UUID wrapper around the same ticket will reset stalls forever and hide churn. If the domain model is still in flux, keep a hard step cap and postpone this particular lab.

Close

The core conclusion stays the same after the exercises: stop the loop when observed state stops changing. Students who rerun the two plans should see STALL_NO_STATE_CHANGE on churn and PLAN_EXHAUSTED on real writes. That pair is enough to review a classmate's agent without reading an entire tool-call transcript by hand. Record the reason code beside the traces so later labs can consume stalls as data rather than folklore.

Top comments (0)