Files
sundynix-agentix/sundynix-gateway/internal/handler/platform_tools.go
T
Blizzard 348f1e0249 feat(jarvis): 能动的手(写文件/执行命令,三道闸) + 定时任务调度
此前 JARVIS 只能看不能动(本地工具纯只读)、也不会调度,补齐这两块。

【能动的手】local_write_file / local_exec,在用户自选工作目录内动手:
- 独立开关:只开只读访问不给这能力,须单独勾「允许写文件/执行命令」
- 原生确认框逐次审批:展示命令原文,默认按钮=拒绝,60s 无人应答按拒绝
  (防无人值守被静默批准);可选「本次会话都允许」,关开关即失效
- 硬黑名单:删库/提权/管道下载执行/写系统路径/装开机项/摸凭据等,
  用户点同意也不执行,连审批框都不弹。20 条危险命令 + 10 条正常命令单测
- 命令 cwd 锁沙箱根、60s 超时、输出 16KB 截断;非零退出不算失败(编译/测试
  错误对模型是有用信息)

【定时任务】sundynix_schedule + leader 锁 ticker(30s 扫) + 三个平台工具:
- 存自然语言指令而非编排图,到点走语音同一条关卡(preflightCore/launchCore)
  执行,跑完经语音事件主动播报结果
- 先推进 NextRunAt 再提交:提交失败也不会下轮重复捞起反复烧钱
- 停机期间错过的不补跑(补一堆历史提醒是骚扰),直接顺推到下一个未来时刻

【顺带修一个必崩的 bug】dispatcher 工具超时硬编码 3 秒,而审批要等人点
(60s)+执行(60s)——local_exec 100% 超时。改成工具在 list_tools 自报
timeout_sec(不在 dispatcher 硬编码工具名),超时链外松内紧:
dispatcher 160s > 网关 150s > runner 转发 140s > 桌面端 60+60s。

live 验证:①「写个 hello.sh 打印日期然后跑一下」→ 写+执行两步,文件真落磁盘
②「建个定时任务 35 秒后跑 wc -l」→ 到点自动触发 → 自主调 local_exec → 出结果

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 13:57:49 +08:00

403 lines
19 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 handler
import (
"context"
"encoding/json"
"fmt"
"log"
"sort"
"strconv"
"strings"
"time"
"github.com/sundynix/sundynix-gateway/internal/store"
"github.com/sundynix/sundynix-shared/contract"
)
// 平台工具族(JARVIS 大脑中枢,见 JARVIS_BRAIN_DESIGN.md §2.1):gateway 自己作为第三个工具
// 提供方(与 mcp-go/mcp-py 同协议)。平台操作的权威——提交关卡 preflightCore/launchCore、
// 归属校验、计费——都在 gateway,工具就长在权威所在地,而不是让 mcp-go 绕一圈回来调。
// dispatcher 经 list_tools 动态发现,加工具只改这里的注册表。
//
// 安全铁律:①一律 inject user_id + 服务端归属校验,模型不可指定别人的资源;
// ②会烧钱的提交(gen_report)必须走 preflightCore(预算/暂停/积分硬拦截)同一关卡。
// platParam 工具参数声明(与 mcp-go paramSpec 同构,list_tools JSON 契约一致)。
type platParam struct {
Name string `json:"name"`
Type string `json:"type"`
Desc string `json:"desc"`
Required bool `json:"required"`
}
// platTool 一个平台工具:元信息 + 处理函数(注册表 = 唯一事实源,dispatch 与 list_tools 共用)。
// timeoutSec:自报超时预算(秒,0=用 dispatcher 默认 3 秒)。本地执行类要等用户点确认框
// (人的反应时间)+ 真跑命令,必须自报更长预算,否则 3 秒必超时。
type platTool struct {
cn string
desc string
params []platParam
inject []string
timeoutSec int
handler func(context.Context, *contract.ToolCall) *contract.ToolResult
}
// platformRegistry 平台工具注册表。想加新工具(run_orchestration/search_kb/usage_today…)只改这里。
func (h *Handler) platformRegistry() map[string]platTool {
return map[string]platTool{
"platform_recent_tasks": {
cn: "最近任务", desc: "查询当前用户最近的任务运行列表(状态/主题/时间)。用户问“我最近的任务怎么样了/都有什么任务”时调用。",
inject: []string{"user_id"}, handler: h.platRecentTasks,
},
"platform_task_status": {
cn: "任务状态", desc: "查询某个任务的当前状态与输出摘要。用户问“那个任务/报告跑完了吗、结果是什么”时调用。",
params: []platParam{{Name: "task_id", Type: "string", Desc: "任务 ID", Required: true}},
inject: []string{"user_id"}, handler: h.platTaskStatus,
},
"platform_gen_report": {
cn: "生成报告", desc: "提交一个报告生成任务(异步跑,规划→分章→成稿)。用户要“写/生成一份 XX 报告”时调用。返回 task_id 后告诉用户已开跑、可稍后询问进度,不要原地等待结果。",
params: []platParam{
{Name: "topic", Type: "string", Desc: "报告主题", Required: true},
{Name: "kb", Type: "string", Desc: "参考知识库名(可选,用户提到才填)"},
},
inject: []string{"user_id", "tenant_id", "session_id"}, handler: h.platGenReport,
},
"platform_open_view": {
cn: "打开界面", desc: "把用户的客户端界面切到某个页面。用户说“打开/带我去/看看 运行页、报告页、知识库”等时调用。view 取值:home(工作台)/studio(编排)/kb(知识库)/runs(运行)/report(报告)/memory(记忆)/usage(用量)。",
params: []platParam{
{Name: "view", Type: "string", Desc: "目标页面:home/studio/kb/runs/report/memory/usage", Required: true},
{Name: "task_id", Type: "string", Desc: "可选:聚焦的任务 ID(配合 runs 页)"},
},
inject: []string{"user_id"}, handler: h.platOpenView,
},
// —— 本地的手(P4,只读起步):执行发生在用户自己的桌面(沙箱工作目录内),服务端只路由。
// 桌面端不在线/没开本地访问时工具会明确报不可用——如实转告用户即可。
"local_list_dir": {
cn: "看本地目录", desc: "列出用户本地工作目录(或其子目录)里的文件。用户问“我这个目录/文件夹里有什么”时调用。仅用户桌面端在线且开启了本地访问才可用。",
params: []platParam{{Name: "path", Type: "string", Desc: "相对工作目录的子路径,空=根目录"}},
inject: []string{"user_id"}, handler: h.platLocalExec,
},
"local_read_file": {
cn: "读本地文件", desc: "读取用户本地工作目录内某个文件的文本内容(大文件截断)。用户让“看看/读一下 某个本地文件”时调用。仅用户桌面端在线且开启了本地访问才可用。",
params: []platParam{{Name: "path", Type: "string", Desc: "相对工作目录的文件路径", Required: true}},
inject: []string{"user_id"}, handler: h.platLocalExec,
},
// —— 能动的手:写文件 / 执行命令。桌面端会弹原生确认框让用户逐次批准(默认拒绝),
// 且命中安全黑名单的命令直接拒绝。被拒时如实告诉用户,别重试绕路。
"local_write_file": {
cn: "写本地文件", desc: "在用户本地工作目录内写入/覆盖一个文本文件。用户明确要求“写个文件/保存到本地/生成脚本”时调用。用户会在桌面端收到确认框,需其批准才真正写入。",
params: []platParam{
{Name: "path", Type: "string", Desc: "相对工作目录的文件路径", Required: true},
{Name: "content", Type: "string", Desc: "完整文件内容(会覆盖原文件)", Required: true},
},
inject: []string{"user_id"}, timeoutSec: 100, handler: h.platLocalExec,
},
"local_exec": {
cn: "执行本地命令", desc: "在用户本地工作目录里执行一条 shell 命令并返回输出(超时 60 秒)。用户要求“跑一下/执行/编译/查一下某个命令结果”时调用。用户会在桌面端收到确认框,需其批准才执行;删库、提权、写系统路径等危险命令会被直接拒绝。一次只提交一条命令,别猜着连环执行。",
params: []platParam{{Name: "command", Type: "string", Desc: "要执行的 shell 命令,如 ls -la、git status、npm test", Required: true}},
inject: []string{"user_id"}, timeoutSec: 160, handler: h.platLocalExec,
},
// —— 调度:让用户能说「每天早上九点帮我看看昨天的任务」。到点由 JARVIS 自己按指令办事并播报。
"platform_schedule_create": {
cn: "建定时任务", desc: "创建一个定时/周期执行的任务。用户说“每天/每小时/几点钟 帮我做某事”“过 N 分钟提醒我”时调用。prompt 写清到点要做什么(就像用户当面对你说的那句话)。到点会自动执行并主动播报结果给用户。",
params: []platParam{
{Name: "title", Type: "string", Desc: "任务名,如「每日任务巡检」", Required: true},
{Name: "prompt", Type: "string", Desc: "到点要执行的指令原话,如「看看我昨天的任务都什么状态,有失败的告诉我」", Required: true},
{Name: "first_delay_sec", Type: "integer", Desc: "距首次执行的秒数。如「10 分钟后」填 600;如「明早9点」自己按当前时间算出秒数", Required: true},
{Name: "interval_sec", Type: "integer", Desc: "重复周期秒数(每天=86400,每小时=3600);只跑一次填 0"},
},
inject: []string{"user_id", "tenant_id", "session_id"}, handler: h.platScheduleCreate,
},
"platform_schedule_list": {
cn: "看定时任务", desc: "列出当前用户的定时任务(含下次执行时间、是否启用)。用户问“我有哪些定时任务/提醒”时调用。",
inject: []string{"user_id"}, handler: h.platScheduleList,
},
"platform_schedule_cancel": {
cn: "取消定时任务", desc: "停用一条定时任务。用户说“取消/停掉那个定时任务”时调用;不知道 id 就先用 platform_schedule_list 查。",
params: []platParam{{Name: "id", Type: "string", Desc: "定时任务 ID", Required: true}},
inject: []string{"user_id"}, handler: h.platScheduleCancel,
},
}
}
// platScheduleCreate 建定时任务。首次执行时间由模型按"多少秒后"给出(它拿得到当前时间工具)。
func (h *Handler) platScheduleCreate(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
title, _ := call.Args["title"].(string)
prompt, _ := call.Args["prompt"].(string)
if strings.TrimSpace(title) == "" || strings.TrimSpace(prompt) == "" {
return &contract.ToolResult{OK: false, Error: "缺少任务名或执行指令"}
}
firstDelay := argInt(call.Args["first_delay_sec"])
interval := argInt(call.Args["interval_sec"])
if firstDelay < 10 {
firstDelay = 10 // 兜底:别让模型算出个立刻/过去的时刻
}
if interval > 0 && interval < 60 {
interval = 60 // 最短周期 1 分钟,防刷爆
}
tid, _ := call.Args["tenant_id"].(string)
sid, _ := call.Args["session_id"].(string)
s := &store.Schedule{
Owner: uid, TenantID: tid, SessionID: sid,
Title: title, Prompt: prompt,
IntervalSec: int64(interval), Enabled: true,
NextRunAt: time.Now().Add(time.Duration(firstDelay) * time.Second),
}
if err := h.db.CreateSchedule(ctx, s); err != nil {
return &contract.ToolResult{OK: false, Error: "创建定时任务失败: " + err.Error()}
}
every := "只执行一次"
if interval > 0 {
every = fmt.Sprintf("每 %d 分钟重复", interval/60)
}
data, _ := json.Marshal(map[string]string{
"id": s.ID, "title": title, "next_run": s.NextRunAt.Format("01-02 15:04"), "repeat": every,
"msg": "已建好定时任务。告诉用户首次执行时间即可,到点会自动执行并主动播报结果。",
})
return &contract.ToolResult{OK: true, Content: string(data)}
}
func (h *Handler) platScheduleList(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
rows := h.db.ListSchedules(ctx, uid)
if len(rows) == 0 {
return &contract.ToolResult{OK: true, Content: "当前没有任何定时任务。"}
}
type item struct {
ID string `json:"id"`
Title string `json:"title"`
Next string `json:"next_run,omitempty"`
Repeat string `json:"repeat"`
Enabled bool `json:"enabled"`
Runs int64 `json:"run_count"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
rep := "一次性"
if r.IntervalSec > 0 {
rep = fmt.Sprintf("每 %d 分钟", r.IntervalSec/60)
}
it := item{ID: r.ID, Title: r.Title, Repeat: rep, Enabled: r.Enabled, Runs: r.RunCount}
if r.Enabled {
it.Next = r.NextRunAt.Format("01-02 15:04")
}
out = append(out, it)
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
func (h *Handler) platScheduleCancel(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
id, _ := call.Args["id"].(string)
if uid == "" || strings.TrimSpace(id) == "" {
return &contract.ToolResult{OK: false, Error: "缺少定时任务 ID"}
}
// CancelSchedule 的 WHERE 带 owner,天然拦住改别人的任务。
if err := h.db.CancelSchedule(ctx, uid, id); err != nil {
return &contract.ToolResult{OK: false, Error: "取消失败: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: "已停用该定时任务(若 ID 不属于当前用户则无事发生)。"}
}
// argInt 把模型给的数字参数(JSON 里可能是 float64/string)转成 int。
func argInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case string:
i, _ := strconv.Atoi(strings.TrimSpace(n))
return i
}
return 0
}
// platLocalExec 把 local_* 调用经 NATS 路由到该用户的桌面 runner(见 local_runner.go)。
// 无 runner 在线时 NATS 无应答 → 明确报"不在线",绝不挂起任务。
func (h *Handler) platLocalExec(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
// 超时链必须外松内紧:dispatcher(160s) > 这里(150s) > runner 转发(140s) > 桌面端审批60s+执行60s。
// 任一层比内层短,用户还在看确认框就被判超时。
cctx, cancel := context.WithTimeout(ctx, 150*time.Second)
defer cancel()
res, err := h.bus.CallTool(cctx, contract.LocalExecSubject(uid), call)
if err != nil {
return &contract.ToolResult{OK: true, Content: "本地执行器不在线:用户桌面端未运行或未开启本地文件访问。请如实告知用户。"}
}
return res
}
// ServePlatformTools 以队列组订阅 sundynix.tools.platform.>,返回 drain 供优雅停机。
func (h *Handler) ServePlatformTools() (func(context.Context), error) {
reg := h.platformRegistry()
names := make([]string, 0, len(reg))
for n := range reg {
names = append(names, n)
}
sort.Strings(names)
drain, err := h.bus.ServeTool(contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform,
func(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
return h.platformDispatch(reg, ctx, call)
})
if err != nil {
return nil, err
}
log.Printf("[platform] 平台工具就绪 %s (queue=%s): %s",
contract.SubjectToolsPlatformAll, contract.QueueToolsPlatform, strings.Join(names, ", "))
return drain, nil
}
func (h *Handler) platformDispatch(reg map[string]platTool, ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
log.Printf("[platform] tool=%s task=%s", call.Tool, call.TaskID)
if call.Tool == "list_tools" {
return platListTools(reg)
}
td, ok := reg[call.Tool]
if !ok {
return &contract.ToolResult{OK: false, Error: "unknown platform tool: " + call.Tool}
}
return td.handler(ctx, call)
}
// platListTools 自省:JSON 契约与 mcp-go listTools 一致(dispatcher toolCatalogEntry 同一解析)。
func platListTools(reg map[string]platTool) *contract.ToolResult {
type info struct {
Name string `json:"name"`
CN string `json:"cn"`
Desc string `json:"desc"`
Agent bool `json:"agent_exposed"`
Params []platParam `json:"params,omitempty"`
Inject []string `json:"inject,omitempty"`
Timeout int `json:"timeout_sec,omitempty"`
}
out := make([]info, 0, len(reg))
for name, td := range reg {
out = append(out, info{
Name: name, CN: td.cn, Desc: td.desc, Agent: true,
Params: td.params, Inject: td.inject, Timeout: td.timeoutSec,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
data, _ := json.Marshal(map[string]any{"service": "gateway-platform", "tools": out})
return &contract.ToolResult{OK: true, Content: string(data)}
}
// ---- 工具实现 ----
// platRecentTasks 最近任务列表(owner 隔离:只看自己的)。
func (h *Handler) platRecentTasks(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
if uid == "" {
return &contract.ToolResult{OK: false, Error: "缺少用户身份"}
}
rows := h.db.RecentRuns(ctx, uid, 10)
type item struct {
TaskID string `json:"task_id"`
Status string `json:"status"`
Topic string `json:"topic,omitempty"`
At string `json:"at"`
}
out := make([]item, 0, len(rows))
for _, r := range rows {
out = append(out, item{TaskID: r.TaskID, Status: r.Status, Topic: r.Topic, At: r.At.Format("01-02 15:04")})
}
if len(out) == 0 {
return &contract.ToolResult{OK: true, Content: "没有任何任务运行记录。"}
}
data, _ := json.Marshal(out)
return &contract.ToolResult{OK: true, Content: string(data)}
}
// platTaskStatus 单任务状态 + 输出摘要。归属校验:不是本人的任务一律说不存在(不泄露存在性)。
func (h *Handler) platTaskStatus(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
taskID, _ := call.Args["task_id"].(string)
if uid == "" || strings.TrimSpace(taskID) == "" {
return &contract.ToolResult{OK: false, Error: "缺少 task_id"}
}
owner := h.db.TaskOwner(ctx, taskID)
if owner == "" || owner != uid {
return &contract.ToolResult{OK: true, Content: "没有找到这个任务(ID 不存在或不属于当前用户)。"}
}
status, detail := h.db.GetTaskStatus(ctx, taskID)
output, _ := h.db.GetRunDetail(ctx, taskID)
if rs := []rune(output); len(rs) > 600 {
output = string(rs[:600]) + "…(已截断)"
}
data, _ := json.Marshal(map[string]string{
"task_id": taskID, "status": status, "detail": detail, "output_preview": output,
})
return &contract.ToolResult{OK: true, Content: string(data)}
}
// platOpenView 界面动作(P2 动作通道):经语音事件通道把 navigate 发到该用户的语音会话,
// 由客户端按白名单执行。视图合法性双重校验(此处 + 会话 onVoiceEvent + 客户端),宁可不动。
func (h *Handler) platOpenView(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
view, _ := call.Args["view"].(string)
taskID, _ := call.Args["task_id"].(string)
if uid == "" || view == "" {
return &contract.ToolResult{OK: false, Error: "缺少 view"}
}
if !navigateViews[view] {
return &contract.ToolResult{OK: false, Error: "未知页面: " + view + "(可用 home/studio/kb/runs/report/memory/usage"}
}
if err := h.bus.PublishVoiceEvent(uid, &contract.VoiceEvent{Action: "navigate", View: view, TaskID: taskID}); err != nil {
return &contract.ToolResult{OK: false, Error: "下发界面动作失败: " + err.Error()}
}
return &contract.ToolResult{OK: true, Content: "已让客户端切到 " + view + " 页。告诉用户已打开即可。"}
}
// platGenReport 提交报告任务——与 HTTP GenerateReport 同一条关卡与发射流程
// preflightCore:预算/暂停/计费租户/积分硬拦截;launchCore:落库+录像+Publish)。
func (h *Handler) platGenReport(ctx context.Context, call *contract.ToolCall) *contract.ToolResult {
uid, _ := call.Args["user_id"].(string)
topic, _ := call.Args["topic"].(string)
kb, _ := call.Args["kb"].(string)
tid, _ := call.Args["tenant_id"].(string) // 发起任务的活跃租户(计费口径跟 HTTP 提交一致)
sid, _ := call.Args["session_id"].(string) // 挂回发起会话(语音会话可续聊“报告好了吗”)
topic = strings.TrimSpace(topic)
if uid == "" || topic == "" {
return &contract.ToolResult{OK: false, Error: "缺少报告主题"}
}
billingTenant, block := h.preflightCore(ctx, uid, tid)
if block != nil {
return &contract.ToolResult{OK: true, Content: "无法提交:" + block.message()}
}
id := newReportID()
graph, _ := json.Marshal(map[string]any{"topic": topic})
task := &contract.Task{
ID: id,
Graph: graph,
Meta: map[string]any{
contract.MetaIntent: contract.IntentReport,
contract.MetaTopic: topic,
contract.MetaKB: kb,
contract.MetaUserID: uid,
contract.MetaTenantID: billingTenant,
contract.MetaSessionID: sid,
},
}
if err := h.launchCore(ctx, uid, task); err != nil {
return &contract.ToolResult{OK: false, Error: "报告任务提交失败: " + err.Error()}
}
data, _ := json.Marshal(map[string]string{
"task_id": id, "msg": "报告任务已提交开跑,请告知用户任务号并说明完成后可询问进度。",
})
return &contract.ToolResult{OK: true, Content: string(data)}
}