A payments retry agent ran on staging overnight.
It stamped the ticket DONE at 02:14.
No capture webhook had fired during that night.
The halt classifier lived on a free model lane.
A truncated JSON body still scored as success.
Finance caught the miss after the morning batch.
That overnight miss was not a prompt-tuning problem.
The root cause was a control-plane placement problem.
The loop had outsourced the bit that stops work.
Split the loop into two planes
Agent loops mix drafting with later irreversible choices.
The data plane can be noisy and cheap.
The control plane must be local and boring.
Data plane work still includes drafts and summaries.
It also covers speculative refactors and fixtures.
Those outputs can be discarded without side effects.
Control plane work includes halt and retry votes.
It also chooses schema repair versus hard fail.
Those outputs hide side effects or commit them.
Free model access can serve the data plane.
A free server can host scratch sandboxes.
Neither should own the halt bit today.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option.
Those lanes fit drafts, fixtures, and throwaway replays.
They do not fit halt, retry-budget, or commit decisions.
What the halt path actually decides
The halt path is a small set of bits.
Each bit must stay deterministic under production load.
- success versus failure for the current step
- remaining retry budget after a tool error
- schema repair versus an immediate hard fail
- whether a side effect already committed
- whether traces are complete enough to stop
- whether the next tool call stays in policy
A free model can narrate those control bits.
It cannot be the only voter on halt.
Narration remains data and the vote remains control.
Red flags
Treat these markers as hard halt-path defects.
Any one of them is enough to fail the plan.
- The drafting model also returns a done flag.
- A missing router config defaults onto a free lane.
- Halt logic lives inside a prompt, not code.
- Retry budget is a suggestion inside generated JSON.
- Schema repair runs on the same free tool host.
- Success is inferred from tone, not from receipts.
- The free server stores the only trace copy.
- Operators cannot freeze routing without a full deploy.
- Loop continuation depends on a best-effort endpoint.
- Privileged tools execute before a local halt check.
These flags show up in reviews as convenience.
They read as speed but ship as silent commits.
Field guide: keep halt local
Move every halt decision into local process memory.
Keep the function pure and free of side effects.
Log the inputs and ignore model prose for votes.
The local halt function sees receipts, not vibes.
Receipts are HTTP statuses, checksums, and resource ids.
Vibe text from a free model lane does not vote.
Minimal policy file
# halt-policy.yml
control_plane:
halt: local_only
retry_budget: local_only
schema_repair_vote: local_only
data_plane:
draft: allow_free_lane
summarize: allow_free_lane
fixture_host: allow_free_server
routing:
missing_target: fail_closed
privileged_tools_require: local_halt
Missing keys must fail closed without extra debate.
A default toward free compute is a defect.
Config silence is not permission for routing.
Artifact: a halt-path guard in Node.js
The guard runs before every outbound tool call.
It also runs before the loop may exit.
Free lanes never see the halt ballot itself.
'use strict';
const CONTROL_KEYS = new Set([
'halt',
'retry_budget',
'schema_repair_vote',
]);
function failClosed(reason, extra = {}) {
const err = new Error(reason);
err.code = 'HALT_PATH_REFUSED';
Object.assign(err, extra);
throw err;
}
function assertLocalHalt(policy, decisionKey, lane) {
if (!CONTROL_KEYS.has(decisionKey)) return;
if (policy.control_plane?.[decisionKey] !== 'local_only') {
failClosed('control key is not local_only', { decisionKey });
}
if (!lane || lane.tier === 'free' || lane.kind === 'free_server') {
failClosed('free lane cannot own halt path', { decisionKey, lane });
}
if (lane.kind !== 'local_fn') {
failClosed('halt lane must be a local function', { decisionKey, lane });
}
}
function receiptsComplete(receipts) {
if (!Array.isArray(receipts) || receipts.length === 0) return false;
return receipts.every((r) =>
r &&
typeof r.status === 'number' &&
r.status >= 200 &&
r.status < 300 &&
typeof r.id === 'string' &&
r.id.length > 0
);
}
function localHalt({ receipts, retriesLeft, policy, lane }) {
assertLocalHalt(policy, 'halt', lane);
if (!receiptsComplete(receipts)) {
return { halt: false, reason: 'incomplete_receipts' };
}
if (retriesLeft < 0) {
return { halt: true, reason: 'budget_exhausted', ok: false };
}
return { halt: true, reason: 'receipts_ok', ok: true };
}
function localRetryBudget({ retriesLeft, lastError, policy, lane }) {
assertLocalHalt(policy, 'retry_budget', lane);
if (retriesLeft <= 0) return { retry: false, retriesLeft: 0 };
if (lastError && lastError.retryable === false) {
return { retry: false, retriesLeft };
}
return { retry: true, retriesLeft: retriesLeft - 1 };
}
module.exports = {
assertLocalHalt,
localHalt,
localRetryBudget,
receiptsComplete,
CONTROL_KEYS,
};
The module never calls a network model.
It never reads generated done flags either.
Receipts decide the bit and prose does not.
Tests that must stay in CI
These tests are the working artifact for review.
They fail the build when halt drifts to free compute.
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
localHalt,
localRetryBudget,
assertLocalHalt,
} = require('./halt-path');
const policy = {
control_plane: {
halt: 'local_only',
retry_budget: 'local_only',
schema_repair_vote: 'local_only',
},
};
const localLane = { kind: 'local_fn', tier: 'paid_local' };
const freeLane = { kind: 'http', tier: 'free' };
const freeServer = { kind: 'free_server', tier: 'free' };
test('refuses halt when the lane is free', () => {
assert.throws(
() => localHalt({
receipts: [{ status: 200, id: 'cap_1' }],
retriesLeft: 2,
policy,
lane: freeLane,
}),
(err) => err.code === 'HALT_PATH_REFUSED'
);
});
test('refuses halt when the host is a free server', () => {
assert.throws(
() => assertLocalHalt(policy, 'halt', freeServer),
(err) => err.code === 'HALT_PATH_REFUSED'
);
});
test('halts only with complete receipts', () => {
const result = localHalt({
receipts: [{ status: 200, id: 'cap_1' }],
retriesLeft: 1,
policy,
lane: localLane,
});
assert.equal(result.halt, true);
assert.equal(result.ok, true);
});
test('does not halt on truncated receipts', () => {
const result = localHalt({
receipts: [{ status: 200, id: '' }],
retriesLeft: 1,
policy,
lane: localLane,
});
assert.equal(result.halt, false);
assert.equal(result.reason, 'incomplete_receipts');
});
test('retry budget is local and numeric', () => {
const result = localRetryBudget({
retriesLeft: 2,
lastError: { retryable: true },
policy,
lane: localLane,
});
assert.equal(result.retry, true);
assert.equal(result.retriesLeft, 1);
});
Run them with stock Node on the repo.
node --test halt-path.test.js
A green run does not prove the agent is wise.
It proves halt cannot silently move to a free lane.
That is the only claim these tests make.
Decision table
| Signal | Data plane | Halt path | Action |
|---|---|---|---|
| draft quality is uneven | allow a free model | keep local | continue drafting |
| router key missing | n/a | fail closed | do not default to free |
| tool returned 206 and empty id | summarize later | do not halt | wait for receipts |
| retriesLeft hits zero | draft a postmortem | halt as failure | page a human |
| free server is the only log store | forbidden | forbidden | copy traces off-box |
model emits done: true
|
ignore | ignore | read receipts only |
| schema invalid | request a local repair vote | no free voter | hard fail if vote is free |
Read the table left to right during review.
If halt path says fail closed, stop the loop.
Do not negotiate with a generated apology text.
Better alternatives
Keep a tiny local state machine for halt.
Use a free model only to explain that machine.
Never let the explanation write the state.
Store traces on a host the team controls.
Ship copies before the loop is allowed to exit.
A free server is a scratch disk, not an audit log.
Prefer receipts from the tool adapter layer.
HTTP status, idempotency keys, and resource ids count.
Model confidence scores do not count as receipts.
If a team wants a second opinion, add a paid judge.
Even then, the judge cannot move halt alone.
Two free opinions still equal zero halt votes.
Exit criteria
Leave the free-lane halt pattern when any item is true.
- Halt JSON is parsed from a model response.
- A missing env var routes control onto free compute.
- Production traces exist only on a free server.
- Operators cannot explain halt without reading model prose.
- A privileged tool can run before localHalt returns.
- Retry budget decrements inside generated text only.
- CI lacks a test that refuses free-tier halt lanes.
- Incident review cannot replay halt from stored receipts.
Any single match is an exit, not a backlog item.
Move halt local during the same working day.
Then reopen the data-plane experiment if needed.
Who should not use free lanes here
This field guide is not a license to mix planes.
Some teams should skip free lanes on agents entirely.
- Teams on payment, identity, or medical write paths
- loops that send webhooks or mutate tickets
- agents with shell, SQL, or payment tools
- shops without a local halt function in git
- on-call rotations that cannot freeze routing today
- orgs that need attested audit storage for traces
Those teams can still draft on a free model.
They must not host halt on that lane.
Scratch servers stay off the commit path.
Limitations
The guard does not rate model quality at all.
It does not replace IAM or tool allowlists.
It does not prove a free server is unsafe in general.
It only pins halt to a local function.
Teams still need boundary tests for tools.
They still need log retention off the scratch host.
Receipts can be forged by a buggy adapter.
Review the adapter as a privileged component always.
Do not treat a bare 200 as business truth without ids.
Free model access and free servers change over time.
This article does not claim quotas, uptime, or hardware.
Re-read the product terms before any non-scratch use.
Teams trying a free draft lane should keep these halt tests in CI.
The free lane stays optional after halt is local.
Top comments (0)