mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
把"能不能并发"从内核硬编码名单改成**工具自己的声明项**,形态照 SDK 的
NoMemory 走。
## ★ 起因:提示词在跟内核不一致
阶段 2.5 写进提示词的「内核默认并行执行」当时是**假的**:toolParallelSafe
只查 stageHost 与 io 两个来源,而全仓 ParallelSafe:true 的生产代码数量
是 **0**。于是除碰巧只发一个工具外,每一批都整批串行回退,而提示词正教
模型把多个查询放同一轮。**内核行为与提示词不一致 = 对模型说谎。**
并发面:0 → 37 个工具(18 插件 ParallelSafe + 19 插件 Serial + 9 内置只读)。
## 声明形态(照 SDK,不自创)
### 插件:结构体字段
s.RegisterTool("config_get", sdk.ToolDef{
Name: ..., Description: ...,
Parameters: map[string]interface{}{...},
// 已核实只读:…
ParallelSafe: true, ← 插在 Parameters 之后、handler 之前
}, p.handleGet(s))
位置与 SDK 的 NoMemory/ContextPolicy/RecallPolicy 一致:Name 在首位,
声明项在末尾,不打散 gofmt 对齐。
### 新增 SDK 声明项:ToolDef.Serial
ParallelSafe 的**反向**标记,判据优先级高于 ParallelSafe。
为什么需要:ParallelSafe 零值 false 已表达"安全",插件无法区分"我没想过"
与"我确认过必须串行"。没有这个区分,工具作者只能靠命名约定传递意图。
内核已消费它(io.ToolDef 同步加字段对齐),并有判据守"Serial 胜出"。
### 内置工具:toolDef 的 toolParallel 选项
内置工具以裸 schema map 下发,没有 ToolDef 结构,所以用变参选项:
toolDef(名字, 描述, 属性) // 默认串行
toolDef(名字, 描述, 属性, "toolParallel") // 已核实只读,可并发
读工具表的老调用点一行不用动,声明就写在工具定义那一行。
## ★ 走过的弯路(都留了判据)
1. **硬编码白名单**:先在 toolParallelSafe 里查一张
builtinParallelSafeTools map。那把声明从"工具自己"搬回了内核 ——
工具改名/新增不会自动跟着变,得靠一条 grep 源码的判据才能发现漂移,
而判据一改就忘。已删,改为从定义读。
2. **判据前提错(同一个坑踩了两次)**:拿裸 &Agent{} 的 buildToolDefs 输出
当"实际可见工具",但这 9 个内置工具全在条件分支里(a.knowledge != nil /
a.social != nil / a.parentID != ""…),裸 Agent 一个都不产出 ⇒ 全部误报
"声明形同虚设"。第一次叫它"幽灵条目",没认出是同一个坑。
3. **注释模仿真实签名污染判据**:toolParallel 的用法注释写着
`toolDef("knowledge_search", ...)`,判据按文本匹配先撞上注释。
4. **buildToolDefs 的 nil 不一致**:开头判了 a.io != nil,末尾却无条件
a.io.ListChannels()。任何无 IO 的 Agent 调它都 panic —— 而 panic 报在
io 包里,根因在 tooldefs.go。已补。
5. **插入脚本用正则找"最后一个顶层字段"**:被嵌套 map 里的同形文本骗到,
823 处错误重排把文件改坏。改用括号深度 + 记录进入深度 3 的行号
(空 properties 会让深度在同一行进出平衡,只判 depth==2 不够)。
工具在 SDK 仓 tools/annotate_parallel/,复用时用绝对路径。
## 提示词措辞同步修正
「默认并行执行」→「尽量并发执行,但这是**逐工具判断**的」,并教模型
**把查询类放同一轮、写操作单独发一轮**(写和查混在一批,整批都串行)。
## 判据
- TestSerialOverridesParallelSafe Serial 优先于 ParallelSafe
- TestToolParallelDeclarationsAudit 并发面不许再归零
- TestNoToolDeclaresBothParallelAndSerial 两者同标即谎话
- TestBuiltinParallelDeclaredWhereDefined 声明写在定义处、且内核真读到
- TestStoreListIgnoresForeignJSON 压测抓到的 List() 缺陷
307 lines
8.2 KiB
Go
307 lines
8.2 KiB
Go
package cmd
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// isWindows 缓存运行时检测结果
|
||
var isWindows = runtime.GOOS == "windows"
|
||
|
||
// shellUnquote 拆解命令字符串,处理单引号/双引号包裹的参数
|
||
func shellUnquote(s string) []string {
|
||
var args []string
|
||
var cur strings.Builder
|
||
inSingle := false
|
||
inDouble := false
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
switch {
|
||
case c == '\'' && !inDouble:
|
||
inSingle = !inSingle
|
||
case c == '"' && !inSingle:
|
||
inDouble = !inDouble
|
||
case (c == ' ' || c == '\t') && !inSingle && !inDouble:
|
||
if cur.Len() > 0 {
|
||
args = append(args, cur.String())
|
||
cur.Reset()
|
||
}
|
||
default:
|
||
cur.WriteByte(c)
|
||
}
|
||
}
|
||
if cur.Len() > 0 {
|
||
args = append(args, cur.String())
|
||
}
|
||
return args
|
||
}
|
||
|
||
func init() {
|
||
plugin.RegisterPluginMeta("cmd", "命令执行", "Command")
|
||
plugin.RegisterFactory("cmd", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return New(name), nil
|
||
})
|
||
}
|
||
|
||
type cmdRecord struct {
|
||
Timestamp time.Time `json:"timestamp"`
|
||
Command string `json:"command"`
|
||
Stdout string `json:"stdout"`
|
||
Stderr string `json:"stderr"`
|
||
ExitCode int `json:"exit_code"`
|
||
Workdir string `json:"workdir"`
|
||
Timeout string `json:"timeout"`
|
||
Duration string `json:"duration"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
const maxHistory = 100
|
||
|
||
type Plugin struct {
|
||
name string
|
||
defaultTimeout string
|
||
maxOutput int
|
||
mu sync.Mutex
|
||
history []cmdRecord
|
||
}
|
||
|
||
func New(name string) *Plugin {
|
||
return &Plugin{name: name}
|
||
}
|
||
|
||
func (p *Plugin) Name() string { return p.name }
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
s.SetAutoRestart(true)
|
||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||
Key: "default_timeout", Type: "string", DisplayName: "默认命令超时",
|
||
Description: "命令执行的默认超时时间,例如 30s, 1m, 5m(默认 30s)",
|
||
Default: "30s",
|
||
})
|
||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||
Key: "max_output_bytes", Type: "int", DisplayName: "最大输出字节数",
|
||
Description: "命令输出的最大字节数,超出部分将被截断(默认 32000)",
|
||
Default: "32000",
|
||
})
|
||
p.maxOutput = 32000
|
||
if v, _ := s.Settings().Get("max_output_bytes"); v != nil {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
if n, err := fmt.Sscanf(s, "%d", &p.maxOutput); err == nil && n > 0 {
|
||
}
|
||
}
|
||
}
|
||
p.defaultTimeout = "30s"
|
||
if v, _ := s.Settings().Get("default_timeout"); v != nil {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
p.defaultTimeout = s
|
||
}
|
||
}
|
||
|
||
s.RegisterTool("cmd_run", sdk.ToolDef{
|
||
Name: "cmd_run",
|
||
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。",
|
||
NoMemory: true,
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"command": map[string]interface{}{
|
||
"type": "string",
|
||
"description": "要执行的命令",
|
||
},
|
||
"timeout": map[string]interface{}{
|
||
"type": "string",
|
||
"description": "超时时间,例如 10s, 1m, 30s(默认 30s)",
|
||
},
|
||
"workdir": map[string]interface{}{
|
||
"type": "string",
|
||
"description": "工作目录(可选,默认由 core.agent.workdir 配置决定)",
|
||
},
|
||
},
|
||
"required": []string{"command"},
|
||
},
|
||
// 执行体只依赖入参;唯一共享 p.history 由 recordCmd 加 p.mu 保护
|
||
ParallelSafe: true,
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
command, _ := args["command"].(string)
|
||
if command == "" {
|
||
return map[string]interface{}{"error": "command is required"}, nil
|
||
}
|
||
|
||
timeoutStr, _ := args["timeout"].(string)
|
||
if timeoutStr == "" {
|
||
timeoutStr = p.defaultTimeout
|
||
}
|
||
timeout, err := time.ParseDuration(timeoutStr)
|
||
if err != nil {
|
||
return map[string]interface{}{"error": fmt.Sprintf("invalid timeout %q: %v", timeoutStr, err)}, nil
|
||
}
|
||
|
||
workdir, _ := args["workdir"].(string)
|
||
if workdir == "" {
|
||
if sett := s.Settings(); sett != nil {
|
||
if v, _ := sett.GetCore("core.agent.workdir"); v != nil {
|
||
if str, ok := v.(string); ok && str != "" {
|
||
workdir = str
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
tStart := time.Now()
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
defer cancel()
|
||
|
||
// Linux/Unix 经 /bin/sh -c 执行完整 shell 语法:管道、分号、&&、
|
||
// 命令替换、heredoc、重定向全部可用。旧实现 shellUnquote 拆词后
|
||
// 直接 exec,导致 pwd; ls / 变成执行名为 "pwd;" 的程序(exit -1)、
|
||
// heredoc 被截断——agent 多次反馈"命令解析奇怪"即此。
|
||
var cmd *exec.Cmd
|
||
if isWindows {
|
||
execCmd := "chcp 65001>nul & " + command
|
||
cmd = exec.CommandContext(ctx, "cmd.exe", "/d", "/c", execCmd)
|
||
} else {
|
||
shell := "/bin/sh"
|
||
if _, err := os.Stat("/bin/bash"); err == nil {
|
||
shell = "/bin/bash" // bash 支持更完整的语法(数组、[[ ]] 等)
|
||
}
|
||
cmd = exec.CommandContext(ctx, shell, "-c", command)
|
||
}
|
||
if workdir != "" {
|
||
cmd.Dir = workdir
|
||
}
|
||
|
||
var stdout, stderr bytes.Buffer
|
||
cmd.Stdout = &stdout
|
||
cmd.Stderr = &stderr
|
||
|
||
rec := cmdRecord{Timestamp: tStart, Command: command, Workdir: workdir, Timeout: timeoutStr}
|
||
exitCode := -1
|
||
|
||
if err := cmd.Run(); err != nil {
|
||
if ctx.Err() != nil {
|
||
rec.Status = "timeout"
|
||
rec.Stdout = p.truncateOutput(stdout.String())
|
||
rec.Stderr = p.truncateOutput(stderr.String())
|
||
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
|
||
p.recordCmd(rec)
|
||
return map[string]interface{}{
|
||
"status": "timeout",
|
||
"stdout": rec.Stdout,
|
||
"stderr": rec.Stderr,
|
||
"error": fmt.Sprintf("命令执行超时(%s)", timeoutStr),
|
||
}, nil
|
||
}
|
||
}
|
||
if cmd.ProcessState != nil {
|
||
exitCode = cmd.ProcessState.ExitCode()
|
||
}
|
||
|
||
rec.Status = "ok"
|
||
rec.Stdout = p.truncateOutput(stdout.String())
|
||
rec.Stderr = p.truncateOutput(stderr.String())
|
||
rec.ExitCode = exitCode
|
||
rec.Duration = time.Since(tStart).Round(time.Millisecond).String()
|
||
p.recordCmd(rec)
|
||
|
||
return map[string]interface{}{
|
||
"status": "ok",
|
||
"stdout": rec.Stdout,
|
||
"stderr": rec.Stderr,
|
||
"exit_code": exitCode,
|
||
"command": command,
|
||
}, nil
|
||
})
|
||
|
||
s.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
|
||
if len(ctx.ToolCalls) == 0 {
|
||
return nil
|
||
}
|
||
tc := ctx.ToolCalls[0]
|
||
if tc.Name != "cmd_run" {
|
||
return nil
|
||
}
|
||
command, _ := tc.Arguments["command"].(string)
|
||
if command == "" {
|
||
return nil
|
||
}
|
||
if blocked, reason := isDangerousCommand(command); blocked {
|
||
ctx.Lock()
|
||
ctx.Response = &reason
|
||
ctx.Unlock()
|
||
return nil
|
||
}
|
||
return nil
|
||
}, sdk.StageScopeOwnTools)
|
||
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) Stop() error {
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) recordCmd(r cmdRecord) {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
p.history = append(p.history, r)
|
||
if len(p.history) > maxHistory {
|
||
p.history = p.history[len(p.history)-maxHistory:]
|
||
}
|
||
}
|
||
|
||
func (p *Plugin) truncateOutput(s string) string {
|
||
maxLen := p.maxOutput
|
||
if maxLen <= 0 {
|
||
maxLen = 32000
|
||
}
|
||
if len(s) > maxLen {
|
||
return s[:maxLen] + fmt.Sprintf("\n... [输出被截断,共 %d 字节]", len(s))
|
||
}
|
||
return strings.TrimRight(s, "\n")
|
||
}
|
||
|
||
func isDangerousCommand(command string) (bool, string) {
|
||
pid := os.Getpid()
|
||
pidStr := fmt.Sprintf("%d", pid)
|
||
lower := strings.ToLower(command)
|
||
|
||
selfTargets := []string{"homed", pidStr}
|
||
killCmds := []string{"kill ", "killall ", "pkill ", "kill -", "kill -9"}
|
||
serviceCmds := []string{
|
||
"systemctl stop homeagent",
|
||
"systemctl restart homeagent",
|
||
"systemctl kill homeagent",
|
||
"service homeagent stop",
|
||
"service homeagent restart",
|
||
}
|
||
|
||
for _, kp := range killCmds {
|
||
if !strings.Contains(lower, kp) {
|
||
continue
|
||
}
|
||
for _, t := range selfTargets {
|
||
if strings.Contains(lower, strings.ToLower(t)) {
|
||
return true, fmt.Sprintf("命令被拦截:禁止向 homed 进程(pid=%s)发送信号", pidStr)
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, sc := range serviceCmds {
|
||
if strings.Contains(lower, sc) {
|
||
return true, fmt.Sprintf("命令被拦截:禁止操作 homed 服务进程(pid=%s)", pidStr)
|
||
}
|
||
}
|
||
|
||
return false, ""
|
||
}
|