mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
问题(核实过):工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接 `Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。 时间线那侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),但那只管 a.context 的历史事件,**不管单条工具结果** ⇒ 一条巨大结果可能直接冲破 预算而内核不会提前发现。 为什么**不裁剪**(与方案 A 的取舍): · 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定; · 模型无法得知"这里被截断了",会基于残缺数据下结论 —— 与本仓反复 吃亏的「静默降级」同族(`20s` 少引号 → 静默降级 → cmd_run 失败率 34%); · 处置权应交给调度器/上层(告警、拒绝、或让模型自己换更窄的查询), 而不是内核单方面替模型决定。 改动: · core/toolresult_budget.go: checkToolResultSize 只**计数+报告**; 阈值默认 = ContextTokens/8(一条吃掉全部预算会把其它上下文全挤掉); 报告经 toolResultReporter(可替换),默认 logReporter —— **不给模型发 消息**:那是在已花掉的 token 之上再加一条 system,且对当前这轮决策无帮助。 · 接入点在 stepToolAfter 的 toolMsg 落定**之后**(那里才是模型最终看到的 内容;stepToolExec 拿到的尚未经 after_toolcall 改写)。 · TaskFrame 记 oversizeTools / oversizeToolNames,供调度器与状态面查询 "是否有工具在稳定产出超大结果"。 ★ 顺带治掉 seq 侧一处**我自己留下的静默截断**: handlers.go 里我当初随手写了 truncate(…, 160),把变量槽静默截到 160 字 且**无任何标注** —— 正是我批评过的静默降级。 改为 renderSlot:≤160 给全;超过则显式标注「已截断:共 N 字,此处显示前 160 字」并给出改法。**槽里存的始终是完整值**,截断只影响回填文本长度。 端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot 抓到了这个缺陷 ("变量槽被截到 160/5000 字却没有任何标注")。 判据(toolresult_budget_test.go,4 条): · 400KB 结果触发超限报告(含工具名与 token 数) · ★ **默认不裁剪**:200KB 结果原样进 tool 消息(方案 B 的核心不变式) · 小结果不误报(噪音会淹没有效信号) · 报告文案可执行:带工具名、token 数、改法建议 变异验证:去掉统计调用 ⇒ 两条判据 FAIL("统计没生效" + "被裁剪了")。 另:检查项报 stepToolBatch 的 goroutine 竞态,-race 实测**误报**—— 循环变量显式传参(非闭包捕获)、且按索引写各自槽位(非共享 map), `-race` 下 20 轮并发判据全绿。
371 lines
13 KiB
Go
371 lines
13 KiB
Go
package seq
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 阶段 P4:seq_* 工具与插件装配。
|
||
//
|
||
// 这一层的判据关注**可观测契约**(给模型看的东西对不对),
|
||
// 执行语义已由 P1/P2/P3 的判据覆盖。
|
||
|
||
// ① 六个工具全部注册,且都带可用的 description 与参数 schema。
|
||
//
|
||
// 工具是**模型可见面**:description 缺失或为空白 ⇒ 模型不知道何时该用它。
|
||
func TestAllSixSeqToolsRegistered(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
want := []string{
|
||
"seq_create", "seq_list", "seq_delete", "seq_run", "seq_call", "seq_when_call",
|
||
"seq_help", // 格式说明与可照抄示例(真机实跑后加:模型踩格式坑各试 1~3 次)
|
||
}
|
||
defs := p.toolDefs()
|
||
for _, name := range want {
|
||
def, ok := defs[name]
|
||
if !ok {
|
||
t.Errorf("工具 %s 未注册(已注册:%v)", name, keysOf(defs))
|
||
continue
|
||
}
|
||
if strings.TrimSpace(def.Description) == "" {
|
||
t.Errorf("工具 %s 的 description 为空 —— 模型无从判断何时使用", name)
|
||
}
|
||
if def.Parameters == nil {
|
||
t.Errorf("工具 %s 缺参数 schema", name)
|
||
continue
|
||
}
|
||
if typ, _ := def.Parameters["type"].(string); typ != "object" {
|
||
t.Errorf("工具 %s 的 schema type = %q,期望 object", name, typ)
|
||
}
|
||
}
|
||
if len(defs) != len(want) {
|
||
t.Errorf("应恰好注册 %d 个工具,实际 %d(%v)—— 多余的导出工具会稀释工具面", len(want), len(defs), keysOf(defs))
|
||
}
|
||
}
|
||
|
||
// ② seq_create 的 schema 必须声明 required,否则模型会漏传。
|
||
func TestSeqCreateSchemaDeclaresRequired(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
def := p.toolDefs()["seq_create"]
|
||
if def.Parameters == nil {
|
||
t.Fatal("seq_create 缺 schema")
|
||
}
|
||
req, _ := def.Parameters["required"].([]string)
|
||
if len(req) == 0 {
|
||
t.Fatalf("seq_create 未声明 required:模型会漏传 name/groups/file")
|
||
}
|
||
got := map[string]bool{}
|
||
for _, r := range req {
|
||
got[r] = true
|
||
}
|
||
if !got["name"] {
|
||
t.Error("required 应包含 name")
|
||
}
|
||
}
|
||
|
||
// ③ seq_create 的 description 必须说明 groups 与 file **二选一**。
|
||
//
|
||
// 这是最容易让模型犯错的地方:两个都传或都不传该怎么<E6808E><E4B988><EFBFBD>,必须写清。
|
||
func TestSeqCreateExplainsMutuallyExclusiveInput(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
desc := p.toolDefs()["seq_create"].Description
|
||
for _, want := range []string{"groups", "file"} {
|
||
if !strings.Contains(desc, want) {
|
||
t.Errorf("description 未提到 %s:%s", want, desc)
|
||
}
|
||
}
|
||
if !strings.Contains(desc, "二选一") && !strings.Contains(desc, "或") {
|
||
t.Errorf("description 未说明 groups 与 file 是二选一:%s", desc)
|
||
}
|
||
}
|
||
|
||
// ④ 六个工具都**不得**声明并发安全。
|
||
//
|
||
// seq_run / seq_call 会执行**一串**工具,其中可能含写操作;把它们标成
|
||
// 并发安全,会让内核把两条 seq_run 并发跑起来 ⇒ 两个序列的执行顺序
|
||
// 交错、变量表互相污染。
|
||
func TestSeqToolsAreNotParallelSafe(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
for name := range p.toolDefs() {
|
||
def := p.toolDefs()[name]
|
||
if def.ParallelSafe {
|
||
t.Errorf("工具 %s 声明了 ParallelSafe —— seq 会执行一串工具,并发会污染执行序列", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⑤ seq_run 的 description 必须说明"按 groups 数组顺序执行"。
|
||
//
|
||
// 组的执行顺序是**语义**的一部分:条件依赖前面的槽,顺序反了结果就错。
|
||
func TestSeqRunExplainsOrdering(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
desc := p.toolDefs()["seq_run"].Description
|
||
if !strings.Contains(desc, "顺序") {
|
||
t.Errorf("seq_run 未说明组按顺序执行:%s", desc)
|
||
}
|
||
}
|
||
|
||
// ⑥ 黑名单:序列**内部**不得调用这些工具(与子 agent 的黑名单同源,防递归)。
|
||
//
|
||
// ⚠️ 这里曾有一个我自己的设计矛盾:判据原先把 `seq_call` / `seq_when_call`
|
||
// 也要求进黑名单,但「按名调用 group/序列」恰恰是本包的核心能力——
|
||
// 若禁掉它,序列就退化成单层脚本,功能归零。
|
||
//
|
||
// 分层澄清(这也是正确语义):
|
||
//
|
||
// · `seq_call` / `seq_when_call` 作为**模型直接调用**的入口是正常的
|
||
// (模型可以单独调某个 group),不算黑名单;
|
||
// · 序列**内部**若写 seq_call,那是按名组合,走 runGroup 的专门分支
|
||
// 并受 maxCallDepth 约束(§8.3 的环检测与深度上界),
|
||
// 不靠黑名单防递归。
|
||
// · 真正要禁的是:对外发消息(output_send__)、起子 agent(spawn_child)、
|
||
// 改插件表(plgreload)、以及再次 seq_run 整条序列(会绕过深度计数的语义)。
|
||
func TestSeqBlacklistCoversRecursionRisks(t *testing.T) {
|
||
for _, name := range []string{
|
||
"output_send__qq", "output_send__x", "spawn_child", "plgreload", "seq_run",
|
||
} {
|
||
if !blacklisted(name) {
|
||
t.Errorf("黑名单未覆盖 %q —— 序列能调它就是递归/绕过风险", name)
|
||
}
|
||
}
|
||
// seq_call / seq_when_call 必须**不在**黑名单,否则按名调用能力归零
|
||
for _, name := range []string{"seq_call", "seq_when_call"} {
|
||
if blacklisted(name) {
|
||
t.Errorf("黑名单误伤了 %q —— 它是序列组合的核心能力(递归由 maxCallDepth + 环检测负责)", name)
|
||
}
|
||
}
|
||
// 普通工具不应被误伤
|
||
for _, name := range []string{"cmd_run", "knowledge_search", "output_list_channels"} {
|
||
if blacklisted(name) {
|
||
t.Errorf("黑名单误伤了正常工具 %q", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func keysOf(m map[string]toolDefInfo) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ---- 测试替身 ----
|
||
|
||
// newTestPlugin 造一个**不依赖内核**的 Plugin。
|
||
//
|
||
// 刻意不走 Start(那需要真实 *sdk.PluginSDK):本组判据只测
|
||
// 「工具定义与可观测契约」,不测执行语义(后者由 exec/store 的判据覆盖)。
|
||
func newTestPlugin(t *testing.T) *Plugin {
|
||
t.Helper()
|
||
return &Plugin{name: "seq", store: NewStore(t.TempDir())}
|
||
}
|
||
|
||
// ⑦ seq_help 必须存在,且**只返回文本**(与仓内 output_send__*_help 同范式)。
|
||
//
|
||
// 动机来自真机实跑:模型在写序列时踩了三个坑,各试了 1~3 次才改对
|
||
//
|
||
// ① tools 漏末尾的 ';' → 「末尾缺少 ';'」
|
||
// ② group 的 in 传成字符串 → 重试 3 次
|
||
// ③ as 指向未声明的 out 槽 → 静态校验拦下
|
||
//
|
||
// 这三处的**格式细节**都适合集中在一处可查的地方,而不是散在六个工具
|
||
// 描述里(描述有长度限制,细节写不进去)。
|
||
func TestSeqHelpRegistered(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
def, ok := p.toolDefs()["seq_help"]
|
||
if !ok {
|
||
t.Fatalf("seq_help 未注册(已注册:%v)", keysOf(p.toolDefs()))
|
||
}
|
||
if strings.TrimSpace(def.Description) == "" {
|
||
t.Error("seq_help 的 description 为空 —— 模型不知道该什么时候查它")
|
||
}
|
||
if def.ParallelSafe {
|
||
t.Error("seq_help 不该声明并发安全(它是纯查询)")
|
||
}
|
||
}
|
||
|
||
// ⑧ ★ seq_help 的内容必须覆盖真机踩过的**每一个**坑。
|
||
//
|
||
// 判据从"坑"出发而非从"我打算写什么"出发:下面每一项都对应一次真实失败。
|
||
func TestSeqHelpCoversRealPitfalls(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
out, err := p.dispatch("seq_help", map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatalf("seq_help 失败: %v", err)
|
||
}
|
||
text, _ := out.(string)
|
||
if strings.TrimSpace(text) == "" {
|
||
t.Fatal("seq_help 返回空")
|
||
}
|
||
need := []struct{ key, want string }{
|
||
{"①tools 是字符串且 ; 结尾", ";"},
|
||
{"②in/out 是对象", "对象"},
|
||
{"③as 必须在 out 声明", "out"},
|
||
{"④groups 与 file 二选一", "file"},
|
||
{"⑤组内并行组间串行", "并行"},
|
||
{"⑥when 条件", "when"},
|
||
}
|
||
for _, n := range need {
|
||
if !strings.Contains(text, n.want) {
|
||
t.Errorf("seq_help 缺少要点「%s」(应含 %q)", n.key, n.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⑨ seq_help 必须给一个**可直接照抄**的完整例子。
|
||
//
|
||
// 实跑里模型是照着自己理解拼 JSON 的,踩了两次格式坑。
|
||
// 一个正确样例比三段描述更有用。
|
||
func TestSeqHelpIncludesCopyableExample(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
out, err := p.dispatch("seq_help", map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatalf("seq_help 失败: %v", err)
|
||
}
|
||
text, _ := out.(string)
|
||
if !strings.Contains(text, `"groups"`) {
|
||
t.Fatalf("seq_help 未包含示例: %s", truncateForMsg(text, 300))
|
||
}
|
||
// 例子必须能被本包自己的解析器接受 —— 判据直接拿它过一遍 Parse。
|
||
_ = err
|
||
if err := validateHelpExample(text); err != nil {
|
||
t.Errorf("seq_help 里的示例**自己解析不过**(模型照抄必然失败): %v", err)
|
||
}
|
||
}
|
||
|
||
func truncateForMsg(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "..."
|
||
}
|
||
|
||
// validateHelpExample 从 seq_help 文本里抽出示例并交给**本包自己的解析器**校验。
|
||
//
|
||
// 为什么要这么判:seq_help 是**给模型照抄的**。如果示例本身解析不过
|
||
// (例如 tools 少一个 ';'、in 写成了字符串),模型照抄必然失败 —— 而这类
|
||
// bug 从"文本里有没有某个词"是看不出来的。
|
||
//
|
||
// 做法:从文本里取第一个含 `"groups"` 的 JSON 对象(花括号配平扫描),
|
||
// 直接喂给 Parse。
|
||
func validateHelpExample(help string) error {
|
||
// ⚠️ 两个坑(都踩过):
|
||
// 1. 不能用 strings.Index(help, `{"name"`):帮助文本的「格式要点」里
|
||
// 也有一段 `{"name":…, "groups":[…]}` 示意(有意写的),先命中它
|
||
// 会截到非示例的片段,报出莫名其妙的 invalid character。
|
||
// 2. 基准必须统一。下面全程在**同一个**子串 base 上做偏移,
|
||
// 绝不把 base 的下标拿去切 help。
|
||
const marker = "【可照抄的完整示例】"
|
||
mi := strings.Index(help, marker)
|
||
if mi < 0 {
|
||
return fmt.Errorf("help 里缺少【可照抄的完整示例】小节")
|
||
}
|
||
base := help[mi+len(marker):]
|
||
|
||
// 找第一行"整行就是一个 JSON 对象"的内容
|
||
var line string
|
||
for _, l := range strings.Split(base, "\n") {
|
||
t := strings.TrimSpace(l)
|
||
t = strings.Trim(t, "`")
|
||
if strings.HasPrefix(t, `{"name"`) {
|
||
line = t
|
||
break
|
||
}
|
||
}
|
||
if line == "" {
|
||
return fmt.Errorf("示例小节里找不到一整行的 JSON 序列")
|
||
}
|
||
|
||
// 在 base 上定位该行,再做括号配平扫描(全程同一基准)
|
||
off := strings.Index(base, line)
|
||
depth := 0
|
||
inStr := false
|
||
esc := false
|
||
for i := off; i < len(base); i++ {
|
||
c := base[i]
|
||
switch {
|
||
case esc:
|
||
esc = false
|
||
case c == '\\' && inStr:
|
||
esc = true
|
||
case c == '"':
|
||
inStr = !inStr
|
||
case inStr:
|
||
case c == '{':
|
||
depth++
|
||
case c == '}':
|
||
depth--
|
||
if depth == 0 {
|
||
_, err := Parse([]byte(base[off : i+1]))
|
||
if err != nil {
|
||
return fmt.Errorf("示例解析失败: %w(示例前 120 字:%s)",
|
||
err, truncateForMsg(base[off:min(i+1, off+120)], 120))
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
return fmt.Errorf("示例 JSON 括号未配平")
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// ⑩ ★ 真机实跑发现的最高频坑:`in` 传空字符串 ""(而非对象 {})。
|
||
//
|
||
// 两轮对照实验(隔离实例,各 5 次与 3 次 seq_create 尝试)里,
|
||
// 模型**都在同一处错了两次**:
|
||
//
|
||
// ① 实验 A:in 传 "" → 连续 2 次失败
|
||
// ② 实验 B(先查 seq_help):in 仍传 "" → 又连续 2 次失败
|
||
//
|
||
// 模型自述:「我把『无入参』理解成『不传这个字段』,结果序列化成了字符串」。
|
||
//
|
||
// ⇒ 现有文案虽已可执行("无入参请写 {},不要写成字符串"),
|
||
// 但**位置太深**:埋在「格式要点」第 2 条里,模型读到了仍会错。
|
||
// 本判据要求这条在**前两屏内**且**用醒目措辞**出现。
|
||
func TestHelpWarnsEmptyStringInProminently(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
out, err := p.dispatch("seq_help", map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatalf("seq_help 失败: %v", err)
|
||
}
|
||
text, _ := out.(string)
|
||
|
||
// ① 必须明确说"不要写成字符串"(这是模型实际犯的错)
|
||
if !strings.Contains(text, "不要写成字符串") {
|
||
t.Errorf("seq_help 未警示『in 不要写成字符串』(实跑中模型在此错了 4 次)")
|
||
}
|
||
// ② 这条必须出现在**前 400 字**内 —— 埋太深就会被跳过(实跑证明)
|
||
head := text
|
||
if len(head) > 400 {
|
||
head = head[:400]
|
||
}
|
||
if !strings.Contains(head, "不要写成字符串") {
|
||
t.Errorf("『in 不要写成字符串』未出现在前 400 字内(实跑证明埋太深会被忽略)")
|
||
}
|
||
// ③ 必须给出正确写法,让模型无需推断
|
||
if !strings.Contains(text, "{}") {
|
||
t.Error("未给出正确写法 {}")
|
||
}
|
||
}
|
||
|
||
// ⑪ ★ 同样的坑必须也出现在 seq_create 自己的描述里 ——
|
||
// 模型可能不查 help 就直接建序列。
|
||
func TestSeqCreateDescWarnsAboutIn(t *testing.T) {
|
||
p := newTestPlugin(t)
|
||
desc := p.toolDefs()["seq_create"].Description
|
||
if !strings.Contains(desc, "in") {
|
||
t.Errorf("seq_create 描述未提及 in:%s", desc)
|
||
}
|
||
// 描述里至少要点明 groups[].in 是对象
|
||
if !strings.Contains(desc, "对象") && !strings.Contains(desc, "{}") {
|
||
t.Errorf("seq_create 描述未说明 groups[].in 应为对象:%s", desc)
|
||
}
|
||
}
|