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
123 lines
6.7 KiB
Markdown
123 lines
6.7 KiB
Markdown
---
|
|
layout: default
|
|
---
|
|
|
|
# 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.
|
|
|
|
### Reliability metadata
|
|
|
|
A wrapper has what it needs to build reliability tooling — loop handling, retry policies, auditing — without coupling to the agent:
|
|
|
|
- **What happened** — a guardrail refusal is tagged with a structured reason on the result, so you switch on it rather than parse a message:
|
|
|
|
```go
|
|
res := next(ctx, call)
|
|
switch res.Refused {
|
|
case ai.RefusedLoop: // the agent repeated an identical call
|
|
case ai.RefusedMaxSteps: // the step budget was exhausted
|
|
case ai.RefusedApproval: // ApproveTool blocked it
|
|
}
|
|
```
|
|
|
|
- **Which run** — `ai.RunInfoFrom(ctx)` returns a correlation id for the run, the agent's name, and the parent run when the call came from a delegated sub-agent:
|
|
|
|
```go
|
|
if run, ok := ai.RunInfoFrom(ctx); ok {
|
|
log.Printf("run=%s parent=%s agent=%s tool=%s", run.RunID, run.ParentID, run.Agent, call.Name)
|
|
}
|
|
```
|
|
|
|
- **Per-call detail** — `call.ID` (correlation), `call.Name`; duration is `time.Since(start)` around `next`, and step/attempt counts are naturally counted by the wrapper itself (it sees every call).
|
|
|
|
## 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](/blog/21) 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
|
|
|
|
- [Plan & Delegate](plan-delegate.html) — the agent's built-in tools
|
|
- [Agents and Workflows](agents-and-workflows.html) — where agents fit
|