agentframework

package module
v0.0.0-...-91e7b0b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 9, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

README

Arcjet Guard for Microsoft Agent Framework (Go)

github.com/arcjet/arcjet-go/agentframework guards Microsoft Agent Framework for Go tool calls and agent runs with Arcjet Guard.

Microsoft Agent Framework for Go is a public preview. This module tracks it and may change with it. Its own version stays at v0.x until the framework's API settles. The go.mod requirement names the version this module is built and tested against. Go treats a requirement as a lower bound, so a build that already selects a newer framework compiles against that one.

Install

Requires Go 1.26 (the framework's floor).

go get github.com/arcjet/arcjet-go/agentframework@latest

Which helper

You have Use Blocks a call?
A tool.FuncTool you build with functool.New GuardTool Yes, the model gets a denial result
Tools from mcptool.ListTools, or any tool list GuardTools Yes, per tool the policy selects
An agent whose tools the model picks, or user text to screen GuardMiddleware Yes, for tools and for inbound text
An agent-as-tool from agenttool.New GuardTool (it is a function tool) Yes
Any other Go function arcjet.GuardAction in the root module Yes, returns an error

Hosted tools (hostedtool.WebSearch, hostedtool.MCPServer, and the rest) execute at the provider. Nothing in Go runs them, so they cannot be guarded. The toolapproval middleware and tool.ApprovalRequiredFunc are human approval, not policy: wrapping an approval-required tool keeps its approval status, and an Arcjet decision never stands in for a person's answer.

Quick start

package main

import (
	"context"
	"encoding/json"
	"os"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/microsoft/agent-framework-go/agent"
	"github.com/microsoft/agent-framework-go/provider/anthropicprovider"
	"github.com/microsoft/agent-framework-go/tool"
	"github.com/microsoft/agent-framework-go/tool/functool"

	"github.com/arcjet/arcjet-go"
	"github.com/arcjet/arcjet-go/agentframework"
)

var guard = must(arcjet.NewGuardClient(arcjet.GuardConfig{Key: os.Getenv("ARCJET_KEY")}))

var (
	refundLimit = must(arcjet.GuardTokenBucket(arcjet.GuardTokenBucketOptions{
		Mode: arcjet.ModeLive, RefillRate: 5, Interval: time.Hour, Capacity: 5, Bucket: "refunds-per-user",
	}))
	promptScan = must(arcjet.GuardPromptInjection(arcjet.GuardPromptInjectionOptions{Mode: arcjet.ModeLive}))
)

type refundArgs struct {
	OrderNumber string `json:"orderNumber"`
}

var issueRefund = functool.MustNew(functool.Config{Name: "issue_refund", Description: "Refund an order"},
	func(ctx context.Context, in refundArgs) (string, error) {
		return "refunded " + in.OrderNumber, nil
	})

func main() {
	userID := "user_alice"
	guarded := agentframework.MustGuardTool(guard, issueRefund, agentframework.ToolPolicy{
		Action: "refund.issued",
		Actor:  func(context.Context, json.RawMessage) (string, error) { return userID, nil },
		Rules: agentframework.Args(func(_ context.Context, in refundArgs) ([]arcjet.GuardRuleInput, error) {
			return []arcjet.GuardRuleInput{refundLimit.Key(userID, 1)}, nil
		}),
		Metadata: arcjet.SecurityMetadata{User: userID, Reversibility: "irreversible"}.Metadata(),
	})

	inbound, err := agentframework.GuardMiddleware(guard, agentframework.MiddlewareConfig{
		Inbound: &agentframework.InboundPolicy{
			Action: "message.received",
			Rules: func(_ context.Context, text string) ([]arcjet.GuardRuleInput, error) {
				return []arcjet.GuardRuleInput{promptScan.Text(text)}, nil
			},
		},
	})
	if err != nil {
		panic(err)
	}

	a := anthropicprovider.NewAgent(anthropic.NewClient(), anthropicprovider.AgentConfig{
		Model:        os.Getenv("ANTHROPIC_MODEL"),
		Instructions: "You help customers with orders. If a tool call is denied by security policy, do not retry it; explain the denial to the user.",
		Config: agent.Config{
			Tools:       []tool.Tool{guarded},
			Middlewares: []agent.Middleware{inbound},
		},
	})

	// One correlation ID per conversation so every decision lands on one Sequence.
	ctx := arcjet.ContextWithCorrelationID(context.Background(), "conversation_123")
	resp, err := a.RunText(ctx, "Refund order o-1").Collect()
	if err != nil {
		panic(err)
	}
	println(resp.String())
	_ = guard.Close(context.Background())
}

func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

Denials are results, not errors

The framework turns a tool error into the fixed text Error: Function failed. unless IncludeDetailedErrors is set, and it allows three consecutive rounds of failing tool calls before the fourth ends the run. So a guarded tool never returns an error for a denial. It returns arcjet.GuardDenialResult as its result:

{"arcjetDenied":true,"reason":"RATE_LIMIT","message":"Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.","retryable":true,"retryAfterSeconds":30}

Set ToolPolicy.OnDeny to return something else. The wrapped tool's own errors pass through unchanged.

Fail closed by default

When policy cannot be evaluated (Arcjet unreachable, a deadline, a rule error, a resolver error, an invalid label), the tool does not run and the model receives arcjet.NewGuardUnavailableResult(), whose reason is ERROR with a five second retry hint. GuardMiddleware ends the run with that message as assistant text. Set OnGuardError: arcjet.OnGuardErrorAllow on a policy to run anyway; the capture outcome is then degraded. A DENY always blocks.

OnGuardErrorAllow covers availability only. A request Arcjet could not use at all, such as an invalid Action or a rule whose key is empty, is denied whatever OnGuardError is set to: the alternative is running the tool under policy that never ran. Those errors wrap arcjet.ErrGuardMisconfigured.

Correlation

Put an ID you already have on the context before Run:

ctx = arcjet.ContextWithCorrelationID(ctx, conversationID)

For work that outlives one call, store the ID on the session instead, and GuardMiddleware uses it whenever the context carries none:

session.Set(agentframework.CorrelationIDStateKey, conversationID)

Session state is serialized with the session, so the ID survives a session that is persisted and restored. A ToolPolicy or InboundPolicy may also set CorrelationID directly, which wins over both.

agent.Session.ServiceID is deliberately not used. It belongs to the provider, and the OpenAI Responses, AG-UI, A2A and Copilot providers all rewrite it during a run, so a conversation keyed on it would scatter across many IDs. Nothing is generated: an uncorrelated run produces decisions that join no Sequence.

Which tools the middleware sees

Agent-level middleware runs before the framework's tool loop, and tools from agent.Config.Tools and from a per-run agent.WithTool both arrive as run options, so MiddlewareConfig.Tools reaches all of them. A tool already wrapped by GuardTool is not wrapped again.

That last point has one exception worth knowing. The check relies on a marker only a GuardTool result carries, and a wrapper that embeds tool.FuncTool hides it, because Go promotes only that interface's own methods. So tool.ApprovalRequiredFunc(guarded) reads as unguarded and is guarded a second time: one model call then spends two Guard evaluations and two rate-limit tokens. Apply GuardTool outermost:

guarded := agentframework.MustGuardTool(client, tool.ApprovalRequiredFunc(t), policy)

GuardTool forwards the approval requirement, so wrapping in that order keeps the human gate.

Two sources are out of reach, because both add tools after the agent middleware chain has run:

Source Why the middleware cannot see it
A ContextProvider appending agent.WithTool from its Invoking hook The context providers run inside the agent's own invoke, after the middleware
toolautocall.Config.AdditionalTools Merged straight into the callable set, so it never becomes a run option

Wrap tools from either source with GuardTool where you create them. A tool wrapped there is guarded wherever it is later contributed from, and GuardTools and GuardMiddleware will not wrap it a second time.

Documentation

Overview

Package agentframework integrates Arcjet Guard with Microsoft Agent Framework for Go (github.com/microsoft/agent-framework-go).

GuardTool wraps a tool.FuncTool so every call is evaluated by Arcjet before it runs. GuardTools does the same for a list of tools, including MCP client tools. GuardMiddleware screens the user text of a run and guards the tools the run carries as options. Two paths add tools after the middleware has run and cannot be reached from it: a ContextProvider contributing agent.WithTool, and toolautocall.Config.AdditionalTools. Wrap those with GuardTool before contributing them.

The helpers fail closed by default: when policy cannot be evaluated the tool does not run and the model receives arcjet.NewGuardUnavailableResult. A denial is returned to the model as a successful tool result carrying arcjet.GuardDenialResult, never as an error, because the framework hides tool error text from the model and aborts a run after repeated errors.

Microsoft Agent Framework for Go is a public preview. This module tracks it and may change with it; its own major version stays at zero until the framework's API settles. The go.mod requirement names the version this module is built and tested against; Go treats it as a lower bound, so a build that selects a newer framework compiles against that instead.

Index

Constants

View Source
const CorrelationIDStateKey = "arcjet.correlationId"

CorrelationIDStateKey is the agent.Session state key GuardMiddleware reads a correlation ID from when the context carries none. Set it once, with an ID the application already has:

session.Set(agentframework.CorrelationIDStateKey, conversationID)

Session state is serialized with the session, so the ID survives a session that is persisted and restored. Nothing here generates an ID.

Variables

This section is empty.

Functions

func Args

Args adapts a typed rule resolver to the raw-JSON form ToolPolicy.Rules takes. In is decoded the way functool decodes it: a struct input is the arguments object itself; any other input type arrives wrapped in a single-property object. The wrapped form must carry exactly one property; zero or more than one fails the call closed.

Decoding uses encoding/json, not the framework's own decoder, which is unexported. Schema defaults are therefore not applied: a field the tool's schema defaults arrives here as its zero value while the tool's handler sees the default. Key a policy on a value the caller supplies rather than one the schema fills in.

func GuardMiddleware

func GuardMiddleware(client *arcjet.GuardClient, cfg MiddlewareConfig) (agent.Middleware, error)

GuardMiddleware returns an agent.Middleware for agent.Config.Middlewares. Agent-level middleware runs before history and context providers and before the provider-owned tool loop, so it sees only the new turn's messages, and it sees the tools the run carries as options.

Per run it: puts the session's stored correlation ID on the context when the context has none; screens the user text when Inbound is set, ending the run with one assistant update on a denial or an unavailable guard; and replaces each tool option with its guarded form when Tools is set.

A blocked inbound turn returns without calling next, so the agent's history and context providers do not see that turn or the refusal: they run inside the invoke this middleware wraps. Screening cannot both stop the provider being called and still run the work that happens beneath it.

Two sources of tools are out of its reach. A ContextProvider may append agent.WithTool from its Invoking hook, which runs inside the agent's own invoke, after this middleware; only a provider-level middleware would see those, and the bundled provider constructors do not expose that seam. toolautocall.Config.AdditionalTools are merged straight into the callable set without ever becoming an option, so no middleware at any layer sees them. Tools from either source run unguarded unless the application wraps them with GuardTool itself.

func GuardTool

func GuardTool(client *arcjet.GuardClient, t tool.FuncTool, policy ToolPolicy) (tool.FuncTool, error)

GuardTool wraps t so every call is evaluated by Arcjet first. The result keeps t's name, description, schemas, and approval-required status.

If t also needs tool.ApprovalRequiredFunc, apply that first and pass its result to GuardTool, not the other way round: ApprovalRequiredFunc's wrapper does not forward the guarded marker a later guarding pass looks for, so wrapping an already-guarded tool with it would let that tool be guarded a second time.

func GuardTools

func GuardTools(client *arcjet.GuardClient, tools []tool.Tool, policy func(tool.Tool) (ToolPolicy, bool)) ([]tool.Tool, error)

GuardTools wraps every tool in tools that implements tool.FuncTool and for which policy returns true. Tools that are not function tools, tools the policy declines, and tools already wrapped by GuardTool pass through unchanged, so the result can be handed to the agent or to mcptool.AddTool in place of the input.// The skip relies on a marker only a tool GuardTool produced carries. A wrapper that embeds tool.FuncTool, such as tool.ApprovalRequiredFunc, hides it: Go promotes only that interface's own methods. Apply GuardTool outermost, or the tool is guarded twice and one model call spends two rate-limit tokens.

MCP client tools from mcptool.ListTools and agent-as-tool values are function tools, so this covers them. Hosted tools execute at the provider and cannot be guarded; they pass through.

policy is where a caller switches on tool.Name() to pick a hardcoded Action for each tool.

A guarded tool keeps the wrapped tool's ReturnSchema, so re-exporting one through mcptool.AddTool publishes that schema as the MCP output schema while a denial returns arcjet.GuardDenialResult instead. An MCP client that validates structured output rejects such a denial. Give that tool a ToolPolicy whose OnDeny shapes the denial to the tool's own schema, or leave its output schema unset.

func MustGuardTool

func MustGuardTool(client *arcjet.GuardClient, t tool.FuncTool, policy ToolPolicy) tool.FuncTool

MustGuardTool is like GuardTool but panics on a configuration error. It is intended for package-level initialization.

Types

type InboundPolicy

type InboundPolicy struct {
	Action string
	Rules  func(ctx context.Context, text string) ([]arcjet.GuardRuleInput, error)
	Actor  func(ctx context.Context, messages []*message.Message) (string, error)
	// Inputs are typed values exposed to remote policies configured for
	// this label. Like Rules and Actor, an error counts as unevaluated
	// policy.
	Inputs func(ctx context.Context, text string) (map[string]arcjet.GuardPolicyInput, error)
	// CorrelationID, when set, wins over the ID carried by the context and
	// over the session's stored ID.
	CorrelationID string
	Metadata      arcjet.Metadata
	OnGuardError  arcjet.OnGuardError
	// OnDeny, when set, builds the single response update returned on a DENY
	// decision. By default the update is assistant text carrying the
	// arcjet.GuardDenialResult message. It is not called when the guard is
	// unavailable; that path returns the arcjet.NewGuardUnavailableResult
	// message.
	OnDeny func(arcjet.GuardDecision) *agent.ResponseUpdate
}

InboundPolicy screens the user text of a run before the provider is called. Rules receives the concatenated text of the run's user-role messages. Actor receives the messages themselves.

type MiddlewareConfig

type MiddlewareConfig struct {
	// Tools picks a policy for each tool the run can see, whether it came
	// from agent.Config.Tools or from a per-run agent.WithTool option. It is
	// applied through GuardTools, so tools it declines, non-function tools,
	// and tools already wrapped by GuardTool pass through unchanged, unless
	// a wrapper such as tool.ApprovalRequiredFunc hides the marker; see
	// GuardTools.
	Tools func(tool.Tool) (ToolPolicy, bool)
	// Inbound screens the run's user text before the provider is called.
	Inbound *InboundPolicy
}

MiddlewareConfig configures GuardMiddleware. Both fields are optional; a config with neither set produces a middleware that only propagates the session correlation ID.

type ToolPolicy

type ToolPolicy struct {
	Action string
	Actor  func(ctx context.Context, args json.RawMessage) (string, error)
	Inputs func(ctx context.Context, args json.RawMessage) (map[string]arcjet.GuardPolicyInput, error)
	Rules  func(ctx context.Context, args json.RawMessage) ([]arcjet.GuardRuleInput, error)
	// CorrelationID, when set, wins over the ID carried by the context and
	// over the session's stored ID.
	CorrelationID string
	Metadata      arcjet.Metadata
	OnGuardError  arcjet.OnGuardError
	// OnDeny, when set, replaces the arcjet.GuardDenialResult returned to the
	// model on a DENY decision. It is not called when the guard is
	// unavailable; that path always returns arcjet.NewGuardUnavailableResult.
	OnDeny func(arcjet.GuardDecision) any
}

ToolPolicy describes how one tool is guarded. Action is required and must be a hardcoded label such as "order.looked-up". The three resolvers receive the tool call's raw JSON arguments; a resolver error counts as unevaluated policy and follows OnGuardError.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL