Skip to content
Merged
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
81 changes: 68 additions & 13 deletions agent/harness/toolapproval/toolapproval.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,24 @@ func saveState(opts []agent.Option, s state) {
// New creates a tool-approval middleware that wraps agent runs with
// human-in-the-loop approval management.
func New(cfg Config) agent.Middleware {
// Config is currently empty and reserved for future extensibility.
_ = cfg
return agent.MiddlewareFunc(run)
return agent.MiddlewareFunc(func(next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] {
return run(cfg, next, ctx, messages, opts...)
})
}

// Config configures tool-approval middleware behavior.
type Config struct{}
type Config struct {
// AutoApprovalRules is an optional list of heuristic functions evaluated after
// standing rules (derived from prior user approvals) but before surfacing the
// approval request to the caller. Each rule receives the tool call and returns
// (approved, error). Returning approved=true auto-approves the request. Rules
// are evaluated in order; the first returning approved=true causes the request
// to be auto-approved without prompting the caller. Returning an error fails
// the current run.
AutoApprovalRules []func(context.Context, *message.FunctionCallContent) (bool, error)
}

func run(next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] {
func run(cfg Config, next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] {
return func(yield func(*agent.ResponseUpdate, error) bool) {
st := loadState(opts)

Expand All @@ -94,7 +103,10 @@ func run(next agent.RunFunc, ctx context.Context, messages []*message.Message, o

// Step 2: If we have queued requests from a previous turn, drain any
// that are now auto-approvable and surface the next one.
drainAutoApprovable(&st)
if err := drainAutoApprovable(ctx, cfg, &st); err != nil {
yield(nil, err)
return
}
if len(st.QueuedRequests) > 0 {
next := st.QueuedRequests[0]
st.QueuedRequests = st.QueuedRequests[1:]
Expand Down Expand Up @@ -145,7 +157,16 @@ func run(next agent.RunFunc, ctx context.Context, messages []*message.Message, o
if matchesRule(st.Rules, req) {
autoApproved = append(autoApproved, req.CreateResponse(true, ""))
} else {
needsApproval = append(needsApproval, req)
matches, err := matchesAutoApprovalRules(ctx, cfg.AutoApprovalRules, req)
if err != nil {
yield(nil, err)
return
}
if matches {
autoApproved = append(autoApproved, req.CreateResponse(true, ""))
} else {
needsApproval = append(needsApproval, req)
}
}
}

Expand Down Expand Up @@ -243,21 +264,29 @@ func prepareInbound(messages []*message.Message, st state) ([]*message.Message,
return messages, st
}

// drainAutoApprovable removes queued requests that now match a standing rule,
// adding auto-approve responses to collected.
func drainAutoApprovable(st *state) {
if len(st.QueuedRequests) == 0 || len(st.Rules) == 0 {
return
// drainAutoApprovable removes queued requests that now match a standing rule
// or an auto-approval rule, adding auto-approve responses to collected.
func drainAutoApprovable(ctx context.Context, cfg Config, st *state) error {
if len(st.QueuedRequests) == 0 {
return nil
}
if len(st.Rules) == 0 && len(cfg.AutoApprovalRules) == 0 {
return nil
}
var remaining []*message.ToolApprovalRequestContent
for _, req := range st.QueuedRequests {
if matchesRule(st.Rules, req) {
matches, err := matchesAutoApprovalRules(ctx, cfg.AutoApprovalRules, req)
if err != nil {
return err
}
if matchesRule(st.Rules, req) || matches {
st.CollectedResponses = append(st.CollectedResponses, req.CreateResponse(true, ""))
} else {
remaining = append(remaining, req)
}
}
st.QueuedRequests = remaining
return nil
}

func matchesRule(rules []Rule, req *message.ToolApprovalRequestContent) bool {
Expand All @@ -277,6 +306,32 @@ func matchesRule(rules []Rule, req *message.ToolApprovalRequestContent) bool {
return false
}

// matchesAutoApprovalRules returns true if any configured auto-approval rule
// approves the request. Rules are evaluated in order; the first returning true
// wins. Returns false when rules is empty or the request is not a function call.
func matchesAutoApprovalRules(ctx context.Context, rules []func(context.Context, *message.FunctionCallContent) (bool, error), req *message.ToolApprovalRequestContent) (bool, error) {
if len(rules) == 0 {
return false, nil
}
fc, ok := req.ToolCall.(*message.FunctionCallContent)
if !ok || fc == nil {
return false, nil
}
for _, rule := range rules {
if rule == nil {
continue
}
matches, err := rule(ctx, fc)
if err != nil {
return false, err
}
if matches {
return true, nil
}
}
return false, nil
}

func serializeArguments(arguments string) (map[string]string, error) {
if strings.TrimSpace(arguments) == "" {
return nil, nil
Expand Down
Loading
Loading