Files
AutoZero/agent/toolfail_test.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

239 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// flakyTool 按入参决定成败:input 含 "fail" 则失败(同时带部分输出)。
func flakyTool() *tools.Tool {
return &tools.Tool{
Name: "flaky",
Description: "test tool",
Parameters: map[string]any{"type": "object"},
Run: func(_ context.Context, input string) (tools.Result, error) {
if strings.Contains(input, "fail") {
// 关键:失败但保留 stdout —— 正是 Result.Err 存在的意义。
return tools.Result{Text: "partial-stdout", Err: errors.New("boom")}, nil
}
return tools.TextResult("ok"), nil
},
}
}
// TestToolFailureStructured:失败回灌必须是结构化的,且不能丢掉已有输出。
func TestToolFailureStructured(t *testing.T) {
a, _ := newTestAgent(t, flakyTool())
got := a.execResult(context.Background(), llm.ToolCall{ID: "1", Name: "flaky", Input: `{"x":"fail"}`}).res
if !strings.Contains(got.Text, "partial-stdout") {
t.Errorf("partial output lost:\n%s", got.Text)
}
if !strings.Contains(got.Text, "[TOOL FAILED] flaky") {
t.Errorf("missing [TOOL FAILED] marker:\n%s", got.Text)
}
// 旧行为是带前导换行的裸 "\nerror: ...";新实现不应再产生前导换行。
if strings.HasPrefix(got.Text, "\n") {
t.Errorf("failure text should not start with a newline: %q", got.Text)
}
// 单次失败还不该出现 [STUCK]。
if strings.Contains(got.Text, "[STUCK]") {
t.Errorf("first failure must not warn about being stuck:\n%s", got.Text)
}
}
// TestFailStreakWarnAndReset:连续失败到阈值给出 [STUCK],成功后清零。
func TestFailStreakWarnAndReset(t *testing.T) {
a, _ := newTestAgent(t, flakyTool())
ctx := context.Background()
call := func(in string) string {
return a.execResult(ctx, llm.ToolCall{ID: "1", Name: "flaky", Input: in}).res.Text
}
if got := call(`{"x":"fail"}`); strings.Contains(got, "[STUCK]") {
t.Errorf("1st failure should not warn:\n%s", got)
}
if got := call(`{"x":"fail"}`); strings.Contains(got, "[STUCK]") {
t.Errorf("2nd failure should not warn:\n%s", got)
}
third := call(`{"x":"fail"}`)
if !strings.Contains(third, "[STUCK]") || !strings.Contains(third, "3 times in a row") {
t.Errorf("3rd consecutive failure should warn about being stuck:\n%s", third)
}
// 成功一次 → 计数清零 → 再失败不再告警。
call(`{"x":"ok"}`)
after := call(`{"x":"fail"}`)
if strings.Contains(after, "[STUCK]") {
t.Errorf("streak should reset after a success:\n%s", after)
}
}
// TestFailStreakIsPerTool:计数按工具名隔离,一个工具失败不影响另一个。
func TestFailStreakIsPerTool(t *testing.T) {
a, _ := newTestAgent(t, flakyTool())
ctx := context.Background()
for i := 0; i < 3; i++ {
a.execResult(ctx, llm.ToolCall{ID: "1", Name: "flaky", Input: `{"x":"fail"}`})
}
// 另一个工具首次失败不应继承 flaky 的连续失败计数。
other := &tools.Tool{
Name: "other",
Parameters: map[string]any{"type": "object"},
Run: func(_ context.Context, _ string) (tools.Result, error) {
return tools.Result{}, errors.New("nope")
},
}
if err := a.Tools.Register(other); err != nil {
t.Fatal(err)
}
got := a.execResult(ctx, llm.ToolCall{ID: "2", Name: "other", Input: "{}"}).res.Text
if strings.Contains(got, "[STUCK]") {
t.Errorf("streak must be per tool:\n%s", got)
}
}
// TestBashExitCodeReported:bash 的非零退出必须成为显式失败信号(Result.Err),
// 且 stdout 仍然保留。
func TestBashExitCodeReported(t *testing.T) {
tl, ok := lookupBash(t)
if !ok {
t.Skip("bash tool not registered")
}
res, err := tl.Run(context.Background(), `{"command":"echo hello-stdout; exit 3"}`)
if err != nil {
t.Fatalf("unexpected error return: %v", err)
}
if res.Err == nil {
t.Fatal("bash must report non-zero exit via Result.Err")
}
if !strings.Contains(res.Text, "hello-stdout") {
t.Errorf("stdout lost on failure: %q", res.Text)
}
if kind, _ := classifyFailure(failInfo{Tool: "bash", Elapsed: time.Second}, res.Err); kind != "nonzero-exit" {
t.Errorf("kind = %q, want nonzero-exit", kind)
}
// 成功退出不应带 Err。
ok2, _ := tl.Run(context.Background(), `{"command":"echo fine"}`)
if ok2.Err != nil {
t.Errorf("successful command must not set Err: %v", ok2.Err)
}
}
func lookupBash(t *testing.T) (*tools.Tool, bool) {
t.Helper()
reg := tools.NewRegistry()
for _, tl := range tools.Builtin() {
_ = reg.Register(tl)
}
return reg.Get("bash")
}
// TestClassifyFailure:覆盖每一条分类规则。
func TestClassifyFailure(t *testing.T) {
cases := []struct {
name string
in failInfo
err error
kind string
}{
{"timeout", failInfo{Tool: "bash"}, errors.New("signal: killed"), "timeout"},
{"deadline", failInfo{Tool: "bash"}, errors.New("context deadline exceeded"), "timeout"},
{"wait-delay", failInfo{Tool: "bash"}, errors.New("exec: wait delay exceeded"), "timeout"},
// 同为 signal: killed,用户停止必须归 cancelled:
// 说成超时就等于指着一条没问题的命令说"它太慢了"。
{"stopped-mid-bash", failInfo{Tool: "bash", Cancelled: true}, errors.New("signal: killed"), "cancelled"},
{"cancelled", failInfo{Tool: "bash"}, context.Canceled, "cancelled"},
{"self-kill", failInfo{Tool: "bash", Input: `{"command":"pkill -f bili-coin-dl; cat /tmp/x.json"}`},
errors.New("signal: terminated"), "self-kill"},
// 没有 -f 的按名字杀不会自匹配,别乱给提示。
{"terminated-other", failInfo{Tool: "bash", Input: `{"command":"make"}`},
errors.New("signal: terminated"), "unknown"},
{"danger", failInfo{Tool: "bash"}, errors.New("dangerous action blocked (rm-recursive): ..."), "danger-blocked"},
{"not-found", failInfo{Tool: "bash"}, errors.New("exit status 127"), "not-found"},
{"not-exec", failInfo{Tool: "bash"}, errors.New("exit status 126"), "not-executable"},
{"permission", failInfo{Tool: "read"}, errors.New("open /x: permission denied"), "permission"},
{"missing-path", failInfo{Tool: "read"}, errors.New("open /nope: no such file or directory"), "missing-path"},
{"unknown-tool", failInfo{Tool: "bash"}, errors.New(`unknown tool "foo"`), "unknown-tool"},
{"denied", failInfo{Tool: "bash"}, errors.New(`user denied tool "bash"`), "denied"},
{"skill", failInfo{Tool: "bash"}, errors.New(`tool "bash" is not allowed by the active skill`), "skill-allowlist"},
{"bad-args", failInfo{Tool: "bash"}, errors.New("invalid tool arguments: bad json"), "bad-args"},
{"fallback", failInfo{Tool: "bash"}, errors.New("something odd"), "unknown"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.in.Elapsed = 42 * time.Second
if c.in.Tool == "" {
c.in.Tool = "bash"
}
kind, hint := classifyFailure(c.in, c.err)
if kind != c.kind {
t.Errorf("kind = %q, want %q", kind, c.kind)
}
if hint == "" {
t.Error("hint must not be empty")
}
})
}
}
// TestSelfKillHintIsActionable:提示必须给出一条能立刻照做的写法。
func TestSelfKillHintIsActionable(t *testing.T) {
_, hint := classifyFailure(failInfo{Tool: "bash",
Input: `{"command":"pkill -f bili-coin-dl; echo killed"}`}, errors.New("signal: terminated"))
for _, want := range []string{"[b]ili", "command line", "bash_bg"} {
if !strings.Contains(hint, want) {
t.Errorf("提示里缺 %q: %s", want, hint)
}
}
}
// TestTimeoutHintReportsReality:超时提示只准说真实发生过的时间。
// 旧文案对所有工具硬写 "bash limit 120s":fetch 超时也被说成 bash,
// 而实际挂了 23 分钟的那次仍然报 120s(session 20261008-214717)。
func TestTimeoutHintReportsReality(t *testing.T) {
_, hint := classifyFailure(failInfo{Tool: "fetch", Elapsed: 20 * time.Second},
errors.New("context deadline exceeded"))
if strings.Contains(hint, "bash") {
t.Errorf("fetch 的超时提示里出现了 bash: %q", hint)
}
if !strings.Contains(hint, "20s") {
t.Errorf("提示应包含真实耗时 20s: %q", hint)
}
_, hint = classifyFailure(failInfo{Tool: "bash", Elapsed: 23 * time.Minute},
errors.New("signal: killed"))
if strings.Contains(hint, "120s") || !strings.Contains(hint, "23m0s") {
t.Errorf("提示应报真实耗时而不是固定 120s: %q", hint)
}
block, kind := formatToolFailure(failInfo{Tool: "bash", Elapsed: 23 * time.Minute},
errors.New("signal: killed"), "", 1)
if !strings.Contains(block, "23m0s") {
t.Errorf("失败块应带真实耗时: %q", block)
}
if kind != "timeout" {
t.Errorf("kind = %q, want timeout(这个值会落进 llm.Message.Fail)", kind)
}
}
// TestDangerBlockHasOwnKind:不可逆动作门的失败要有自己的类别
// (stats/retro 按 kind 归集摩擦点,混进 unknown 就看不出是被门拦的)。
func TestDangerBlockHasOwnKind(t *testing.T) {
block, kind := formatToolFailure(failInfo{Tool: "bash"},
errors.New("dangerous action blocked (rm-recursive): do not work around it"), "", 1)
if !strings.Contains(block, "(danger-blocked)") {
t.Errorf("失败块应标 danger-blocked: %q", block)
}
if kind != "danger-blocked" {
t.Errorf("kind = %q", kind)
}
}