Files
mulit-agent/controller/internal/controlplane/agent_directory.go
T
toot aa57ea69de 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 下语法报错。
2026-09-16 15:30:50 +08:00

262 lines
12 KiB
Go

package controlplane
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// AgentDirectoryFilter is a bounded, server-side filter for the Agent directory.
// Empty values mean "all"; it deliberately has no free-form SQL fields.
type AgentDirectoryFilter struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Name string `json:"name"`
ConnectionStatus string `json:"connectionStatus"`
MCPStatus string `json:"mcpStatus"`
LLMStatus string `json:"llmStatus"`
SkillStatus string `json:"skillStatus"`
}
// AgentDirectoryItem is the safe, one-row operational summary used in a paged list.
type AgentDirectoryItem struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
LastSeenAt string `json:"lastSeenAt"`
LLMProfile string `json:"llmProfile"`
LLMStatus string `json:"llmStatus"`
LLMFailure string `json:"llmFailure"`
SkillTotal int `json:"skillTotal"`
SkillApplied int `json:"skillApplied"`
SkillStatus string `json:"skillStatus"`
SkillFailure string `json:"skillFailure"`
MCPStatus string `json:"mcpStatus"`
MCPFailure string `json:"mcpFailure"`
LastDeploymentAt string `json:"lastDeploymentAt"`
}
type AgentDirectoryPage struct {
Items []AgentDirectoryItem `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
}
type DeploymentFailure struct {
Resource string `json:"resource"`
Reason string `json:"reason"`
UpdatedAt string `json:"updatedAt"`
}
// AgentOperationalDetail keeps the detail view intentionally operational: recent
// tasks, safe audit/deployment trail and current deployment failures only.
type AgentOperationalDetail struct {
Agent Agent `json:"agent"`
Deployment AgentDirectoryItem `json:"deployment"`
RecentTasks []Task `json:"recentTasks"`
RecentAudit []AuditEvent `json:"recentAudit"`
DeploymentHistory []AuditEvent `json:"deploymentHistory"`
Failures []DeploymentFailure `json:"failures"`
}
var agentDirectoryBaseSQL = `WITH skill_summary AS (
SELECT agent_id, COUNT(*) AS total,
SUM(CASE WHEN apply_status = 'applied' THEN 1 ELSE 0 END) AS applied,
SUM(CASE WHEN apply_status = 'failed' THEN 1 ELSE 0 END) AS failed,
SUM(CASE WHEN apply_status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN apply_status = 'sent' THEN 1 ELSE 0 END) AS sent,
MAX(updated_at) AS updated_at,
MAX(CASE WHEN apply_status = 'failed' THEN last_error ELSE '' END) AS failure
FROM agent_skill_assignments GROUP BY agent_id
), directory AS (
SELECT a.agent_id, a.name, a.status, a.last_seen_at,
COALESCE(p.name, '') AS llm_profile, COALESCE(l.apply_status, '未部署') AS llm_status,
COALESCE(l.last_error, '') AS llm_failure,
COALESCE(s.total, 0) AS skill_total, COALESCE(s.applied, 0) AS skill_applied,
CASE WHEN COALESCE(s.total, 0) = 0 THEN '未选择'
WHEN COALESCE(s.failed, 0) > 0 THEN 'failed'
WHEN COALESCE(s.pending, 0) > 0 THEN 'pending'
WHEN COALESCE(s.sent, 0) > 0 THEN 'sent'
ELSE 'applied' END AS skill_status,
COALESCE(s.failure, '') AS skill_failure,
` + mcpStatusExpression("a.agent_id") + ` AS mcp_status,
` + mcpErrorExpression("a.agent_id") + ` AS mcp_failure,
COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), '') AS mcp_updated_at,
MAX(COALESCE(l.updated_at, ''), COALESCE((SELECT MAX(updated_at) FROM agent_mcp_assignments m WHERE m.agent_id = a.agent_id), ''), COALESCE(s.updated_at, '')) AS last_deployment_at
FROM managed_agents a
LEFT JOIN agent_llm_assignments l ON l.agent_id = a.agent_id
LEFT JOIN llm_profiles p ON p.profile_id = l.profile_id
LEFT JOIN skill_summary s ON s.agent_id = a.agent_id
)
`
func normaliseAgentDirectoryFilter(filter AgentDirectoryFilter) (AgentDirectoryFilter, error) {
filter.Name = strings.TrimSpace(filter.Name)
filter.ConnectionStatus = strings.TrimSpace(filter.ConnectionStatus)
filter.MCPStatus = strings.TrimSpace(filter.MCPStatus)
filter.LLMStatus = strings.TrimSpace(filter.LLMStatus)
filter.SkillStatus = strings.TrimSpace(filter.SkillStatus)
if len([]rune(filter.Name)) > 80 {
return filter, errors.New("Agent 名称搜索不能超过 80 个字符")
}
if filter.Page < 1 {
filter.Page = 1
}
if filter.PageSize < 1 {
filter.PageSize = 12
}
if filter.PageSize > 50 {
filter.PageSize = 50
}
if !oneOf(filter.ConnectionStatus, "", "online", "offline") ||
!oneOf(filter.MCPStatus, "", "未启用", "pending", "sent", "applied", "failed") ||
!oneOf(filter.LLMStatus, "", "未部署", "pending", "sent", "applied", "failed") ||
!oneOf(filter.SkillStatus, "", "未选择", "pending", "sent", "applied", "failed") {
return filter, errors.New("Agent 筛选条件无效")
}
return filter, nil
}
func oneOf(value string, allowed ...string) bool {
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
func agentDirectoryWhere(filter AgentDirectoryFilter) (string, []any) {
clauses, args := make([]string, 0, 5), make([]any, 0, 5)
if filter.Name != "" {
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(filter.Name)
clauses, args = append(clauses, "name LIKE ? ESCAPE '\\'"), append(args, "%"+escaped+"%")
}
for _, criterion := range []struct{ column, value string }{{"status", filter.ConnectionStatus}, {"mcp_status", filter.MCPStatus}, {"llm_status", filter.LLMStatus}, {"skill_status", filter.SkillStatus}} {
if criterion.value != "" {
clauses, args = append(clauses, criterion.column+" = ?"), append(args, criterion.value)
}
}
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// AgentDirectory provides database pagination and all Agent filters in one query.
func (c *ControlPlane) AgentDirectory(filter AgentDirectoryFilter) (AgentDirectoryPage, error) {
c.expireStaleAgents()
filter, err := normaliseAgentDirectoryFilter(filter)
if err != nil {
return AgentDirectoryPage{}, err
}
where, args := agentDirectoryWhere(filter)
page := AgentDirectoryPage{Items: make([]AgentDirectoryItem, 0), Page: filter.Page, PageSize: filter.PageSize}
if err := c.db.QueryRow(agentDirectoryBaseSQL+"SELECT COUNT(*) FROM directory"+where, args...).Scan(&page.TotalItems); err != nil {
return page, fmt.Errorf("无法统计 Agent 列表: %w", err)
}
page.TotalPages = (page.TotalItems + filter.PageSize - 1) / filter.PageSize
if page.TotalPages > 0 && page.Page > page.TotalPages {
page.Page = page.TotalPages
}
queryArgs := append(append([]any{}, args...), filter.PageSize, (page.Page-1)*filter.PageSize)
rows, err := c.db.Query(agentDirectoryBaseSQL+`SELECT agent_id, name, status, last_seen_at, llm_profile, llm_status, llm_failure,
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at
FROM directory`+where+" ORDER BY CASE status WHEN 'online' THEN 0 ELSE 1 END, name COLLATE NOCASE, agent_id LIMIT ? OFFSET ?", queryArgs...)
if err != nil {
return page, fmt.Errorf("无法读取 Agent 列表: %w", err)
}
defer rows.Close()
for rows.Next() {
var item AgentDirectoryItem
if err := rows.Scan(&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt); err != nil {
return page, err
}
item.LLMFailure, item.SkillFailure, item.MCPFailure = sanitizeDisplayText(item.LLMFailure), sanitizeDisplayText(item.SkillFailure), sanitizeDisplayText(item.MCPFailure)
page.Items = append(page.Items, item)
}
return page, rows.Err()
}
// AgentOperationalDetails returns bounded, safe operational context for one Agent.
func (c *ControlPlane) AgentOperationalDetails(agentID string) (AgentOperationalDetail, error) {
if strings.TrimSpace(agentID) == "" {
return AgentOperationalDetail{}, errors.New("Agent 标识无效")
}
// Fetch by ID directly so duplicate display names cannot select another Agent.
var item AgentDirectoryItem
err := c.db.QueryRow(agentDirectoryBaseSQL+`SELECT agent_id, name, status, last_seen_at, llm_profile, llm_status, llm_failure,
skill_total, skill_applied, skill_status, skill_failure, mcp_status, mcp_failure, last_deployment_at FROM directory WHERE agent_id = ?`, agentID).Scan(
&item.ID, &item.Name, &item.Status, &item.LastSeenAt, &item.LLMProfile, &item.LLMStatus, &item.LLMFailure, &item.SkillTotal, &item.SkillApplied, &item.SkillStatus, &item.SkillFailure, &item.MCPStatus, &item.MCPFailure, &item.LastDeploymentAt,
)
if errors.Is(err, sql.ErrNoRows) {
return AgentOperationalDetail{}, errors.New("未找到 Agent")
}
if err != nil {
return AgentOperationalDetail{}, fmt.Errorf("无法读取 Agent 详情: %w", err)
}
detail := AgentOperationalDetail{Agent: Agent{ID: item.ID, Name: item.Name, Status: item.Status, LastSeenAt: item.LastSeenAt}, Deployment: item, RecentTasks: make([]Task, 0), RecentAudit: make([]AuditEvent, 0), DeploymentHistory: make([]AuditEvent, 0), Failures: make([]DeploymentFailure, 0)}
detail.RecentTasks, err = c.recentTasksForAgent(agentID, 8)
if err != nil {
return detail, err
}
detail.RecentAudit, err = c.agentAuditEvents(agentID, false, 12)
if err != nil {
return detail, err
}
detail.DeploymentHistory, err = c.agentAuditEvents(agentID, true, 12)
if err != nil {
return detail, err
}
for _, failure := range []DeploymentFailure{{"LLM", item.LLMFailure, item.LastDeploymentAt}, {"Skill", item.SkillFailure, item.LastDeploymentAt}, {"MCP", item.MCPFailure, item.LastDeploymentAt}} {
if strings.TrimSpace(failure.Reason) != "" {
failure.Reason = sanitizeDisplayText(failure.Reason)
detail.Failures = append(detail.Failures, failure)
}
}
return detail, nil
}
func (c *ControlPlane) recentTasksForAgent(agentID string, maximum int) ([]Task, error) {
rows, err := c.db.Query("SELECT task_id, agent_id, COALESCE(NULLIF(agent_name, ''), '未知 Agent'), instruction, status, created_at, COALESCE(started_at, ''), COALESCE(finished_at, ''), COALESCE(deadline_at, ''), COALESCE(attempt, 1), COALESCE(retry_of, ''), COALESCE(output, ''), COALESCE(error, ''), COALESCE(execution_source, ''), COALESCE(session_id, '') FROM task_runs WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?", agentID, maximum)
if err != nil {
return nil, fmt.Errorf("无法读取 Agent 最近任务: %w", err)
}
defer rows.Close()
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.SessionID); err != nil {
return nil, err
}
tasks = append(tasks, task)
}
return tasks, rows.Err()
}
func (c *ControlPlane) agentAuditEvents(agentID string, deploymentOnly bool, maximum int) ([]AuditEvent, error) {
where := "(actor = ? OR target LIKE ?)"
args := []any{"agent:" + agentID, "agent:" + agentID + "%"}
if deploymentOnly {
where += " AND (action LIKE 'llm.%' OR action LIKE 'skill.%' OR action LIKE 'mcp.%')"
}
rows, err := c.db.Query("SELECT event_id, action, actor, target, outcome, detail, created_at FROM audit_events WHERE "+where+" ORDER BY event_id DESC LIMIT ?", append(args, maximum)...)
if err != nil {
return nil, fmt.Errorf("无法读取 Agent 审计记录: %w", err)
}
defer rows.Close()
events := make([]AuditEvent, 0)
for rows.Next() {
var event AuditEvent
if err := rows.Scan(&event.ID, &event.Action, &event.Actor, &event.Target, &event.Outcome, &event.Detail, &event.CreatedAt); err != nil {
return nil, err
}
event.Detail = sanitizeDisplayText(event.Detail)
events = append(events, event)
}
return events, rows.Err()
}