ci / vet-and-test (pull_request) Successful in 42s
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).
122 lines
4.9 KiB
Go
122 lines
4.9 KiB
Go
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")
|
||
}
|
||
}
|