Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
92 changes: 92 additions & 0 deletions tool/no_progress_guard.go
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zero value is active, so a zero-valued NoProgressGuard can fire on the first call. Please make the default state inert or document constructor-only use.

中文 零值是激活的,所以零值的 `NoProgressGuard` 会在第一次调用时触发。请让默认状态保持无效,或明确只支持构造函数使用。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the current head a460b2b33386f9d6e12cfb013aa68ab5a82442dc: thresholds below two, including a zero-valued guard, are normalized to an effective threshold of two. The zero-value regression test verifies that the first observation does not trigger and the second identical observation does.

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
}
91 changes: 91 additions & 0 deletions tool/no_progress_guard_test.go
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()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Loading