mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
动机来自真机实跑:模型写序列时踩了三个坑,各试 1~3 次才改对
① tools 漏末尾的 ';' → 「末尾缺少 ';'」
② group 的 in 传成字符串 → 重试 3 次
③ as 指向未声明的 out 槽 → 静态校验拦下
这三处都是**格式细节**,塞不进工具描述(有长度限制),却恰是模型最易错处。
散落在七个描述里等于没有集中入口。
实现(help.go + tools.go + plugin.go):
· seq_help 无参数、纯文本返回(与仓内 output_send__*_help 同范式)
· 「格式要点」逐条写明:in/out 必须是**对象**、tools 必须是**字符串**、
每个 tool 后(含最后一个)都要 ';'、as 必须在 out 声明、
groups 与 file 二选一、组内并行组间串行
· 「条件 when」列出支持的表达式形态
· 「可照抄的完整示例」给一行**单行紧凑**的合法序列
★ 判据(plugin_test.go,3 条):
· seq_help 已注册、有 description、不声明并发安全
· 内容覆盖真机踩过的**每一个**坑(判据从"坑"出发而非从"打算写什么")
· ★ 示例**自己能被本包解析器接受**:validateHelpExample 从帮助文本里
抽出示例喂给 Parse —— 模型是照抄的,示例自己解析不过就是给模型挖坑。
而"从文本里有没有某个词"是看不出这类 bug 的。
过程中判据自己错了两次(都被这条示例判据照出来):
1. 抽取用 strings.Index(help, `{"name"`) ⇒ 先命中「格式要点」里**有意写的**
示意片段,截到非示例的内容,报出莫名其妙的 invalid character '…'。
2. 修完又混用两套偏移基准(base 的下标拿去切 help)⇒ invalid character '\xaf'。
⇒ 重写为全程在同一 base 上定位。
★ 两次都说明:**判据的抽取逻辑本身就是需要验证的代码**,
它出错时报出的信息极具误导性(看起来像实现有 bug)。
示例形态也改过一次:原为多行缩进 JSON,改为**单行紧凑** —— 模型照抄时
免去缩进/换行带来的额外风险。
变异验证:去掉示例里的末尾 ';' ⇒ 示例判据 FAIL。
另一处:加 seq_help 后「恰好注册 6 个工具」判据 FAIL(实际 7)——
这正是那条判据的用意(防止悄悄多加工具稀释工具面),已更新并注明原因。
回归:go build ./... 通过;internal/plugins/... core sdk 全绿。
318 lines
11 KiB
Go
318 lines
11 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
|
||
}
|