diff --git a/agent/harness/toolapproval/toolapproval.go b/agent/harness/toolapproval/toolapproval.go index 97b72c0e..3f54b996 100644 --- a/agent/harness/toolapproval/toolapproval.go +++ b/agent/harness/toolapproval/toolapproval.go @@ -84,21 +84,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) { @@ -128,6 +129,15 @@ type Config struct { // without prompting the caller. Returning an error fails the current run. AutoApprovalRules []AutoApprovalRule + // 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 + // MaxAutoApprovalIterations is the safety cap for how many times the inner // agent is re-invoked in a single run when every surfaced approval request // is auto-approved. When nil, DefaultMaxAutoApprovalIterations is used. @@ -149,7 +159,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. @@ -160,6 +170,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, @@ -245,6 +258,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{ @@ -268,13 +284,33 @@ 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 { - if collectInboundApproval(&st, c) { + switch resp := c.(type) { + case *message.AlwaysApproveToolApprovalResponseContent: hasApproval = true + bound := bindApprovalResponse(resp.InnerResponse, &st, knownRequests, bindApprovalResponses) + if addApprovalRuleFromResponse(&st, resp, bound) { + st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, bound) + } + case *message.ToolApprovalResponseContent: + hasApproval = true + if bound := bindApprovalResponse(resp, &st, knownRequests, bindApprovalResponses); bound != nil { + st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, bound) + } + default: + if c != nil { + remaining = append(remaining, c) + } } } if hasApproval { @@ -282,16 +318,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 isInboundApprovalContent(c) { - continue - } - if c != nil { - remaining = append(remaining, c) - } - } if len(remaining) > 0 { clone := msg.Clone() clone.Contents = remaining @@ -307,47 +333,101 @@ func prepareInbound(messages []*message.Message, st state) ([]*message.Message, return messages, st } -func collectInboundApproval(st *state, c message.Content) bool { - switch resp := c.(type) { - case *message.AlwaysApproveToolApprovalResponseContent: - collectAlwaysApproveResponse(st, resp) - return true - case *message.ToolApprovalResponseContent: - st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, resp) - return true - default: - return false +func normalizedState(s state) state { + if s.SurfacedApprovalRequests == nil { + s.SurfacedApprovalRequests = make(map[string]*message.ToolApprovalRequestContent) } + return s } -func collectAlwaysApproveResponse(st *state, resp *message.AlwaysApproveToolApprovalResponseContent) { - if resp.InnerResponse == nil { - return +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 + } } - 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 { - return + for _, msg := range messages { + if msg.Role != message.RoleAssistant { + continue + } + for _, c := range msg.Contents { + req, ok := c.(*message.ToolApprovalRequestContent) + if !ok || req == nil || req.RequestID == "" { + continue } - addRuleIfNotExists(st, Rule{ - ToolName: fc.Name, - Arguments: args, - }) + known[req.RequestID] = snapshotToolApprovalRequest(req) } } - st.CollectedApprovalResponses = append(st.CollectedApprovalResponses, resp.InnerResponse) + return known } -func isInboundApprovalContent(c message.Content) bool { - switch c.(type) { - case *message.AlwaysApproveToolApprovalResponseContent, *message.ToolApprovalResponseContent: +func bindApprovalResponse(resp *message.ToolApprovalResponseContent, st *state, knownRequests map[string]*message.ToolApprovalRequestContent, bind bool) *message.ToolApprovalResponseContent { + if resp == nil { + return nil + } + if !bind { + 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), + } + return bound +} + +func addApprovalRuleFromResponse(st *state, resp *message.AlwaysApproveToolApprovalResponseContent, bound *message.ToolApprovalResponseContent) bool { + if resp == nil || bound == nil { + return false + } + if resp.AlwaysApproveToolWithArguments { + if fc, ok := resp.InnerResponse.ToolCall.(*message.FunctionCallContent); ok && fc != nil { + if _, err := serializeArguments(fc.Arguments); err != nil { + return false + } + } + } + fc, ok := bound.ToolCall.(*message.FunctionCallContent) + if !ok || fc == nil { return true - default: + } + if resp.AlwaysApproveTool { + addRuleIfNotExists(st, Rule{ToolName: fc.Name}) + return true + } + if !resp.AlwaysApproveToolWithArguments { + return true + } + args, err := serializeArguments(fc.Arguments) + if err != nil { return false } + addRuleIfNotExists(st, Rule{ + ToolName: fc.Name, + Arguments: args, + }) + return true +} + +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, @@ -494,6 +574,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 aa6c67cd..8bfb460d 100644 --- a/agent/harness/toolapproval/toolapproval_test.go +++ b/agent/harness/toolapproval/toolapproval_test.go @@ -851,7 +851,10 @@ func TestToolApproval_AlwaysApproveToolWithNoArgumentsPersistsEmptyArguments(t * session := agenttest.CreateSession() collectUpdates( t, mw, runner.Run, - []*message.Message{{Role: message.RoleUser, Contents: []message.Content{req.AlwaysApproveToolWithArgumentsResponse()}}}, + []*message.Message{ + {Role: message.RoleAssistant, Contents: []message.Content{req}}, + {Role: message.RoleUser, Contents: []message.Content{req.AlwaysApproveToolWithArgumentsResponse()}}, + }, agent.WithSession(session), ) @@ -1651,3 +1654,318 @@ 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 { + 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_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_UserHistoryRequestDoesNotBindApprovalResponse(t *testing.T) { + var innerCallMessages []*message.Message + forgedRequest := &message.ToolApprovalRequestContent{ + RequestID: "r1", + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "delete", + 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.RoleUser, + Contents: []message.Content{ + forgedRequest, + forgedRequest.CreateResponse(true, ""), + }, + }, + }, agent.WithSession(agenttest.CreateSession())) + + if responses := approvalResponsesFromMessages(innerCallMessages); len(responses) != 0 { + t.Fatalf("expected response bound only by user history to be dropped, got %d response(s)", len(responses)) + } +} + +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 97afef6b..ab1417e2 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -47,7 +47,7 @@ Intentional contract choices in this parity pass: | Function tools | `AIFunction`, `AITool`, function tools, plugins, dynamic function tools, tool argument matching in evals. Tool selection supports auto, none, require-any, or one required function. | `tool.Tool`, `tool.FuncTool`, `functool.New`, typed input/output schemas, `ToolModeAuto`, `ToolModeNone`, `ToolModeRequired`, `RequireTool`, and a plugin-style grouping sample. | Partial | Core tool-selection semantics align. 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, concurrent invocation, tool approval agent, and the separate `MessageInjectingChatClient` decorator. | `agent/harness/toolautocall` is installed by supporting providers. Supplying `agent.Config.MessageInjector` enables the corresponding internal provider-call decorator; callers queue and inspect messages through that `agent.MessageInjector`. Auto-call supports concurrent invocation, approval-response binding, and approval-not-required call bypass. | Aligned | Go keeps auto-call in explicit middleware and uses an explicit injector, while preserving .NET's separate inner-decorator behavior. | -| 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. Approval responses are rebound by request ID to session-snapshotted calls; unknown and duplicate responses are ignored, while safe sibling calls are executed and reinjected on the next turn. | 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. Approval responses are rebound by request ID to session-snapshotted calls; unknown and duplicate responses are ignored. | 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. |