Files
HomeAgent/internal/plugins/cmd/plugin.go
root b5f024b054 refactor(prompt): 提示词去工具化 + WebUI 插件状态按运行期真相展示
## 1. 系统提示词/人格瘦身(工具用法搬去 tooldef 与 skill)

用户指出:默认系统提示词与 personal 里有大量本该属于 skill / 工具描述的内容。
核对属实,且已造成**真实故障**:提示词教模型用 cmd_run,而 cmd 外置按需后
工具根本不在清单里 ⇒ 模型第一轮调用不存在的工具,拿到「工具 cmd_run 不存在」。

改动:
- kernelmod/agent.defaultSystemPrompt:删掉 output_* 用法、工具分类清单、
  cmd_run/terminal/files 策略、并行策略、媒体工具清单、emoji 风格要求。
  只留:身份 + 「工具描述与技能索引会注入,先读再用」+「只依据工具返回值作答」。
- config.DefaultPersonaPrompt:只留身份/称呼/语气 + 版本以运行时快照为准
  (后者由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住)。
- 命令与文件策略下沉到工具描述:cmd_run 补「完整 bash,支持管道/heredoc/重定向」,
  files_write 补「原子替换 + 覆盖受保护路径前留档」。
- 对话时序块自带语义说明(原先靠提示词解释,提示词一精简就丢语义)。
- 文档新增「提示词分层」章节(中英),写明归属判据与这次的反面教材。

## 2. WebUI:插件「启动状态」按运行期真相展示

旧实现只看内核 status 的 loaded 一个布尔,三种形态被混成同一个「未加载」:
auto_start=false 的按需待命(正常)、该起没起(异常)、plugin_stop 后的运行态停止。
用户看到一屏「未加载」以为插件全坏了。

- sdk.PluginRuntimeInfo 新增 AutoStart / RuntimeStopped;registry 从清单填充。
- pluginmgr /plugins 列表暴露 auto_start / runtime_stopped / builtin / tool_count。
- webui 新增 POST /plugins/<name>/start|stop(运行态,不动持久禁用清单)。
- dashboard 状态列改为五态:已禁用 / 进程丢失 / 已运行 / 按需待命 / 未运行,
  并额外标注「自启 / 按需」;按钮改为 启动/停止(运行态)与 禁用/启用(持久态)分列。
- 新增两条回归判据(引用 auto_start/runtime_stopped/alive 字段;startPlugin 走 /start)。

## 验收(本机实例实测)

- files 保持 auto_start=false(按需);问 agent 写文件 → 它先 plugin_start cmd+files,
  再 files_write/files_read,内容校验一致;未用 shell 代替文件操作。
- WebUI 插件页截图确认:a2a/acp/agentcli 显示「已运行 自启」,
  ai_image 显示「按需待命 按需」+「启动」按钮。
- go test(config/agent core/plugin/pluginmgr/webui/sdk)全绿。
2026-10-20 08:57:36 +08:00

307 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cmd
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "github.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: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。经完整 bash 执行,支持管道、分号、&&、命令替换、heredoc、重定向;不支持交互(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, ""
}