e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
67 lines
4.7 KiB
Markdown
67 lines
4.7 KiB
Markdown
---
|
|
layout: blog
|
|
title: "Agent Guardrails"
|
|
permalink: /blog/23
|
|
description: "An autonomous agent fails in mundane ways — it loops, it runs away, it takes an action it shouldn't. Go Micro separates orchestration from execution safety, and gives every agent three guardrails at the point where tools actually run."
|
|
---
|
|
|
|
# Agent Guardrails
|
|
|
|
*June 16, 2026 • Asim Aslam*
|
|
|
|
The interesting failures of an autonomous agent aren't dramatic. It calls the same tool with the same arguments over and over, making no progress. It takes thirty steps on a task that needed three, quietly running up cost. It performs an action that a human, or a policy, should have stood in front of. When an agent [runs on its own](/blog/21), there's no one watching to catch any of it.
|
|
|
|
A useful way to think about this — which came up in a community discussion recently — is to separate **orchestration** from **execution safety**. The model decides what to do; that's orchestration. Whether a decided action is actually allowed to run is a separate concern, and it shouldn't be tangled into the model or the services. Go Micro keeps them apart: every tool call an agent makes passes through one choke point, and that's where the guardrails live. They apply uniformly to service calls, custom tools, and `delegate`, and they touch neither the model nor your services.
|
|
|
|
There are three.
|
|
|
|
## Stop on count
|
|
|
|
`MaxSteps` bounds the total tool executions in one request. Past the limit, calls are refused and the model is told to stop and summarize. It's the blunt backstop against runaway cost.
|
|
|
|
```go
|
|
micro.NewAgent("worker", micro.AgentMaxSteps(8))
|
|
```
|
|
|
|
## Stop on repeat
|
|
|
|
This is the one the discussion was really about, and the one we didn't have. `MaxSteps` bounds *how many* calls, but not *whether they're the same call*. An agent can spend its entire budget calling one tool with identical arguments — each call succeeds, and none of them moves anything forward. A circuit breaker won't catch it either, because a circuit breaker reacts to *failures*, and a pointlessly repeated call isn't failing.
|
|
|
|
`LoopLimit` catches it: it bounds how many times the agent may call the same tool with the same arguments in one request. When the limit is hit, the call is refused with a message that names the loop, so the model changes approach instead of spinning:
|
|
|
|
> loop detected: you have already called "search.Search.Query" with the same arguments 3 times and the result will not change. Stop repeating it — try a different approach, or finish with what you have.
|
|
|
|
That refusal-with-reason is what lets the agent recover on its own. It's **on by default** (a lenient 3), because identical repeated calls are never useful; `AgentLoopLimit(0)` disables it.
|
|
|
|
```go
|
|
micro.NewAgent("worker", micro.AgentLoopLimit(3))
|
|
```
|
|
|
|
## Gate the action
|
|
|
|
`ApproveTool` is a hook called before each action runs. Return `false` to block it, with a reason the model sees — for human-in-the-loop approval, spend limits, allow/deny lists, or any policy:
|
|
|
|
```go
|
|
micro.NewAgent("worker", micro.AgentApproveTool(
|
|
func(tool string, input map[string]any) (bool, string) {
|
|
if strings.HasPrefix(tool, "billing_") {
|
|
return false, "billing actions require sign-off"
|
|
}
|
|
return true, ""
|
|
}))
|
|
```
|
|
|
|
## The hook is the seam
|
|
|
|
`ApproveTool` is also where an **external safety layer** plugs in. It sees every tool call before execution and can veto, so you can route decisions to your own rules, a budget service, or a third-party runtime-policy engine — without Go Micro depending on any of them. That's the value of separating the two concerns: orchestration stays in the agent, execution safety stays in the hook, and you can replace the safety layer without touching the agent. Loop detection ships built in because it's near-universal; everything beyond it is a policy you supply.
|
|
|
|
## At the edge, too
|
|
|
|
When agents reach tools **through the MCP gateway**, the gateway adds its own per-tool policies, independent of the agent: `RateLimit` (requests per second) and `CircuitBreaker` (a tool that fails repeatedly is temporarily blocked, so a failing dependency doesn't cascade). With the agent-side guardrails that's a complete set — bound the count, stop the spin, gate the action, rate-limit and circuit-break at the edge.
|
|
|
|
## Why it's the autonomy story
|
|
|
|
None of this matters much when you're sitting in a chat watching the agent work — you are the guardrail. It matters when no one is. An agent triggered by an event, running unattended, needs to fail safely and recover by itself rather than burning resources in a loop nobody sees. Guardrails aren't a feature bolted onto agents; for an autonomous agent they're part of what makes it safe to run at all.
|
|
|
|
See the [Agent Guardrails guide](/docs/guides/agent-guardrails) for the full reference.
|