Files
Agent-System-Backend/internal/runtime/e4_escalation_test.go
T
weichen 03b4fdabaf
ci / vet-and-test (pull_request) Successful in 42s
Restrict the source tag to material reads; never tag the model's own files
Why: The first E4 live comparison regressed badly (toolFailures 10->24,
toolCalls 63->166) and the instance transcript shows the loop: tagging
kernel read_file/grep results put a trailing [src_N] line on the
model's own workspace files, the model treated it as external
tampering, and re-read/re-edit cycles cascaded into the read-before-
write and file-changed guards.

Why: The ledger only ever needed SOURCES - searches always, and
workspace reads only under inputs/ (the upload material area). Grep
cannot distinguish materials from self-produced files, so it stays
untagged.

Evidence: go test ./... green; new anchor TestEvidenceLedger
OnlyTagsMaterialReads pins the inputs/-only rule; the re-run live
comparison follows on the Dev side.

Impact: evidenceTools loses read_file/grep; isEvidenceRead gates
workspace reads to inputs/; everything else (search tags, escalation
reminders, prompts) unchanged.

Refs: Dev task feature/agent-e4-escalation-ledger (fix-up of #131).
2026-10-10 18:11:24 +00:00

122 lines
4.9 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 runtime
import (
"context"
"encoding/json"
"strings"
"testing"
core "agent-system-core"
)
// ---- E4:R4 升级提醒 ----
// 两连败后 reminders 注入换路指令;成功一次即重置、可再次触发。
func TestEscalationOnConsecutiveToolFailures(t *testing.T) {
f := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execStatus: "error", execOutput: "boom"}
r := &LLMRunner{Stream: scriptStream(padTurns(
textDeltaTurn("", toolCall("run_python", `{"code":"1"}`), toolCall("run_python", `{"code":"2"}`)),
textDeltaTurn("换路了。"),
), nil)}
if err := r.Execute(f); err != nil {
t.Fatal(err)
}
// 完整 Execute 冒烟(真实事件路径跑通)+ 状态机断言(onEvent 终点计数
// → escalationReminders 文案)。
st2 := &directRun{l: &LLMRunner{}, inflight: map[string]*toolSlot{}}
st2.noteFailureOutcome("error")
if got := st2.escalationReminders(); len(got) != 0 {
t.Fatalf("single failure must not escalate: %v", got)
}
st2.noteFailureOutcome("error")
got := st2.escalationReminders()
if len(got) != 1 || !strings.Contains(got[0], "停止原样重试") {
t.Fatalf("two consecutive failures must escalate: %v", got)
}
if again := st2.escalationReminders(); len(again) != 0 {
t.Fatalf("escalation must be once per streak: %v", again)
}
st2.noteFailureOutcome("ok")
if st2.consecToolFailures != 0 || st2.failStreakNoted {
t.Fatal("success must reset the failure streak")
}
}
// 检索连续空结果(成功但无实质内容)触发「换关键词或如实写缺失」。
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")
}
}