feat(seq): 六个 seq_* 工具、插件装配,并补内核两处缺口(插件线 P4)

内核缺口(都是 P3 落地时暴露的真实缺陷):
· **GetAllTools 丢 ParallelSafe**:它只带出 Name/Description/Parameters,
  插件看到的设备工具一律"不可并发" ⇒ 设备工具的并发声明**对插件不可见**。
· **ToolAPI 缺按名查**:新增 ToolDefByName。插件需要在**运行前**判断目标
  是否存在/是否并发安全(工具动态注册,"不存在"是常态),
  而 GetAllTools 只能拿到全量列表。查不到返回 nil,不 panic。

seq 插件:
· plugin.go:插件骨架 + kernelRunner(把 sdk.ToolAPI 收窄成三个方法,
  判据因此能用假实现驱动,不必构造整个内核)
· tools.go:六个工具定义(独立真相源,注册/判据/文档都从它取)
· handlers.go:seq_create/list/delete/run/call/when_call 的实现
· register.go + all.go:按 skillmgr 同一范式 init 注册

★ 过程中解决一个**我自己的设计矛盾**:
判据原先要求 `seq_call` / `seq_when_call` 进黑名单,但"按名调用
group/序列"恰恰是本包的核心能力——禁掉它,序列就退化成单层脚本。
分层澄清后:黑名单只管**对外发消息 / 起子 agent / 改插件表 / 再跑整条
序列**;seq_call 系列留给序列内部组合,其递归由 maxCallDepth + 环检测
负责(设计文档 §8.3 本来就是这么定的,我把两层混了)。
`seq_run` 留在黑名单:序列内再跑整条序列语义上是递归。

**六个工具一律不声明 ParallelSafe**:seq_run/seq_call 会执行一串工具,
其中可能含写操作;标成并发安全会让内核把两条 seq_run 并发跑,
两个序列的执行顺序交错、变量表互相污染。

安全性:序列名与文件路径都做穿越防护(`..` 段、分隔符、空名)。

过程中四次自伤:
1. 臆造 `jsonMarshalIndent`(不存在)→ 改 encoding/json.MarshalIndent;
   并把 execGroup 的 runner 传错成 p(应 p.runner)。
2. seq 判据里写了 `black(name)`,而 blacklisted 是**谓词**不是函数。
3. 一次 python 替换删漏,把「跨序列目标存在性检查」那段从 CheckNew
   里整段摘掉又贴回原处——靠编译错误发现。
4. ★ 注册失败我写了 panic:内置插件在 main() 装配期加载,panic 会
   **直接拖垮内核启动**,而"某个工具没注册上"只该让该工具不可用。
   已改为 log.Printf + 继续(与 clawhubadapter / mcp 一致)。

变异验证:把 seq_run 移出黑名单 ⇒ 黑名单判据 FAIL。

判据(plugin_test.go,6 条):六个工具全部注册且 description/参数 schema
非空;seq_create 声明 required 并说明 groups/file 二选一;
seq_run 说明"按数组顺序";六个工具均未声明 ParallelSafe;
黑名单含递归风险项且**不误伤** seq_call 与普通工具。

回归:seq -race 全绿;internal/sdk/... internal/plugins/... 12 包全绿。
This commit is contained in:
JianFeeeee
2026-09-27 13:05:28 +08:00
parent 3d753126a3
commit 71c894c182
8 changed files with 986 additions and 1 deletions

View File

@ -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
}