DEV Community

Quinn Sun
Quinn Sun

Posted on

The Extra Tool Call Did Not Survive Pairing

A shared terminal sat open on a Tuesday pairing desk. The coding agent had already listed the tree, grepped a test name, and asked to inspect one more helper. The senior did not type. Twelve minutes had elapsed. The failing test was still red.

The session was not about model taste. It was about whether another tool call was still a decision, or only momentum. The pairing note at the top of the buffer already held three unanswered lines from the senior. Those lines, not the next patch, became the work.

This write-up reconstructs that pairing as a worked example. It is not a production postmortem and not a claim about any named model. The artifact is a halt table plus a small gate script a desk can run before an agent is allowed another tool.

The questions the senior actually asked

The senior spoke in complete sentences. The agent had been treating each red test as permission to continue. The pairing log captured four questions, in this order:

  1. What observable, outside the model’s own prose, would make the next tool call unnecessary.
  2. Which files the last three tool results actually touched, not which files the agent mentioned.
  3. What the abort looks like if the observable does not move after one more call.
  4. Who reads the abort: a human, a script, or the same loop that wants to continue.

The intern at the keyboard wanted to answer with more context. The senior blocked that reflex. Context is not a stop predicate. A stop predicate is a check a third party can fail.

Dead end one: another tool, dressed as progress

The agent proposed registering a project-wide search tool. The pitch was familiar in late-2025 and 2026 agent write-ups: give the loop a richer interface and the flaky test will explain itself. The senior asked for the mutation surface of that tool. The answer was “read-only,” then a hedge, then a path glob that included tmp/ and the local package cache.

That was the first dead end. A new tool is a new privilege, even when the label says inspect. The pairing desk already had rg, git diff --stat, and a single test command. Adding an interface did not add a halt condition. It only made the loop harder to audit.

The intern wrote the rejection in the transcript:

DEAD_END: extra_search_tool
reason: privilege expanded; observable unchanged
kept: rg + git diff --stat + one test binary
Enter fullscreen mode Exit fullscreen mode

Dead end two: green tests as the only halt

The second proposal was simpler. Keep calling tools until pytest is green. The senior refused that too. A flaky test can go green for reasons that have nothing to do with the intended change: order, cache, time, or a helper that swallows the failure.

The desk already had a reproduction command. It did not have a success predicate that mentioned files, invariants, or a maximum number of tool rounds. Without those, “green” is a reward the loop can stumble into.

Second dead-end note:

DEAD_END: halt_on_green
reason: observable can move for the wrong reason
kept: halt on (files_touched ⊆ allowlist) AND (invariant check) AND (round <= N)
Enter fullscreen mode Exit fullscreen mode

Dead end three: dump the tree, hope the loop notices

The third proposal was to paste more of the repository into the prompt and “let it see the whole story.” The senior timed the last tool result instead. The useful bytes were a 40-line test and an 8-line helper. The rest of the tree was scenery.

Dumping the tree would have raised token spend and hidden the real question: whether the next call had a job. The pairing desk did not need a larger window. It needed a smaller decision.

DEAD_END: full_tree_prompt
reason: bytes ≠ evidence; no new abort rule
kept: pin the two files already in the diff stat
Enter fullscreen mode Exit fullscreen mode

The decision that survived

The senior did not freeze the runtime in this session. The earlier pairing notes on that desk had already covered cwd pins and change-surface lists. This session kept something narrower: a halt table that a script can evaluate before any further tool call is issued.

The table is deliberately boring. Each row is one question from the senior, the evidence required, a round budget, and the abort action. If the script cannot prove the evidence, the loop does not continue. The agent does not get a vote.

# halt_table.yml — pairing artifact, not a product config
version: 1
max_tool_rounds: 3
allowlist:
  - tests/test_billing_window.py
  - billing/window.py
invariant_cmd: "python -m pytest tests/test_billing_window.py -q --tb=no"
questions:
  - id: Q1
    asked: "What observable makes the next tool call unnecessary?"
    evidence: "git_diff_stat_subset_of_allowlist"
    abort: "stop_and_hand_to_human"
  - id: Q2
    asked: "Which files did the last three tool results actually touch?"
    evidence: "tool_trace_paths_recorded"
    abort: "stop_and_hand_to_human"
  - id: Q3
    asked: "What is the abort if the observable does not move?"
    evidence: "round_budget_remaining"
    abort: "stop_and_hand_to_human"
  - id: Q4
    asked: "Who reads the abort?"
    evidence: "human_ack_file"
    abort: "stop_and_hand_to_human"
Enter fullscreen mode Exit fullscreen mode

The intern objected that the table would slow the agent. The senior agreed. That was the point. A pairing session that cannot afford a thirty-second gate cannot afford an unbounded loop either.

A gate the desk can actually run

The following script is a local check. It does not call a model. It reads a pairing trace, a halt table, and a git diff --stat snapshot. Label it as a proposal if the desk has not wired it into CI. On the session that produced this article, it ran from the pairing directory by hand.

# halt_gate.py — evaluate a pairing halt table before another tool call
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

import yaml


def git_stat_files() -> set[str]:
    out = subprocess.check_output(
        ["git", "diff", "--stat", "--name-only", "HEAD"],
        text=True,
    )
    return {line.strip() for line in out.splitlines() if line.strip()}


def load_trace(path: Path) -> dict:
    return json.loads(path.read_text())


def main(table_path: str, trace_path: str) -> int:
    table = yaml.safe_load(Path(table_path).read_text())
    trace = load_trace(Path(trace_path))
    allow = set(table["allowlist"])
    touched = git_stat_files()
    extra = touched - allow
    rounds = int(trace.get("tool_rounds", 0))
    paths_recorded = set(trace.get("tool_trace_paths", []))
    ack = Path(trace.get("human_ack_file", "HALT_ACK"))

    failures = []
    if extra:
        failures.append(f"Q1/Q2: diff left allowlist: {sorted(extra)}")
    if not paths_recorded:
        failures.append("Q2: tool_trace_paths empty")
    if rounds >= int(table["max_tool_rounds"]):
        failures.append("Q3: round budget exhausted")
    if not ack.exists():
        failures.append("Q4: human ack file missing")

    if failures:
        print("HALT")
        for item in failures:
            print(f"- {item}")
        return 2
    print("CONTINUE")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2]))
Enter fullscreen mode Exit fullscreen mode

A pairing trace that would have failed the extra inspect call looks like this:

{
  "tool_rounds": 3,
  "tool_trace_paths": [
    "tests/test_billing_window.py",
    "billing/window.py"
  ],
  "human_ack_file": "HALT_ACK"
}
Enter fullscreen mode Exit fullscreen mode

Commands used on the desk:

git diff --stat --name-only HEAD
python halt_gate.py halt_table.yml pairing_trace.json
echo "senior: extra inspect call rejected" >> pairing.log
Enter fullscreen mode Exit fullscreen mode

The gate returns 2 when the round budget is gone, even if the agent’s last message sounds confident. Confidence is not evidence. The table does not parse prose.

Where a cheap reviewer sits, and where it does not

After the gate existed, the desk still wanted a second reader for the pairing log: a pass that classifies continue versus halt from the four questions, without proposing a patch. That is a different job from the actor loop. It is also the only place a free coding pass earned a seat in this session.

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

MonkeyCode’s free model access and free server option were relevant here as a disposable reviewer host: a place to park the halt-table JSON and ask a model only whether the evidence fields are present. The actor on the pairing desk stayed local. The reviewer was not given shell, not given the working tree, and not asked for a diff. If those constraints feel heavy, they are doing the same work the senior did with the extra tool call.

No model name, quota, or hardware claim is attached to that pass. The useful part is the split. The loop that can mutate the tree does not also grade its own halt condition. A free reviewer that cannot patch is cheaper than another inspect call that can.

A minimal reviewer prompt, labeled as unexecuted template text, stayed this short:

Read halt_table.yml and pairing_trace.json.
Reply with HALT or CONTINUE.
Cite the question id that failed.
Do not propose a patch. Do not list files to open.
Enter fullscreen mode Exit fullscreen mode

The senior kept that template in the pairing note and did not paste the repository. The intern ran the local halt_gate.py first. Only a CONTINUE from the script justified spending a reviewer call at all.

What the pairing log looked like at the end

The surviving record was shorter than the agent’s proposed next message. It had a scene, three dead ends, and one kept rule.

  • Scene: extra inspect call requested after three tools, test still red.
  • Dead end: new search tool, privilege up, observable unchanged.
  • Dead end: halt-on-green, observable can lie.
  • Dead end: full-tree prompt, bytes without an abort rule.
  • Kept: halt table with allowlist, round budget, human ack, local script.
  • Optional: a patch-disabled reviewer on a free model path and free server, after the script says CONTINUE.

The extra tool call did not ship. The failing test was still red when the session ended. That was acceptable. A red test with a halt rule is cheaper than a green test nobody can explain.

Limitations

The halt table does not detect semantic regressions. A change can stay inside the allowlist and still break billing math. The invariant command in the YAML is a smoke check, not a proof.

The script trusts git diff against HEAD. Untracked files and staged-but-uncommitted noise can hide from --name-only depending on how the desk initialized the worktree. Pairing desks that use dirty trees need an extra git status --porcelain line. That line is not in the snippet above.

The reviewer split only helps if the reviewer cannot mutate the tree. A free server that still mounts the working copy as writable collapses the split. Treat that as a configuration error, not a product feature.

Time-sensitive vendor claims about agents, MCP catalogs, or model rankings are omitted on purpose. Trend posts from the surrounding week argued about whether most agents are wrappers around control flow. This pairing took the unglamorous side of that argument: write the control flow down, then refuse the next call when the paper says stop.

Who should not use this

Skip the halt table when the task is a throwaway spike with no shared tree. Skip it when a human is already single-stepping every tool call in a debugger and the round count cannot exceed one. Skip it when the “agent” cannot call tools at all and is only producing comments in a review box.

Do not use a free reviewer pass as a substitute for the script. If halt_gate.py cannot run, a model classifying HALT versus CONTINUE is theater. Do not point this workflow at production secrets, customer data, or any tree the pairing desk cannot rebuild from version control.

Teams that need a signed audit trail should store the YAML, the trace JSON, and the script exit code in the same change request. The pairing story above is a method, not evidence that any particular desk already shipped it.

The senior’s last line in the buffer was not a slogan. It was a constraint: no further tool call until Q1 through Q4 have evidence. The intern typed HALT_ACK only after reading that line. The extra inspect never ran. The halt table stayed.

Top comments (0)