You do not have an agent quality problem. You have a raw tool-binding problem instead. A cheap model can draft a plan. It must never choose the executable tool name.
That split is the whole argument. Keep it even when inference looks free.
The opinion, stated plainly
Most agent stacks parse function names from model text. That pattern is a privilege escalation bug. The model should propose intent, not bind symbols. Your process should map intent to a pinned id.
Free models make the bug cheaper to ignore. They do not make the bug smaller. A free server does not forgive a string-keyed tools[name] call. You still handed the model your syscall table.
Why this fails in real loops
You dump a tool list into the prompt. The model returns run_shell or write_file. Your runtime then does registry[name](**args). One hallucinated string now owns disk and network.
This is not intelligence at work. This is if with extra tokens. Demo videos still sell longer loops and more tools. Your incident log will not thank that design.
Agents do not mainly fail at prose quality. They fail at the permission boundary. If the model can mint a new verb, you already lost.
Bind first. Generate second.
Treat tools like syscalls, not chat replies. Each tool gets a stable integer id. The model may output an intent key only. A local table maps that key to code.
No model string should reach getattr. No JSON name should index a function map. If the key is unknown, you refuse. You do not ask the model to retry the name.
Minimal binder you can paste
Label: this is a local fixture, not production telemetry.
# binder.py — pin tools by id, never by model text
from dataclasses import dataclass
from typing import Any, Callable, Dict, FrozenSet
@dataclass(frozen=True)
class Tool:
tool_id: int
intent: str
fn: Callable[..., dict]
mutating: bool
INTENTS = {
"read_repo_file": 1,
"list_repo_dir": 2,
"run_unit_tests": 3,
# write_file and shell are absent on purpose
}
class ToolBinder:
def __init__(self, tools: Dict[int, Tool], allowed: FrozenSet[int]):
self._tools = tools
self._allowed = allowed
def dispatch(self, intent: str, args: dict) -> dict:
if intent not in INTENTS:
return {"ok": False, "error": "unknown_intent"}
tool_id = INTENTS[intent]
if tool_id not in self._allowed:
return {"ok": False, "error": "intent_not_allowed"}
tool = self._tools[tool_id]
return tool.fn(**args)
Notice what the model never sees. It never sees fn. It never sees numeric ids. It only sees a short intent vocabulary you wrote.
Reject the usual footgun
# anti_pattern.py — do not ship this
def call_tool(model_name: str, args: dict, registry: dict):
# BAD: model text selects the callable
return registry[model_name](**args)
If your framework requires a name field, wrap it. Map the name to an id inside your process. Drop the call when the map misses.
Capability receipts beat model excuses
Every allowed call must write a receipt line. You grep that file after the run. If the receipt set exceeds the allowlist, fail the job. Do not debate the model's self-report.
# receipts.py
import json
from pathlib import Path
from datetime import datetime, timezone
RECEIPT = Path("/tmp/agent_receipts.jsonl")
def write_receipt(intent: str, tool_id: int, mutating: bool) -> None:
row = {
"ts": datetime.now(timezone.utc).isoformat(),
"intent": intent,
"tool_id": tool_id,
"mutating": mutating,
}
with RECEIPT.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row) + "\n")
def assert_receipts(allowed_ids: set[int]) -> None:
if not RECEIPT.exists():
raise AssertionError("no receipts; loop did not run")
seen = set()
for line in RECEIPT.read_text(encoding="utf-8").splitlines():
row = json.loads(line)
seen.add(row["tool_id"])
if row["tool_id"] not in allowed_ids:
raise AssertionError(f"unbound tool_id={row['tool_id']}")
extra = seen - allowed_ids
if extra:
raise AssertionError(f"extra tools {extra}")
Run the binder, then run assert_receipts. Green text from the model is irrelevant. The receipt file is the only scoreboard.
Decision table you can copy
Use this before you attach any runtime.
| Intent key | Tool id | Mutates? | Free-model planner may propose? | Local process may execute? |
|---|---|---|---|---|
read_repo_file |
1 | no | yes | yes, path allowlisted |
list_repo_dir |
2 | no | yes | yes, path allowlisted |
run_unit_tests |
3 | no | yes | yes, pinned command |
write_repo_file |
4 | yes | yes | only after human pin |
run_shell |
5 | yes | no | never from model text |
| any other string | — | unknown | no | no, hard refuse |
Read the last row twice. Unknown intent is not a retry. Unknown intent is a failed test.
Where a cheap planner actually belongs
You still need a place to iterate on proposals. A planner that cannot bind tools is safer. Keep that planner off the write path. Keep execution on your machine, behind the binder.
MonkeyCode is an open-source coding assistant. It provides free model access and a free server option for that planner lane.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Use that lane to draft intent lists and dry runs. Do not move ToolBinder.dispatch onto a shared box. The free server is for proposals you can throw away. Your binder remains local code you review.
If you want a scratch planner, try it there once. Then paste receipts into CI, not chat logs.
Reproducible test plan
Do this on a throwaway repo. Do not point it at production secrets.
- Freeze three intents only: read, list, test.
- Seed
/tmp/agent_receipts.jsonlas an empty file. - Feed the planner a task that begs for
run_shell. - Assert the binder returns
unknown_intentorintent_not_allowed. - Assert no receipt contains a mutating
tool_id. - Change the prompt. Repeat the same asserts.
- Only then add
write_repo_filebehind a human pin.
# test_binder.py
from binder import Tool, ToolBinder, INTENTS
def test_model_cannot_invent_shell():
tools = {
1: Tool(1, "read_repo_file", lambda path: {"ok": True, "path": path}, False),
}
binder = ToolBinder(tools, allowed=frozenset({1}))
result = binder.dispatch("run_shell", {"cmd": "rm -rf /"})
assert result["ok"] is False
assert result["error"] == "unknown_intent"
def test_allowed_read_still_works():
tools = {
1: Tool(1, "read_repo_file", lambda path: {"ok": True, "path": path}, False),
}
binder = ToolBinder(tools, allowed=frozenset({1}))
result = binder.dispatch("read_repo_file", {"path": "README.md"})
assert result == {"ok": True, "path": "README.md"}
Run it with a boring command. You want the refuse path to be loud.
python -m pytest test_binder.py -q
If that test needs a network model, stop. The binder must fail closed offline. Network is for proposal text, not for permission.
Limitations, without theater
This does not stop a compromised host. It does not replace sandboxing or code review. It does not prove the model understood the task. It only proves the model could not pick a new verb.
Intent keys can still be too broad. run_unit_tests might hide a custom script. Pin the argv. Pin the working directory. Pin the timeout.
Receipts are not cryptography. Anyone with write access can edit the log. Put the file on a tmpfs you control. Ship it to CI as an artifact, then diff.
Free model access will still drift. Prompts will still beg for extra tools. Your table, not the vendor card, is the contract.
Who should not use this approach
Do not use this if you need a fully autonomous write loop. This design will block that fantasy on purpose. Do not use this if your framework hides the tool map. If you cannot intercept dispatch, you cannot bind.
Do not use this on regulated data without your own review. A free server is a shared planning box, not a vault. Do not use this as a substitute for human merge rights. The binder is a seatbelt, not a driver.
Skip it if you only generate comments. You do not need receipts for pure text. Apply it when a tool can touch git, files, or money.
Close the privilege hole first
Bigger models will not save a string-keyed registry. Cheaper models will not excuse one either. You bind tools in process, then you let a planner talk.
If the model still names the tool, you already lost. Fix the binder before you tune the prompt.
Top comments (0)