Files
weichen 6709325c38
ci / vet-and-test (pull_request) Successful in 40s
Switch the R4 failure trigger to a sliding window
Why: The consecutive-failure counter never fired in the live suite (0
reminders despite 20 failures) because real failures interleave with
successes - each success reset the streak. The pattern to catch is
"spinning overall", not "N in a row".

Evidence: go test ./... green; the anchor test now covers scattered
failures (3 in the last 6 outcomes escalates, 2 does not, once per run);
the next live run must show the reminder actually appearing in the
transcript.

Impact: recentOutcomes ring (last 6) replaces consecToolFailures; the
zero-hit search trigger keeps its own threshold (2); message wording
matches the window semantics.

Refs: Dev task feature/agent-e4-escalation-ledger (second fix-up).
2026-10-10 18:43:03 +00:00

113 lines
4.5 KiB
Go
Raw Permalink 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 runtime
import (
"context"
"encoding/json"
"strings"
"testing"
core "agent-system-core"
)
// ---- E4:R4 升级提醒 ----
// 滑动窗口判据:近 6 次结果中 3 次失败(可与成功交错)触发换路指令;
// 每 run 一次;2 次失败不触发。
func TestEscalationOnConsecutiveToolFailures(t *testing.T) {
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error")
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error")
if got := st.escalationReminders(); len(got) != 0 {
t.Fatalf("two scattered failures must not escalate: %v", got)
}
st.noteFailureOutcome("ok")
st.noteFailureOutcome("error") // 窗口内第 3 败(交错分布)
got := st.escalationReminders()
if len(got) != 1 || !strings.Contains(got[0], "停止原样重试") {
t.Fatalf("three failures in the window must escalate: %v", got)
}
if again := st.escalationReminders(); len(again) != 0 {
t.Fatalf("escalation must fire once per run: %v", again)
}
}
// 检索连续空结果(成功但无实质内容)触发「换关键词或如实写缺失」。
func TestEscalationOnZeroHitSearches(t *testing.T) {
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "未找到相关内容"}
r := &LLMRunner{Stream: scriptStream(padTurns(
textDeltaTurn("", toolCall("knowledge_search", `{"query":"a"}`), toolCall("knowledge_search", `{"query":"b"}`)),
textDeltaTurn("好了。"),
), nil)}
if err := r.Execute(f); err != nil {
t.Fatal(err)
}
st := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
st.noteEvidenceCall("knowledge_search", "ok", "未找到相关内容")
if got := st.escalationReminders(); len(got) != 0 {
t.Fatalf("one zero hit must not escalate: %v", got)
}
st.noteEvidenceCall("knowledge_search", "ok", "无结果")
got := st.escalationReminders()
if len(got) != 1 || !strings.Contains(got[0], "材料中无此信息") {
t.Fatalf("two zero hits must escalate: %v", got)
}
// 有实质结果后计数重置。
st.noteEvidenceCall("knowledge_search", "ok", "营收 3,200 万元")
if st.zeroHitSearches != 0 {
t.Fatal("substantive hit must reset zero-hit count")
}
}
// ---- E4:结构化 source_id 账本 ----
func TestEvidenceLedgerTagsSource(t *testing.T) {
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execOutput: "上半年营收 3,200 万元。"}
r := &LLMRunner{Stream: scriptStream(padTurns(
textDeltaTurn("", toolCall("knowledge_search", `{"query":"营收"}`)),
textDeltaTurn("总结完成。"),
), nil)}
if err := r.Execute(f); err != nil {
t.Fatal(err)
}
// 结果文本必须带来源标记,且账本登记了对应条目(直接经 runTool 断言)。
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("knowledge_search", `{}`)
if !strings.Contains(res.Content, "【src_1|knowledge_search】") {
t.Fatalf("tool result must carry the source tag: %.120s", res.Content)
}
if len(st.ledger) != 1 || st.ledger[0].ID != "src_1" || !strings.Contains(st.ledger[0].Text, "3,200") {
t.Fatalf("ledger = %+v", st.ledger)
}
// 第二次登记递增编号。
res2 := st.runTool("fs_read_file", `{}`)
if !strings.Contains(res2.Content, "【src_2|fs_read_file】") {
t.Fatalf("second tag must be src_2: %.120s", res2.Content)
}
}
// 非证据工具不登记、不加标记。
func TestEvidenceLedgerSkipsNonEvidenceTools(t *testing.T) {
f := &fakeRunCtx{}
st := &directRun{l: &LLMRunner{}, rc: f, eff: f.Settings(), inflight: map[string]*toolSlot{}}
res := st.runTool("time_now", `{}`)
if strings.Contains(res.Content, "src_") || len(st.ledger) != 0 {
t.Fatalf("non-evidence tool must not enter the ledger: %.80s %+v", res.Content, st.ledger)
}
}
// 材料读(inputs/)打标;模型自产文件的读与 grep 不打——尾注会污染
// read→edit→build 编辑循环(E4 首轮 eval 实测教训,回归锚点)。
func TestEvidenceLedgerOnlyTagsMaterialReads(t *testing.T) {
if !isEvidenceRead("read_file", json.RawMessage(`{"path":"inputs/年报.docx.md"}`)) {
t.Fatal("inputs/ read must count as evidence")
}
if isEvidenceRead("read_file", json.RawMessage(`{"path":"方案.md"}`)) {
t.Fatal("own-workspace read must NOT count as evidence")
}
if isEvidenceRead("grep", json.RawMessage(`{"pattern":"营收"}`)) {
t.Fatal("grep must never count as evidence")
}
}