Back to Go Micro

Agent Guardrails

internal/website/docs/guides/agent-guardrails.md

6.0.05.6 KB
Original Source

Agent Guardrails

An autonomous agent decides its own actions at runtime, which is what makes it useful — and what makes it risky. The common failure modes are mundane: it loops, repeating the same call without making progress; it runs away, taking far more steps (and cost) than the task warrants; it takes an action that should have had a human or a policy in the way.

Go Micro separates orchestration (the model deciding what to do) from execution safety (whether a decided action is allowed to run). Every tool call an agent makes passes through one choke point, and that's where the guardrails live — so they apply uniformly to service calls, custom tools, and delegate, without touching the model or your services.

The three agent guardrails

Stop on count — MaxSteps

Bounds the total number of tool executions in a single Ask. Once exceeded, further calls are refused and the model is told to stop and summarize. The blunt backstop against runaway cost.

go
micro.NewAgent("worker", micro.AgentMaxSteps(8))

Stop on repeat — LoopLimit

Bounds how many times the agent may call the same tool with the same arguments in one Ask. Identical repeated calls make no progress — MaxSteps only bounds them by total count, and a circuit breaker only catches failures, not a call that succeeds and is pointlessly repeated. When the limit is hit, the call is refused with a message that tells the model it's looping, so it 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.

go
micro.NewAgent("worker", micro.AgentLoopLimit(3))

LoopLimit is on by default (a lenient 3) because identical repeated calls are never useful. Set AgentLoopLimit(0) to disable it.

Gate the action — ApproveTool

A hook called before each action runs. Return false to block it, with a reason that's surfaced to the model. Use it 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, ""
    }))

ApproveTool is the integration seam

ApproveTool is also where an external policy engine 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-safety layer — without go-micro depending on it. Orchestration stays in the agent; execution safety stays in the hook. That separation is the whole point: you can swap the safety layer without touching the agent.

Wrap the whole execution — WrapTool

ApproveTool is a before gate. When you need the full lifecycle — timing, logging, metrics, retries, or inspecting the result — wrap the execution instead. WrapTool is the tool-side analogue of go-micro's client.CallWrapper and server.HandlerWrapper: a wrapper takes the next handler and returns a new one, so code before the next(...) call runs before the tool, and code after runs after.

go
import "go-micro.dev/v6/ai"

func logging(next ai.ToolHandler) ai.ToolHandler {
    return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
        start := time.Now()
        res := next(ctx, call)
        log.Printf("id=%s tool=%s took=%s", call.ID, call.Name, time.Since(start))
        return res
    }
}

micro.NewAgent("worker", micro.AgentWrapTool(logging))

The handler signature is the same one every provider uses to execute a tool, and it mirrors a service handler — context first, the call in, a result out:

go
type ToolHandler func(ctx context.Context, call ToolCall) ToolResult
type ToolWrapper func(ToolHandler) ToolHandler

call.ID is a correlation ID carried through from the provider, so a wrapper can tie a tool call back to the request it came from. call.Scan(&v) decodes the arguments into a typed struct when you'd rather not work with the raw map.

Wrappers run outside the built-in guardrails, so they observe every call and its result — including a guardrail's refusal. Multiple wrappers compose outermost-first (the first registered is the outer layer). A "before/after" hook is just the two halves of one wrapper, and retry is calling next again — so the wrapper is the single, composable seam for everything around execution, while MaxSteps, LoopLimit, and ApproveTool remain the named guardrails on top of it.

Execution safety at the gateway

When agents reach tools through the MCP gateway, the gateway adds its own per-tool policies, independent of the agent:

  • RateLimit — requests-per-second per tool.
  • CircuitBreaker — a tool that fails repeatedly is temporarily blocked, so a failing dependency doesn't cascade.

Together with the agent-side guardrails, that's a full set: bound the count, stop the spin, gate the action, rate-limit and circuit-break at the edge.

Why it matters for autonomous agents

These are most important when no human is in the loop. An agent triggered by an event runs unattended — there's no one to notice it looping or to approve a risky call. The guardrails are what let it fail safely and recover on its own rather than quietly burning resources.

See also