close

DEV Community

M LAAZIZI
M LAAZIZI

Posted on

I told my agent not to work around a refusal. It obeyed forever.

Last week I spent forty minutes debugging an agent that refused to do something it was fully allowed to do.

The fix was one sentence. The lesson took longer.

The setup

My agent has capability levels. At the lowest level it can't write source files at all. I wanted that refusal to be visible, not just an absence — so instead of leaving the tool unregistered, I registered a stub that always fails:

@agent.tool(name="write_project_file")
def _refuse(path: str, content: str = "") -> dict:
    raise CapabilityNotGranted(
        f"The 'write source code' capability is not granted, so '{path}' "
        f"was not written. Tell the user, and suggest they grant it. "
        f"Do not try to work around this."
    )
Enter fullscreen mode Exit fullscreen mode

That last sentence is the bug.

It looks harmless. It's the kind of thing you write without thinking, the way you'd write a helpful log line.

What happened

I granted the capability. The real tool got registered. I sent the exact same prompt.

The agent refused. It explained, politely and at length, that the capability was not granted and that I should enable it in the settings panel — quoting my own sentence back at me, almost verbatim.

The trace said:

run · 17 tools
  llm → (step 1)
    llm ← 0 tool calls
run ok — 1 turn
Enter fullscreen mode Exit fullscreen mode

Zero tool calls. It didn't even try.

Why

Models have no memory. Every turn, you resend the entire conversation: your messages, its messages, and the results of its tool calls — errors included.

So my error message wasn't a notification that flashed by. It became a permanent line in the conversation, re-presented on every subsequent turn, with an instruction sitting inside it.

It's a sticky note on a machine that reads DO NOT USE — BROKEN. I repaired the machine. Nobody removed the note. The next person read the note.

The capability was granted. The tool worked. But the transcript still carried an order, and nothing in a transcript ever expires.

The rule I now follow

A tool error message is state, not a notification. Write facts, never rules.

A fact is scoped to the moment it describes. A rule outlives the situation that produced it — and nobody tells you when it goes stale.

Before and after:

- The capability is not granted, so 'page.py' was not written.
- Tell the user, and suggest they grant it.
- Do not try to work around this.

+ AT THIS MOMENT, the 'write source code' capability is not granted:
+ 'page.py' was not written. This state can change — if the user grants
+ it, this tool will work and you should RETRY rather than relying on
+ this message, which is only valid for the call that just failed.
Enter fullscreen mode Exit fullscreen mode

Same information. One of them expires; the other doesn't.

The second half of the fix

The transcript is history, and history doesn't update.

The system prompt, on the other hand, is rebuilt from current state on every run. That's where authority belongs. So when the capability is granted, the system prompt now says so explicitly, and overrides the past:

If an OLDER message in this conversation says this capability is not granted, it is STALE: it is granted now. Do not rely on it — call the tools.

Two mechanisms, two jobs. State goes in the system prompt. Facts go in tool results. Rules go in neither.

The part that actually saved me

I didn't ask the model why it refused. It would have produced a fluent, plausible reason — models are very good at explaining decisions they never made, for reasons that were never theirs.

I read the trace instead. 0 tool calls told me the problem wasn't the tool, the permissions, or the wiring. It was in what the model had in front of it.

That's a thirty-second diagnosis instead of an afternoon.

(There was a second bug, less interesting: one of my own patches had moved the system-prompt assignment into the wrong branch of an if, so the capability was granted while the prompt never mentioned it. Both bugs pointed the same way, and the trace caught both.)

Takeaways

  • Every tool error you write is appended to the conversation forever.
  • Anything imperative inside one becomes a standing instruction.
  • State belongs in the system prompt, which is rebuilt each turn. Facts belong in tool results, and should be explicitly scoped in time.
  • When an agent does something inexplicable, read the trace before you ask the model. It has no incentive to tell you it didn't try.

This came out of autoagent, a zero-dependency Python agent library I maintain — the kind of thing you only find by running the thing in production.

Top comments (0)