diff --git a/agent/harness/harness_test.go b/agent/harness/harness_test.go index 2ce455b1..0c519bbb 100644 --- a/agent/harness/harness_test.go +++ b/agent/harness/harness_test.go @@ -1856,3 +1856,182 @@ func (p *borrowedProvider) Close() error { p.closed = true return nil } + +func newLocalAgentWithAutocall(cfg agent.Config, run agent.RunFunc, autocallCfg toolautocall.Config) *agent.Agent { + return agent.New(agent.ProviderConfig{ + ProviderName: "test", + Run: run, + Middlewares: []agent.Middleware{toolautocall.New(autocallCfg)}, + }, cfg) +} + +func TestToolApproval_SharesMaximumFunctionCallsAcrossAutoApprovedReEntries(t *testing.T) { + settings := harness.Config{ + DisableTodo: true, + DisableAgentMode: true, + DisableSkills: true, + DisableFileMemory: true, + } + settings.ToolApproval.AutoApprovalRules = []toolapproval.AutoApprovalRule{toolapproval.AllToolsAutoApprovalRule} + + var executions int + fn := functool.MustNew(functool.Config{Name: "step_action"}, func(context.Context, struct{}) (string, error) { + executions++ + return fmt.Sprintf("step %d done", executions), nil + }) + + maxCalls := 2 + runner := &agenttest.Runner{Responses: agenttest.NewResponseBuilder(). + // Request 1: first auto-approved batch + AddFunctionCall("call-1", fn.Name(), `{}`). + NewTurn(). + // Request 1: second auto-approved batch + AddFunctionCall("call-2", fn.Name(), `{}`). + NewTurn(func(_ context.Context, messages []*message.Message, opts ...agent.Option) { + for tl := range agent.AllOptions(opts, agent.WithTool) { + if tl.Name() == fn.Name() { + t.Errorf("expected %s to be stripped after reaching budget of 2", fn.Name()) + } + } + }). + AddText("request 1 completed"). + NewTurn(). + // Request 2 (independent run): first auto-approved batch with fresh budget + AddFunctionCall("call-3", fn.Name(), `{}`). + NewTurn(). + // Request 2: second auto-approved batch + AddFunctionCall("call-4", fn.Name(), `{}`). + NewTurn(func(_ context.Context, messages []*message.Message, opts ...agent.Option) { + for tl := range agent.AllOptions(opts, agent.WithTool) { + if tl.Name() == fn.Name() { + t.Errorf("expected %s to be stripped in request 2 after reaching budget of 2", fn.Name()) + } + } + }). + AddText("request 2 completed"). + Build()} + + a := newLocalAgentWithAutocall(harnessConfig(t, settings)(agent.Config{ + Tools: []tool.Tool{tool.ApprovalRequiredFunc(fn)}, + }), runner.Run, toolautocall.Config{ + MaximumFunctionCallsPerRequest: &maxCalls, + }) + + session := &agent.Session{} + + // Request 1 + resp1, err := a.RunText(t.Context(), "start request 1", agent.WithSession(session)).Collect() + if err != nil { + t.Fatal(err) + } + if executions != 2 { + t.Fatalf("expected 2 executions in request 1, got %d", executions) + } + if resp1.String() != "request 1 completed" { + t.Fatalf("unexpected response 1: %q", resp1.String()) + } + + // Request 2: independent run with same session must reset the budget + resp2, err := a.RunText(t.Context(), "start request 2", agent.WithSession(session)).Collect() + if err != nil { + t.Fatal(err) + } + if executions != 4 { + t.Fatalf("expected 4 total executions across both requests, got %d", executions) + } + if resp2.String() != "request 2 completed" { + t.Fatalf("unexpected response 2: %q", resp2.String()) + } +} + +func TestToolApproval_SharesMaximumFunctionCallsAcrossHumanApprovalResumption(t *testing.T) { + settings := harness.Config{ + DisableTodo: true, + DisableAgentMode: true, + DisableSkills: true, + DisableFileMemory: true, + } + + var executions int + fn := functool.MustNew(functool.Config{Name: "step_action"}, func(context.Context, struct{}) (string, error) { + executions++ + return fmt.Sprintf("step %d done", executions), nil + }) + + maxCalls := 1 + runner := &agenttest.Runner{Responses: agenttest.NewResponseBuilder(). + // Turn 1 (initial run): returns FCC requiring approval + AddFunctionCall("call-1", fn.Name(), `{}`). + NewTurn(func(_ context.Context, messages []*message.Message, opts ...agent.Option) { + // Resumed run after user approval: since budget is 1 and approved call was executed, + // schema tools must be stripped. + for tl := range agent.AllOptions(opts, agent.WithTool) { + if tl.Name() == fn.Name() { + t.Errorf("expected %s to be stripped after reaching budget of 1", fn.Name()) + } + } + }). + AddText("completed after human approval"). + NewTurn(func(_ context.Context, messages []*message.Message, opts ...agent.Option) { + // Turn 3 (independent run with same session): budget must be reset, so tool is present again + hasTool := false + for tl := range agent.AllOptions(opts, agent.WithTool) { + if tl.Name() == fn.Name() { + hasTool = true + } + } + if !hasTool { + t.Errorf("expected %s to be available in independent run", fn.Name()) + } + }). + AddText("independent request completed"). + Build()} + + a := newLocalAgentWithAutocall(harnessConfig(t, settings)(agent.Config{ + Tools: []tool.Tool{tool.ApprovalRequiredFunc(fn)}, + }), runner.Run, toolautocall.Config{ + MaximumFunctionCallsPerRequest: &maxCalls, + }) + + session := &agent.Session{} + + // Step 1: initial run surfaces approval request + resp1, err := a.RunText(t.Context(), "start request 1", agent.WithSession(session)).Collect() + if err != nil { + t.Fatal(err) + } + var request *message.ToolApprovalRequestContent + for content := range resp1.Contents() { + if approval, ok := content.(*message.ToolApprovalRequestContent); ok { + request = approval + } + } + if request == nil { + t.Fatal("expected approval request to be surfaced") + } + if executions != 0 { + t.Fatalf("expected 0 executions before approval, got %d", executions) + } + + // Step 2: resume with approval response in a new a.RunMessage call (allocates a new runToken) + approvalMsg := message.New(request.CreateResponse(true, "approved")) + resp2, err := a.RunMessage(t.Context(), approvalMsg, agent.WithSession(session)).Collect() + if err != nil { + t.Fatal(err) + } + if executions != 1 { + t.Fatalf("expected 1 execution after approval, got %d", executions) + } + if resp2.String() != "completed after human approval" { + t.Fatalf("unexpected response 2: %q", resp2.String()) + } + + // Step 3: independent run with the same session starts with a fresh budget + resp3, err := a.RunText(t.Context(), "start request 2", agent.WithSession(session)).Collect() + if err != nil { + t.Fatal(err) + } + if resp3.String() != "independent request completed" { + t.Fatalf("unexpected response 3: %q", resp3.String()) + } +} diff --git a/agent/harness/toolautocall/autocall.go b/agent/harness/toolautocall/autocall.go index 8baf4abe..30d382fe 100644 --- a/agent/harness/toolautocall/autocall.go +++ b/agent/harness/toolautocall/autocall.go @@ -29,6 +29,7 @@ const ( defaultMaximumIterationsPerRequest = 40 pendingApprovalRequestsStateKey = "toolautocall.pendingApprovalRequests" pendingAutoApprovedCallsStateKey = "toolautocall.pendingAutoApprovedCalls" + budgetConsumedCallsStateKey = "toolautocall.consumedFunctionCalls" ) // Tracer instruments individual tool invocations without depending on a tracing @@ -125,6 +126,13 @@ type Config struct { // middleware as described by [Config]. Negative values are invalid. MaximumIterationsPerRequest *int + // MaximumFunctionCallsPerRequest is the maximum number of tool invocations allowed + // in a single run. When the limit is reached, the middleware makes a final provider + // request without schema tools so no more local function calls are requested. + // When nil, no call-count limit is enforced. Zero allows the model to run without + // invoking any automatic function calls. Negative values are invalid. + MaximumFunctionCallsPerRequest *int + // NewID generates synthetic IDs for generated tool-result messages and // approval-related fallback messages. If nil, a canonical dashed UUID is used. NewID func() string @@ -149,6 +157,7 @@ type autocall struct { allowConcurrentInvocations bool maximumConsecutiveErrorsPerRequest int maximumIterationsPerRequest int + maximumFunctionCallsPerRequest *int newID func() string disableApprovalResponseBinding bool disableApprovalNotRequiredBypassing bool @@ -167,6 +176,11 @@ func New(cfg Config) agent.Middleware { if cfg.MaximumIterationsPerRequest != nil { maximumIterationsPerRequest = *cfg.MaximumIterationsPerRequest } + var maximumFunctionCallsPerRequest *int + if cfg.MaximumFunctionCallsPerRequest != nil { + val := *cfg.MaximumFunctionCallsPerRequest + maximumFunctionCallsPerRequest = &val + } ac := &autocall{ logger: slogx.Logger{ Logger: cfg.Logger, @@ -181,6 +195,7 @@ func New(cfg Config) agent.Middleware { allowConcurrentInvocations: cfg.AllowConcurrentInvocations, maximumConsecutiveErrorsPerRequest: maximumConsecutiveErrorsPerRequest, maximumIterationsPerRequest: maximumIterationsPerRequest, + maximumFunctionCallsPerRequest: maximumFunctionCallsPerRequest, newID: cfg.NewID, disableApprovalResponseBinding: cfg.DisableApprovalResponseBinding, disableApprovalNotRequiredBypassing: cfg.DisableApprovalNotRequiredFunctionBypassing, @@ -230,6 +245,10 @@ func (f *autocall) run(next agent.RunFunc, ctx context.Context, messages []*mess yield(nil, fmt.Errorf("toolautocall: MaximumIterationsPerRequest must be 0 or greater, got %d", f.maximumIterationsPerRequest)) return } + if f.maximumFunctionCallsPerRequest != nil && *f.maximumFunctionCallsPerRequest < 0 { + yield(nil, fmt.Errorf("toolautocall: MaximumFunctionCallsPerRequest must be 0 or greater, got %d", *f.maximumFunctionCallsPerRequest)) + return + } if err := ctx.Err(); err != nil { yield(nil, err) return @@ -244,6 +263,17 @@ func (f *autocall) run(next agent.RunFunc, ctx context.Context, messages []*mess } var messagesCloned bool session, _ := agent.GetOption(opts, agent.WithSession) + totalFunctionCalls, err := f.loadBudgetConsumedCalls(session, messages) + if err != nil { + yield(nil, err) + return + } + var pendingApprovalProduced bool + defer func() { + if !pendingApprovalProduced { + f.clearBudgetConsumedCalls(session) + } + }() serviceID, _ := agent.GetOption(opts, agent.WithServiceID) serviceManagedHistory := strings.TrimSpace(serviceID) != "" ambiguousRequestIDs := make(map[string]struct{}) @@ -315,9 +345,25 @@ func (f *autocall) run(next agent.RunFunc, ctx context.Context, messages []*mess return } } - // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. + // Check if budget is already exhausted before invoking approved functions. var newMsg *message.Message - newMsg, errCount, err = f.invokeApprovedToolApprovalResponses(ctx, notInvokedMsgs, tools, 0) + var executedCount int + if f.maximumFunctionCallsPerRequest != nil && totalFunctionCalls >= *f.maximumFunctionCallsPerRequest { + // Budget reached: generate non-execution error results so history remains valid. + budgetExceededContents := f.generateBudgetExceededFunctionResults(ctx, notInvokedMsgs) + if len(budgetExceededContents) > 0 { + newMsg = &message.Message{ + Role: message.RoleTool, + ID: toolMsgID, + Contents: budgetExceededContents, + } + } + } else { + // Invoke approved approval responses, which generates some additional FRC wrapped in ChatMessage. + newMsg, executedCount, errCount, err = f.invokeApprovedToolApprovalResponses(ctx, notInvokedMsgs, tools, 0) + totalFunctionCalls += executedCount + f.saveBudgetConsumedCalls(session, totalFunctionCalls) + } if newMsg != nil { opts = updateOptionsForNextIteration(opts) messages = slices.Insert(messages, approvedResultInsertIdx, newMsg) @@ -345,8 +391,12 @@ func (f *autocall) run(next agent.RunFunc, ctx context.Context, messages []*mess yield(nil, err) return } - if i >= f.maximumIterationsPerRequest { - f.logger.Debug(ctx, "reached maximum iteration count; stopping function invocation loop", "maximumIterationsPerRequest", f.maximumIterationsPerRequest) + if i >= f.maximumIterationsPerRequest || (f.maximumFunctionCallsPerRequest != nil && totalFunctionCalls >= *f.maximumFunctionCallsPerRequest) { + if i >= f.maximumIterationsPerRequest { + f.logger.Debug(ctx, "reached maximum iteration count; stopping function invocation loop", "maximumIterationsPerRequest", f.maximumIterationsPerRequest) + } else { + f.logger.Debug(ctx, "reached maximum function call count; stopping function invocation loop", "maximumFunctionCallsPerRequest", *f.maximumFunctionCallsPerRequest, "totalFunctionCalls", totalFunctionCalls) + } opts = prepareOptionsForLastIteration(opts) } tools, requiresApproval := f.createToolsMap(opts) @@ -400,13 +450,15 @@ func (f *autocall) run(next agent.RunFunc, ctx context.Context, messages []*mess } hasApprovalRequiringFcc, lastApprovalCheckedFCCIdx = checkForApprovalRequiringFCC(functionCallContents, tools, hasApprovalRequiringFcc, lastApprovalCheckedFCCIdx) if hasApprovalRequiringFcc { + pendingApprovalProduced = true + f.saveBudgetConsumedCalls(session, totalFunctionCalls) // If we've encountered a function call content that requires approval, // we need to ask for approval for all functions, since we cannot mix and match. // Convert all function call contents into approval requests from the last yielded update index // and yield all those updates. for ; lastYieldedUpdateIdx < len(updates); lastYieldedUpdateIdx++ { updateToYield := updates[lastYieldedUpdateIdx] - updatedContent, err := f.prepareApprovalContents(ctx, updateToYield.Contents, tools, session) + updatedContent, err := f.prepareApprovalContents(ctx, updateToYield.Contents, tools, session, totalFunctionCalls) if err != nil { yield(nil, err) return @@ -439,7 +491,7 @@ func (f *autocall) run(next agent.RunFunc, ctx context.Context, messages []*mess } // 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 || (f.maximumFunctionCallsPerRequest != nil && totalFunctionCalls >= *f.maximumFunctionCallsPerRequest) || hasApprovalRequiringFcc || f.shouldTerminateLoopBasedOnHandleableFunctions(ctx, functionCallContents, tools) { break } @@ -447,8 +499,11 @@ 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 executedCount int var err error - newMsg, errCount, err = f.processFunctionCalls(ctx, tools, functionCallContents, errCount) + newMsg, executedCount, errCount, err = f.processFunctionCalls(ctx, tools, functionCallContents, errCount) + totalFunctionCalls += executedCount + f.saveBudgetConsumedCalls(session, totalFunctionCalls) // Stream any generated function results. This mirrors what's done for ResponseAsync, where the returned messages // includes all activities, including generated function results. @@ -490,7 +545,7 @@ func (f *autocall) run(next agent.RunFunc, ctx context.Context, messages []*mess } } -func (f *autocall) prepareApprovalContents(ctx context.Context, contents message.Contents, tools map[string]tool.SchemaTool, session *agent.Session) (message.Contents, error) { +func (f *autocall) prepareApprovalContents(ctx context.Context, contents message.Contents, tools map[string]tool.SchemaTool, session *agent.Session, totalFunctionCalls int) (message.Contents, error) { var updated message.Contents var autoApproved []*message.FunctionCallContent for i, c := range contents { @@ -500,7 +555,8 @@ func (f *autocall) prepareApprovalContents(ctx context.Context, contents message } declaration, known := tools[fcc.Name] _, invocable := declaration.(tool.FuncTool) - if !f.disableApprovalNotRequiredBypassing && session != nil && known && invocable && !toolRequiresApproval(declaration) { + atBudgetLimit := f.maximumFunctionCallsPerRequest != nil && totalFunctionCalls >= *f.maximumFunctionCallsPerRequest + if !f.disableApprovalNotRequiredBypassing && session != nil && known && invocable && !toolRequiresApproval(declaration) && !atBudgetLimit { autoApproved = append(autoApproved, snapshotFunctionCall(fcc)) updated[i] = nil continue @@ -635,6 +691,17 @@ func prepareOptionsForLastIteration(opts []agent.Option) []agent.Option { if len(nonSchemaTools) == 0 { updated = slices.DeleteFunc(updated, isToolModeOption) + } else if mode, ok := agent.GetOption(opts, agent.WithToolMode); ok { + if requiredToolName, ok := mode.RequiredTool(); ok { + hasRequiredTool := slices.ContainsFunc(nonSchemaTools, func(t tool.Tool) bool { + return t.Name() == requiredToolName + }) + if !hasRequiredTool { + updated = slices.DeleteFunc(updated, isToolModeOption) + } + } else if mode.Mode() == tool.ToolModeRequired { + updated = slices.DeleteFunc(updated, isToolModeOption) + } } return updated @@ -825,6 +892,43 @@ func approvalResponseNeedsProcessingByRequestID(msgs []*message.Message) map[str return result } +type budgetConsumedCallsState struct { + Consumed int `json:"consumed"` +} + +func (f *autocall) loadBudgetConsumedCalls(session *agent.Session, messages []*message.Message) (int, error) { + if session == nil { + return 0, nil + } + var state budgetConsumedCallsState + ok, err := session.Get(budgetConsumedCallsStateKey, &state) + if err != nil { + return 0, err + } + if ok { + if hasAnyApprovalContent(messages) { + return state.Consumed, nil + } + session.Delete(budgetConsumedCallsStateKey) + return 0, nil + } + return 0, nil +} + +func (f *autocall) saveBudgetConsumedCalls(session *agent.Session, consumed int) { + if session != nil { + session.Set(budgetConsumedCallsStateKey, budgetConsumedCallsState{ + Consumed: consumed, + }) + } +} + +func (f *autocall) clearBudgetConsumedCalls(session *agent.Session) { + if session != nil { + session.Delete(budgetConsumedCallsStateKey) + } +} + type pendingApprovalRequestsState struct { Requests []*message.ToolApprovalRequestContent `json:"requests,omitempty"` } @@ -1139,10 +1243,10 @@ func snapshotFunctionCall(call *message.FunctionCallContent) *message.FunctionCa return &clone } -func (f *autocall) invokeApprovedToolApprovalResponses(ctx context.Context, approvals []toolApprovalResultWithRequestMessage, tools map[string]tool.SchemaTool, errCount int) (*message.Message, int, error) { +func (f *autocall) invokeApprovedToolApprovalResponses(ctx context.Context, approvals []toolApprovalResultWithRequestMessage, tools map[string]tool.SchemaTool, errCount int) (*message.Message, int, int, error) { // Check if there are any function calls to do for any approved tool requests and execute them. if len(approvals) == 0 { - return nil, errCount, nil + return nil, 0, errCount, nil } funcCalls := make([]*message.FunctionCallContent, 0, len(approvals)) for _, approval := range approvals { @@ -1153,7 +1257,7 @@ func (f *autocall) invokeApprovedToolApprovalResponses(ctx context.Context, appr } } if len(funcCalls) == 0 { - return nil, errCount, nil + return nil, 0, errCount, nil } return f.processFunctionCalls(ctx, tools, funcCalls, errCount) } @@ -1174,7 +1278,7 @@ type functionInvocationResult struct { err error } -func (f *autocall) processFunctionCalls(ctx context.Context, tools map[string]tool.SchemaTool, funcCalls []*message.FunctionCallContent, errCount int) (*message.Message, int, error) { +func (f *autocall) processFunctionCalls(ctx context.Context, tools map[string]tool.SchemaTool, funcCalls []*message.FunctionCallContent, errCount int) (*message.Message, int, int, error) { // We must add a response for every tool call, regardless of whether we successfully executed it or not. // If we successfully execute it, we'll add the result. If we don't, we'll add an error. if len(funcCalls) == 0 { // invariant @@ -1244,8 +1348,12 @@ func (f *autocall) processFunctionCalls(ctx context.Context, tools map[string]to results = append(results, functionInvocationResult{status: functionInvocationStatusNotInvoked, call: fc, err: skippedErr}) } } + var executedCalls int for _, result := range results { result.call.InformationalOnly = true + if result.status != functionInvocationStatusNotFound && result.status != functionInvocationStatusNotInvoked { + executedCalls++ + } } newMsg := f.createResponseMessage(results) @@ -1253,7 +1361,7 @@ func (f *autocall) processFunctionCalls(ctx context.Context, tools map[string]to errCount, runErr = f.updateConsecutiveErrorCountOrThrow(ctx, newMsg, errCount) } - return newMsg, errCount, runErr + return newMsg, executedCalls, errCount, runErr } func (f *autocall) updateConsecutiveErrorCountOrThrow(ctx context.Context, added *message.Message, errCount int) (int, error) { @@ -1701,3 +1809,25 @@ func (f *autocall) generateRejectedFunctionResults(ctx context.Context, rejectio } return contents } + +func (f *autocall) generateBudgetExceededFunctionResults(ctx context.Context, approvals []toolApprovalResultWithRequestMessage) message.Contents { + if len(approvals) == 0 { + return nil + } + contents := make(message.Contents, 0, len(approvals)) + for _, app := range approvals { + fcc, ok := approvalToolCallAsFunctionCall(app.Response.ToolCall) + if !ok { + continue + } + f.logger.Debug(ctx, "function execution skipped: function call budget reached", "funcName", fcc.Name) + fcc.InformationalOnly = true + resultContent := &message.FunctionResultContent{ + CallID: fcc.CallID, + Error: errors.New("function call budget reached"), + Result: "Error: Function invocation limit reached before the deferred tool could run.", + } + contents = append(contents, resultContent) + } + return contents +} diff --git a/agent/harness/toolautocall/autocall_approval_test.go b/agent/harness/toolautocall/autocall_approval_test.go index 2475a1b2..228027fa 100644 --- a/agent/harness/toolautocall/autocall_approval_test.go +++ b/agent/harness/toolautocall/autocall_approval_test.go @@ -5,8 +5,10 @@ package toolautocall_test import ( "context" "encoding/json" + "errors" "fmt" "iter" + "strings" "testing" "github.com/microsoft/agent-framework-go/agent" @@ -2130,3 +2132,187 @@ func createFunc2() tool.FuncTool { return fmt.Sprintf("Result 2: %d", args.I), nil }) } + +func TestFunctionInvoking_MaximumFunctionCallsIncludesApprovedCalls(t *testing.T) { + var toolCalls int + approvedTool := tool.ApprovalRequiredFunc(functool.MustNew(functool.Config{Name: "action1"}, func(ctx context.Context, _ struct{}) (string, error) { + toolCalls++ + return "action1 done", nil + })) + otherTool := functool.MustNew(functool.Config{Name: "action2"}, func(ctx context.Context, _ struct{}) (string, error) { + toolCalls++ + return "action2 done", nil + }) + + var providerRuns int + next := func(_ context.Context, _ []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerRuns++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + if providerRuns == 1 { + // Initial run: surface approval request for action1 + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "call-1", Name: "action1"}, + }, + }, nil) + return + } + // Resumed run after approval: since limit is 1 and approved call was executed, + // schema tools must be stripped so no automatic calls can be requested. + for optTool := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := optTool.(tool.SchemaTool); ok { + yield(nil, fmt.Errorf("expected schema tools to be stripped after approved tool hit limit")) + return + } + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final response after approved tool"}}, + }, nil) + } + } + + session := &agent.Session{} + cfg := toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(1), + } + middleware := toolautocall.New(cfg) + + // Step 1: initial run surfaces approval request + var request *message.ToolApprovalRequestContent + for update, err := range middleware.Run(next, t.Context(), []*message.Message{message.NewText("run action")}, agent.WithSession(session), agent.WithTool(approvedTool), agent.WithTool(otherTool)) { + if err != nil { + t.Fatal(err) + } + for _, c := range update.Contents { + if req, ok := c.(*message.ToolApprovalRequestContent); ok { + request = req + } + } + } + if request == nil { + t.Fatal("expected approval request to be surfaced") + } + if toolCalls != 0 { + t.Fatalf("expected 0 tool calls executed before approval, got %d", toolCalls) + } + + // Step 2: run with approval response + approvalMsg := message.New(request.CreateResponse(true, "")) + var resp agent.Response + for update, err := range middleware.Run(next, t.Context(), []*message.Message{approvalMsg}, agent.WithSession(session), agent.WithTool(approvedTool), agent.WithTool(otherTool)) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if toolCalls != 1 { + t.Fatalf("expected 1 approved tool call executed, got %d", toolCalls) + } + if text := resp.String(); text != "final response after approved tool" { + t.Fatalf("expected final text response, got %q", text) + } +} + +func TestFunctionInvoking_ResumedApprovalWithZeroBudgetSkipsExecution(t *testing.T) { + toolCalls := 0 + approvedTool := tool.ApprovalRequiredFunc(functool.MustNew(functool.Config{Name: "Action"}, + func(ctx context.Context, args struct{}) (string, error) { + toolCalls++ + return "executed action", nil + }, + )) + + nextCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + nextCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + if nextCalls == 1 { + // Initial run: return FCC requiring approval + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "call_action_1", Name: "Action"}, + }, + }, nil) + return + } + // Turn 2: resumed approval run with zero budget + // Verify that the tool message contains budget exceeded error result + var hasBudgetExceededResult bool + for _, m := range msgs { + if m.Role == message.RoleTool { + for _, c := range m.Contents { + if frc, ok := c.(*message.FunctionResultContent); ok && frc.CallID == "call_action_1" { + if frc.Error != nil && strings.Contains(frc.Result.(string), "Function invocation limit reached") { + hasBudgetExceededResult = true + } + } + } + } + } + if !hasBudgetExceededResult { + yield(nil, errors.New("expected budget exceeded non-execution result in tool message")) + return + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final response without executing Action"}}, + }, nil) + } + } + + session := &agent.Session{} + + // Step 1: initial run surfaces approval request (default unlimited budget) + var request *message.ToolApprovalRequestContent + for update, err := range toolautocall.New(toolautocall.Config{}).Run( + next, + t.Context(), + []*message.Message{message.NewText("run action")}, + agent.WithSession(session), + agent.WithTool(approvedTool), + ) { + if err != nil { + t.Fatal(err) + } + for _, c := range update.Contents { + if req, ok := c.(*message.ToolApprovalRequestContent); ok { + request = req + } + } + } + if request == nil { + t.Fatal("expected approval request to be surfaced") + } + if toolCalls != 0 { + t.Fatalf("expected 0 tool calls executed before approval, got %d", toolCalls) + } + + // Step 2: resume approval with MaximumFunctionCallsPerRequest = 0 + approvalMsg := message.New(request.CreateResponse(true, "")) + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(0), + }).Run( + next, + t.Context(), + []*message.Message{approvalMsg}, + agent.WithSession(session), + agent.WithTool(approvedTool), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if toolCalls != 0 { + t.Fatalf("expected 0 tool calls executed with zero budget, got %d", toolCalls) + } + if text := resp.String(); text != "final response without executing Action" { + t.Fatalf("unexpected final response: %q", text) + } +} diff --git a/agent/harness/toolautocall/autocall_test.go b/agent/harness/toolautocall/autocall_test.go index 618754cf..810986f1 100644 --- a/agent/harness/toolautocall/autocall_test.go +++ b/agent/harness/toolautocall/autocall_test.go @@ -1267,6 +1267,609 @@ func TestFunctionInvoking_NegativeMaximumIterationsReturnsError(t *testing.T) { } } +func TestFunctionInvoking_NegativeMaximumFunctionCallsReturnsError(t *testing.T) { + invoked := false + next := func(context.Context, []*message.Message, ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + invoked = true + return func(func(*agent.ResponseUpdate, error) bool) {} + } + + var got error + for _, err := range toolautocall.New(toolautocall.Config{MaximumFunctionCallsPerRequest: new(-1)}).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + ) { + if err != nil { + got = err + break + } + } + + if got == nil || !strings.Contains(got.Error(), "MaximumFunctionCallsPerRequest must be 0 or greater") { + t.Fatalf("Run() error = %v, want function-call-limit validation error", got) + } + if invoked { + t.Fatal("provider was invoked for invalid configuration") + } +} + +func TestFunctionInvoking_ZeroMaximumFunctionCallsDisablesAutomaticCallsAndReturnsFinalResponse(t *testing.T) { + providerCalls := 0 + toolCalls := 0 + next := func(_ context.Context, _ []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + for t := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := t.(tool.SchemaTool); ok { + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(nil, errors.New("expected schema tools to be stripped when MaximumFunctionCallsPerRequest is 0")) + } + } + } + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final text response"}}, + }, nil) + } + } + testTool := functool.MustNew(functool.Config{Name: "Func1"}, func(context.Context, struct{}) (string, error) { + toolCalls++ + return "ok", nil + }) + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{MaximumFunctionCallsPerRequest: new(0)}).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(testTool), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + if providerCalls != 1 { + t.Fatalf("provider calls = %d, want 1", providerCalls) + } + if toolCalls != 0 { + t.Fatalf("tool calls = %d, want 0", toolCalls) + } + if text := resp.String(); text != "final text response" { + t.Fatalf("response text = %q, want %q", text, "final text response") + } +} + +func TestFunctionInvoking_MaximumFunctionCallsCapsSingleCallsPerIteration(t *testing.T) { + maxCalls := 3 + actualCallCount := 0 + + tools := []tool.Tool{ + functool.MustNew(functool.Config{Name: "VoidReturn"}, + func(ctx context.Context, args struct{}) (string, error) { + actualCallCount++ + return "Success: Function completed.", nil + }), + } + + plan := []*message.Message{ + message.New(&message.TextContent{Text: "hello"}), + } + + for i := 0; i < maxCalls+5; i++ { + plan = append(plan, + &message.Message{Role: message.RoleAssistant, Contents: []message.Content{ + &message.FunctionCallContent{CallID: fmt.Sprintf("callId%d", i), Name: "VoidReturn"}, + }}, + ) + plan = append(plan, + &message.Message{Role: message.RoleTool, Contents: []message.Content{ + &message.FunctionResultContent{CallID: fmt.Sprintf("callId%d", i), Result: "Success: Function completed."}, + }}, + ) + } + + expectedPlan := plan[:maxCalls*2+2] + + autocallOptions := toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(maxCalls), + } + + invokeAndAssert(t, tools, plan, expectedPlan, autocallOptions) + + if actualCallCount != maxCalls { + t.Errorf("Expected %d function calls, got %d", maxCalls, actualCallCount) + } +} + +func TestFunctionInvoking_MaximumFunctionCallsAllowsCurrentBatchToFinish(t *testing.T) { + maxCalls := 2 + actualCallCount := 0 + + tools := []tool.Tool{ + functool.MustNew(functool.Config{Name: "ToolCall"}, + func(ctx context.Context, args struct{}) (string, error) { + actualCallCount++ + return "done", nil + }), + } + + providerCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + if providerCalls == 1 { + // First turn: model returns a batch of 3 function calls (overshooting limit of 2) + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "call1", Name: "ToolCall"}, + &message.FunctionCallContent{CallID: "call2", Name: "ToolCall"}, + &message.FunctionCallContent{CallID: "call3", Name: "ToolCall"}, + }, + }, nil) + return + } + // Second turn: tools should be stripped because 3 >= 2 + for t := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := t.(tool.SchemaTool); ok { + yield(nil, errors.New("expected schema tools to be stripped after batch exceeded limit")) + return + } + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "batch finished, here is the answer"}}, + }, nil) + } + } + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(maxCalls), + }).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(tools[0]), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if actualCallCount != 3 { + t.Fatalf("expected all 3 calls in the batch to finish, got %d", actualCallCount) + } + if providerCalls != 2 { + t.Fatalf("expected 2 provider calls (1 tool batch + 1 final text response), got %d", providerCalls) + } + if text := resp.String(); text != "batch finished, here is the answer" { + t.Fatalf("expected final text response, got %q", text) + } +} + +func TestFunctionInvoking_MaximumFunctionCallsCountsFailedExecutions(t *testing.T) { + maxCalls := 1 + actualCallCount := 0 + + tools := []tool.Tool{ + functool.MustNew(functool.Config{Name: "FailingTool"}, + func(ctx context.Context, args struct{}) (string, error) { + actualCallCount++ + return "", errors.New("tool internal failure") + }), + } + + providerCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + if providerCalls == 1 { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "call1", Name: "FailingTool"}, + }, + }, nil) + return + } + // Second turn: limit of 1 reached (despite failure), tools stripped + for t := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := t.(tool.SchemaTool); ok { + yield(nil, errors.New("expected schema tools to be stripped after failed tool call hit limit")) + return + } + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "handled error, concluding response"}}, + }, nil) + } + } + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(maxCalls), + }).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(tools[0]), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if actualCallCount != 1 { + t.Fatalf("expected failed call to count as execution attempt, got %d", actualCallCount) + } + if providerCalls != 2 { + t.Fatalf("expected 2 provider calls, got %d", providerCalls) + } + if text := resp.String(); text != "handled error, concluding response" { + t.Fatalf("expected final text response, got %q", text) + } +} + +func TestFunctionInvoking_MaximumFunctionCallsIgnoresProviderHandledCalls(t *testing.T) { + maxCalls := 1 + localCalls := 0 + + localTool := functool.MustNew(functool.Config{Name: "LocalTool"}, + func(ctx context.Context, args struct{}) (string, error) { + localCalls++ + return "local result", nil + }) + + providerCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + if providerCalls == 1 { + // Server-handled tool call: update already contains the result + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "server_call_1", Name: "ServerTool"}, + &message.FunctionResultContent{CallID: "server_call_1", Result: "server handled result"}, + }, + }, nil) + // Local tool call in the same turn + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "local_call_1", Name: "LocalTool"}, + }, + }, nil) + return + } + // Turn 2: Local call reached limit of 1 (server call did NOT increment totalFunctionCalls) + for t := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := t.(tool.SchemaTool); ok { + yield(nil, errors.New("expected schema tools to be stripped after 1 local call")) + return + } + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final response after server and local tool"}}, + }, nil) + } + } + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(maxCalls), + }).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(localTool), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if localCalls != 1 { + t.Fatalf("expected 1 local call, got %d", localCalls) + } + if providerCalls != 2 { + t.Fatalf("expected 2 provider calls, got %d", providerCalls) + } +} + +func TestFunctionInvoking_MaximumFunctionCallsIgnoresNotFoundCalls(t *testing.T) { + maxCalls := 1 + localCalls := 0 + + localTool := functool.MustNew(functool.Config{Name: "LocalTool"}, + func(ctx context.Context, args struct{}) (string, error) { + localCalls++ + return "local result", nil + }) + + providerCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + if providerCalls == 1 { + // Model calls an unknown/non-existent tool + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "unknown_call_1", Name: "NonExistentTool"}, + }, + }, nil) + return + } + if providerCalls == 2 { + // Turn 2: Unknown call did not consume budget; LocalTool should still be available + hasLocalTool := false + for tl := range agent.AllOptions(opts, agent.WithTool) { + if tl.Name() == "LocalTool" { + hasLocalTool = true + break + } + } + if !hasLocalTool { + yield(nil, errors.New("expected LocalTool to remain available after not-found call")) + return + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "local_call_1", Name: "LocalTool"}, + }, + }, nil) + return + } + // Turn 3: LocalTool execution consumed budget of 1; schema tools must now be stripped + for tl := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := tl.(tool.SchemaTool); ok { + yield(nil, errors.New("expected schema tools to be stripped after LocalTool execution")) + return + } + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final response after unknown and local tool"}}, + }, nil) + } + } + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(maxCalls), + }).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(localTool), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if localCalls != 1 { + t.Fatalf("expected 1 local call, got %d", localCalls) + } + if providerCalls != 3 { + t.Fatalf("expected 3 provider calls, got %d", providerCalls) + } + if text := resp.String(); text != "final response after unknown and local tool" { + t.Fatalf("unexpected response: %q", text) + } +} + +func TestFunctionInvoking_MaximumFunctionCallsCountsPanickingExecutions(t *testing.T) { + maxCalls := 1 + panics := 0 + + panicTool := functool.MustNew(functool.Config{Name: "PanicTool"}, + func(ctx context.Context, args struct{}) (string, error) { + panics++ + panic("tool exploded") + }) + + providerCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + if providerCalls == 1 { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.FunctionCallContent{CallID: "panic_call_1", Name: "PanicTool"}, + }, + }, nil) + return + } + // Turn 2: Panic was counted towards the budget, so schema tools are stripped + for tl := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := tl.(tool.SchemaTool); ok { + yield(nil, errors.New("expected schema tools to be stripped after panicking execution")) + return + } + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final response after panic"}}, + }, nil) + } + } + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(maxCalls), + }).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(panicTool), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if panics != 1 { + t.Fatalf("expected 1 panic, got %d", panics) + } + if providerCalls != 2 { + t.Fatalf("expected 2 provider calls, got %d", providerCalls) + } + if text := resp.String(); text != "final response after panic" { + t.Fatalf("unexpected response: %q", text) + } +} + +type dummyNonSchemaTool struct { + name string +} + +func (d dummyNonSchemaTool) Name() string { return d.name } +func (d dummyNonSchemaTool) Description() string { return "dummy non-schema tool" } + +func TestFunctionInvoking_ZeroBudgetClearsRequiredToolChoiceWhenSchemaToolRemoved(t *testing.T) { + fooTool := functool.MustNew(functool.Config{Name: "Foo"}, + func(ctx context.Context, args struct{}) (string, error) { + return "foo result", nil + }) + webSearchTool := dummyNonSchemaTool{name: "WebSearch"} + + providerCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + // Zero budget: Foo must be removed, WebSearch must be retained, + // and required tool mode for Foo must be cleared. + var schemaTools []tool.Tool + var nonSchemaTools []tool.Tool + for tl := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := tl.(tool.SchemaTool); ok { + schemaTools = append(schemaTools, tl) + } else { + nonSchemaTools = append(nonSchemaTools, tl) + } + } + if len(schemaTools) != 0 { + yield(nil, fmt.Errorf("expected 0 schema tools, got %d", len(schemaTools))) + return + } + if len(nonSchemaTools) != 1 || nonSchemaTools[0].Name() != "WebSearch" { + yield(nil, fmt.Errorf("expected WebSearch non-schema tool, got %v", nonSchemaTools)) + return + } + if mode, ok := agent.GetOption(opts, agent.WithToolMode); ok { + yield(nil, fmt.Errorf("expected tool mode to be cleared, got %v", mode)) + return + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final response without Foo"}}, + }, nil) + } + } + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(0), + }).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(fooTool), + agent.WithTool(webSearchTool), + agent.WithToolMode(tool.RequireTool("Foo")), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if providerCalls != 1 { + t.Fatalf("expected 1 provider call, got %d", providerCalls) + } + if text := resp.String(); text != "final response without Foo" { + t.Fatalf("unexpected response: %q", text) + } +} + +func TestFunctionInvoking_ZeroBudgetClearsGenericRequiredToolModeWhenSchemaToolRemoved(t *testing.T) { + fooTool := functool.MustNew(functool.Config{Name: "Foo"}, + func(ctx context.Context, args struct{}) (string, error) { + return "foo result", nil + }) + webSearchTool := dummyNonSchemaTool{name: "WebSearch"} + + providerCalls := 0 + next := func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + providerCalls++ + return func(yield func(*agent.ResponseUpdate, error) bool) { + var schemaTools []tool.Tool + var nonSchemaTools []tool.Tool + for tl := range agent.AllOptions(opts, agent.WithTool) { + if _, ok := tl.(tool.SchemaTool); ok { + schemaTools = append(schemaTools, tl) + } else { + nonSchemaTools = append(nonSchemaTools, tl) + } + } + if len(schemaTools) != 0 { + yield(nil, fmt.Errorf("expected 0 schema tools, got %d", len(schemaTools))) + return + } + if len(nonSchemaTools) != 1 || nonSchemaTools[0].Name() != "WebSearch" { + yield(nil, fmt.Errorf("expected WebSearch non-schema tool, got %v", nonSchemaTools)) + return + } + if mode, ok := agent.GetOption(opts, agent.WithToolMode); ok { + yield(nil, fmt.Errorf("expected tool mode to be cleared, got %v", mode)) + return + } + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "final response without generic required"}}, + }, nil) + } + } + + var resp agent.Response + for update, err := range toolautocall.New(toolautocall.Config{ + MaximumFunctionCallsPerRequest: new(0), + }).Run( + next, + t.Context(), + []*message.Message{message.NewText("hello")}, + agent.WithTool(fooTool), + agent.WithTool(webSearchTool), + agent.WithToolMode(tool.ToolModeRequired), + ) { + if err != nil { + t.Fatal(err) + } + resp.Update(update) + } + + if providerCalls != 1 { + t.Fatalf("expected 1 provider call, got %d", providerCalls) + } + if text := resp.String(); text != "final response without generic required" { + t.Fatalf("unexpected response: %q", text) + } +} + // TestFunctionInvoking_SupportsToolsProvidedByAdditionalTools tests AdditionalTools functionality func TestFunctionInvoking_SupportsToolsProvidedByAdditionalTools(t *testing.T) { type Func1Args struct{}