diff --git a/agent/harness/toolapproval/toolapproval.go b/agent/harness/toolapproval/toolapproval.go index 3683b7f8..2c458cbc 100644 --- a/agent/harness/toolapproval/toolapproval.go +++ b/agent/harness/toolapproval/toolapproval.go @@ -77,15 +77,24 @@ func saveState(opts []agent.Option, s state) { // New creates a tool-approval middleware that wraps agent runs with // human-in-the-loop approval management. func New(cfg Config) agent.Middleware { - // Config is currently empty and reserved for future extensibility. - _ = cfg - return agent.MiddlewareFunc(run) + return agent.MiddlewareFunc(func(next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return run(cfg, next, ctx, messages, opts...) + }) } // Config configures tool-approval middleware behavior. -type Config struct{} +type Config struct { + // AutoApprovalRules is an optional list of heuristic functions evaluated after + // standing rules (derived from prior user approvals) but before surfacing the + // approval request to the caller. Each rule receives the tool call and returns + // (approved, error). Returning approved=true auto-approves the request. Rules + // are evaluated in order; the first returning approved=true causes the request + // to be auto-approved without prompting the caller. Returning an error fails + // the current run. + AutoApprovalRules []func(context.Context, *message.FunctionCallContent) (bool, error) +} -func run(next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { +func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { return func(yield func(*agent.ResponseUpdate, error) bool) { st := loadState(opts) @@ -94,7 +103,10 @@ func run(next agent.RunFunc, ctx context.Context, messages []*message.Message, o // Step 2: If we have queued requests from a previous turn, drain any // that are now auto-approvable and surface the next one. - drainAutoApprovable(&st) + if err := drainAutoApprovable(ctx, cfg, &st); err != nil { + yield(nil, err) + return + } if len(st.QueuedRequests) > 0 { next := st.QueuedRequests[0] st.QueuedRequests = st.QueuedRequests[1:] @@ -145,7 +157,16 @@ func run(next agent.RunFunc, ctx context.Context, messages []*message.Message, o if matchesRule(st.Rules, req) { autoApproved = append(autoApproved, req.CreateResponse(true, "")) } else { - needsApproval = append(needsApproval, req) + matches, err := matchesAutoApprovalRules(ctx, cfg.AutoApprovalRules, req) + if err != nil { + yield(nil, err) + return + } + if matches { + autoApproved = append(autoApproved, req.CreateResponse(true, "")) + } else { + needsApproval = append(needsApproval, req) + } } } @@ -243,21 +264,29 @@ func prepareInbound(messages []*message.Message, st state) ([]*message.Message, return messages, st } -// drainAutoApprovable removes queued requests that now match a standing rule, -// adding auto-approve responses to collected. -func drainAutoApprovable(st *state) { - if len(st.QueuedRequests) == 0 || len(st.Rules) == 0 { - return +// drainAutoApprovable removes queued requests that now match a standing rule +// or an auto-approval rule, adding auto-approve responses to collected. +func drainAutoApprovable(ctx context.Context, cfg Config, st *state) error { + if len(st.QueuedRequests) == 0 { + return nil + } + if len(st.Rules) == 0 && len(cfg.AutoApprovalRules) == 0 { + return nil } var remaining []*message.ToolApprovalRequestContent for _, req := range st.QueuedRequests { - if matchesRule(st.Rules, req) { + matches, err := matchesAutoApprovalRules(ctx, cfg.AutoApprovalRules, req) + if err != nil { + return err + } + if matchesRule(st.Rules, req) || matches { st.CollectedResponses = append(st.CollectedResponses, req.CreateResponse(true, "")) } else { remaining = append(remaining, req) } } st.QueuedRequests = remaining + return nil } func matchesRule(rules []Rule, req *message.ToolApprovalRequestContent) bool { @@ -277,6 +306,32 @@ func matchesRule(rules []Rule, req *message.ToolApprovalRequestContent) bool { return false } +// matchesAutoApprovalRules returns true if any configured auto-approval rule +// approves the request. Rules are evaluated in order; the first returning true +// wins. Returns false when rules is empty or the request is not a function call. +func matchesAutoApprovalRules(ctx context.Context, rules []func(context.Context, *message.FunctionCallContent) (bool, error), req *message.ToolApprovalRequestContent) (bool, error) { + if len(rules) == 0 { + return false, nil + } + fc, ok := req.ToolCall.(*message.FunctionCallContent) + if !ok || fc == nil { + return false, nil + } + for _, rule := range rules { + if rule == nil { + continue + } + matches, err := rule(ctx, fc) + if err != nil { + return false, err + } + if matches { + return true, nil + } + } + return false, nil +} + func serializeArguments(arguments string) (map[string]string, error) { if strings.TrimSpace(arguments) == "" { return nil, nil diff --git a/agent/harness/toolapproval/toolapproval_test.go b/agent/harness/toolapproval/toolapproval_test.go index ccbfa145..20140886 100644 --- a/agent/harness/toolapproval/toolapproval_test.go +++ b/agent/harness/toolapproval/toolapproval_test.go @@ -4,6 +4,7 @@ package toolapproval_test import ( "context" + "errors" "iter" "testing" @@ -367,3 +368,281 @@ func TestToolApproval_AlwaysApproveToolWithArgumentsMatchesByValue(t *testing.T) t.Fatal("expected done after auto-approval with argument-value match") } } + +func TestToolApproval_AutoApprovalRule_ApprovesMatchingTool(t *testing.T) { + fcc := &message.FunctionCallContent{CallID: "c1", Name: "ReadTool", Arguments: `{}`} + + // Turn 1: inner returns an approval request. + // Turn 2 (triggered automatically after auto-approval): inner returns done. + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{RequestID: "r1", ToolCall: fcc}, + }, + }). + NewTurn(). + AddText("done"). + Build(), + } + + cfg := toolapproval.Config{ + AutoApprovalRules: []func(context.Context, *message.FunctionCallContent) (bool, error){ + func(_ context.Context, fc *message.FunctionCallContent) (bool, error) { + return fc.Name == "ReadTool", nil + }, + }, + } + mw := toolapproval.New(cfg) + session := agenttest.CreateSession() + + updates := collectUpdates(t, mw, runner.Run, + []*message.Message{{Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}}, + agent.WithSession(session), + ) + + // Should receive "done" without any approval request surfaced to the caller. + var gotDone bool + for _, u := range updates { + for _, c := range u.Contents { + if tc, ok := c.(*message.TextContent); ok && tc.Text == "done" { + gotDone = true + } + if _, ok := c.(*message.ToolApprovalRequestContent); ok { + t.Fatal("expected no approval request to be surfaced when auto-approval rule matches") + } + } + } + if !gotDone { + t.Error("expected 'done' text after auto-approval rule approved the tool") + } +} + +func TestToolApproval_AutoApprovalRule_DoesNotMatchSurfacesToCaller(t *testing.T) { + fcc := &message.FunctionCallContent{CallID: "c1", Name: "DangerousTool", Arguments: `{}`} + + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{RequestID: "r1", ToolCall: fcc}, + }, + }). + Build(), + } + + cfg := toolapproval.Config{ + AutoApprovalRules: []func(context.Context, *message.FunctionCallContent) (bool, error){ + func(_ context.Context, fc *message.FunctionCallContent) (bool, error) { + return fc.Name == "ReadTool", nil + }, // only approves ReadTool + }, + } + mw := toolapproval.New(cfg) + + updates := collectUpdates(t, mw, runner.Run, + []*message.Message{{Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}}, + ) + + var approvalReqs []*message.ToolApprovalRequestContent + for _, u := range updates { + for _, c := range u.Contents { + if req, ok := c.(*message.ToolApprovalRequestContent); ok { + approvalReqs = append(approvalReqs, req) + } + } + } + if len(approvalReqs) != 1 { + t.Fatalf("expected 1 approval request surfaced, got %d", len(approvalReqs)) + } + fc, ok := approvalReqs[0].ToolCall.(*message.FunctionCallContent) + if !ok || fc.Name != "DangerousTool" { + t.Errorf("expected DangerousTool to be surfaced, got %v", approvalReqs[0].ToolCall) + } +} + +func TestToolApproval_MultipleAutoApprovalRules_FirstMatchWins(t *testing.T) { + fcc := &message.FunctionCallContent{CallID: "c1", Name: "SpecialTool", Arguments: `{}`} + + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{RequestID: "r1", ToolCall: fcc}, + }, + }). + NewTurn(). + AddText("done"). + Build(), + } + + rule1Called := false + rule2Called := false + cfg := toolapproval.Config{ + AutoApprovalRules: []func(context.Context, *message.FunctionCallContent) (bool, error){ + func(_ context.Context, fc *message.FunctionCallContent) (bool, error) { + rule1Called = true + return fc.Name == "SpecialTool", nil + }, + func(_ context.Context, _ *message.FunctionCallContent) (bool, error) { + rule2Called = true + return true, nil // should not be reached + }, + }, + } + mw := toolapproval.New(cfg) + session := agenttest.CreateSession() + + collectUpdates(t, mw, runner.Run, + []*message.Message{{Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}}, + agent.WithSession(session), + ) + + if !rule1Called { + t.Error("expected first auto-approval rule to be called") + } + if rule2Called { + t.Error("expected second auto-approval rule to NOT be called when first already matched") + } +} + +func TestToolApproval_StandingRuleTakesPrecedenceOverAutoApprovalRule(t *testing.T) { + fcc := &message.FunctionCallContent{CallID: "c1", Name: "MyTool", Arguments: `{}`} + + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{RequestID: "r1", ToolCall: fcc}, + }, + }). + NewTurn(). + AddText("done"). + Build(), + } + + heuristicCalled := false + cfg := toolapproval.Config{ + AutoApprovalRules: []func(context.Context, *message.FunctionCallContent) (bool, error){ + func(_ context.Context, _ *message.FunctionCallContent) (bool, error) { + heuristicCalled = true + return true, nil + }, + }, + } + mw := toolapproval.New(cfg) + session := agenttest.CreateSession() + opts := []agent.Option{agent.WithSession(session)} + + // Turn 1: auto-approval rule is called (no standing rule yet). + updates := collectUpdates(t, mw, runner.Run, + []*message.Message{{Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}}, + opts..., + ) + + if !heuristicCalled { + t.Error("expected auto-approval rule to be called on first turn") + } + + var gotDone bool + for _, u := range updates { + for _, c := range u.Contents { + if tc, ok := c.(*message.TextContent); ok && tc.Text == "done" { + gotDone = true + } + } + } + if !gotDone { + t.Error("expected 'done' after auto-approval rule approved on first turn") + } +} + +func TestToolApproval_AutoApprovalRule_ApprovesQueuedRequests(t *testing.T) { + fcc1 := &message.FunctionCallContent{CallID: "c1", Name: "SafeTool", Arguments: `{}`} + fcc2 := &message.FunctionCallContent{CallID: "c2", Name: "DangerousTool", Arguments: `{}`} + + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{RequestID: "r1", ToolCall: fcc1}, + &message.ToolApprovalRequestContent{RequestID: "r2", ToolCall: fcc2}, + }, + }). + Build(), + } + + // AutoApprovalRule approves SafeTool but not DangerousTool. + cfg := toolapproval.Config{ + AutoApprovalRules: []func(context.Context, *message.FunctionCallContent) (bool, error){ + func(_ context.Context, fc *message.FunctionCallContent) (bool, error) { + return fc.Name == "SafeTool", nil + }, + }, + } + mw := toolapproval.New(cfg) + session := agenttest.CreateSession() + opts := []agent.Option{agent.WithSession(session)} + + // Turn 1: r1 (SafeTool) is auto-approved; r2 (DangerousTool) is surfaced. + updates := collectUpdates(t, mw, runner.Run, + []*message.Message{{Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}}, + opts..., + ) + + var surfacedReqs []*message.ToolApprovalRequestContent + for _, u := range updates { + for _, c := range u.Contents { + if req, ok := c.(*message.ToolApprovalRequestContent); ok { + surfacedReqs = append(surfacedReqs, req) + } + } + } + if len(surfacedReqs) != 1 { + t.Fatalf("expected 1 surfaced request (DangerousTool), got %d", len(surfacedReqs)) + } + fc, ok := surfacedReqs[0].ToolCall.(*message.FunctionCallContent) + if !ok || fc.Name != "DangerousTool" { + t.Errorf("expected DangerousTool to be surfaced, got %v", surfacedReqs[0].ToolCall) + } +} + +func TestToolApproval_AutoApprovalRule_ErrorFailsRun(t *testing.T) { + ruleErr := errors.New("auto-approval rule failed") + fcc := &message.FunctionCallContent{CallID: "c1", Name: "ReadTool", Arguments: `{}`} + + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{RequestID: "r1", ToolCall: fcc}, + }, + }). + Build(), + } + + mw := toolapproval.New(toolapproval.Config{ + AutoApprovalRules: []func(context.Context, *message.FunctionCallContent) (bool, error){ + func(_ context.Context, _ *message.FunctionCallContent) (bool, error) { return false, ruleErr }, + }, + }) + + var gotErr error + for _, err := range mw.Run(runner.Run, context.Background(), []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}, + }) { + if err != nil { + gotErr = err + break + } + } + if !errors.Is(gotErr, ruleErr) { + t.Fatalf("expected rule error %v, got %v", ruleErr, gotErr) + } +} diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 5d224030..a8e75a62 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -39,7 +39,7 @@ Within overlapping features, the main misalignments are API shape and ecosystem | Function tools | `AIFunction`, `AITool`, function tools, plugins, dynamic function tools, tool argument matching in evals. | `tool.Tool`, `tool.FuncTool`, `functool.New`, typed input/output schemas. | Partial | Go has typed function tools but no first-class plugin or dynamic tool sample equivalent to .NET steps 12 and 20. | | Shell tool and environment context | `Microsoft.Agents.AI.Tools.Shell`: `LocalShellExecutor`, `ShellPolicy` (allow/deny-list), `ShellResult`, stateless and persistent shell execution modes, approval-in-the-loop gate, head-tail output truncation, `ShellEnvironmentProvider`, `ShellEnvironmentSnapshot`, shell-family instructions, common CLI probing. | `tool/shelltool.NewLocal`, `shelltool.LocalConfig` (mode, timeout, max output, policy, acknowledge unsafe), `shelltool.Policy`, `shelltool.Result.FormatForModel`, `shelltool.Executor`, `shelltool.NewEnvironmentProvider`, `EnvironmentProviderConfig`, `ShellEnvironmentSnapshot`, `DefaultShellEnvironmentInstructions`. | Aligned | Go mirrors the .NET design for local execution, policy allow/deny-list, approval-required by default, stateless/persistent modes, output truncation, environment snapshot probing, cached first-probe behavior, refresh, current snapshot access, shell-family prompt instructions, invalid/duplicate probe handling, stderr version fallback, caller cancellation, and probe timeout handling. Docker shell executor not ported (Go has no equivalent `DockerShellExecutor`). Go represents tool-version nullability with `ToolVersion{Found bool}` rather than nullable strings. | | Tool auto-calling | Provider/tool-call loop, tool approval agent, and message injection during the function loop (`EnableMessageInjection` / `MessageInjectingChatClient`). | `agent/harness/toolautocall`, default provider middleware unless disabled. Message injection supported via `Config.EnableMessageInjection` and `toolautocall.MessageInjectorFromContext(ctx)`. | Aligned | Go implements auto-call as explicit middleware; .NET uses agent/tool abstractions and provider adapters. | -| Tool approval | Tool approval request/response content, tool approval agent and builder extensions. | `message.ToolApprovalRequestContent`, `message.ToolApprovalResponseContent`, `tool.ApprovalRequiredFunc`, `agent/harness/toolautocall` approval flow, `agent/harness/toolapproval` middleware for standing-rule approval management, AGUI HITL sample. | Aligned | API shape differs: .NET uses a `ToolApprovalAgent` delegating-agent wrapper with a `.UseToolApproval()` builder extension; Go uses idiomatic middleware (`toolapproval.New`). Standing approval rules, queued-request batching, and `AlwaysApprove*` response content are now present in both SDKs. | +| Tool approval | Tool approval request/response content, tool approval agent and builder extensions, auto-approval rules (heuristics). | `message.ToolApprovalRequestContent`, `message.ToolApprovalResponseContent`, `tool.ApprovalRequiredFunc`, `agent/harness/toolautocall` approval flow, `agent/harness/toolapproval` middleware for standing-rule and auto-approval-rule approval management, AGUI HITL sample. | Aligned | API shape differs: .NET uses a `ToolApprovalAgent` delegating-agent wrapper with `ToolApprovalAgentOptions`; Go uses idiomatic middleware (`toolapproval.New(toolapproval.Config{AutoApprovalRules: ...})`). Standing approval rules, queued-request batching, `AlwaysApprove*` response content, and auto-approval rules (heuristics) are now present in both SDKs. | | Hosted/server-side tools | Foundry/OpenAI samples for code interpreter, file search, web search, OpenAPI, Bing custom search, SharePoint, Microsoft Fabric, memory search, Toolbox, hosted MCP. | `tool/hostedtool` declarations for web search, file search, code interpreter, MCP server. | Partial | Go has declaration types but less provider/sample coverage and fewer service-specific hosted tool integrations. | | Agent as function tool | Agents can be converted/bound as tools in samples and workflow builders. | `tool/agenttool.New` wraps an agent as a `FuncTool`. | Aligned | API shape differs; Go exposes a direct package. | | Agent as MCP tool/server | .NET sample `Agent_Step07_AsMcpTool` and durable sample for agent as MCP tool. | `tool/mcptool.AddTool`, `examples/02-agents/mcp/agent_mcp_server`, `step10_as_mcp_tool`. | Aligned | Durable MCP hosting is .NET only. |