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
24 changes: 22 additions & 2 deletions agent/harness/toolapproval/toolapproval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions agent/harness/toolapproval/toolapproval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
Loading