diff --git a/agent/harness/toolautocall/autocall.go b/agent/harness/toolautocall/autocall.go index 07c90299..d2fb85bb 100644 --- a/agent/harness/toolautocall/autocall.go +++ b/agent/harness/toolautocall/autocall.go @@ -75,6 +75,13 @@ type Config struct { // schema tool that is not invocable always stops the loop. The default is false. TerminateOnUnknownCalls bool + // EnableExecutableFunctionBypassing enables experimental handling of invocable + // function calls that share a provider response with declaration-only calls. + // Invocable calls are executed before the function-calling loop stops. When false, + // the complete mixed response is returned so the caller can handle it. The default + // is false. + EnableExecutableFunctionBypassing bool + // AllowConcurrentInvocations controls whether multiple function calls from the // same provider response may execute in parallel. When false, function calls are // processed serially. The default is false. @@ -111,6 +118,7 @@ type autocall struct { additionalTools []tool.Tool includeDetailedErrors bool terminateOnUnknownCalls bool + enableExecutableFunctionBypassing bool allowConcurrentInvocations bool maximumConsecutiveErrorsPerRequest int maximumIterationsPerRequest int @@ -118,6 +126,11 @@ type autocall struct { enableMessageInjection bool } +type functionCallExecutionPlan struct { + process []*message.FunctionCallContent + terminate bool +} + // New creates a new function-invoking chat client that wraps the provided client. func New(cfg Config) agent.Middleware { if cfg.NewID == nil { @@ -133,6 +146,7 @@ func New(cfg Config) agent.Middleware { additionalTools: cfg.AdditionalTools, includeDetailedErrors: cfg.IncludeDetailedErrors, terminateOnUnknownCalls: cfg.TerminateOnUnknownCalls, + enableExecutableFunctionBypassing: cfg.EnableExecutableFunctionBypassing, allowConcurrentInvocations: cfg.AllowConcurrentInvocations, maximumConsecutiveErrorsPerRequest: cmp.Or(cfg.MaximumConsecutiveErrorsPerRequest, defaultMaximumConsecutiveErrorsPerRequest), maximumIterationsPerRequest: cmp.Or(cfg.MaximumIterationsPerRequest, defaultMaximumIterationsPerRequest), @@ -295,9 +309,11 @@ func (f *autocall) Run(next agent.RunFunc, ctx context.Context, messages []*mess return } } + executionPlan := f.buildFunctionCallExecutionPlan(ctx, functionCallContents, tools) + // If there's nothing more to do, break out of the loop and allow the handling at the // end to configure the response with aggregated data from previous requests. - if i >= f.maximumIterationsPerRequest || hasApprovalRequiringFcc || f.shouldTerminateLoopBasedOnHandleableFunctions(ctx, functionCallContents, tools) { + if i >= f.maximumIterationsPerRequest || hasApprovalRequiringFcc || (executionPlan.terminate && len(executionPlan.process) == 0) { // When message injection is enabled, check if any tools enqueued messages // during this iteration. If so, add them to the conversation and continue // the loop so the provider sees the new user messages — even though no @@ -321,7 +337,7 @@ func (f *autocall) Run(next agent.RunFunc, ctx context.Context, messages []*mess // Process all of the functions, adding their results into the history. var newMsg *message.Message var err error - newMsg, errCount, err = f.processFunctionCalls(ctx, tools, functionCallContents, errCount) + newMsg, errCount, err = f.processFunctionCalls(ctx, tools, executionPlan.process, errCount) if err != nil { yield(nil, err) return @@ -341,7 +357,7 @@ func (f *autocall) Run(next agent.RunFunc, ctx context.Context, messages []*mess // matching .NET's FunctionInvokingChatClient, which does // augmentedHistory.AddMessages(response) rather than reconstructing from the // function calls alone. - processedFunctionCalls := functionCallContents[:len(newMsg.Contents)] + processedFunctionCalls := executionPlan.process // Coalesce the buffered updates for this iteration so streamed text/reasoning // fragments merge, then carry the text and reasoning over alongside the @@ -372,6 +388,10 @@ func (f *autocall) Run(next agent.RunFunc, ctx context.Context, messages []*mess } } + if executionPlan.terminate { + break + } + // Use the augmented history as the new set of messages to send. // We include the original messages, the assistant message with function calls, // and the tool results so that the downstream provider receives a well-formed @@ -515,10 +535,10 @@ func prepareOptionsForLastIteration(opts []agent.Option) []agent.Option { return updated } -func (f *autocall) shouldTerminateLoopBasedOnHandleableFunctions(ctx context.Context, funcCalls []*message.FunctionCallContent, tools map[string]tool.SchemaTool) bool { +func (f *autocall) buildFunctionCallExecutionPlan(ctx context.Context, funcCalls []*message.FunctionCallContent, tools map[string]tool.SchemaTool) functionCallExecutionPlan { if len(funcCalls) == 0 { // There are no functions to call, so there's no reason to keep going. - return true + return functionCallExecutionPlan{terminate: true} } if len(tools) == 0 { // There are functions to call but we have no tools, so we can't handle them. @@ -529,10 +549,15 @@ func (f *autocall) shouldTerminateLoopBasedOnHandleableFunctions(ctx context.Con f.logger.Warn(ctx, "function not found", "funcName", fc.Name) } } - return f.terminateOnUnknownCalls + if f.terminateOnUnknownCalls { + return functionCallExecutionPlan{terminate: true} + } + return functionCallExecutionPlan{process: funcCalls} } // At this point, we have both function call requests and some tools. // Look up each function. + processable := make([]*message.FunctionCallContent, 0, len(funcCalls)) + terminate := false for _, fc := range funcCalls { declaration, ok := tools[fc.Name] if !ok { @@ -541,18 +566,28 @@ func (f *autocall) shouldTerminateLoopBasedOnHandleableFunctions(ctx context.Con // creating a NotFound response message. if f.terminateOnUnknownCalls { f.logger.Warn(ctx, "function not found", "funcName", fc.Name) - return true + return functionCallExecutionPlan{terminate: true} } + processable = append(processable, fc) continue } if _, ok := declaration.(tool.FuncTool); !ok { // The schema tool was found but it's not invocable. Regardless of TerminateOnUnknownCallRequests, - // we need to break out of the loop so that callers can handle all the call requests. + // callers need to receive the call request. Unless executable function bypassing is enabled, + // return the complete batch without executing any invocable siblings. f.logger.Debug(ctx, "function is not invocable; terminating loop", "funcName", fc.Name) - return true + if !f.enableExecutableFunctionBypassing { + return functionCallExecutionPlan{terminate: true} + } + terminate = true + continue } + processable = append(processable, fc) + } + if len(processable) == 0 { + return functionCallExecutionPlan{terminate: true} } - return false + return functionCallExecutionPlan{process: processable, terminate: terminate} } func (f *autocall) createToolsMap(tools iter.Seq[tool.Tool]) (mtools map[string]tool.SchemaTool, anyRequiredApproval bool) { diff --git a/agent/harness/toolautocall/autocall_test.go b/agent/harness/toolautocall/autocall_test.go index 5edd0804..3b391912 100644 --- a/agent/harness/toolautocall/autocall_test.go +++ b/agent/harness/toolautocall/autocall_test.go @@ -1363,6 +1363,81 @@ func TestFunctionInvoking_NonInvocableSchemaToolTerminates(t *testing.T) { invokeAndAssert(t, tools, plan, nil, toolautocall.Config{}) } +func TestFunctionInvoking_MixedInvocableAndNonInvocableSchemaToolsTerminateByDefault(t *testing.T) { + var toolInvoked atomic.Int32 + + localCall := &message.FunctionCallContent{CallID: "callId2", Name: "LocalFunc", Arguments: `{}`} + declaredCall := &message.FunctionCallContent{CallID: "callId1", Name: "DeclaredFunc", Arguments: `{}`} + + tools := []tool.Tool{ + schemaOnlyTool{Tool: agenttest.NewTool("DeclaredFunc", "declared function")}, + functool.MustNew(functool.Config{Name: "LocalFunc"}, + func(ctx context.Context, args struct{}) (string, error) { + toolInvoked.Add(1) + return "local result", nil + }), + } + + plan := []*message.Message{ + message.NewText("hello"), + {Role: message.RoleAssistant, Contents: []message.Content{ + declaredCall, + localCall, + }}, + } + + invokeAndAssert(t, tools, plan, nil, toolautocall.Config{}) + + if got := toolInvoked.Load(); got != 0 { + t.Fatalf("expected no local tool invocation, got %d", got) + } + if localCall.InformationalOnly { + t.Fatal("expected unprocessed invocable FunctionCallContent to remain actionable") + } + if declaredCall.InformationalOnly { + t.Fatal("expected declaration-only FunctionCallContent to remain actionable") + } +} + +func TestFunctionInvoking_MixedInvocableAndNonInvocableSchemaToolExecutesInvocableSiblingWhenEnabled(t *testing.T) { + var toolInvoked atomic.Int32 + + localCall := &message.FunctionCallContent{CallID: "callId2", Name: "LocalFunc", Arguments: `{}`} + declaredCall := &message.FunctionCallContent{CallID: "callId1", Name: "DeclaredFunc", Arguments: `{}`} + + tools := []tool.Tool{ + schemaOnlyTool{Tool: agenttest.NewTool("DeclaredFunc", "declared function")}, + functool.MustNew(functool.Config{Name: "LocalFunc"}, + func(ctx context.Context, args struct{}) (string, error) { + toolInvoked.Add(1) + return "local result", nil + }), + } + + plan := []*message.Message{ + message.NewText("hello"), + {Role: message.RoleAssistant, Contents: []message.Content{ + declaredCall, + localCall, + }}, + {Role: message.RoleTool, Contents: []message.Content{ + &message.FunctionResultContent{CallID: "callId2", Result: "local result"}, + }}, + } + + invokeAndAssert(t, tools, plan, nil, toolautocall.Config{EnableExecutableFunctionBypassing: true}) + + if got := toolInvoked.Load(); got != 1 { + t.Fatalf("expected one local tool invocation, got %d", got) + } + if !localCall.InformationalOnly { + t.Fatal("expected processed invocable FunctionCallContent to be informational-only") + } + if declaredCall.InformationalOnly { + t.Fatal("expected declaration-only FunctionCallContent to remain actionable") + } +} + func TestFunctionInvoking_NonSchemaToolWithMatchingNameIsUnknown(t *testing.T) { tools := []tool.Tool{ agenttest.NewTool("PlainTool", "plain tool"), diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 22f52a52..711d9e35 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -38,7 +38,7 @@ Within overlapping features, the main misalignments are API shape and ecosystem | Annotations/citations | MEAI annotation/content support through `AIContent`. | `message.Annotation`, citation annotations, annotated text spans. | Aligned | No direct binary compatibility; mapping is provider-specific. | | 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 auto-calling | Provider/tool-call loop, tool approval agent, message injection (`EnableMessageInjection` / `MessageInjectingChatClient`), and opt-in mixed executable/declaration-only call handling (`EnableExecutableFunctionBypassing`). | `agent/harness/toolautocall`, default provider middleware unless disabled. Message injection is supported via `Config.EnableMessageInjection`; mixed executable/declaration-only call handling is default-off via `Config.EnableExecutableFunctionBypassing`. | Aligned | When mixed-call handling is enabled, Go executes invocable siblings during the current run before returning declaration-only calls; .NET stores executable calls in session state and re-injects them on the next request. | | 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; 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. |