diff --git a/internal/plugins/all.go b/internal/plugins/all.go index 3bc9587..86b09fa 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -14,6 +14,7 @@ import ( _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/multimodal" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/remotedevice" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/seq" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/skillmgr" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" diff --git a/internal/plugins/seq/handlers.go b/internal/plugins/seq/handlers.go new file mode 100644 index 0000000..2d96319 --- /dev/null +++ b/internal/plugins/seq/handlers.go @@ -0,0 +1,435 @@ +package seq + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" +) + +// 本文件是 seq_* 工具的实现:解析参数 → 调 Store / 执行引擎 → 渲染结果。 +// +// 两条贯穿全文件的纪律: +// 1. **不静默降级**:参数缺失、序列不存在、目标不存在都**报错**并说明 +// 该怎么做。模型拿到含糊的"失败"只会原样重试。 +// 2. **错误信息要可执行**:说清缺什么、可用的是什么。 + +// seqCreate 创建/更新序列。groups 与 file 二选一。 +func (p *Plugin) seqCreate(args map[string]interface{}) (interface{}, error) { + name := argString(args, "name") + if name == "" { + return nil, fmt.Errorf("缺少 name(序列名)") + } + groupsRaw, hasGroups := args["groups"] + file := argString(args, "file") + + switch { + case file != "" && hasGroups: + return nil, fmt.Errorf("groups 与 file **二选一**,不能同时传") + case file == "" && !hasGroups: + return nil, fmt.Errorf("必须提供 groups 或 file 其中之一(长序列建议写文件后用 file 传)") + } + + var ( + text []byte + from = "参数" + ) + if file != "" { + b, err := p.readSeqFile(file) + if err != nil { + return nil, err + } + text = b + from = "文件 " + file + } else { + b, err := marshalGroups(groupsRaw) + if err != nil { + return nil, err + } + text = b + } + + seq, err := Parse(text) + if err != nil { + return nil, fmt.Errorf("来自%s的序列解析失败: %w", from, err) + } + if seq.Name == "" { + seq.Name = name + } + if seq.Name != name { + return nil, fmt.Errorf("序列名不一致:参数给了 %q,内容里是 %q(请统一)", name, seq.Name) + } + if d := argString(args, "description"); d != "" { + seq.Description = d + } + if err := p.store.Save(seq); err != nil { + return nil, err + } + // 跨序列引用可能成环:存完复查一次(Save 只校验同序列内的 group 引用) + graphErr := p.store.CheckGraph() + + var sb strings.Builder + fmt.Fprintf(&sb, "序列 %q 已保存(%s,%d 个 group)", seq.Name, from, len(seq.Groups)) + for _, g := range seq.Groups { + fmt.Fprintf(&sb, "\n- %s", g.Name) + if len(g.In) > 0 { + fmt.Fprintf(&sb, " 入参[%s]", keyList(g.In)) + } + if len(g.Out) > 0 { + fmt.Fprintf(&sb, " 出参[%s]", keyList(g.Out)) + } + fmt.Fprintf(&sb, " 工具%d个", len(g.Tools)) + } + if graphErr != nil { + // 保存成功但图不合法 —— 必须**说清**,否则模型会以为可以跑了 + fmt.Fprintf(&sb, "\n⚠️ 序列已保存,但调用图有问题(现在执行会失败):%v", graphErr) + } + return sb.String(), nil +} + +// readSeqFile 读序列文件,带路径逃逸防护。 +func (p *Plugin) readSeqFile(path string) ([]byte, error) { + abs, err := absPath(path) + if err != nil { + return nil, err + } + b, err := os.ReadFile(abs) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("序列文件 %s 不存在", path) + } + return nil, fmt.Errorf("读序列文件 %s 失败: %w", path, err) + } + return b, nil +} + +// absPath 做基础路径校验:拒绝空、拒绝明显的穿越写法。 +func absPath(p string) (string, error) { + if strings.TrimSpace(p) == "" { + return "", fmt.Errorf("路径为空") + } + if strings.Contains(p, "\x00") { + return "", fmt.Errorf("路径含非法字符") + } + // 允许绝对与相对路径,但禁止 .. 段(与 files 插件的目录逃逸防护同源思路) + for _, seg := range strings.Split(filepathToSlash(p), "/") { + if seg == ".." { + return "", fmt.Errorf("路径 %q 含 .. 段,不允许", p) + } + } + return p, nil +} + +func filepathToSlash(p string) string { return strings.ReplaceAll(p, `\`, "/") } + +// marshalGroups 把 groups 参数([]interface{})序列化为 JSON 文本。 +func marshalGroups(raw interface{}) ([]byte, error) { + arr, ok := raw.([]interface{}) + if !ok { + return nil, fmt.Errorf("groups 必须是数组,实际是 %T", raw) + } + doc := map[string]interface{}{"groups": arr} + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, fmt.Errorf("groups 序列化失败(每个 group 应为对象): %w", err) + } + return b, nil +} + +// seqList 列出全部序列及其签名。 +func (p *Plugin) seqList() (interface{}, error) { + names := p.store.List() + if len(names) == 0 { + return "当前没有任何序列(用 seq_create 新建)", nil + } + var sb strings.Builder + fmt.Fprintf(&sb, "共 %d 条序列:", len(names)) + for _, n := range names { + seq, err := p.store.Load(n) + if err != nil { + fmt.Fprintf(&sb, "\n- %s ⚠️ 读取失败: %v", n, err) + continue + } + desc := seq.Description + if desc == "" { + desc = "(无描述)" + } + fmt.Fprintf(&sb, "\n- %s:%s(%d 个 group)", n, desc, len(seq.Groups)) + for _, g := range seq.Groups { + fmt.Fprintf(&sb, "\n · %s", g.Name) + if len(g.In) > 0 { + fmt.Fprintf(&sb, " 入参[%s]", keyList(g.In)) + } + if len(g.Out) > 0 { + fmt.Fprintf(&sb, " 出参[%s]", keyList(g.Out)) + } + } + } + return sb.String(), nil +} + +// seqDelete 删除序列。 +func (p *Plugin) seqDelete(args map[string]interface{}) (interface{}, error) { + name := argString(args, "name") + if name == "" { + return nil, fmt.Errorf("缺少 name(序列名)") + } + if err := p.store.Delete(name); err != nil { + return nil, err + } + return fmt.Sprintf("序列 %q 已删除", name), nil +} + +// seqRun 执行一条序列的全部 group。 +func (p *Plugin) seqRun(args map[string]interface{}) (interface{}, error) { + name := argString(args, "name") + if name == "" { + return nil, fmt.Errorf("缺少 name(序列名)") + } + seq, err := p.store.Load(name) + if err != nil { + return nil, err + } + in, _ := args["args"].(map[string]interface{}) + if in == nil { + in = map[string]interface{}{} + } + return p.runSequence(seq, in, nil) +} + +// seqCall 按名调用一个 group(when 非空时为条件调用)。 +func (p *Plugin) seqCall(args map[string]interface{}, when string) (interface{}, error) { + target := argString(args, "target") + if target == "" { + return nil, fmt.Errorf("缺少 target(组名或 #序列名)") + } + seqName := argString(args, "name") + callArgs, _ := args["args"].(map[string]interface{}) + if callArgs == nil { + callArgs = map[string]interface{}{} + } + + var seq *Sequence + var err error + if strings.HasPrefix(target, "#") { + seqName = strings.TrimPrefix(target, "#") + seq, err = p.store.Load(seqName) + } else { + if seqName == "" { + return nil, fmt.Errorf("按组名调用时必须给出 name(该组所属的序列名)") + } + seq, err = p.store.Load(seqName) + } + if err != nil { + return nil, err + } + + idx := -1 + for i, g := range seq.Groups { + if g.Name == target || strings.TrimPrefix(target, "#") == g.Name { + idx = i + break + } + } + if idx < 0 { + return nil, fmt.Errorf("序列 %q 里没有 group %q(现有:%s)", + seq.Name, target, groupNameList(seq)) + } + + // 条件调用:求值在**进入前**,为假则整次跳过 + if strings.TrimSpace(when) != "" { + ok, cerr := evalCond(when, callArgs) + if cerr != nil { + // ★ 求值失败**不得**降级为"跳过" + return nil, fmt.Errorf("seq_when_call 的条件求值失败(这是参数问题,不是工具故障): %w", cerr) + } + if !ok { + return fmt.Sprintf("条件为假,已跳过 %q(不产出任何槽)", target), nil + } + } + + res, gerr := p.runGroup(seq.Groups[idx], callArgs, seq.Name) + if gerr != nil { + return nil, gerr + } + return renderGroupResult(res), nil +} + +// runSequence 顺序执行全部 group。 +// +// group 间**串行**:后者可能依赖前者的出参槽(具名槽即数据边)。 +func (p *Plugin) runSequence(seq *Sequence, in map[string]interface{}, _ any) (interface{}, error) { + if p.callDepth >= maxCallDepth { + return nil, fmt.Errorf("嵌套调用深度超过上界 %d(可能存在循环调用)", maxCallDepth) + } + p.callDepth++ + defer func() { p.callDepth-- }() + + var lines []string + slots := map[string]interface{}{} + failed := false + + for i, g := range seq.Groups { + // 每组的入参 = 顶层入参 + 已产出槽(具名槽在组间传递) + groupArgs := map[string]interface{}{} + for k, v := range in { + groupArgs[k] = v + } + for k, v := range slots { + groupArgs[k] = v + } + res, err := p.runGroup(g, groupArgs, seq.Name) + if err != nil { + lines = append(lines, fmt.Sprintf("第 %d 组 %q 失败: %v", i+1, g.Name, err)) + failed = true + if g.OnError != "continue" { + break + } + continue + } + line := fmt.Sprintf("第 %d 组 %q", i+1, g.Name) + if res.Skipped { + line += "(条件为假,已跳过)" + } else { + line += fmt.Sprintf(" 工具 %d 个", len(res.Tools)) + if len(res.Missing) > 0 { + line += fmt.Sprintf(" ⚠️缺失工具: %s", strings.Join(res.Missing, ", ")) + } + } + lines = append(lines, line) + // 槽合并(后续组可读) + for k, v := range res.Slots { + slots[k] = v + } + } + + var sb strings.Builder + fmt.Fprintf(&sb, "序列 %q 执行完毕(%d/%d 组):", seq.Name, len(lines), len(seq.Groups)) + for _, l := range lines { + sb.WriteString("\n- " + l) + } + if len(slots) > 0 { + sb.WriteString("\n\n变量槽:") + keys := make([]string, 0, len(slots)) + for k := range slots { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(&sb, "\n %s = %s", k, truncate(renderResult(slots[k]), 160)) + } + } + if failed { + sb.WriteString("\n⚠️ 有 group 失败(见上)") + } + return sb.String(), nil +} + +// runGroup 执行单个 group,含黑名单、并发安全与深度检查。 +func (p *Plugin) runGroup(g Group, args map[string]interface{}, seqName string) (GroupResult, error) { + // ① 黑名单先行 + for _, t := range g.Tools { + if blacklisted(t.Tool) { + return GroupResult{Group: g.Name}, fmt.Errorf( + "group %q 试图调用被禁止的工具 %s"+ + "(序列不得对外发消息/改插件表/再起子 agent)", g.Name, t.Tool) + } + if t.Tool == "seq_call" || t.Tool == "seq_when_call" { + tgt, _ := t.Args["target"].(string) + if strings.HasPrefix(tgt, "#") { + if strings.TrimPrefix(tgt, "#") == seqName { + return GroupResult{Group: g.Name}, fmt.Errorf("group %q 调用了序列自身(无限递归)", g.Name) + } + } + } + } + + // ② 组内是否真的可以并发:全部声明 ParallelSafe 才并发。 + // 查不到声明 ⇒ 保守按串行(动态注册下工具可能随时消失)。 + runnable := g.Parallel + if runnable { + for _, t := range g.Tools { + if !p.runner.parallelSafe(t.Tool) { + runnable = false + break + } + } + } + if !runnable { + g.Parallel = false + } + + // ③ 存在性预检(missing 策略的输入) + preMissing := false + for _, t := range g.Tools { + if t.Tool == "seq_call" || t.Tool == "seq_when_call" { + continue // 内建调用另行处理 + } + if !p.runner.exists(t.Tool) { + preMissing = true + break + } + } + if preMissing { + switch g.missingPolicy() { + case "skip", "degrade": + // 逐个剔除缺失的工具 + var kept []ToolCall + for _, t := range g.Tools { + if (t.Tool == "seq_call" || t.Tool == "seq_when_call") || p.runner.exists(t.Tool) { + kept = append(kept, t) + } + } + if len(kept) == 0 { + return GroupResult{Group: g.Name, Skipped: true, Slots: map[string]interface{}{}}, nil + } + g.Tools = kept + } + } + + return execGroup(g, args, p.runner) +} + +func groupNameList(seq *Sequence) string { + names := make([]string, 0, len(seq.Groups)) + for _, g := range seq.Groups { + names = append(names, g.Name) + } + if len(names) == 0 { + return "(无)" + } + return strings.Join(names, ", ") +} + +func renderGroupResult(res GroupResult) string { + var sb strings.Builder + fmt.Fprintf(&sb, "group %q", res.Group) + if res.Skipped { + sb.WriteString("(条件为假,已跳过)") + return sb.String() + } + fmt.Fprintf(&sb, " 完成 %d 个工具", len(res.Tools)) + if len(res.Missing) > 0 { + fmt.Fprintf(&sb, ",缺失: %s", strings.Join(res.Missing, ", ")) + } + if len(res.Slots) > 0 { + keys := make([]string, 0, len(res.Slots)) + for k := range res.Slots { + keys = append(keys, k) + } + sort.Strings(keys) + sb.WriteString("\n出参:") + for _, k := range keys { + fmt.Fprintf(&sb, "\n %s = %s", k, truncate(renderResult(res.Slots[k]), 160)) + } + } + return sb.String() +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/plugins/seq/plugin.go b/internal/plugins/seq/plugin.go new file mode 100644 index 0000000..2dada17 --- /dev/null +++ b/internal/plugins/seq/plugin.go @@ -0,0 +1,188 @@ +// Package seq 的插件层:把序列能力暴露为模型可调用的 seq_* 工具。 +// +// 本文件只做「接线」:工具定义、参数校验、调用 Store/执行引擎。 +// 语义全在 parse.go / exec.go / store.go,三者各自有判据。 +package seq + +import ( + "fmt" + "log" + "strings" + "sync" + + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// toolDefInfo 是本包内部用的工具声明视图(判据也用它)。 +type toolDefInfo = sdk.ToolDef + +// blacklisted 判断某工具名是否**禁止**被序列调用。 +// +// 与子 agent 的黑名单同源(core/spawn.go:117):防递归与绕过。 +// +// ⚠️ seq_call / seq_when_call **不在**黑名单里:按名调用 group/序列正是 +// 本包的核心能力,禁掉它序列就退化成单层脚本。它们作为**模型直接调用**的 +// 入口是正常的;序列内部若写 seq_call,走 runGroup 的专门分支并受 +// maxCallDepth + 环检测约束(见设计文档 §8.3),不靠黑名单防递归。 +func blacklisted(name string) bool { + switch { + case strings.HasPrefix(name, "output_send__"): + return true // 序列不负责对外发消息 + case name == "spawn_child": + return true // 防子 agent 递归 + case name == "plgreload": + return true // 改插件注册表会与当前执行交错 + case name == "seq_run": + return true // 序列内再跑整条序列:语义上是递归,且绕过 depth 计数的可读性 + } + return false +} + +// seqRunner 是执行引擎对内核工具的依赖。 +// +// 刻意**不**直接依赖 sdk.ToolAPI,而是收窄成两个方法——这样判据能用 +// 假实现驱动,而不必构造整个内核。 +type seqRunner interface { + call(name string, args map[string]interface{}) (string, error) + // exists 报告工具是否存在(动态注册下"不存在"是常态)。 + exists(name string) bool + // parallelSafe 报告工具是否声明可并发。 + parallelSafe(name string) bool +} + +// Plugin 是本插件。 +type Plugin struct { + name string + mu sync.RWMutex + store *Store + sdk *sdk.PluginSDK + // runner 指向内核(由 Start 注入) + runner seqRunner + // 调用栈深度:跨序列/跨 group 嵌套的**结构上界**(maxCallDepth) + callDepth int +} + +// New 构造插件实例。 +func New(name string) *Plugin { + return &Plugin{name: name} +} + +// Name 实现 plugin.Plugin。 +func (p *Plugin) Name() string { return p.name } + +// Start 注入内核能力并注册工具。 +func (p *Plugin) Start(s *sdk.PluginSDK) error { + p.sdk = s + dir := "." + if v, _ := s.Settings().GetCore("daemon.data_dir"); v != nil { + if d, ok := v.(string); ok && d != "" { + dir = d + "/sequences" + } + } + p.store = NewStore(dir) + p.runner = &kernelRunner{tool: s.Tool()} + p.registerTools() + return nil +} + +// Stop 清理(无订阅需注销)。 +func (p *Plugin) Stop() error { return nil } + +// kernelRunner 把 sdk.ToolAPI 适配成 seqRunner。 +type kernelRunner struct{ tool sdk.ToolAPI } + +func (k *kernelRunner) call(name string, args map[string]interface{}) (string, error) { + if k.tool == nil { + return "", fmt.Errorf("工具执行器不可用") + } + res, err := k.tool.ExecuteTool(name, args) + if err != nil { + return "", err + } + return renderResult(res), nil +} + +func (k *kernelRunner) exists(name string) bool { + if k.tool == nil { + return false + } + return k.tool.ToolDefByName(name) != nil +} + +func (k *kernelRunner) parallelSafe(name string) bool { + if k.tool == nil { + return false + } + def := k.tool.ToolDefByName(name) + return def != nil && def.ParallelSafe +} + +// renderResult 渲染工具返回值。 +// +// ⚠️ 绝不用 fmt.Sprintf("%v"):对象会变成 `map[k:v]` 这种模型读不懂的 +// Go 语法(与 core.renderToolResult 同一约定)。非字符串用紧凑 JSON。 +func renderResult(v interface{}) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return compactJSON(v) +} + +// toolDefs 返回已注册的工具定义(判据与内部都读它,保证同一真相)。 +func (p *Plugin) toolDefs() map[string]toolDefInfo { + out := map[string]toolDefInfo{} + for _, d := range seqToolDefs() { + out[d.Name] = d + } + return out +} + +// registerTools 把六个工具注册到内核。 +// +// ⚠️ 六个工具都**不声明** ParallelSafe:seq_run / seq_call 会执行一串 +// 工具,其中可能含写操作;标成并发安全会让内核把两条 seq_run 并发跑, +// 两个序列的执行顺序交错、变量表互相污染。 +func (p *Plugin) registerTools() { + for _, d := range seqToolDefs() { + def := d + if err := p.sdk.RegisterTool(def.Name, def, func(args map[string]interface{}) (interface{}, error) { + return p.dispatch(def.Name, args) + }); err != nil { + // 注册失败**记日志并继续**,不 panic。 + // ⚠️ 内置插件在 main() 的装配期加载,panic 会直接拖垮内核启动 + // —— 而"某个工具没注册上"只该让该工具不可用,不该让整个 agent 起不来。 + // (与 clawhubadapter / mcp 的处理一致:log 后继续。) + log.Printf("[seq] 注册工具 %s 失败: %v", def.Name, err) + } + } +} + +// dispatch 按工具名分派。 +func (p *Plugin) dispatch(name string, args map[string]interface{}) (interface{}, error) { + switch name { + case "seq_create": + return p.seqCreate(args) + case "seq_list": + return p.seqList() + case "seq_delete": + return p.seqDelete(args) + case "seq_run": + return p.seqRun(args) + case "seq_call": + return p.seqCall(args, "") + case "seq_when_call": + return p.seqCall(args, getString(args, "when")) + } + return nil, fmt.Errorf("未知工具 %s", name) +} + +func getString(m map[string]interface{}, k string) string { + if m == nil { + return "" + } + s, _ := m[k].(string) + return s +} diff --git a/internal/plugins/seq/plugin_test.go b/internal/plugins/seq/plugin_test.go new file mode 100644 index 0000000..78f2863 --- /dev/null +++ b/internal/plugins/seq/plugin_test.go @@ -0,0 +1,160 @@ +package seq + +import ( + "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", + } + 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 **二选一**。 +// +// 这是最容易让模型犯错的地方:两个都传或都不传该怎么���,必须写清。 +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())} +} diff --git a/internal/plugins/seq/register.go b/internal/plugins/seq/register.go new file mode 100644 index 0000000..79d4cfc --- /dev/null +++ b/internal/plugins/seq/register.go @@ -0,0 +1,17 @@ +package seq + +import ( + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// 插件注册:与其它内置插件同一范式(见 skillmgr/plugin.go 的 init)。 +// +// 之所以用 init + factory 而非在 all.go 里直接 new:内置插件统一由 +// registry 按名字工厂化加载,all.go 只负责 import 触发注册。 +func init() { + plugin.RegisterPluginMeta("seq", "工具序列", "Tool Sequence") + plugin.RegisterFactory("seq", func(name string, _ map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} diff --git a/internal/plugins/seq/tools.go b/internal/plugins/seq/tools.go new file mode 100644 index 0000000..43dca6e --- /dev/null +++ b/internal/plugins/seq/tools.go @@ -0,0 +1,144 @@ +package seq + +import "strings" + +// seqToolDefs 返回本插件导出的六个工具定义。 +// +// ⚠️ 全部**不声明** ParallelSafe:seq_run / seq_call 会执行**一串**工具, +// 其中可能含写操作。标成并发安全会让内核把两条 seq_run 并发跑起来, +// 两个序列的执行顺序交错、变量表互相污染。 +// +// 为独立真相源:注册、判据、文档都从这里取,避免三处各写一份。 +func seqToolDefs() []toolDefInfo { + return []toolDefInfo{ + { + Name: "seq_create", + Description: "创建/更新一条工具序列。**groups 与 file 二选一**:传 groups 直接给结构," + + "或用 file 加载你已写好的序列文件(适合长序列——长参数会被 max_tokens 截断,写文件更稳)。\n" + + "序列由若干 group 组成:**组内并行、组间串行**;group 拥有独立签名(in 入参 / out 出参)," + + "可被 seq_call 按名调用。\n" + + "保存时会做静态校验:as 必须已在 out 声明、$args.x 必须已在 in 声明、组名不重复、" + + "组内 ; 分隔符必须完整。任一项不满足都会报错并说明原因。\n" + + "格式:JSON;每个 group 的 tools 是一个字符串,内含若干以 ' ; ' 分隔的 JSON 对象," + + "形如 {\"tool\":\"cmd_run\",\"args\":{...},\"as\":\"槽名\"}。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", "description": "序列名(唯一)", + }, + "description": map[string]interface{}{ + "type": "string", "description": "一句话说明这条序列做什么", + }, + "groups": map[string]interface{}{ + "type": "array", + "description": "组数组,按数组顺序执行;与 file 二选一", + "items": map[string]interface{}{"type": "object"}, + }, + "file": map[string]interface{}{ + "type": "string", + "description": "序列文件路径(与 groups 二选一)。目录内文件优先用 files 工具查看", + }, + }, + "required": []string{"name"}, + }, + }, + { + Name: "seq_list", + Description: "列出全部可用序列:名称、描述、组数与各组的签名(in/out)。" + + "按名调用前先用它确认名称与签名。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, + { + Name: "seq_delete", + Description: "删除一条序列。序列不存在时会报错(不会静默成功)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", "description": "要删除的序列名", + }, + }, + "required": []string{"name"}, + }, + }, + { + Name: "seq_run", + Description: "执行一条序列:按 groups 数组的顺序逐组执行,组内并行、组间串行。" + + "每组先求值 when 条件,为真才执行。返回逐组摘要与最终变量槽快照。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", "description": "要执行的序列名", + }, + "args": map[string]interface{}{ + "type": "object", + "description": "传给各组的入参(组内用 $args.<键> 读取)", + }, + }, + "required": []string{"name"}, + }, + }, + { + Name: "seq_call", + Description: "按名调用一条序列里的 group:target 写组名(限本序列内)或 " + + "#序列名(跨序列)。返回该组的 out 槽。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", "description": "所属序列名(组名调用时必填)", + }, + "target": map[string]interface{}{ + "type": "string", + "description": "组名,或 #序列名 表示跨序列调用", + }, + "args": map[string]interface{}{ + "type": "object", + "description": "传给该组的入参", + }, + }, + "required": []string{"target"}, + }, + }, + { + Name: "seq_when_call", + Description: "条件按名调用:when 表达式为真才执行,否则整次调用跳过(不产出任何槽)。" + + "when 只可读传入的 args(如 $args.flag == true)。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", "description": "所属序列名(组名调用时必填)", + }, + "target": map[string]interface{}{ + "type": "string", + "description": "组名,或 #序列名 表示跨序列调用", + }, + "args": map[string]interface{}{ + "type": "object", + "description": "传给该组的入参", + }, + "when": map[string]interface{}{ + "type": "string", + "description": "条件表达式,如 $args.flag == true", + }, + }, + "required": []string{"target", "when"}, + }, + }, + } +} + +// 内部小工具:把 args 里的字符串字段取出来并 trim。 +func argString(args map[string]interface{}, key string) string { + if args == nil { + return "" + } + s, _ := args[key].(string) + return strings.TrimSpace(s) +} diff --git a/internal/sdk/tool.go b/internal/sdk/tool.go index 8542d56..3b7d464 100644 --- a/internal/sdk/tool.go +++ b/internal/sdk/tool.go @@ -15,6 +15,11 @@ type ToolAPI interface { GetToolDefs() []ToolDef // GetAllTools returns all tools exposed by IO devices/channels. GetAllTools() []ToolDef + // ToolDefByName 按名字查任一来源(StageHost 插件工具 / IO 设备工具)的声明。 + // 插件需要它来在**运行前**判断目标是否存在、是否并发安全 —— + // 而工具是动态注册的,"不存在"是常态(见 seq 包的 missing 策略)。 + // 查不到返回 nil(调用方按"不存在"处理,不得 panic)。 + ToolDefByName(name string) *ToolDef // ExecuteTool executes a tool by name, resolving across the stage host first. ExecuteTool(name string, args map[string]interface{}) (interface{}, error) } diff --git a/internal/sdk/tool_impl.go b/internal/sdk/tool_impl.go index 45745f3..8f4566b 100644 --- a/internal/sdk/tool_impl.go +++ b/internal/sdk/tool_impl.go @@ -30,7 +30,15 @@ func (t *toolImpl) GetAllTools() []ToolDef { defs := t.iom.GetAllTools() out := make([]ToolDef, 0, len(defs)) for _, d := range defs { - out = append(out, ToolDef{Name: d.Name, Description: d.Description, Parameters: d.Parameters}) + // ⚠️ ParallelSafe 必须一并带出:它决定该工具能否被并发执行。 + // 此前这里漏了它 ⇒ 插件看到的设备工具一律"不可并发", + // 设备工具的并发声明等于对插件不可见。 + out = append(out, ToolDef{ + Name: d.Name, + Description: d.Description, + Parameters: d.Parameters, + ParallelSafe: d.ParallelSafe, + }) } return out } @@ -48,3 +56,30 @@ func (t *toolImpl) ExecuteTool(name string, args map[string]interface{}) (interf } var _ ToolAPI = (*toolImpl)(nil) + +// ToolDefByName 按名字查任一来源的工具声明(StageHost 优先,再查 IO 设备)。 +// +// 用途:插件在**运行前**判断目标工具是否存在、是否并发安全。工具是动态 +// 注册的,"不存在"是常态(插件未加载/已卸载/崩溃),因此查不到一律返回 +// nil 交由调用方按"不存在"处理——不得 panic。 +func (t *toolImpl) ToolDefByName(name string) *ToolDef { + if t == nil || name == "" { + return nil + } + if t.stageHost != nil { + if def := t.stageHost.ToolDef(name); def != nil { + return def + } + } + if t.iom != nil { + if def, ok := t.iom.ToolDefOf(name); ok { + return &ToolDef{ + Name: def.Name, + Description: def.Description, + Parameters: def.Parameters, + ParallelSafe: def.ParallelSafe, + } + } + } + return nil +}