feat(controller): 工具仓库、Skills 录入、多轮追问与终端运行日志

- 插件/MCP 改为通用工具仓库:内置、HTTP MCP、本机命令(stdio)三类工具,可增删改、
  按 Agent 分配;定义变更自动提升下发版本并重发,删除即回收分配并通知 Agent 清空。
- 旧 desktop-filesystem 降级为仓库中的一个内置条目;本机预设工具改为总控只展示、
  Agent 默认启用,任务执行来源新增 builtin(审计 task.builtin_tool_execution)。
- Skills 补齐录入闭环:新建或按名称覆盖更新,并可从旧版技能目录(SKILL.md + skill.json)
  批量导入;同名更新保持标识不变并自动重新应用,旧包入口脚本只提示不执行。
- 单独指派支持基于某条已完成任务的多轮追问:任务内容框仍下发无上下文的一次性任务,
  追问时由总控组装历史(仅成功轮次、默认最近 50 轮 / 约 6 万字符),会话按 Agent 隔离;
  执行动态把同一会话合并为一条记录,任务详情弹窗底部提供继续对话输入框。
- 任务详情支持运行中观测:打开期间每 2 秒刷新,终端风格输出全部事件并自动滚动;
  progress 事件不改变任务状态且顺延无进展超时,避免正在执行工具的任务被误判超时。
- 修复 build.ps1 缺 UTF-8 BOM 导致 build.bat 在 PowerShell 5.1 下语法报错。
This commit is contained in:
toot
2026-09-16 15:30:50 +08:00
parent 8a6c55d94a
commit aa57ea69de
19 changed files with 1977 additions and 154 deletions
@@ -97,7 +97,7 @@ func TestPairDispatchAndTaskEvent(t *testing.T) {
if statuses, err := controller.AgentLLMStatuses(); err != nil || len(statuses) != 1 || statuses[0].ApplyStatus != "applied" {
t.Fatalf("expected applied LLM deployment, got %#v, %v", statuses, err)
}
if err := controller.EnableDesktopFilesystem([]string{agentID}); err != nil {
if err := controller.SetAgentMCPTools(agentID, []string{"desktop-filesystem"}); err != nil {
t.Fatal(err)
}
var mcpSnapshot map[string]any
@@ -486,6 +486,60 @@ func TestTaskTextRejectsCredentialsAndInternalReasoning(t *testing.T) {
}
}
func TestTaskProgressIsRecordedWithoutChangingStateAndRenewsDeadline(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
now := time.Now().UTC()
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('agent-1', '进展测试机', 'test', 'online', ?)", now.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, deadline_at, timeout_seconds) VALUES ('tool-task', 'agent-1', '进展测试机', '长任务', 'running', ?, ?, 300)", now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "progress", "message": "调用 computer_time:{} api_key: do-not-show"})
var status, deadline string
if err := controller.db.QueryRow("SELECT status, deadline_at FROM task_runs WHERE task_id = 'tool-task'").Scan(&status, &deadline); err != nil || status != "running" {
t.Fatalf("progress must not change the task state: %q / %v", status, err)
}
parsed, err := time.Parse(time.RFC3339Nano, deadline)
if err != nil || parsed.Before(time.Now().UTC().Add(4*time.Minute)) {
t.Fatalf("progress did not renew the no-progress deadline: %q / %v", deadline, err)
}
detail, err := controller.TaskDetails("tool-task")
if err != nil {
t.Fatal(err)
}
progressEvents := 0
for _, event := range detail.Events {
if event.Type == "progress" {
progressEvents++
if strings.Contains(event.Message, "do-not-show") || !strings.Contains(event.Message, "[已脱敏]") {
t.Fatalf("progress text leaked a credential: %q", event.Message)
}
}
}
if progressEvents != 1 {
t.Fatalf("expected one recorded progress step, got %d in %#v", progressEvents, detail.Events)
}
// Progress for a task that already ended must be ignored entirely.
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "completed", "message": "完成"})
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "progress", "message": "迟到的进展"})
if detail, err = controller.TaskDetails("tool-task"); err != nil {
t.Fatal(err)
}
for _, event := range detail.Events {
if event.Message == "迟到的进展" {
t.Fatal("a finished task must not gain new progress steps")
}
}
}
func TestAgentProgressRenewsNoProgressDeadline(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))