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:
@@ -0,0 +1,200 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Multi-turn conversations keep a single Agent's follow-up work in context, so a
|
||||
// user can say "把它复制到某个目录" without repeating where the previous step put it.
|
||||
const (
|
||||
defaultSessionTurnLimit = 50
|
||||
maxSessionTurnLimit = 200
|
||||
// One turn is bounded, and the assembled history has its own budget: the
|
||||
// controller always sends plain user/assistant text, never tool transcripts.
|
||||
maxSessionTurnChars = 4000
|
||||
maxSessionHistory = 60000
|
||||
)
|
||||
|
||||
// createTaskSession starts an empty conversation with the default turn limit.
|
||||
func (c *ControlPlane) createTaskSession(agentID string) (string, error) {
|
||||
sessionID, err := randomHex(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := c.db.Exec(
|
||||
"INSERT INTO task_sessions(session_id, agent_id, turn_limit, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
sessionID, agentID, defaultSessionTurnLimit, now, now,
|
||||
); err != nil {
|
||||
return "", fmt.Errorf("无法创建会话: %w", err)
|
||||
}
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
func normaliseTurnLimit(turnLimit int) int {
|
||||
if turnLimit < 1 {
|
||||
return defaultSessionTurnLimit
|
||||
}
|
||||
if turnLimit > maxSessionTurnLimit {
|
||||
return maxSessionTurnLimit
|
||||
}
|
||||
return turnLimit
|
||||
}
|
||||
|
||||
func (c *ControlPlane) sessionTurnCount(sessionID string) (int, error) {
|
||||
var turns int
|
||||
if err := c.db.QueryRow("SELECT COUNT(*) FROM task_session_messages WHERE session_id = ? AND role = 'user'", sessionID).Scan(&turns); err != nil {
|
||||
return 0, fmt.Errorf("无法统计会话轮数: %w", err)
|
||||
}
|
||||
return turns, nil
|
||||
}
|
||||
|
||||
// ContinueTaskSession dispatches a follow-up turn based on one completed task.
|
||||
// The one-shot task that starts a thread becomes its first turn, so 单独指派 keeps
|
||||
// sending context-free tasks while only an explicit follow-up carries context.
|
||||
func (c *ControlPlane) ContinueTaskSession(taskID, instruction string, timeoutSeconds int, inputs []TaskImageInput) (CreateTaskSessionResult, error) {
|
||||
images, err := normalizeTaskImages(inputs)
|
||||
if err != nil {
|
||||
return CreateTaskSessionResult{}, err
|
||||
}
|
||||
var agentID, sessionID, previousInstruction, previousOutput, status string
|
||||
if err := c.db.QueryRow(
|
||||
"SELECT agent_id, COALESCE(session_id, ''), instruction, COALESCE(output, ''), status FROM task_runs WHERE task_id = ?", taskID,
|
||||
).Scan(&agentID, &sessionID, &previousInstruction, &previousOutput, &status); err != nil {
|
||||
return CreateTaskSessionResult{}, errors.New("未找到要继续的任务")
|
||||
}
|
||||
if status != "completed" {
|
||||
return CreateTaskSessionResult{}, errors.New("只有已完成的任务才能继续对话")
|
||||
}
|
||||
if sessionID == "" {
|
||||
created, createErr := c.createTaskSession(agentID)
|
||||
if createErr != nil {
|
||||
return CreateTaskSessionResult{}, createErr
|
||||
}
|
||||
sessionID = created
|
||||
c.appendSessionTurn(sessionID, previousInstruction, previousOutput)
|
||||
_, _ = c.db.Exec("UPDATE task_runs SET session_id = ? WHERE task_id = ?", sessionID, taskID)
|
||||
c.recordAudit("task.session_started", "controller", "agent:"+agentID, "completed", "已基于一条已完成任务开始多轮对话。")
|
||||
}
|
||||
followUpID, err := c.createTaskWithImages(agentID, instruction, timeoutSeconds, 1, "", images, sessionID)
|
||||
if err != nil {
|
||||
return CreateTaskSessionResult{}, err
|
||||
}
|
||||
turns, countErr := c.sessionTurnCount(sessionID)
|
||||
if countErr != nil {
|
||||
return CreateTaskSessionResult{TaskID: followUpID, SessionID: sessionID, Turns: 1}, nil
|
||||
}
|
||||
return CreateTaskSessionResult{TaskID: followUpID, SessionID: sessionID, Turns: turns + 1}, nil
|
||||
}
|
||||
|
||||
// TaskSessionTurn is one stored turn of a conversation: exactly the plain
|
||||
// question/answer text the Agent receives as context.
|
||||
type TaskSessionTurn struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Answer string `json:"answer"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// TaskSessionTurns returns the stored turns of one conversation, oldest first, so
|
||||
// the task detail dialog can show what the follow-up request will carry.
|
||||
func (c *ControlPlane) TaskSessionTurns(sessionID string) ([]TaskSessionTurn, error) {
|
||||
turns := make([]TaskSessionTurn, 0)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return turns, nil
|
||||
}
|
||||
rows, err := c.db.Query("SELECT role, content, created_at FROM task_session_messages WHERE session_id = ? ORDER BY position", sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取会话内容: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var role, content, createdAt string
|
||||
if err := rows.Scan(&role, &content, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case role == "user":
|
||||
turns = append(turns, TaskSessionTurn{Instruction: content, CreatedAt: createdAt})
|
||||
case len(turns) > 0 && turns[len(turns)-1].Answer == "":
|
||||
turns[len(turns)-1].Answer = content
|
||||
}
|
||||
}
|
||||
return turns, rows.Err()
|
||||
}
|
||||
|
||||
// sessionHistory returns the newest turns that fit the budget, oldest first, so a
|
||||
// follow-up request carries the context the model needs without unbounded growth.
|
||||
func (c *ControlPlane) sessionHistory(sessionID string, turnLimit int) ([]map[string]string, error) {
|
||||
rows, err := c.db.Query(
|
||||
"SELECT role, content FROM task_session_messages WHERE session_id = ? ORDER BY position DESC LIMIT ?",
|
||||
sessionID, normaliseTurnLimit(turnLimit)*2,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法读取会话历史: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
newestFirst := make([]map[string]string, 0)
|
||||
for rows.Next() {
|
||||
var role, content string
|
||||
if err := rows.Scan(&role, &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
newestFirst = append(newestFirst, map[string]string{"role": role, "content": content})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Keep the newest messages that fit the character budget.
|
||||
budget, kept := maxSessionHistory, make([]map[string]string, 0, len(newestFirst))
|
||||
for _, message := range newestFirst {
|
||||
budget -= len([]rune(message["content"]))
|
||||
if budget < 0 && len(kept) > 0 {
|
||||
break
|
||||
}
|
||||
kept = append(kept, message)
|
||||
}
|
||||
history := make([]map[string]string, 0, len(kept))
|
||||
for index := len(kept) - 1; index >= 0; index-- {
|
||||
history = append(history, kept[index])
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// appendSessionTurn records one completed turn and trims the oldest turns once the
|
||||
// conversation exceeds its limit. Failed or cancelled turns never enter history.
|
||||
func (c *ControlPlane) appendSessionTurn(sessionID, instruction, output string) {
|
||||
instruction, output = strings.TrimSpace(instruction), strings.TrimSpace(output)
|
||||
if instruction == "" || output == "" {
|
||||
return
|
||||
}
|
||||
var limitValue, messageCount int
|
||||
if err := c.db.QueryRow("SELECT turn_limit FROM task_sessions WHERE session_id = ?", sessionID).Scan(&limitValue); err != nil {
|
||||
return
|
||||
}
|
||||
if err := c.db.QueryRow("SELECT COUNT(*) FROM task_session_messages WHERE session_id = ?", sessionID).Scan(&messageCount); err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := c.db.Exec(
|
||||
"INSERT INTO task_session_messages(session_id, position, role, content, created_at) VALUES (?, ?, 'user', ?, ?), (?, ?, 'assistant', ?, ?)",
|
||||
sessionID, messageCount+1, limit(instruction, maxSessionTurnChars), now,
|
||||
sessionID, messageCount+2, limit(output, maxSessionTurnChars), now,
|
||||
); err != nil {
|
||||
return
|
||||
}
|
||||
// Drop the oldest turns beyond the configured limit.
|
||||
if excess := messageCount + 2 - normaliseTurnLimit(limitValue)*2; excess > 0 {
|
||||
_, _ = c.db.Exec(
|
||||
"DELETE FROM task_session_messages WHERE session_id = ? AND position <= ?",
|
||||
sessionID, excess,
|
||||
)
|
||||
}
|
||||
_, _ = c.db.Exec("UPDATE task_sessions SET updated_at = ? WHERE session_id = ?", now, sessionID)
|
||||
}
|
||||
Reference in New Issue
Block a user