Repository navigation
plugin/toolloopwarning: stop repeated tool bundles after warning #2549
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
25cc9d9
d56c0e4
eefce95
4d1c3a6
f73c174
a460b2b
2427ed9
379f9c7
60af8a0
e1cde87
e454738
1e9a112
be3ceff
24633be
44ffc17
96ebd0a
eb45657
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| // Tencent is pleased to support the open source community by making trpc-agent-go available. | ||
| // | ||
| // Copyright (C) 2026 Tencent. All rights reserved. | ||
| // | ||
| // trpc-agent-go is licensed under the Apache License Version 2.0. | ||
| // | ||
| // | ||
|
|
||
| package tool | ||
|
|
||
| import ( | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "encoding/json" | ||
| "sync" | ||
| ) | ||
|
|
||
| // NoProgressGuard detects consecutive tool calls with the same ordered action | ||
| // and observation. It is disabled unless a caller explicitly uses it. | ||
| // | ||
| // A NoProgressGuard must not be copied after first use. Its methods are safe | ||
| // for concurrent use. | ||
| type NoProgressGuard struct { | ||
| mu sync.Mutex | ||
| threshold int | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The zero value is active, so a zero-valued 中文零值是激活的,所以零值的 `NoProgressGuard` 会在第一次调用时触发。请让默认状态保持无效,或明确只支持构造函数使用。
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the current head |
||
| last string | ||
| repeats int | ||
| } | ||
|
|
||
| // NewNoProgressGuard creates a guard that triggers after threshold identical | ||
| // consecutive action/observation pairs. Thresholds less than two are treated as two. | ||
| func NewNoProgressGuard(threshold int) *NoProgressGuard { | ||
| if threshold < 2 { | ||
| threshold = 2 | ||
| } | ||
| return &NoProgressGuard{threshold: threshold} | ||
| } | ||
|
|
||
| // Observe records one ordered tool action and its finalized observation. | ||
| // It returns true when the configured repeat threshold has been reached. | ||
| func (g *NoProgressGuard) Observe(name string, arguments, observation any) bool { | ||
| if g == nil { | ||
| return false | ||
| } | ||
| g.mu.Lock() | ||
| defer g.mu.Unlock() | ||
| fingerprint, ok := noProgressFingerprint(name, arguments, observation) | ||
| if !ok { | ||
| g.last = "" | ||
| g.repeats = 0 | ||
| return false | ||
| } | ||
| if fingerprint == g.last { | ||
| g.repeats++ | ||
| } else { | ||
| g.last = fingerprint | ||
| g.repeats = 1 | ||
| } | ||
| threshold := g.threshold | ||
| if threshold < 2 { | ||
| threshold = 2 | ||
| } | ||
| return g.repeats >= threshold | ||
| } | ||
|
|
||
| // Reset clears the consecutive-repeat state. | ||
| func (g *NoProgressGuard) Reset() { | ||
| if g != nil { | ||
| g.mu.Lock() | ||
| defer g.mu.Unlock() | ||
| g.last = "" | ||
| g.repeats = 0 | ||
| } | ||
| } | ||
|
|
||
| func noProgressFingerprint(name string, arguments, observation any) (string, bool) { | ||
| argumentsJSON, err := json.Marshal(arguments) | ||
| if err != nil { | ||
| return "", false | ||
| } | ||
| observationJSON, err := json.Marshal(observation) | ||
| if err != nil { | ||
| return "", false | ||
| } | ||
| hash := sha256.New() | ||
| hash.Write([]byte(name)) | ||
| hash.Write([]byte{0}) | ||
| hash.Write(argumentsJSON) | ||
| hash.Write([]byte{0}) | ||
| hash.Write(observationJSON) | ||
| return hex.EncodeToString(hash.Sum(nil)), true | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // Tencent is pleased to support the open source community by making trpc-agent-go available. | ||
| // | ||
| // Copyright (C) 2026 Tencent. All rights reserved. | ||
| // | ||
| // trpc-agent-go is licensed under the Apache License Version 2.0. | ||
| // | ||
| // | ||
|
|
||
| package tool | ||
|
|
||
| import ( | ||
| "math" | ||
| "sync" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestNoProgressGuardZeroValueUsesMinimumThreshold(t *testing.T) { | ||
| var guard NoProgressGuard | ||
| if guard.Observe("lookup", nil, nil) { | ||
| t.Fatal("zero-valued guard must not trigger on the first observation") | ||
| } | ||
| if !guard.Observe("lookup", nil, nil) { | ||
| t.Fatal("zero-valued guard must trigger on the second identical observation") | ||
| } | ||
| } | ||
|
|
||
| func TestNoProgressGuardTriggersOnlyOnConsecutiveIdenticalObservations(t *testing.T) { | ||
| guard := NewNoProgressGuard(2) | ||
| if guard.Observe("lookup", map[string]any{"q": "x"}, "empty") { | ||
| t.Fatal("first observation must not trigger") | ||
| } | ||
| if !guard.Observe("lookup", map[string]any{"q": "x"}, "empty") { | ||
| t.Fatal("second identical observation must trigger") | ||
| } | ||
| if guard.Observe("lookup", map[string]any{"q": "x"}, "found") { | ||
| t.Fatal("changed observation must reset the repeat count") | ||
| } | ||
| } | ||
|
|
||
| func TestNoProgressGuardCanonicalizesJSONArguments(t *testing.T) { | ||
| guard := NewNoProgressGuard(2) | ||
| if guard.Observe("lookup", map[string]any{"a": 1, "b": 2}, "empty") { | ||
| t.Fatal("first observation must not trigger") | ||
| } | ||
| if !guard.Observe("lookup", map[string]any{"b": 2, "a": 1}, "empty") { | ||
| t.Fatal("equivalent JSON objects must have the same fingerprint") | ||
| } | ||
| } | ||
|
|
||
| func TestNoProgressGuardReset(t *testing.T) { | ||
| guard := NewNoProgressGuard(2) | ||
| guard.Observe("lookup", nil, nil) | ||
| guard.Reset() | ||
| if guard.Observe("lookup", nil, nil) { | ||
| t.Fatal("reset must clear repeat state") | ||
| } | ||
| } | ||
|
|
||
| func TestNoProgressGuardMarshalFailureResetsState(t *testing.T) { | ||
| guard := NewNoProgressGuard(2) | ||
| if guard.Observe("lookup", nil, nil) { | ||
| t.Fatal("first valid observation must not trigger") | ||
| } | ||
| if !guard.Observe("lookup", nil, nil) { | ||
| t.Fatal("second identical valid observation must trigger") | ||
| } | ||
| if guard.Observe("lookup", math.NaN(), nil) { | ||
| t.Fatal("failed argument serialization must not trigger") | ||
| } | ||
| if guard.Observe("lookup", math.Inf(1), nil) { | ||
| t.Fatal("distinct failed argument serializations must not be treated as repeated") | ||
| } | ||
| if guard.Observe("lookup", nil, nil) { | ||
| t.Fatal("marshal failure must reset the repeat state") | ||
| } | ||
| } | ||
|
|
||
| func TestNoProgressGuardSupportsConcurrentUse(t *testing.T) { | ||
| guard := NewNoProgressGuard(2) | ||
| var wg sync.WaitGroup | ||
| for i := 0; i < 8; i++ { | ||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| for j := 0; j < 100; j++ { | ||
| guard.Observe("lookup", nil, nil) | ||
| } | ||
| }() | ||
| } | ||
| wg.Wait() | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
Uh oh!
There was an error while loading. Please reload this page.