Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 45 additions & 10 deletions agent/harness/toolautocall/autocall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -111,13 +118,19 @@ type autocall struct {
additionalTools []tool.Tool
includeDetailedErrors bool
terminateOnUnknownCalls bool
enableExecutableFunctionBypassing bool
allowConcurrentInvocations bool
maximumConsecutiveErrorsPerRequest int
maximumIterationsPerRequest int
newID func() string
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 {
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap vs upstream .NET — execution strategy diverges from InvocableFunctionBypassingChatClient

The upstream .NET commit aaaa56bc solves the mixed-call problem via a store-and-defer approach: InvocableFunctionBypassingChatClient removes invocable calls from the current response, stores them in AgentSessionStateBag, and returns only declaration-only calls to the caller. On the next request, the stored calls are re-injected as pre-approved ToolApprovalResponseContent so FunctionInvokingChatClient reconstructs and executes them.

The Go implementation here takes a different approach: it executes invocable siblings immediately in the same turn.

Concern .NET EnableInvocableFunctionBypassing Go EnableExecutableFunctionBypassing
Invocable call timing Deferred to next request via session state Executed immediately in current turn
Session state used Yes (AgentSessionStateBag) No
Re-injection mechanism Pre-approved ToolApprovalResponseContent N/A
Experimental gate [Experimental] attribute opt-in field (no experimental marker)

This is a meaningful behavioral divergence. The PR description calls it a port of #7388, but the execution model is structurally different. Please either:

  1. Confirm this is an intentional, language-idiomatic divergence and document it in docs/dotnet-go-sdk-feature-comparison.md, or
  2. Align with the upstream store-and-defer model if cross-turn session-state persistence is required for correctness in multi-turn scenarios.

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) {
Expand Down
75 changes: 75 additions & 0 deletions agent/harness/toolautocall/autocall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion docs/dotnet-go-sdk-feature-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading