Files
mulit-agent/controller/internal/controlplane/mentions.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

270 lines
8.9 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 controlplane
import (
"errors"
"regexp"
"sort"
"strconv"
"strings"
)
// MentionTask is one independently-addressed task extracted from the public
// multi-Agent composer. A line may create more than one task when it names
// multiple Agents before its shared task text.
type MentionTask struct {
Line int `json:"line"`
Mention string `json:"mention"`
AgentID string `json:"agentID"`
AgentName string `json:"agentName"`
Instruction string `json:"instruction"`
TaskID string `json:"taskID"`
Error string `json:"error"`
}
// MentionDispatchResponse reports every line/target outcome. It never silently
// broadcasts untagged text or substitutes a similar Agent name.
type MentionDispatchResponse struct {
Tasks []MentionTask `json:"tasks"`
Issues []string `json:"issues"`
}
type mentionCandidate struct {
ID string
Name string
Status string
}
// DispatchMentions accepts newline-separated assignments. Each non-empty line
// must begin with one or more @AgentName markers followed by its task text:
//
// @设计机 @文案机 做同一套发布方案
// @测试机 验证安装包
//
// The first line produces two independent task runs with the same instruction;
// the second line produces a different run. Invalid targets remain isolated to
// their own line and are never redirected.
func (c *ControlPlane) DispatchMentions(message string, timeoutSeconds int) (MentionDispatchResponse, error) {
return c.dispatchMentionsWithImages(message, timeoutSeconds, nil)
}
func (c *ControlPlane) DispatchMentionsWithImages(message string, timeoutSeconds int, inputs []TaskImageInput) (MentionDispatchResponse, error) {
images, err := normalizeTaskImages(inputs)
if err != nil {
return MentionDispatchResponse{}, err
}
return c.dispatchMentionsWithImages(message, timeoutSeconds, images)
}
func (c *ControlPlane) dispatchMentionsWithImages(message string, timeoutSeconds int, images []taskImage) (MentionDispatchResponse, error) {
resolved, issues, err := c.resolveMentions(message)
if err != nil {
return MentionDispatchResponse{}, err
}
response := MentionDispatchResponse{Tasks: make([]MentionTask, 0, len(resolved)), Issues: issues}
// Invalid, offline, duplicate, or unknown targets cannot turn a single valid
// assignment into a stricter multi-task submission. Only independently
// dispatchable tasks require explicit image numbers.
validTasks := 0
for _, item := range resolved {
if item.Error == "" {
validTasks++
}
}
multiTarget := validTasks > 1
for _, item := range resolved {
result := item
if item.Error != "" {
response.Tasks = append(response.Tasks, result)
continue
}
selected, imageErr := imagesForMention(item.Instruction, images, multiTarget)
if imageErr != nil {
result.Error = imageErr.Error()
response.Tasks = append(response.Tasks, result)
continue
}
// Batch @ assignments stay one-shot: only 单独指派 keeps a conversation.
taskID, createErr := c.createTaskWithImages(item.AgentID, item.Instruction, timeoutSeconds, 1, "", selected, "")
if createErr != nil {
result.Error = sanitizeDisplayText(createErr.Error())
} else {
result.TaskID = taskID
}
response.Tasks = append(response.Tasks, result)
}
return response, nil
}
var imageReference = regexp.MustCompile(`图片?\s*([0-9]+)`)
func imagesForMention(instruction string, images []taskImage, requireReference bool) ([]taskImage, error) {
if len(images) == 0 {
return nil, nil
}
matches := imageReference.FindAllStringSubmatch(instruction, -1)
if len(matches) == 0 {
if requireReference {
return nil, errors.New("多项带图任务必须在该行标明“图片 1”等编号,未下发。")
}
return images, nil
}
selected := make([]taskImage, 0, len(matches))
seen := map[int]bool{}
for _, match := range matches {
index, _ := strconv.Atoi(match[1])
if index < 1 || index > len(images) {
return nil, errors.New("引用的图片编号不存在,未下发。")
}
if !seen[index] {
selected = append(selected, images[index-1])
seen[index] = true
}
}
return selected, nil
}
func (c *ControlPlane) resolveMentions(message string) ([]MentionTask, []string, error) {
message = strings.TrimSpace(message)
if message == "" {
return nil, nil, errors.New("请输入以 @Agent名称 开始的任务")
}
if len([]rune(message)) > 6000 {
return nil, nil, errors.New("任务内容不能超过 6000 个字符")
}
candidates, err := c.mentionCandidates()
if err != nil {
return nil, nil, err
}
if len(candidates) == 0 {
return nil, nil, errors.New("当前没有已配对的 Agent,无法选择任务目标")
}
items := make([]MentionTask, 0)
issues := make([]string, 0)
for lineNumber, rawLine := range strings.Split(message, "\n") {
line := strings.TrimSpace(rawLine)
if line == "" {
continue
}
lineItems, issue := c.resolveMentionLine(line, lineNumber+1, candidates)
items = append(items, lineItems...)
if issue != "" {
issues = append(issues, issue)
}
}
if len(items) == 0 && len(issues) == 0 {
return nil, nil, errors.New("请输入至少一条任务")
}
return items, issues, nil
}
func (c *ControlPlane) resolveMentionLine(line string, lineNumber int, candidates []mentionCandidate) ([]MentionTask, string) {
remaining := strings.TrimSpace(line)
targets := make([]mentionCandidate, 0)
for strings.HasPrefix(remaining, "@") || strings.HasPrefix(remaining, "@") {
segment := strings.TrimPrefix(strings.TrimPrefix(remaining, "@"), "@")
name, matches := matchMentionName(segment, candidates)
if len(matches) == 0 {
return nil, "第 " + stringLine(lineNumber) + " 行存在未识别的 Agent 名称,整行未下发。"
}
if len(matches) > 1 {
return nil, "第 " + stringLine(lineNumber) + " 行存在重名 Agent,整行未下发;请先在 Agent 管理中改名。"
}
target := matches[0]
if anyMentionTarget(targets, target.ID) {
return nil, "第 " + stringLine(lineNumber) + " 行重复选择了 @" + name + ",整行未下发。"
}
targets = append(targets, target)
remaining = strings.TrimLeft(strings.TrimPrefix(segment, name), " \t;,,")
}
if len(targets) == 0 {
return nil, "第 " + stringLine(lineNumber) + " 行未指定 Agent;请用 @ 选择目标。"
}
instruction := strings.TrimSpace(remaining)
items := make([]MentionTask, 0, len(targets))
for _, target := range targets {
item := MentionTask{Line: lineNumber, Mention: target.Name, AgentID: target.ID, AgentName: target.Name, Instruction: instruction}
if instruction == "" {
item.Error = "该行未提供任务内容,未下发。"
} else if target.Status != "online" || c.clientFor(target.ID) == nil {
item.Error = "目标 Agent 当前离线,未下发。"
} else if err := validateSafeTaskText(instruction); err != nil {
item.Error = err.Error()
}
items = append(items, item)
}
return items, ""
}
func (c *ControlPlane) mentionCandidates() ([]mentionCandidate, error) {
rows, err := c.db.Query("SELECT agent_id, name, status FROM managed_agents ORDER BY name, agent_id")
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]mentionCandidate, 0)
for rows.Next() {
var item mentionCandidate
if err := rows.Scan(&item.ID, &item.Name, &item.Status); err != nil {
return nil, err
}
item.Name = strings.TrimSpace(item.Name)
if item.Name != "" {
items = append(items, item)
}
}
sort.Slice(items, func(i, j int) bool { return len([]rune(items[i].Name)) > len([]rune(items[j].Name)) })
return items, rows.Err()
}
func matchMentionName(segment string, candidates []mentionCandidate) (string, []mentionCandidate) {
for _, candidate := range candidates {
if !strings.HasPrefix(segment, candidate.Name) {
continue
}
remainder := strings.TrimPrefix(segment, candidate.Name)
if remainder != "" && !isMentionBoundary([]rune(remainder)[0]) {
continue
}
matches := make([]mentionCandidate, 0, 2)
for _, other := range candidates {
if other.Name == candidate.Name {
matches = append(matches, other)
}
}
return candidate.Name, matches
}
return "", nil
}
func anyMentionTarget(targets []mentionCandidate, agentID string) bool {
for _, target := range targets {
if target.ID == agentID {
return true
}
}
return false
}
func isMentionBoundary(value rune) bool {
return value == ' ' || value == '\t' || value == '\n' || value == '\r' || value == '@' || value == '@' || strings.ContainsRune(";,,", value)
}
func stringLine(value int) string {
return strconv.Itoa(value)
}
func validateSafeTaskText(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return errors.New("请输入文本内容")
}
if len([]rune(trimmed)) > 6000 {
return errors.New("任务内容不能超过 6000 个字符")
}
if pairingCredentialPattern.MatchString(trimmed) || rawAPIKeyPattern.MatchString(trimmed) || sanitizeDisplayText(trimmed) != trimmed {
return errors.New("内容包含敏感凭据或内部推理,已拒绝下发")
}
return nil
}