diff --git a/agent/harness/toolapproval/toolapproval.go b/agent/harness/toolapproval/toolapproval.go index d07482b6..178fa756 100644 --- a/agent/harness/toolapproval/toolapproval.go +++ b/agent/harness/toolapproval/toolapproval.go @@ -26,7 +26,10 @@ import ( "github.com/microsoft/agent-framework-go/tool" ) -const stateKey = "toolApprovalState" +const ( + stateKey = "toolApprovalState" + defaultMaxAutoApprovalTurns = 40 +) // Rule is a standing approval rule. If Arguments is nil, all invocations of // the named tool are auto-approved. Otherwise only invocations with an exact @@ -121,7 +124,7 @@ func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*messag } // Step 3: Main loop — call inner agent, classify approval requests. - for { + for iteration := 0; ; iteration++ { // Inject collected approval responses as user messages. callMessages := messages if len(st.CollectedApprovalResponses) > 0 { @@ -130,6 +133,23 @@ func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*messag st.CollectedApprovalResponses = nil } + if iteration >= defaultMaxAutoApprovalTurns { + // Cap reached: forward one final inner turn as-is so any approval request + // is surfaced to the caller instead of continuing the auto-approval chain. + for update, err := range next(ctx, callMessages, opts...) { + if err != nil { + yield(nil, err) + return + } + if !yield(update, nil) { + saveState(opts, st) + return + } + } + saveState(opts, st) + return + } + var approvalRequests []*message.ToolApprovalRequestContent for update, err := range next(ctx, callMessages, opts...) { if err != nil { diff --git a/agent/harness/toolapproval/toolapproval_test.go b/agent/harness/toolapproval/toolapproval_test.go index 29ef6efd..af28de9e 100644 --- a/agent/harness/toolapproval/toolapproval_test.go +++ b/agent/harness/toolapproval/toolapproval_test.go @@ -1232,3 +1232,47 @@ func TestToolApproval_NilUpdatePassthrough(t *testing.T) { t.Errorf("expected 1 text update, got %d", textUpdates) } } + +func TestToolApproval_AutoApprovedRequestsStopAtIterationCap(t *testing.T) { + var callCount int + next := func(_ context.Context, _ []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + callCount++ + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.ToolApprovalRequestContent{ + RequestID: "r1", + ToolCall: &message.FunctionCallContent{ + CallID: "c1", + Name: "load_skill", + }, + }, + }, + }, nil) + } + } + + mw := toolapproval.New(toolapproval.Config{ + AutoApprovalRules: []func(context.Context, *message.FunctionCallContent) (bool, error){ + func(context.Context, *message.FunctionCallContent) (bool, error) { return true, nil }, + }, + }) + + updates := collectUpdates(t, mw, next, []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "hi"}}}, + }) + + if callCount != 41 { + t.Fatalf("expected 41 inner invocations (40 auto-approved turns plus one final surfaced turn), got %d", callCount) + } + if len(updates) != 1 { + t.Fatalf("expected 1 surfaced update after hitting the cap, got %d", len(updates)) + } + if len(updates[0].Contents) != 1 { + t.Fatalf("expected final update to contain the unsplit approval request, got %#v", updates[0].Contents) + } + if _, ok := updates[0].Contents[0].(*message.ToolApprovalRequestContent); !ok { + t.Fatalf("expected final update to surface the approval request, got %#v", updates[0].Contents[0]) + } +}