From 3182b3c233c89c35269a0b6ec41e3280c2578d48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:26:53 +0000 Subject: [PATCH 1/2] [dotnet-port-api] Bind tool approval responses to surfaced requests Port .NET approval-response binding from microsoft/agent-framework#7111 into the Go tool approval middleware. Add surfaced-request tracking and default response rebinding in agent/harness/toolapproval, expose a DisableApprovalResponseBinding opt-out in toolapproval.Config, add focused regression coverage, and update the feature comparison note. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/harness/toolapproval/toolapproval.go | 210 +++++++++--- .../harness/toolapproval/toolapproval_test.go | 307 +++++++++++++++++- docs/dotnet-go-sdk-feature-comparison.md | 2 +- 3 files changed, 468 insertions(+), 51 deletions(-) diff --git a/agent/harness/toolapproval/toolapproval.go b/agent/harness/toolapproval/toolapproval.go index b277204b..4640a0ec 100644 --- a/agent/harness/toolapproval/toolapproval.go +++ b/agent/harness/toolapproval/toolapproval.go @@ -18,6 +18,7 @@ import ( "encoding/json" "fmt" "iter" + "maps" "slices" "strings" @@ -51,21 +52,22 @@ func (r Rule) matches(toolName string, arguments map[string]string) bool { // state is persisted in the session across turns. type state struct { - Rules []Rule `json:"rules,omitempty"` - CollectedApprovalResponses []*message.ToolApprovalResponseContent `json:"collectedResponses,omitempty"` - QueuedApprovalRequests []*message.ToolApprovalRequestContent `json:"queuedRequests,omitempty"` + Rules []Rule `json:"rules,omitempty"` + CollectedApprovalResponses []*message.ToolApprovalResponseContent `json:"collectedResponses,omitempty"` + QueuedApprovalRequests []*message.ToolApprovalRequestContent `json:"queuedRequests,omitempty"` + SurfacedApprovalRequests map[string]*message.ToolApprovalRequestContent `json:"surfacedRequests,omitempty"` } func loadState(opts []agent.Option) state { session, ok := agent.GetOption(opts, agent.WithSession) if !ok { - return state{} + return normalizedState(state{}) } var s state if found, _ := session.Get(stateKey, &s); found { - return s + return normalizedState(s) } - return state{} + return normalizedState(state{}) } func saveState(opts []agent.Option, s state) { @@ -94,6 +96,15 @@ type Config struct { // to be auto-approved without prompting the caller. Returning an error fails // the current run. AutoApprovalRules []func(context.Context, *message.FunctionCallContent) (bool, error) + + // DisableApprovalResponseBinding disables rebinding inbound approval responses + // to the tool approval requests previously surfaced by this middleware. + // + // When false (the default), only approval responses tied to a surfaced or + // history-carried approval request are honored, and the recorded request's tool + // call is injected downstream so an approved call matches what was surfaced for + // approval. When true, inbound approval responses are forwarded unchanged. + DisableApprovalResponseBinding bool } func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -101,7 +112,7 @@ func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*messag st := loadState(opts) // Step 1: Process inbound approval responses from the caller. - messages, st = prepareInbound(messages, st) + messages, st = prepareInbound(messages, st, !cfg.DisableApprovalResponseBinding) // Step 2: If we have queued requests from a previous turn, drain any // that are now auto-approvable and surface the next one. @@ -112,6 +123,9 @@ func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*messag if len(st.QueuedApprovalRequests) > 0 { next := st.QueuedApprovalRequests[0] st.QueuedApprovalRequests = st.QueuedApprovalRequests[1:] + if !cfg.DisableApprovalResponseBinding { + recordSurfacedApprovalRequests(&st, next) + } saveState(opts, st) yield(&agent.ResponseUpdate{ Role: message.RoleAssistant, @@ -180,6 +194,9 @@ func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*messag first := needsApproval[0] st.QueuedApprovalRequests = append(st.QueuedApprovalRequests, needsApproval[1:]...) st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, autoApproved...) + if !cfg.DisableApprovalResponseBinding { + recordSurfacedApprovalRequests(&st, first) + } // Non-approval updates were already yielded during streaming. if !yield(&agent.ResponseUpdate{ @@ -203,37 +220,29 @@ func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*messag // prepareInbound processes caller messages, extracting approval responses // and any "always approve" flags into standing rules. -func prepareInbound(messages []*message.Message, st state) ([]*message.Message, state) { +func prepareInbound(messages []*message.Message, st state, bindApprovalResponses bool) ([]*message.Message, state) { + knownRequests := make(map[string]*message.ToolApprovalRequestContent) + if bindApprovalResponses { + knownRequests = knownApprovalRequests(messages, st) + } + var cleaned []*message.Message for i, msg := range messages { var hasApproval bool + var remaining []message.Content for _, c := range msg.Contents { switch resp := c.(type) { case *message.AlwaysApproveToolApprovalResponseContent: hasApproval = true - if resp.InnerResponse != nil { - if fc, ok := resp.InnerResponse.ToolCall.(*message.FunctionCallContent); ok && fc != nil { - if resp.AlwaysApproveTool { - addRuleIfNotExists(&st, Rule{ToolName: fc.Name}) - } else if resp.AlwaysApproveToolWithArguments { - args, err := serializeArguments(fc.Arguments) - if err != nil { - // If we can't parse the arguments, skip adding a rule. - break - } - addRuleIfNotExists(&st, Rule{ - ToolName: fc.Name, - Arguments: args, - }) - } - } - } - if resp.InnerResponse != nil { - st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, resp.InnerResponse) - } + bound := bindApprovalResponse(resp.InnerResponse, &st, knownRequests, bindApprovalResponses) + addApprovalRuleFromResponse(&st, resp, bound) case *message.ToolApprovalResponseContent: hasApproval = true - st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, resp) + bindApprovalResponse(resp, &st, knownRequests, bindApprovalResponses) + default: + if c != nil { + remaining = append(remaining, c) + } } } if hasApproval { @@ -241,19 +250,6 @@ func prepareInbound(messages []*message.Message, st state) ([]*message.Message, cleaned = make([]*message.Message, 0, len(messages)) cleaned = append(cleaned, messages[:i]...) } - // Strip approval contents from the message, keep the rest. - var remaining []message.Content - for _, c := range msg.Contents { - if _, ok := c.(*message.ToolApprovalResponseContent); ok { - continue - } - if _, ok := c.(*message.AlwaysApproveToolApprovalResponseContent); ok { - continue - } - if c != nil { - remaining = append(remaining, c) - } - } if len(remaining) > 0 { clone := msg.Clone() clone.Contents = remaining @@ -269,6 +265,94 @@ func prepareInbound(messages []*message.Message, st state) ([]*message.Message, return messages, st } +func normalizedState(s state) state { + if s.SurfacedApprovalRequests == nil { + s.SurfacedApprovalRequests = make(map[string]*message.ToolApprovalRequestContent) + } + return s +} + +func knownApprovalRequests(messages []*message.Message, st state) map[string]*message.ToolApprovalRequestContent { + known := make(map[string]*message.ToolApprovalRequestContent, len(st.SurfacedApprovalRequests)) + for requestID, req := range st.SurfacedApprovalRequests { + if req != nil { + known[requestID] = req + } + } + for _, msg := range messages { + for _, c := range msg.Contents { + req, ok := c.(*message.ToolApprovalRequestContent) + if !ok || req == nil || req.RequestID == "" { + continue + } + known[req.RequestID] = req + } + } + return known +} + +func bindApprovalResponse(resp *message.ToolApprovalResponseContent, st *state, knownRequests map[string]*message.ToolApprovalRequestContent, bind bool) *message.ToolApprovalResponseContent { + if resp == nil { + return nil + } + if !bind { + st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, resp) + return resp + } + + matchedRequest, ok := knownRequests[resp.RequestID] + if !ok || matchedRequest == nil { + return nil + } + + delete(knownRequests, resp.RequestID) + delete(st.SurfacedApprovalRequests, resp.RequestID) + + bound := &message.ToolApprovalResponseContent{ + ContentHeader: cloneContentHeader(resp.ContentHeader), + RequestID: resp.RequestID, + Reason: resp.Reason, + Approved: resp.Approved, + ToolCall: cloneToolCallContent(matchedRequest.ToolCall), + } + st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, bound) + return bound +} + +func addApprovalRuleFromResponse(st *state, resp *message.AlwaysApproveToolApprovalResponseContent, bound *message.ToolApprovalResponseContent) { + if resp == nil || bound == nil { + return + } + fc, ok := bound.ToolCall.(*message.FunctionCallContent) + if !ok || fc == nil { + return + } + if resp.AlwaysApproveTool { + addRuleIfNotExists(st, Rule{ToolName: fc.Name}) + return + } + if !resp.AlwaysApproveToolWithArguments { + return + } + args, err := serializeArguments(fc.Arguments) + if err != nil { + return + } + addRuleIfNotExists(st, Rule{ + ToolName: fc.Name, + Arguments: args, + }) +} + +func recordSurfacedApprovalRequests(st *state, requests ...*message.ToolApprovalRequestContent) { + for _, req := range requests { + if req == nil || req.RequestID == "" { + continue + } + st.SurfacedApprovalRequests[req.RequestID] = snapshotToolApprovalRequest(req) + } +} + // drainAutoApprovable removes queued requests that now match a standing rule, // are for tools that do not require approval, or match an auto-approval rule, // adding auto-approve responses to collected. @@ -412,6 +496,48 @@ func addRuleIfNotExists(st *state, rule Rule) { st.Rules = append(st.Rules, rule) } +func snapshotToolApprovalRequest(req *message.ToolApprovalRequestContent) *message.ToolApprovalRequestContent { + if req == nil { + return nil + } + return &message.ToolApprovalRequestContent{ + ContentHeader: cloneContentHeader(req.ContentHeader), + RequestID: req.RequestID, + ToolCall: cloneToolCallContent(req.ToolCall), + } +} + +func cloneToolCallContent(content message.ToolCallContent) message.ToolCallContent { + switch content := content.(type) { + case nil: + return nil + case *message.FunctionCallContent: + if content == nil { + return nil + } + cloned := *content + cloned.ContentHeader = cloneContentHeader(content.ContentHeader) + return &cloned + case *message.MCPServerToolCallContent: + if content == nil { + return nil + } + cloned := *content + cloned.ContentHeader = cloneContentHeader(content.ContentHeader) + return &cloned + default: + return content + } +} + +func cloneContentHeader(header message.ContentHeader) message.ContentHeader { + return message.ContentHeader{ + AdditionalProperties: maps.Clone(header.AdditionalProperties), + Annotations: slices.Clone(header.Annotations), + RawRepresentation: header.RawRepresentation, + } +} + func responseMessage(responses []*message.ToolApprovalResponseContent) *message.Message { contents := make([]message.Content, len(responses)) for i, r := range responses { diff --git a/agent/harness/toolapproval/toolapproval_test.go b/agent/harness/toolapproval/toolapproval_test.go index 69bfb905..470d8b9c 100644 --- a/agent/harness/toolapproval/toolapproval_test.go +++ b/agent/harness/toolapproval/toolapproval_test.go @@ -30,6 +30,34 @@ func collectUpdates(t *testing.T, mw agent.Middleware, next agent.RunFunc, messa return updates } +func firstApprovalRequest(t *testing.T, updates []*agent.ResponseUpdate) *message.ToolApprovalRequestContent { + t.Helper() + for _, u := range updates { + if u == nil { + continue + } + for _, c := range u.Contents { + if req, ok := c.(*message.ToolApprovalRequestContent); ok { + return req + } + } + } + t.Fatal("expected approval request") + return nil +} + +func approvalResponsesFromMessages(messages []*message.Message) []*message.ToolApprovalResponseContent { + var responses []*message.ToolApprovalResponseContent + for _, msg := range messages { + for _, c := range msg.Contents { + if resp, ok := c.(*message.ToolApprovalResponseContent); ok { + responses = append(responses, resp) + } + } + } + return responses +} + func TestToolApproval_PassthroughWithoutApprovalRequests(t *testing.T) { runner := &agenttest.Runner{ Responses: agenttest.NewResponseBuilder().AddText("hello").Build(), @@ -534,19 +562,32 @@ func TestToolApproval_AlwaysApproveToolWithNoArgumentsMatchesLaterNoArgumentsCal } func TestToolApproval_AlwaysApproveToolWithNoArgumentsPersistsEmptyArguments(t *testing.T) { - req := &message.ToolApprovalRequestContent{ - RequestID: "r1", - ToolCall: &message.FunctionCallContent{ - CallID: "c1", - Name: "send_payment", - }, - } runner := &agenttest.Runner{ - Responses: agenttest.NewResponseBuilder().AddText("done").Build(), + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{ + RequestID: "r1", + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "send_payment", + }, + }, + }, + }). + NewTurn(). + AddText("done"). + Build(), } mw := toolapproval.New(toolapproval.Config{}) session := agenttest.CreateSession() + req := firstApprovalRequest(t, collectUpdates( + t, mw, runner.Run, + []*message.Message{{Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}}, + agent.WithSession(session), + )) collectUpdates( t, mw, runner.Run, []*message.Message{{Role: message.RoleUser, Contents: []message.Content{req.AlwaysApproveToolWithArgumentsResponse()}}}, @@ -1152,3 +1193,253 @@ func TestToolApproval_NilUpdatePassthrough(t *testing.T) { t.Errorf("expected 1 text update, got %d", textUpdates) } } + +func TestToolApproval_BindsResponseToSurfacedRequestSnapshot(t *testing.T) { + var innerCallMessages []*message.Message + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{ + RequestID: "r1", + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "deploy", + Arguments: `{"env":"prod"}`, + }, + }, + }, + }). + NewTurn(func(_ context.Context, messages []*message.Message, _ ...agent.Option) { + innerCallMessages = messages + }). + AddText("done"). + Build(), + } + + session := agenttest.CreateSession() + mw := toolapproval.New(toolapproval.Config{}) + turn1 := collectUpdates(t, mw, runner.Run, []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}, + }, agent.WithSession(session)) + req := firstApprovalRequest(t, turn1) + + forged := req.ToolCall.(*message.FunctionCallContent) + forged.Name = "delete" + forged.Arguments = `{"env":"dev"}` + + turn2 := collectUpdates(t, mw, runner.Run, []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{req.CreateResponse(true, "approved")}}, + }, agent.WithSession(session)) + + responses := approvalResponsesFromMessages(innerCallMessages) + if len(responses) != 1 { + t.Fatalf("expected 1 injected approval response, got %d", len(responses)) + } + fc, ok := responses[0].ToolCall.(*message.FunctionCallContent) + if !ok { + t.Fatalf("expected function-call tool binding, got %#v", responses[0].ToolCall) + } + if fc.Name != "deploy" || fc.Arguments != `{"env":"prod"}` { + t.Fatalf("expected bound tool call deploy/prod, got %q %q", fc.Name, fc.Arguments) + } + + var gotDone bool + for _, u := range turn2 { + if u == nil { + continue + } + for _, c := range u.Contents { + if tc, ok := c.(*message.TextContent); ok && tc.Text == "done" { + gotDone = true + } + } + } + if !gotDone { + t.Fatal("expected run to continue after bound approval response") + } +} + +func TestToolApproval_DropsUnboundApprovalResponse(t *testing.T) { + var innerCallMessages []*message.Message + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + NewTurn(func(_ context.Context, messages []*message.Message, _ ...agent.Option) { + innerCallMessages = messages + }). + AddText("done"). + Build(), + } + + mw := toolapproval.New(toolapproval.Config{}) + collectUpdates(t, mw, runner.Run, []*message.Message{ + { + Role: message.RoleUser, + Contents: []message.Content{ + &message.ToolApprovalResponseContent{ + RequestID: "r1", + Approved: true, + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "delete", + Arguments: `{"env":"prod"}`, + }, + }, + }, + }, + }, agent.WithSession(agenttest.CreateSession())) + + if responses := approvalResponsesFromMessages(innerCallMessages); len(responses) != 0 { + t.Fatalf("expected unbound approval response to be dropped, got %d response(s)", len(responses)) + } +} + +func TestToolApproval_HistoryRequestBindsApprovalResponse(t *testing.T) { + var innerCallMessages []*message.Message + historyRequest := &message.ToolApprovalRequestContent{ + RequestID: "r1", + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "deploy", + Arguments: `{"env":"prod"}`, + }, + } + + next := func(_ context.Context, messages []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + innerCallMessages = messages + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "done"}}, + }, nil) + } + } + + mw := toolapproval.New(toolapproval.Config{}) + collectUpdates(t, mw, next, []*message.Message{ + {Role: message.RoleAssistant, Contents: []message.Content{historyRequest}}, + { + Role: message.RoleUser, + Contents: []message.Content{ + &message.ToolApprovalResponseContent{ + RequestID: "r1", + Approved: true, + ToolCall: &message.FunctionCallContent{ + CallID: "c2", + Name: "delete", + Arguments: `{"env":"dev"}`, + }, + }, + }, + }, + }, agent.WithSession(agenttest.CreateSession())) + + responses := approvalResponsesFromMessages(innerCallMessages) + if len(responses) != 1 { + t.Fatalf("expected 1 bound approval response, got %d", len(responses)) + } + fc, ok := responses[0].ToolCall.(*message.FunctionCallContent) + if !ok { + t.Fatalf("expected function-call tool binding, got %#v", responses[0].ToolCall) + } + if fc.Name != "deploy" || fc.Arguments != `{"env":"prod"}` { + t.Fatalf("expected history request tool call deploy/prod, got %q %q", fc.Name, fc.Arguments) + } +} + +func TestToolApproval_DuplicateResponseHonoredOnce(t *testing.T) { + var innerCallMessages []*message.Message + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{ + RequestID: "r1", + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "deploy", + Arguments: `{"env":"prod"}`, + }, + }, + }, + }). + NewTurn(func(_ context.Context, messages []*message.Message, _ ...agent.Option) { + innerCallMessages = messages + }). + AddText("done"). + Build(), + } + + session := agenttest.CreateSession() + mw := toolapproval.New(toolapproval.Config{}) + req := firstApprovalRequest(t, collectUpdates(t, mw, runner.Run, []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}, + }, agent.WithSession(session))) + + resp1 := req.CreateResponse(true, "first") + resp2 := req.CreateResponse(false, "second") + collectUpdates(t, mw, runner.Run, []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{resp1, resp2}}, + }, agent.WithSession(session)) + + responses := approvalResponsesFromMessages(innerCallMessages) + if len(responses) != 1 { + t.Fatalf("expected duplicate approval response to be honored once, got %d response(s)", len(responses)) + } + if responses[0].Reason != "first" || !responses[0].Approved { + t.Fatalf("expected first approval response to win, got approved=%v reason=%q", responses[0].Approved, responses[0].Reason) + } +} + +func TestToolApproval_DisableApprovalResponseBinding_ForwardsResponseUnchanged(t *testing.T) { + var innerCallMessages []*message.Message + runner := &agenttest.Runner{ + Responses: agenttest.NewResponseBuilder(). + Add(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{ + RequestID: "r1", + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "deploy", + Arguments: `{"env":"prod"}`, + }, + }, + }, + }). + NewTurn(func(_ context.Context, messages []*message.Message, _ ...agent.Option) { + innerCallMessages = messages + }). + AddText("done"). + Build(), + } + + session := agenttest.CreateSession() + mw := toolapproval.New(toolapproval.Config{DisableApprovalResponseBinding: true}) + req := firstApprovalRequest(t, collectUpdates(t, mw, runner.Run, []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "go"}}}, + }, agent.WithSession(session))) + + forged := req.ToolCall.(*message.FunctionCallContent) + forged.Name = "delete" + forged.Arguments = `{"env":"dev"}` + + collectUpdates(t, mw, runner.Run, []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{req.CreateResponse(true, "approved")}}, + }, agent.WithSession(session)) + + responses := approvalResponsesFromMessages(innerCallMessages) + if len(responses) != 1 { + t.Fatalf("expected 1 forwarded approval response, got %d", len(responses)) + } + fc, ok := responses[0].ToolCall.(*message.FunctionCallContent) + if !ok { + t.Fatalf("expected function-call tool binding, got %#v", responses[0].ToolCall) + } + if fc.Name != "delete" || fc.Arguments != `{"env":"dev"}` { + t.Fatalf("expected disabled binding to preserve forwarded tool call, got %q %q", fc.Name, fc.Arguments) + } +} diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 22f52a52..1e54e564 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, and a plugin-style grouping sample. | Partial | Go has typed function tools and plugin-style tool grouping, but no first-class plugin abstraction 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, 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. | +| 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: ..., DisableApprovalResponseBinding: ...})`). Standing approval rules, queued-request batching, `AlwaysApprove*` response content, approval-response rebinding to surfaced requests by default, 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; Foundry-first samples cover code interpreter, web search, MCP client tools, and local MCP tools; OpenAI Responses hosted-tool coverage remains provider-specific. | Partial | Go has declaration types and initial Foundry/OpenAI Responses hosted-tool coverage, but fewer service-specific Foundry hosted tool integrations and no Foundry toolbox lifecycle sample. | | 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. | From 003a819203fb5d533b1d1de4465840432710fc8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:36:18 +0000 Subject: [PATCH 2/2] Fix tool approval test formatting Co-authored-by: michelle-clayton-work <262183035+michelle-clayton-work@users.noreply.github.com> --- agent/harness/toolapproval/toolapproval_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/harness/toolapproval/toolapproval_test.go b/agent/harness/toolapproval/toolapproval_test.go index e74eb57f..8bfb460d 100644 --- a/agent/harness/toolapproval/toolapproval_test.go +++ b/agent/harness/toolapproval/toolapproval_test.go @@ -1654,6 +1654,7 @@ func TestToolApproval_MaxAutoApprovalIterationsBelowOneReturnsError(t *testing.T t.Fatalf("expected MaxAutoApprovalIterations validation error, got %v", got) } } + func firstApprovalRequest(t *testing.T, updates []*agent.ResponseUpdate) *message.ToolApprovalRequestContent { t.Helper() for _, u := range updates {