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
+113 -22
View File
@@ -73,6 +73,9 @@ type Task struct {
Error string `json:"error"`
Execution string `json:"execution"`
AttachmentCount int `json:"attachmentCount"`
// SessionID groups the turns of one multi-turn conversation; empty for a
// one-shot task, which is what 单独指派 sends.
SessionID string `json:"sessionID"`
}
// TaskEvent is one safe, persisted point in a task's execution timeline.
@@ -336,6 +339,13 @@ func (c *ControlPlane) queryAgents(where string) ([]Agent, error) {
return agents, rows.Err()
}
// CreateTaskSessionResult tells the UI which conversation a task joined.
type CreateTaskSessionResult struct {
TaskID string `json:"taskID"`
SessionID string `json:"sessionID"`
Turns int `json:"turns"`
}
// CreateTask persists and sends a text-only task to an authenticated Agent.
func (c *ControlPlane) CreateTask(agentID, instruction string, timeoutSeconds int) (string, error) {
return c.createTask(agentID, instruction, timeoutSeconds, 1, "")
@@ -347,7 +357,7 @@ func (c *ControlPlane) CreateTaskWithImages(agentID, instruction string, timeout
if err != nil {
return "", err
}
return c.createTaskWithImages(agentID, instruction, timeoutSeconds, 1, "", images)
return c.createTaskWithImages(agentID, instruction, timeoutSeconds, 1, "", images, "")
}
// CreateTasks dispatches independently-addressed tasks for the task board. This is
@@ -374,24 +384,24 @@ func (c *ControlPlane) CreateTasks(requests []TaskRequest) ([]TaskDispatchResult
}
func (c *ControlPlane) createTask(agentID, instruction string, timeoutSeconds, attempt int, retryOf string) (string, error) {
return c.createTaskWithImages(agentID, instruction, timeoutSeconds, attempt, retryOf, nil)
return c.createTaskWithImages(agentID, instruction, timeoutSeconds, attempt, retryOf, nil, "")
}
func (c *ControlPlane) createTaskWithImages(agentID, instruction string, timeoutSeconds, attempt int, retryOf string, images []taskImage) (string, error) {
func (c *ControlPlane) createTaskWithImages(agentID, instruction string, timeoutSeconds, attempt int, retryOf string, images []taskImage, sessionID string) (string, error) {
if err := validateSafeTaskText(instruction); err != nil {
return "", err
}
return c.createTaskWithInstructionsAndImages(agentID, instruction, instruction, timeoutSeconds, attempt, retryOf, images)
return c.createTaskWithInstructionsAndImages(agentID, instruction, instruction, timeoutSeconds, attempt, retryOf, images, sessionID)
}
// createTaskWithInstructions stores the concise user-visible instruction while
// optionally dispatching a bounded private context to the target Agent. The
// protocol and lifecycle stay exactly the same for both kinds of task.
func (c *ControlPlane) createTaskWithInstructions(agentID, instruction, dispatchInstruction string, timeoutSeconds, attempt int, retryOf string) (string, error) {
return c.createTaskWithInstructionsAndImages(agentID, instruction, dispatchInstruction, timeoutSeconds, attempt, retryOf, nil)
return c.createTaskWithInstructionsAndImages(agentID, instruction, dispatchInstruction, timeoutSeconds, attempt, retryOf, nil, "")
}
func (c *ControlPlane) createTaskWithInstructionsAndImages(agentID, instruction, dispatchInstruction string, timeoutSeconds, attempt int, retryOf string, images []taskImage) (string, error) {
func (c *ControlPlane) createTaskWithInstructionsAndImages(agentID, instruction, dispatchInstruction string, timeoutSeconds, attempt int, retryOf string, images []taskImage, sessionID string) (string, error) {
c.expireStaleAgents()
instruction = strings.TrimSpace(instruction)
dispatchInstruction = strings.TrimSpace(dispatchInstruction)
@@ -412,8 +422,8 @@ func (c *ControlPlane) createTaskWithInstructionsAndImages(agentID, instruction,
now := time.Now().UTC().Format(time.RFC3339Nano)
deadline := time.Now().UTC().Add(time.Duration(timeoutSeconds) * time.Second).Format(time.RFC3339Nano)
if _, err := c.db.Exec(
"INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, deadline_at, timeout_seconds, attempt, retry_of) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?)",
taskID, agentID, agentName, instruction, now, deadline, timeoutSeconds, attempt, retryOf,
"INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, deadline_at, timeout_seconds, attempt, retry_of, session_id) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)",
taskID, agentID, agentName, instruction, now, deadline, timeoutSeconds, attempt, retryOf, sessionID,
); err != nil {
return "", fmt.Errorf("无法保存任务: %w", err)
}
@@ -454,16 +464,38 @@ func (c *ControlPlane) dispatchQueuedTasks(agentID string) {
}
func (c *ControlPlane) sendQueuedTask(client *connection, taskID string) error {
var instruction, deadline string
var instruction, deadline, sessionID string
var attempt int
if err := c.db.QueryRow("SELECT instruction, deadline_at, attempt FROM task_runs WHERE task_id = ? AND status = 'queued'", taskID).Scan(&instruction, &deadline, &attempt); err != nil {
if err := c.db.QueryRow("SELECT instruction, deadline_at, attempt, COALESCE(session_id, '') FROM task_runs WHERE task_id = ? AND status = 'queued'", taskID).Scan(&instruction, &deadline, &attempt, &sessionID); err != nil {
return err
}
images, err := c.taskImages(taskID)
if err != nil {
return err
}
return client.send(map[string]any{"type": "task_dispatch", "schema_version": protocolVersion, "task_id": taskID, "instruction": instruction, "attachments": images, "sent_at": time.Now().UTC().Format(time.RFC3339Nano), "deadline_at": deadline, "attempt": attempt})
payload := map[string]any{"type": "task_dispatch", "schema_version": protocolVersion, "task_id": taskID, "instruction": instruction, "attachments": images, "sent_at": time.Now().UTC().Format(time.RFC3339Nano), "deadline_at": deadline, "attempt": attempt}
if sessionID != "" {
history, historyErr := c.sessionHistory(sessionID, c.sessionTurnLimit(sessionID))
if historyErr != nil {
return historyErr
}
payload["session_id"] = sessionID
if len(history) > 0 {
// A follow-up turn carries the earlier plain user/assistant text so the
// model keeps the thread without shipping tool transcripts.
payload["history"] = history
}
}
return client.send(payload)
}
// sessionTurnLimit reads the configured conversation length, defaulting safely.
func (c *ControlPlane) sessionTurnLimit(sessionID string) int {
var turnLimit int
if err := c.db.QueryRow("SELECT turn_limit FROM task_sessions WHERE session_id = ?", sessionID).Scan(&turnLimit); err != nil {
return defaultSessionTurnLimit
}
return normaliseTurnLimit(turnLimit)
}
// CancelTask asks the target Agent to stop an active task. The Agent owns final acknowledgement.
@@ -490,9 +522,9 @@ func (c *ControlPlane) CancelTask(taskID string) error {
// RetryTask creates a distinct retry attempt for a non-successful task without overwriting history.
func (c *ControlPlane) RetryTask(taskID string) (string, error) {
var agentID, instruction, status string
var agentID, instruction, status, sessionID string
var timeoutSeconds, attempt int
if err := c.db.QueryRow("SELECT agent_id, instruction, status, timeout_seconds, attempt FROM task_runs WHERE task_id = ?", taskID).Scan(&agentID, &instruction, &status, &timeoutSeconds, &attempt); err != nil {
if err := c.db.QueryRow("SELECT agent_id, instruction, status, timeout_seconds, attempt, COALESCE(session_id, '') FROM task_runs WHERE task_id = ?", taskID).Scan(&agentID, &instruction, &status, &timeoutSeconds, &attempt, &sessionID); err != nil {
return "", errors.New("未找到要重试的任务")
}
if status != "failed" && status != "cancelled" && status != "timed_out" {
@@ -502,7 +534,8 @@ func (c *ControlPlane) RetryTask(taskID string) (string, error) {
if imageErr != nil {
return "", imageErr
}
retryID, err := c.createTaskWithImages(agentID, instruction, timeoutSeconds, attempt+1, taskID, images)
// A retry repeats the same turn, so it stays in the same conversation.
retryID, err := c.createTaskWithImages(agentID, instruction, timeoutSeconds, attempt+1, taskID, images, sessionID)
if err == nil {
c.recordAudit("task.retry_created", "controller", "agent:"+agentID, "queued", "已创建失败任务的重试尝试。")
}
@@ -512,7 +545,7 @@ func (c *ControlPlane) RetryTask(taskID string) (string, error) {
// RecentTasks provides durable task summaries for the dashboard.
func (c *ControlPlane) RecentTasks() ([]Task, error) {
c.expireTasks()
rows, err := c.db.Query("SELECT r.task_id, r.agent_id, COALESCE(NULLIF(r.agent_name, ''), '未知 Agent'), r.instruction, r.status, r.created_at, COALESCE(r.started_at, ''), COALESCE(r.finished_at, ''), COALESCE(r.deadline_at, ''), COALESCE(r.attempt, 1), COALESCE(r.retry_of, ''), COALESCE(r.output, ''), COALESCE(r.error, ''), COALESCE(r.execution_source, ''), COUNT(i.position) FROM task_runs r LEFT JOIN task_images i ON i.task_id = r.task_id GROUP BY r.task_id ORDER BY r.created_at DESC LIMIT 24")
rows, err := c.db.Query("SELECT r.task_id, r.agent_id, COALESCE(NULLIF(r.agent_name, ''), '未知 Agent'), r.instruction, r.status, r.created_at, COALESCE(r.started_at, ''), COALESCE(r.finished_at, ''), COALESCE(r.deadline_at, ''), COALESCE(r.attempt, 1), COALESCE(r.retry_of, ''), COALESCE(r.output, ''), COALESCE(r.error, ''), COALESCE(r.execution_source, ''), COUNT(i.position), COALESCE(r.session_id, '') FROM task_runs r LEFT JOIN task_images i ON i.task_id = r.task_id GROUP BY r.task_id ORDER BY r.created_at DESC LIMIT 24")
if err != nil {
return nil, err
}
@@ -520,7 +553,7 @@ func (c *ControlPlane) RecentTasks() ([]Task, error) {
tasks := make([]Task, 0)
for rows.Next() {
var task Task
if err := rows.Scan(&task.ID, &task.AgentID, &task.AgentName, &task.Instruction, &task.Status, &task.CreatedAt, &task.StartedAt, &task.FinishedAt, &task.DeadlineAt, &task.Attempt, &task.RetryOf, &task.Output, &task.Error, &task.Execution, &task.AttachmentCount); err != nil {
if err := rows.Scan(&task.ID, &task.AgentID, &task.AgentName, &task.Instruction, &task.Status, &task.CreatedAt, &task.StartedAt, &task.FinishedAt, &task.DeadlineAt, &task.Attempt, &task.RetryOf, &task.Output, &task.Error, &task.Execution, &task.AttachmentCount, &task.SessionID); err != nil {
return nil, err
}
tasks = append(tasks, task)
@@ -532,8 +565,8 @@ func (c *ControlPlane) RecentTasks() ([]Task, error) {
func (c *ControlPlane) TaskDetails(taskID string) (TaskDetail, error) {
c.expireTasks()
var detail TaskDetail
err := c.db.QueryRow("SELECT r.task_id, r.agent_id, COALESCE(NULLIF(r.agent_name, ''), '未知 Agent'), r.instruction, r.status, r.created_at, COALESCE(r.started_at, ''), COALESCE(r.finished_at, ''), COALESCE(r.deadline_at, ''), COALESCE(r.attempt, 1), COALESCE(r.retry_of, ''), COALESCE(r.output, ''), COALESCE(r.error, ''), COALESCE(r.execution_source, ''), COUNT(i.position) FROM task_runs r LEFT JOIN task_images i ON i.task_id = r.task_id WHERE r.task_id = ? GROUP BY r.task_id", taskID).Scan(
&detail.Task.ID, &detail.Task.AgentID, &detail.Task.AgentName, &detail.Task.Instruction, &detail.Task.Status, &detail.Task.CreatedAt, &detail.Task.StartedAt, &detail.Task.FinishedAt, &detail.Task.DeadlineAt, &detail.Task.Attempt, &detail.Task.RetryOf, &detail.Task.Output, &detail.Task.Error, &detail.Task.Execution, &detail.Task.AttachmentCount,
err := c.db.QueryRow("SELECT r.task_id, r.agent_id, COALESCE(NULLIF(r.agent_name, ''), '未知 Agent'), r.instruction, r.status, r.created_at, COALESCE(r.started_at, ''), COALESCE(r.finished_at, ''), COALESCE(r.deadline_at, ''), COALESCE(r.attempt, 1), COALESCE(r.retry_of, ''), COALESCE(r.output, ''), COALESCE(r.error, ''), COALESCE(r.execution_source, ''), COUNT(i.position), COALESCE(r.session_id, '') FROM task_runs r LEFT JOIN task_images i ON i.task_id = r.task_id WHERE r.task_id = ? GROUP BY r.task_id", taskID).Scan(
&detail.Task.ID, &detail.Task.AgentID, &detail.Task.AgentName, &detail.Task.Instruction, &detail.Task.Status, &detail.Task.CreatedAt, &detail.Task.StartedAt, &detail.Task.FinishedAt, &detail.Task.DeadlineAt, &detail.Task.Attempt, &detail.Task.RetryOf, &detail.Task.Output, &detail.Task.Error, &detail.Task.Execution, &detail.Task.AttachmentCount, &detail.Task.SessionID,
)
if errors.Is(err, sql.ErrNoRows) {
return TaskDetail{}, errors.New("未找到任务")
@@ -712,7 +745,7 @@ func (c *ControlPlane) attach(client *connection, agentID string) {
func (c *ControlPlane) pushConfigurationSnapshots(agentID string) {
go c.pushLLMConfig(agentID)
go c.pushSkills(agentID)
go c.pushMCP(agentID)
go c.pushMCP(agentID, false)
}
func (c *ControlPlane) disconnect(client *connection) {
@@ -832,19 +865,23 @@ func (c *ControlPlane) expireStaleAgents() {
func (c *ControlPlane) applyTaskEvent(agentID string, event map[string]any) {
taskID, _ := event["task_id"].(string)
status, _ := event["status"].(string)
if status == "progress" {
c.applyTaskProgress(agentID, taskID, event)
return
}
if taskID == "" || !isTaskStatus(status) {
return
}
var expectedAgentID, currentStatus string
var expectedAgentID, currentStatus, sessionID, instruction string
var timeoutSeconds int
if c.db.QueryRow("SELECT agent_id, status, timeout_seconds FROM task_runs WHERE task_id = ?", taskID).Scan(&expectedAgentID, &currentStatus, &timeoutSeconds) != nil || expectedAgentID != agentID || isTerminal(currentStatus) {
if c.db.QueryRow("SELECT agent_id, status, timeout_seconds, COALESCE(session_id, ''), instruction FROM task_runs WHERE task_id = ?", taskID).Scan(&expectedAgentID, &currentStatus, &timeoutSeconds, &sessionID, &instruction) != nil || expectedAgentID != agentID || isTerminal(currentStatus) {
return
}
message, _ := event["message"].(string)
output, _ := event["output"].(string)
errText, _ := event["error"].(string)
execution, _ := event["execution"].(string)
if execution != "desktop-filesystem" && execution != "full-access" && execution != "llm" && execution != "demo" {
if execution != "desktop-filesystem" && execution != "full-access" && execution != "builtin" && execution != "mcp" && execution != "llm" && execution != "demo" {
execution = ""
}
message = sanitizeDisplayText(message)
@@ -864,6 +901,11 @@ func (c *ControlPlane) applyTaskEvent(agentID string, event map[string]any) {
message = "Agent 上报状态:" + status
}
c.recordEvent(taskID, status, limit(message, 1000), now)
// Only a successful turn joins the conversation, so a failed attempt cannot
// poison the context of the next follow-up instruction.
if status == "completed" && sessionID != "" {
c.appendSessionTurn(sessionID, instruction, output)
}
c.recordAudit("task.status_changed", "agent:"+agentID, "agent:"+agentID, status, "Agent 已上报任务状态变更。")
if execution == "desktop-filesystem" && isTerminal(status) {
detail := output
@@ -879,6 +921,46 @@ func (c *ControlPlane) applyTaskEvent(agentID string, event map[string]any) {
}
c.recordAudit("task.full_access_execution", "agent:"+agentID, "task:"+taskID+":full-access", status, detail)
}
if execution == "builtin" && isTerminal(status) {
detail := output
if detail == "" {
detail = errText
}
c.recordAudit("task.builtin_tool_execution", "agent:"+agentID, "task:"+taskID+":builtin", status, detail)
}
if execution == "mcp" && isTerminal(status) {
detail := output
if detail == "" {
detail = errText
}
c.recordAudit("mcp.tool_execution", "agent:"+agentID, "task:"+taskID+":mcp", status, detail)
}
}
// applyTaskProgress records one intermediate step of a running task without
// changing its lifecycle state. A running tool call is also real progress, so
// the no-progress deadline is renewed instead of expiring a busy task.
func (c *ControlPlane) applyTaskProgress(agentID, taskID string, event map[string]any) {
if taskID == "" {
return
}
var expectedAgentID, currentStatus string
var timeoutSeconds int
if c.db.QueryRow("SELECT agent_id, status, timeout_seconds FROM task_runs WHERE task_id = ?", taskID).Scan(&expectedAgentID, &currentStatus, &timeoutSeconds) != nil ||
expectedAgentID != agentID || isTerminal(currentStatus) {
return
}
message, _ := event["message"].(string)
message = limit(sanitizeDisplayText(message), 500)
if message == "" {
return
}
now := time.Now().UTC()
if isActiveTask(currentStatus) {
deadline := now.Add(time.Duration(timeoutSeconds) * time.Second).Format(time.RFC3339Nano)
_, _ = c.db.Exec("UPDATE task_runs SET deadline_at = ? WHERE task_id = ?", deadline, taskID)
}
c.recordEvent(taskID, "progress", message, now.Format(time.RFC3339Nano))
}
func (c *ControlPlane) expireTasks() {
@@ -978,7 +1060,10 @@ func initialiseSchema(db *sql.DB) error {
"CREATE TABLE IF NOT EXISTS skill_packages (skill_id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, content TEXT NOT NULL, checksum TEXT NOT NULL, updated_at TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS agent_skill_assignments (agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, apply_status TEXT NOT NULL, last_error TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, PRIMARY KEY(agent_id, skill_id))",
"CREATE TABLE IF NOT EXISTS agent_skill_selections (agent_id TEXT PRIMARY KEY, updated_at TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS mcp_tools (tool_id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL, endpoint TEXT NOT NULL DEFAULT '', command TEXT NOT NULL DEFAULT '', args TEXT NOT NULL DEFAULT '[]', version TEXT NOT NULL DEFAULT '1.0.0', updated_at TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS agent_mcp_assignments (agent_id TEXT NOT NULL, tool_id TEXT NOT NULL, deployment_version INTEGER NOT NULL DEFAULT 1, apply_status TEXT NOT NULL, last_error TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, PRIMARY KEY(agent_id, tool_id))",
"CREATE TABLE IF NOT EXISTS task_sessions (session_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, turn_limit INTEGER NOT NULL DEFAULT 50, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS task_session_messages (session_id TEXT NOT NULL, position INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(session_id, position))",
"CREATE TABLE IF NOT EXISTS audit_events (event_id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, actor TEXT NOT NULL, target TEXT NOT NULL, outcome TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL, operator TEXT NOT NULL DEFAULT '', target_agent_id TEXT NOT NULL DEFAULT '', resource_type TEXT NOT NULL DEFAULT 'system', result TEXT NOT NULL DEFAULT '', security_detail TEXT NOT NULL DEFAULT '')",
}
for _, statement := range statements {
@@ -994,8 +1079,11 @@ func initialiseSchema(db *sql.DB) error {
"ALTER TABLE task_runs ADD COLUMN retry_of TEXT",
"ALTER TABLE task_runs ADD COLUMN execution_source TEXT",
"ALTER TABLE task_runs ADD COLUMN agent_name TEXT NOT NULL DEFAULT ''",
"ALTER TABLE task_runs ADD COLUMN session_id TEXT NOT NULL DEFAULT ''",
"ALTER TABLE llm_profiles ADD COLUMN supports_images BOOLEAN NOT NULL DEFAULT FALSE",
"ALTER TABLE agent_mcp_assignments ADD COLUMN deployment_version INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE mcp_tools ADD COLUMN command TEXT NOT NULL DEFAULT ''",
"ALTER TABLE mcp_tools ADD COLUMN args TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE audit_events ADD COLUMN operator TEXT NOT NULL DEFAULT ''",
"ALTER TABLE audit_events ADD COLUMN target_agent_id TEXT NOT NULL DEFAULT ''",
"ALTER TABLE audit_events ADD COLUMN resource_type TEXT NOT NULL DEFAULT 'system'",
@@ -1011,6 +1099,9 @@ func initialiseSchema(db *sql.DB) error {
if _, err := db.Exec("UPDATE task_runs SET agent_name = COALESCE((SELECT name FROM managed_agents WHERE managed_agents.agent_id = task_runs.agent_id), '') WHERE COALESCE(agent_name, '') = ''"); err != nil {
return fmt.Errorf("无法补全任务 Agent 名称: %w", err)
}
if err := seedBuiltinMCPTools(db); err != nil {
return err
}
if err := backfillAuditEventFields(db); err != nil {
return err
}