Files
mulit-agent/controller/internal/controlplane/mentions.go
T

269 lines
8.8 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
}
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
}