first commit
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// execTool is a configurable AgentTool for executor tests.
|
||||
type execTool struct {
|
||||
name string
|
||||
schema string
|
||||
run func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error)
|
||||
mode agentcore.ToolExecutionMode
|
||||
}
|
||||
|
||||
func (t execTool) Name() string { return t.name }
|
||||
func (t execTool) Description() string { return "exec" }
|
||||
func (t execTool) Schema() json.RawMessage {
|
||||
if t.schema == "" {
|
||||
return nil
|
||||
}
|
||||
return json.RawMessage(t.schema)
|
||||
}
|
||||
func (t execTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
if t.mode == "" {
|
||||
return agentcore.ToolExecutionParallel
|
||||
}
|
||||
return t.mode
|
||||
}
|
||||
func (t execTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return t.run(ctx, id, args, onUpdate)
|
||||
}
|
||||
|
||||
func newExecCfg(t *testing.T, tool agentcore.AgentTool) ToolExecutorConfig {
|
||||
t.Helper()
|
||||
r := NewToolRegistry()
|
||||
if err := r.Register(tool); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
return ToolExecutorConfig{Registry: r}
|
||||
}
|
||||
|
||||
func textOf(msg agentcore.ToolResultMessage) string {
|
||||
if len(msg.Content) == 0 {
|
||||
return ""
|
||||
}
|
||||
if tc, ok := msg.Content[0].(agentcore.TextContent); ok {
|
||||
return tc.Text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestExecutorNormal(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("done")}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
|
||||
var events []agentcore.AgentEvent
|
||||
emit := func(ctx context.Context, ev agentcore.AgentEvent) error { events = append(events, ev); return nil }
|
||||
msg, term := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, emit)
|
||||
|
||||
if msg.IsError || textOf(msg) != "done" {
|
||||
t.Fatalf("normal result wrong: %+v", msg)
|
||||
}
|
||||
if term {
|
||||
t.Error("normal result should not terminate")
|
||||
}
|
||||
wantKinds := []string{agentcore.EventToolExecutionStart, agentcore.EventToolExecutionEnd}
|
||||
if len(events) != 2 || events[0].EventType() != wantKinds[0] || events[1].EventType() != wantKinds[1] {
|
||||
t.Errorf("events wrong: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorUnknownTool(t *testing.T) {
|
||||
cfg := ToolExecutorConfig{Registry: NewToolRegistry()}
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "ghost"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("unknown tool should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorValidationFailure(t *testing.T) {
|
||||
schema := `{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"],"additionalProperties":false}`
|
||||
tool := execTool{name: "need", schema: schema, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
t.Fatal("execute must not run on validation failure")
|
||||
return agentcore.AgentToolResult{}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "need", Arguments: json.RawMessage(`{}`)}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("validation failure should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorBlock(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
t.Fatal("execute must not run when blocked")
|
||||
return agentcore.AgentToolResult{}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.BeforeToolCall = func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
|
||||
return &agentcore.BeforeToolCallDecision{Block: true}
|
||||
}
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("blocked call should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorToolError(t *testing.T) {
|
||||
tool := execTool{name: "boom", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{}, errors.New("kaboom")
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "boom"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("tool error should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorPanicRecovered(t *testing.T) {
|
||||
tool := execTool{name: "panic", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
panic("oops")
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "panic"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("panic should be recovered into error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorAfterToolCallOverride(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("orig")}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
newContent := agentcore.ContentList{agentcore.NewTextContent("overridden")}
|
||||
isErr := true
|
||||
term := true
|
||||
cfg.AfterToolCall = func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
|
||||
return &agentcore.AfterToolCallResult{Content: &newContent, IsError: &isErr, Terminate: &term}
|
||||
}
|
||||
msg, terminate := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, nil)
|
||||
if textOf(msg) != "overridden" {
|
||||
t.Errorf("content override failed: %q", textOf(msg))
|
||||
}
|
||||
if !msg.IsError {
|
||||
t.Error("isError override failed")
|
||||
}
|
||||
if !terminate {
|
||||
t.Error("terminate override failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorUpdateCallback(t *testing.T) {
|
||||
tool := execTool{name: "stream", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("partial")}})
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("final")}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
var updates int
|
||||
emit := func(ctx context.Context, ev agentcore.AgentEvent) error {
|
||||
if ev.EventType() == agentcore.EventToolExecutionUpdate {
|
||||
updates++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "stream"}, emit)
|
||||
if updates != 1 {
|
||||
t.Errorf("expected 1 update event, got %d", updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorAbortedContext(t *testing.T) {
|
||||
tool := execTool{name: "echo", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
t.Fatal("execute must not run when context already cancelled")
|
||||
return agentcore.AgentToolResult{}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
msg, _ := executeToolCall(ctx, cfg, agentcore.AgentToolCall{ID: "1", Name: "echo"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("aborted call should be error result: %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudget proves the executor-layer byte budget applies to any
|
||||
// tool: a stub tool emitting output larger than the budget gets its result text
|
||||
// truncated with an accurate "[truncated N bytes]" marker, while a small output
|
||||
// is left untouched.
|
||||
func TestExecutorResultBudget(t *testing.T) {
|
||||
const budget = 1000
|
||||
big := strings.Repeat("A", budget) + strings.Repeat("B", budget) // 2*budget bytes
|
||||
tool := execTool{name: "flood", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(big)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.MaxResultBytes = budget
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flood"}, nil)
|
||||
got := textOf(msg)
|
||||
if len(got) >= len(big) {
|
||||
t.Fatalf("output not truncated: len=%d, original=%d", len(got), len(big))
|
||||
}
|
||||
half := budget / 2
|
||||
removed := len(big) - 2*half
|
||||
marker := fmt.Sprintf("\n[truncated %d bytes]\n", removed)
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("missing/incorrect truncation marker %q in output %q", marker, got)
|
||||
}
|
||||
want := big[:half] + marker + big[len(big)-half:]
|
||||
if got != want {
|
||||
t.Fatalf("truncated output mismatch:\n got=%q\nwant=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudgetSmallOutputUntouched proves outputs within budget are
|
||||
// passed through verbatim.
|
||||
func TestExecutorResultBudgetSmallOutputUntouched(t *testing.T) {
|
||||
small := "just a little output"
|
||||
tool := execTool{name: "tiny", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(small)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.MaxResultBytes = 1000
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "tiny"}, nil)
|
||||
if got := textOf(msg); got != small {
|
||||
t.Fatalf("small output altered: got=%q want=%q", got, small)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudgetDefault proves the default (zero MaxResultBytes) uses
|
||||
// toolResultMaxBytes and truncates output beyond it.
|
||||
func TestExecutorResultBudgetDefault(t *testing.T) {
|
||||
big := strings.Repeat("x", toolResultMaxBytes+5000)
|
||||
tool := execTool{name: "flood", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(big)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool) // MaxResultBytes == 0 -> default
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flood"}, nil)
|
||||
got := textOf(msg)
|
||||
if !strings.Contains(got, "[truncated ") {
|
||||
t.Fatalf("default budget did not truncate: len=%d", len(got))
|
||||
}
|
||||
if len(got) > toolResultMaxBytes+64 {
|
||||
t.Fatalf("default-truncated output too large: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorResultBudgetDisabled proves a negative MaxResultBytes disables the
|
||||
// budget entirely.
|
||||
func TestExecutorResultBudgetDisabled(t *testing.T) {
|
||||
big := strings.Repeat("y", toolResultMaxBytes*2)
|
||||
tool := execTool{name: "flood", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(big)}}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
cfg.MaxResultBytes = -1
|
||||
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flood"}, nil)
|
||||
if got := textOf(msg); got != big {
|
||||
t.Fatalf("disabled budget altered output: len=%d want=%d", len(got), len(big))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool-execution retry (node #252) ---------------------------------------
|
||||
|
||||
// countingTool returns a transient error for its first failN attempts, then
|
||||
// succeeds; if failN < 0 it always fails. It records how many times Execute ran.
|
||||
type retryStub struct {
|
||||
failN int // number of leading failures before success; <0 = always fail
|
||||
err error // error to return on a failing attempt
|
||||
attempts int32
|
||||
}
|
||||
|
||||
func (s *retryStub) run(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
n := atomic.AddInt32(&s.attempts, 1)
|
||||
if s.failN < 0 || int(n) <= s.failN {
|
||||
return agentcore.AgentToolResult{}, s.err
|
||||
}
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("ok")}}, nil
|
||||
}
|
||||
|
||||
func newRetryCfg(t *testing.T, name string, s *retryStub) ToolExecutorConfig {
|
||||
t.Helper()
|
||||
return newExecCfg(t, execTool{name: name, run: s.run})
|
||||
}
|
||||
|
||||
func TestExecutorRetryTransientThenSuccess(t *testing.T) {
|
||||
// Fails 2 times with a transient error, then succeeds. With the default cap
|
||||
// (2 retries = 3 attempts) this should ultimately succeed on attempt 3.
|
||||
s := &retryStub{failN: 2, err: syscall.ECONNRESET}
|
||||
cfg := newRetryCfg(t, "flaky", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "flaky"}, nil)
|
||||
if msg.IsError {
|
||||
t.Fatalf("expected eventual success, got error: %q", textOf(msg))
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 3 {
|
||||
t.Fatalf("expected 3 attempts (2 retries), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryCapExhausted(t *testing.T) {
|
||||
// Always fails with a transient error: must stop after maxToolRetries+1
|
||||
// attempts (default cap) and give up with an error result.
|
||||
s := &retryStub{failN: -1, err: syscall.ETIMEDOUT}
|
||||
cfg := newRetryCfg(t, "always", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "always"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result after exhausting retries: %+v", msg)
|
||||
}
|
||||
want := int32(maxToolRetries + 1)
|
||||
if got := atomic.LoadInt32(&s.attempts); got != want {
|
||||
t.Fatalf("expected %d attempts, got %d", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryTerminalNoRetry(t *testing.T) {
|
||||
// A terminal (non-transient) error must be tried exactly once.
|
||||
s := &retryStub{failN: -1, err: errors.New("invalid argument: bad")}
|
||||
cfg := newRetryCfg(t, "terminal", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "terminal"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("terminal error must not retry: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryDisabled(t *testing.T) {
|
||||
// MaxToolRetries < 0 disables retry even for a transient error.
|
||||
s := &retryStub{failN: -1, err: syscall.ECONNRESET}
|
||||
cfg := newRetryCfg(t, "notretry", s)
|
||||
cfg.MaxToolRetries = -1
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "notretry"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("disabled retry must try once: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryCustomCap(t *testing.T) {
|
||||
// A custom positive cap is honored: always-failing transient error stops at
|
||||
// cap+1 attempts.
|
||||
s := &retryStub{failN: -1, err: syscall.EAGAIN}
|
||||
cfg := newRetryCfg(t, "custom", s)
|
||||
cfg.MaxToolRetries = 4
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "custom"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 5 {
|
||||
t.Fatalf("expected 5 attempts (cap 4), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryCanceledContextNoRetry(t *testing.T) {
|
||||
// context.Canceled surfaced by the tool must never be retried.
|
||||
s := &retryStub{failN: -1, err: context.Canceled}
|
||||
cfg := newRetryCfg(t, "cancel", s)
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "cancel"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("context.Canceled must not retry: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorRetryStopsWhenOuterCtxCanceled(t *testing.T) {
|
||||
// If the outer ctx is cancelled during execution, retries stop even though
|
||||
// the returned error is transient.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &retryStub{failN: -1, err: syscall.ECONNRESET}
|
||||
tool := execTool{name: "abortmid", run: func(c context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
atomic.AddInt32(&s.attempts, 1)
|
||||
cancel() // outer ctx dies after the first attempt
|
||||
return agentcore.AgentToolResult{}, syscall.ECONNRESET
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
msg, _ := executeToolCall(ctx, cfg, agentcore.AgentToolCall{ID: "1", Name: "abortmid"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result: %+v", msg)
|
||||
}
|
||||
if got := atomic.LoadInt32(&s.attempts); got != 1 {
|
||||
t.Fatalf("cancelled outer ctx must stop retry: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableToolError(t *testing.T) {
|
||||
transient := []error{
|
||||
syscall.ETIMEDOUT,
|
||||
syscall.ECONNRESET,
|
||||
syscall.EAGAIN,
|
||||
context.DeadlineExceeded,
|
||||
os.ErrDeadlineExceeded,
|
||||
fmt.Errorf("dial tcp: %w", syscall.ECONNRESET),
|
||||
errors.New("connection refused"),
|
||||
errors.New("resource temporarily unavailable"),
|
||||
errors.New("read: i/o timeout"),
|
||||
&net.DNSError{IsTimeout: true},
|
||||
}
|
||||
for _, err := range transient {
|
||||
if !isRetryableToolError(err) {
|
||||
t.Errorf("expected transient (retryable): %v", err)
|
||||
}
|
||||
}
|
||||
terminal := []error{
|
||||
nil,
|
||||
context.Canceled,
|
||||
fmt.Errorf("wrapped: %w", context.Canceled),
|
||||
errors.New("file not found"),
|
||||
errors.New("invalid argument"),
|
||||
os.ErrNotExist,
|
||||
toolPanic{value: "boom"},
|
||||
}
|
||||
for _, err := range terminal {
|
||||
if isRetryableToolError(err) {
|
||||
t.Errorf("expected terminal (not retryable): %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorRetrySuccessResultWithIsErrorNotRetried proves that a
|
||||
// (result, nil) whose own content signals an error is NOT retried: only a
|
||||
// non-nil Go error triggers retry.
|
||||
func TestExecutorRetrySuccessResultWithIsErrorNotRetried(t *testing.T) {
|
||||
var attempts int32
|
||||
term := false
|
||||
tool := execTool{name: "toolerr", run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("tool-level error")}, Terminate: &term}, nil
|
||||
}}
|
||||
cfg := newExecCfg(t, tool)
|
||||
// afterToolCall marks it as an error result; still must not be retried.
|
||||
isErr := true
|
||||
cfg.AfterToolCall = func(ctx context.Context, call agentcore.AgentToolCall, result agentcore.AgentToolResult, isError bool) *agentcore.AfterToolCallResult {
|
||||
return &agentcore.AfterToolCallResult{IsError: &isErr}
|
||||
}
|
||||
msg, _ := executeToolCall(context.Background(), cfg, agentcore.AgentToolCall{ID: "1", Name: "toolerr"}, nil)
|
||||
if !msg.IsError {
|
||||
t.Fatalf("expected error result from afterToolCall override")
|
||||
}
|
||||
if got := atomic.LoadInt32(&attempts); got != 1 {
|
||||
t.Fatalf("(result,nil) must not be retried: got %d attempts", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user