mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-07 21:17:58 +02:00
Add files via upload
This commit is contained in:
@@ -1890,6 +1890,25 @@ func (a *Agent) repairOrphanToolMessages(messages *[]ChatMessage) bool {
|
||||
return removed
|
||||
}
|
||||
|
||||
// ToolsForRole 返回与单 Agent 循环一致的工具定义(OpenAI function 格式),供 Eino DeepAgent 等编排层绑定 MCP 工具。
|
||||
func (a *Agent) ToolsForRole(roleTools []string) []Tool {
|
||||
return a.getAvailableTools(roleTools)
|
||||
}
|
||||
|
||||
// ExecuteMCPToolForConversation 在指定会话上下文中执行 MCP 工具(行为与主 Agent 循环中的工具调用一致,如自动注入 conversation_id)。
|
||||
func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationID, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
||||
a.mu.Lock()
|
||||
prev := a.currentConversationID
|
||||
a.currentConversationID = conversationID
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
a.currentConversationID = prev
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
return a.executeToolViaMCP(ctx, toolName, args)
|
||||
}
|
||||
|
||||
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
|
||||
func extractQuotedToolName(errMsg string) string {
|
||||
start := strings.Index(errMsg, "\"")
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
// Package agents 从 agents/ 目录加载 Markdown 代理定义(子代理 + 可选主代理 orchestrator.md / kind: orchestrator)。
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// OrchestratorMarkdownFilename 固定文件名:存在则视为 Deep 主代理定义,且不参与子代理列表。
|
||||
const OrchestratorMarkdownFilename = "orchestrator.md"
|
||||
|
||||
// FrontMatter 对应 Markdown 文件头部字段(与文档示例一致)。
|
||||
type FrontMatter struct {
|
||||
Name string `yaml:"name"`
|
||||
ID string `yaml:"id"`
|
||||
Description string `yaml:"description"`
|
||||
Tools interface{} `yaml:"tools"` // 字符串 "A, B" 或 []string
|
||||
MaxIterations int `yaml:"max_iterations"`
|
||||
BindRole string `yaml:"bind_role,omitempty"`
|
||||
Kind string `yaml:"kind,omitempty"` // orchestrator = 主代理(亦可仅用文件名 orchestrator.md)
|
||||
}
|
||||
|
||||
// OrchestratorMarkdown 从 agents 目录解析出的主代理(Deep 协调者)定义。
|
||||
type OrchestratorMarkdown struct {
|
||||
Filename string
|
||||
EinoName string // 写入 deep.Config.Name / 流式事件过滤
|
||||
DisplayName string
|
||||
Description string
|
||||
Instruction string
|
||||
}
|
||||
|
||||
// MarkdownDirLoad 一次扫描 agents 目录的结果(子代理不含主代理文件)。
|
||||
type MarkdownDirLoad struct {
|
||||
SubAgents []config.MultiAgentSubConfig
|
||||
Orchestrator *OrchestratorMarkdown
|
||||
FileEntries []FileAgent // 含主代理与所有子代理,供管理 API 列表
|
||||
}
|
||||
|
||||
// IsOrchestratorMarkdown 判断该文件是否表示主代理:固定文件名 orchestrator.md,或 front matter kind: orchestrator。
|
||||
func IsOrchestratorMarkdown(filename string, fm FrontMatter) bool {
|
||||
base := filepath.Base(strings.TrimSpace(filename))
|
||||
if strings.EqualFold(base, OrchestratorMarkdownFilename) {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(fm.Kind), "orchestrator")
|
||||
}
|
||||
|
||||
// WantsMarkdownOrchestrator 保存前判断是否会把该文件作为主代理(用于唯一性校验)。
|
||||
func WantsMarkdownOrchestrator(filename string, kindField string, raw string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(kindField), "orchestrator") {
|
||||
return true
|
||||
}
|
||||
base := filepath.Base(strings.TrimSpace(filename))
|
||||
if strings.EqualFold(base, OrchestratorMarkdownFilename) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return false
|
||||
}
|
||||
sub, err := ParseMarkdownSubAgent(filename, raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(sub.Kind), "orchestrator")
|
||||
}
|
||||
|
||||
// SplitFrontMatter 分离 YAML front matter 与正文(--- ... ---)。
|
||||
func SplitFrontMatter(content string) (frontYAML string, body string, err error) {
|
||||
s := strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(s, "---") {
|
||||
return "", s, nil
|
||||
}
|
||||
rest := strings.TrimPrefix(s, "---")
|
||||
rest = strings.TrimLeft(rest, "\r\n")
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return "", "", fmt.Errorf("agents: 缺少结束的 --- 分隔符")
|
||||
}
|
||||
fm := strings.TrimSpace(rest[:end])
|
||||
body = strings.TrimSpace(rest[end+4:])
|
||||
body = strings.TrimLeft(body, "\r\n")
|
||||
return fm, body, nil
|
||||
}
|
||||
|
||||
func parseToolsField(v interface{}) []string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return splitToolList(t)
|
||||
case []interface{}:
|
||||
var out []string
|
||||
for _, x := range t {
|
||||
if s, ok := x.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
var out []string
|
||||
for _, s := range t {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func splitToolList(s string) []string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '|'
|
||||
})
|
||||
var out []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SlugID 从 name 生成可用的代理 id(小写、连字符)。
|
||||
func SlugID(name string) string {
|
||||
var b strings.Builder
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
lastDash := false
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case unicode.IsLetter(r) && r < unicode.MaxASCII, unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
case r == ' ' || r == '_' || r == '/' || r == '.':
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
s := strings.Trim(b.String(), "-")
|
||||
if s == "" {
|
||||
return "agent"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sanitizeEinoAgentID 规范化 Deep 主代理在 Eino 中的 Name:小写 ASCII、数字、连字符,与默认 cyberstrike-deep 一致。
|
||||
func sanitizeEinoAgentID(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(r) && r < unicode.MaxASCII, unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
case r == '-':
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
return "cyberstrike-deep"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseMarkdownAgentRaw(filename string, content string) (FrontMatter, string, error) {
|
||||
var fm FrontMatter
|
||||
fmStr, body, err := SplitFrontMatter(content)
|
||||
if err != nil {
|
||||
return fm, "", err
|
||||
}
|
||||
if strings.TrimSpace(fmStr) == "" {
|
||||
return fm, "", fmt.Errorf("agents: %s 无 YAML front matter", filename)
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(fmStr), &fm); err != nil {
|
||||
return fm, "", fmt.Errorf("agents: 解析 front matter: %w", err)
|
||||
}
|
||||
return fm, body, nil
|
||||
}
|
||||
|
||||
func orchestratorFromParsed(filename string, fm FrontMatter, body string) (*OrchestratorMarkdown, error) {
|
||||
display := strings.TrimSpace(fm.Name)
|
||||
if display == "" {
|
||||
display = "Orchestrator"
|
||||
}
|
||||
rawID := strings.TrimSpace(fm.ID)
|
||||
if rawID == "" {
|
||||
rawID = SlugID(display)
|
||||
}
|
||||
eino := sanitizeEinoAgentID(rawID)
|
||||
return &OrchestratorMarkdown{
|
||||
Filename: filepath.Base(strings.TrimSpace(filename)),
|
||||
EinoName: eino,
|
||||
DisplayName: display,
|
||||
Description: strings.TrimSpace(fm.Description),
|
||||
Instruction: strings.TrimSpace(body),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func orchestratorConfigFromOrchestrator(o *OrchestratorMarkdown) config.MultiAgentSubConfig {
|
||||
if o == nil {
|
||||
return config.MultiAgentSubConfig{}
|
||||
}
|
||||
return config.MultiAgentSubConfig{
|
||||
ID: o.EinoName,
|
||||
Name: o.DisplayName,
|
||||
Description: o.Description,
|
||||
Instruction: o.Instruction,
|
||||
Kind: "orchestrator",
|
||||
}
|
||||
}
|
||||
|
||||
func subAgentFromFrontMatter(filename string, fm FrontMatter, body string) (config.MultiAgentSubConfig, error) {
|
||||
var out config.MultiAgentSubConfig
|
||||
name := strings.TrimSpace(fm.Name)
|
||||
if name == "" {
|
||||
return out, fmt.Errorf("agents: %s 缺少 name 字段", filename)
|
||||
}
|
||||
id := strings.TrimSpace(fm.ID)
|
||||
if id == "" {
|
||||
id = SlugID(name)
|
||||
}
|
||||
out.ID = id
|
||||
out.Name = name
|
||||
out.Description = strings.TrimSpace(fm.Description)
|
||||
out.Instruction = strings.TrimSpace(body)
|
||||
out.RoleTools = parseToolsField(fm.Tools)
|
||||
out.MaxIterations = fm.MaxIterations
|
||||
out.BindRole = strings.TrimSpace(fm.BindRole)
|
||||
out.Kind = strings.TrimSpace(fm.Kind)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func collectMarkdownBasenames(dir string) ([]string, error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
st, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return nil, fmt.Errorf("agents: 不是目录: %s", dir)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
n := e.Name()
|
||||
if strings.HasPrefix(n, ".") {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(filepath.Ext(n), ".md") {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(n, "README.md") {
|
||||
continue
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// LoadMarkdownAgentsDir 扫描 agents 目录:拆出至多一个主代理与其余子代理。
|
||||
func LoadMarkdownAgentsDir(dir string) (*MarkdownDirLoad, error) {
|
||||
out := &MarkdownDirLoad{}
|
||||
names, err := collectMarkdownBasenames(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range names {
|
||||
p := filepath.Join(dir, n)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fm, body, err := parseMarkdownAgentRaw(n, string(b))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", n, err)
|
||||
}
|
||||
if IsOrchestratorMarkdown(n, fm) {
|
||||
if out.Orchestrator != nil {
|
||||
return nil, fmt.Errorf("agents: 仅能定义一个主代理(Deep 协调者),已有 %s,又与 %s 冲突", out.Orchestrator.Filename, n)
|
||||
}
|
||||
orch, err := orchestratorFromParsed(n, fm, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", n, err)
|
||||
}
|
||||
out.Orchestrator = orch
|
||||
out.FileEntries = append(out.FileEntries, FileAgent{
|
||||
Filename: n,
|
||||
Config: orchestratorConfigFromOrchestrator(orch),
|
||||
IsOrchestrator: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
sub, err := subAgentFromFrontMatter(n, fm, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", n, err)
|
||||
}
|
||||
out.SubAgents = append(out.SubAgents, sub)
|
||||
out.FileEntries = append(out.FileEntries, FileAgent{Filename: n, Config: sub, IsOrchestrator: false})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParseMarkdownSubAgent 将单个 Markdown 文件解析为 MultiAgentSubConfig。
|
||||
func ParseMarkdownSubAgent(filename string, content string) (config.MultiAgentSubConfig, error) {
|
||||
fm, body, err := parseMarkdownAgentRaw(filename, content)
|
||||
if err != nil {
|
||||
return config.MultiAgentSubConfig{}, err
|
||||
}
|
||||
if IsOrchestratorMarkdown(filename, fm) {
|
||||
orch, err := orchestratorFromParsed(filename, fm, body)
|
||||
if err != nil {
|
||||
return config.MultiAgentSubConfig{}, err
|
||||
}
|
||||
return orchestratorConfigFromOrchestrator(orch), nil
|
||||
}
|
||||
return subAgentFromFrontMatter(filename, fm, body)
|
||||
}
|
||||
|
||||
// LoadMarkdownSubAgents 读取目录下所有子代理 .md(不含主代理 orchestrator.md / kind: orchestrator)。
|
||||
func LoadMarkdownSubAgents(dir string) ([]config.MultiAgentSubConfig, error) {
|
||||
load, err := LoadMarkdownAgentsDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return load.SubAgents, nil
|
||||
}
|
||||
|
||||
// FileAgent 单个 Markdown 文件及其解析结果。
|
||||
type FileAgent struct {
|
||||
Filename string
|
||||
Config config.MultiAgentSubConfig
|
||||
IsOrchestrator bool
|
||||
}
|
||||
|
||||
// LoadMarkdownAgentFiles 列出目录下全部 .md(含主代理),供管理 API 使用。
|
||||
func LoadMarkdownAgentFiles(dir string) ([]FileAgent, error) {
|
||||
load, err := LoadMarkdownAgentsDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return load.FileEntries, nil
|
||||
}
|
||||
|
||||
// MergeYAMLAndMarkdown 合并 config.yaml 中的 sub_agents 与 Markdown 定义:同 id 时 Markdown 覆盖 YAML;仅存在于 Markdown 的条目追加在 YAML 顺序之后。
|
||||
func MergeYAMLAndMarkdown(yamlSubs []config.MultiAgentSubConfig, mdSubs []config.MultiAgentSubConfig) []config.MultiAgentSubConfig {
|
||||
mdByID := make(map[string]config.MultiAgentSubConfig)
|
||||
for _, m := range mdSubs {
|
||||
id := strings.TrimSpace(m.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
mdByID[id] = m
|
||||
}
|
||||
yamlIDSet := make(map[string]bool)
|
||||
for _, y := range yamlSubs {
|
||||
yamlIDSet[strings.TrimSpace(y.ID)] = true
|
||||
}
|
||||
out := make([]config.MultiAgentSubConfig, 0, len(yamlSubs)+len(mdSubs))
|
||||
for _, y := range yamlSubs {
|
||||
id := strings.TrimSpace(y.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if m, ok := mdByID[id]; ok {
|
||||
out = append(out, m)
|
||||
} else {
|
||||
out = append(out, y)
|
||||
}
|
||||
}
|
||||
for _, m := range mdSubs {
|
||||
id := strings.TrimSpace(m.ID)
|
||||
if id == "" || yamlIDSet[id] {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EffectiveSubAgents 供多代理运行时使用。
|
||||
func EffectiveSubAgents(yamlSubs []config.MultiAgentSubConfig, agentsDir string) ([]config.MultiAgentSubConfig, error) {
|
||||
md, err := LoadMarkdownSubAgents(agentsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(md) == 0 {
|
||||
return yamlSubs, nil
|
||||
}
|
||||
return MergeYAMLAndMarkdown(yamlSubs, md), nil
|
||||
}
|
||||
|
||||
// BuildMarkdownFile 根据配置序列化为可写回磁盘的 Markdown。
|
||||
func BuildMarkdownFile(sub config.MultiAgentSubConfig) ([]byte, error) {
|
||||
fm := FrontMatter{
|
||||
Name: sub.Name,
|
||||
ID: sub.ID,
|
||||
Description: sub.Description,
|
||||
MaxIterations: sub.MaxIterations,
|
||||
BindRole: sub.BindRole,
|
||||
}
|
||||
if k := strings.TrimSpace(sub.Kind); k != "" {
|
||||
fm.Kind = k
|
||||
}
|
||||
if len(sub.RoleTools) > 0 {
|
||||
fm.Tools = sub.RoleTools
|
||||
}
|
||||
head, err := yaml.Marshal(fm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
b.Write(head)
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(strings.TrimSpace(sub.Instruction))
|
||||
if !strings.HasSuffix(sub.Instruction, "\n") && sub.Instruction != "" {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMarkdownAgentsDir_OrchestratorExcludedFromSubs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orch := filepath.Join(dir, OrchestratorMarkdownFilename)
|
||||
if err := os.WriteFile(orch, []byte(`---
|
||||
id: cyberstrike-deep
|
||||
name: Main
|
||||
description: Test desc
|
||||
---
|
||||
|
||||
Hello orchestrator
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subPath := filepath.Join(dir, "worker.md")
|
||||
if err := os.WriteFile(subPath, []byte(`---
|
||||
id: worker
|
||||
name: Worker
|
||||
description: W
|
||||
---
|
||||
|
||||
Do work
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
load, err := LoadMarkdownAgentsDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if load.Orchestrator == nil || load.Orchestrator.EinoName != "cyberstrike-deep" {
|
||||
t.Fatalf("orchestrator: %+v", load.Orchestrator)
|
||||
}
|
||||
if len(load.SubAgents) != 1 || load.SubAgents[0].ID != "worker" {
|
||||
t.Fatalf("subs: %+v", load.SubAgents)
|
||||
}
|
||||
if len(load.FileEntries) != 2 {
|
||||
t.Fatalf("file entries: %d", len(load.FileEntries))
|
||||
}
|
||||
var orchFile *FileAgent
|
||||
for i := range load.FileEntries {
|
||||
if load.FileEntries[i].IsOrchestrator {
|
||||
orchFile = &load.FileEntries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if orchFile == nil || orchFile.Filename != OrchestratorMarkdownFilename {
|
||||
t.Fatal("missing orchestrator file entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMarkdownAgentsDir_DuplicateOrchestrator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(dir, OrchestratorMarkdownFilename), []byte("---\nname: A\n---\n\nx\n"), 0644)
|
||||
_ = os.WriteFile(filepath.Join(dir, "b.md"), []byte("---\nname: B\nkind: orchestrator\n---\n\ny\n"), 0644)
|
||||
_, err := LoadMarkdownAgentsDir(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate orchestrator error")
|
||||
}
|
||||
}
|
||||
+30
-4
@@ -298,6 +298,19 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
skillsManager := skills.NewManager(skillsDir, log.Logger)
|
||||
log.Logger.Info("Skills管理器已初始化", zap.String("skillsDir", skillsDir))
|
||||
|
||||
agentsDir := cfg.AgentsDir
|
||||
if agentsDir == "" {
|
||||
agentsDir = "agents"
|
||||
}
|
||||
if !filepath.IsAbs(agentsDir) {
|
||||
agentsDir = filepath.Join(configDir, agentsDir)
|
||||
}
|
||||
if err := os.MkdirAll(agentsDir, 0755); err != nil {
|
||||
log.Logger.Warn("创建 agents 目录失败", zap.String("path", agentsDir), zap.Error(err))
|
||||
}
|
||||
markdownAgentsHandler := handler.NewMarkdownAgentsHandler(agentsDir)
|
||||
log.Logger.Info("多代理 Markdown 子 Agent 目录", zap.String("agentsDir", agentsDir))
|
||||
|
||||
// 注册Skills工具到MCP服务器(让AI可以按需调用,带数据库存储支持统计)
|
||||
// 创建一个适配器,将database.DB适配为SkillStatsStorage接口
|
||||
var skillStatsStorage skills.SkillStatsStorage
|
||||
@@ -309,6 +322,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
// 创建处理器
|
||||
agentHandler := handler.NewAgentHandler(agent, db, cfg, log.Logger)
|
||||
agentHandler.SetSkillsManager(skillsManager) // 设置Skills管理器
|
||||
agentHandler.SetAgentsMarkdownDir(agentsDir)
|
||||
// 如果知识库已启用,设置知识库管理器到AgentHandler以便记录检索日志
|
||||
if knowledgeManager != nil {
|
||||
agentHandler.SetKnowledgeManager(knowledgeManager)
|
||||
@@ -443,6 +457,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
chatUploadsHandler,
|
||||
roleHandler,
|
||||
skillsHandler,
|
||||
markdownAgentsHandler,
|
||||
fofaHandler,
|
||||
terminalHandler,
|
||||
mcpServer,
|
||||
@@ -572,6 +587,7 @@ func setupRoutes(
|
||||
chatUploadsHandler *handler.ChatUploadsHandler,
|
||||
roleHandler *handler.RoleHandler,
|
||||
skillsHandler *handler.SkillsHandler,
|
||||
markdownAgentsHandler *handler.MarkdownAgentsHandler,
|
||||
fofaHandler *handler.FofaHandler,
|
||||
terminalHandler *handler.TerminalHandler,
|
||||
mcpServer *mcp.Server,
|
||||
@@ -611,6 +627,16 @@ func setupRoutes(
|
||||
protected.GET("/agent-loop/tasks", agentHandler.ListAgentTasks)
|
||||
protected.GET("/agent-loop/tasks/completed", agentHandler.ListCompletedTasks)
|
||||
|
||||
// Eino DeepAgent 多代理(与单 Agent 并存,需 config.multi_agent.enabled)
|
||||
// 多代理路由常注册;是否可用由运行时 h.config.MultiAgent.Enabled 决定(应用配置后无需重启)
|
||||
protected.POST("/multi-agent", agentHandler.MultiAgentLoop)
|
||||
protected.POST("/multi-agent/stream", agentHandler.MultiAgentLoopStream)
|
||||
protected.GET("/multi-agent/markdown-agents", markdownAgentsHandler.ListMarkdownAgents)
|
||||
protected.GET("/multi-agent/markdown-agents/:filename", markdownAgentsHandler.GetMarkdownAgent)
|
||||
protected.POST("/multi-agent/markdown-agents", markdownAgentsHandler.CreateMarkdownAgent)
|
||||
protected.PUT("/multi-agent/markdown-agents/:filename", markdownAgentsHandler.UpdateMarkdownAgent)
|
||||
protected.DELETE("/multi-agent/markdown-agents/:filename", markdownAgentsHandler.DeleteMarkdownAgent)
|
||||
|
||||
// 信息收集 - FOFA 查询(后端代理)
|
||||
protected.POST("/fofa/search", fofaHandler.Search)
|
||||
// 信息收集 - 自然语言解析为 FOFA 语法(需人工确认后再查询)
|
||||
@@ -1147,7 +1173,7 @@ func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandl
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "目录路径,默认 ."},
|
||||
"path": map[string]interface{}{"type": "string", "description": "目录路径,默认 ."},
|
||||
},
|
||||
"required": []string{"connection_id"},
|
||||
},
|
||||
@@ -1179,7 +1205,7 @@ func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandl
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
},
|
||||
"required": []string{"connection_id", "path"},
|
||||
},
|
||||
@@ -1211,8 +1237,8 @@ func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandl
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
|
||||
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
|
||||
},
|
||||
"required": []string{"connection_id", "path", "content"},
|
||||
},
|
||||
|
||||
+79
-34
@@ -31,23 +31,68 @@ type Config struct {
|
||||
RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式)
|
||||
Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色
|
||||
SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录
|
||||
AgentsDir string `yaml:"agents_dir,omitempty" json:"agents_dir,omitempty"` // 多代理子 Agent Markdown 定义目录(*.md,YAML front matter)
|
||||
MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"`
|
||||
}
|
||||
|
||||
// MultiAgentConfig 基于 CloudWeGo Eino DeepAgent 的多代理编排(与单 Agent /agent-loop 并存)。
|
||||
type MultiAgentConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
DefaultMode string `yaml:"default_mode" json:"default_mode"` // single | multi,供前端默认展示
|
||||
RobotUseMultiAgent bool `yaml:"robot_use_multi_agent" json:"robot_use_multi_agent"` // 为 true 时钉钉/飞书/企微机器人走 Eino 多代理
|
||||
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
||||
MaxIteration int `yaml:"max_iteration" json:"max_iteration"` // Deep 主代理最大推理轮次
|
||||
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations" json:"sub_agent_max_iterations"`
|
||||
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
||||
SubAgents []MultiAgentSubConfig `yaml:"sub_agents" json:"sub_agents"`
|
||||
}
|
||||
|
||||
// MultiAgentSubConfig 子代理(Eino ChatModelAgent),由 DeepAgent 通过 task 工具调度。
|
||||
type MultiAgentSubConfig struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Instruction string `yaml:"instruction" json:"instruction"`
|
||||
BindRole string `yaml:"bind_role,omitempty" json:"bind_role,omitempty"` // 可选:关联主配置 roles 中的角色名;未配 role_tools 时沿用该角色的 tools,并把 skills 写入指令提示
|
||||
RoleTools []string `yaml:"role_tools" json:"role_tools"` // 与单 Agent 角色工具相同 key;空表示全部工具(bind_role 可补全 tools)
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // 仅 Markdown:kind=orchestrator 表示 Deep 主代理(与 orchestrator.md 二选一约定)
|
||||
}
|
||||
|
||||
// MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。
|
||||
type MultiAgentPublic struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultMode string `json:"default_mode"`
|
||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
SubAgentCount int `json:"sub_agent_count"`
|
||||
}
|
||||
|
||||
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
|
||||
type MultiAgentAPIUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultMode string `json:"default_mode"`
|
||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
}
|
||||
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
||||
type RobotsConfig struct {
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||
}
|
||||
|
||||
// RobotWecomConfig 企业微信机器人配置
|
||||
type RobotWecomConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Token string `yaml:"token" json:"token"` // 回调 URL 校验 Token
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Token string `yaml:"token" json:"token"` // 回调 URL 校验 Token
|
||||
EncodingAESKey string `yaml:"encoding_aes_key" json:"encoding_aes_key"` // EncodingAESKey
|
||||
CorpID string `yaml:"corp_id" json:"corp_id"` // 企业 ID
|
||||
Secret string `yaml:"secret" json:"secret"` // 应用 Secret
|
||||
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
|
||||
CorpID string `yaml:"corp_id" json:"corp_id"` // 企业 ID
|
||||
Secret string `yaml:"secret" json:"secret"` // 应用 Secret
|
||||
AgentID int64 `yaml:"agent_id" json:"agent_id"` // 应用 AgentId
|
||||
}
|
||||
|
||||
// RobotDingtalkConfig 钉钉机器人配置
|
||||
@@ -59,9 +104,9 @@ type RobotDingtalkConfig struct {
|
||||
|
||||
// RobotLarkConfig 飞书机器人配置
|
||||
type RobotLarkConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
||||
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
||||
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
||||
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
|
||||
}
|
||||
|
||||
@@ -79,7 +124,7 @@ type MCPConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
AuthHeader string `yaml:"auth_header,omitempty"` // 鉴权 header 名,留空表示不鉴权
|
||||
AuthHeader string `yaml:"auth_header,omitempty"` // 鉴权 header 名,留空表示不鉴权
|
||||
AuthHeaderValue string `yaml:"auth_header_value,omitempty"` // 鉴权 header 值,需与请求中该 header 一致
|
||||
}
|
||||
|
||||
@@ -164,17 +209,17 @@ type ToolConfig struct {
|
||||
|
||||
// ParameterConfig 参数配置
|
||||
type ParameterConfig struct {
|
||||
Name string `yaml:"name"` // 参数名称
|
||||
Type string `yaml:"type"` // 参数类型: string, int, bool, array
|
||||
Description string `yaml:"description"` // 参数描述
|
||||
Required bool `yaml:"required,omitempty"` // 是否必需
|
||||
Default interface{} `yaml:"default,omitempty"` // 默认值
|
||||
ItemType string `yaml:"item_type,omitempty"` // 当 type 为 array 时,数组元素类型,如 string, number, object
|
||||
Flag string `yaml:"flag,omitempty"` // 命令行标志,如 "-u", "--url", "-p"
|
||||
Position *int `yaml:"position,omitempty"` // 位置参数的位置(从0开始)
|
||||
Format string `yaml:"format,omitempty"` // 参数格式: "flag", "positional", "combined" (flag=value), "template"
|
||||
Template string `yaml:"template,omitempty"` // 模板字符串,如 "{flag} {value}" 或 "{value}"
|
||||
Options []string `yaml:"options,omitempty"` // 可选值列表(用于枚举)
|
||||
Name string `yaml:"name"` // 参数名称
|
||||
Type string `yaml:"type"` // 参数类型: string, int, bool, array
|
||||
Description string `yaml:"description"` // 参数描述
|
||||
Required bool `yaml:"required,omitempty"` // 是否必需
|
||||
Default interface{} `yaml:"default,omitempty"` // 默认值
|
||||
ItemType string `yaml:"item_type,omitempty"` // 当 type 为 array 时,数组元素类型,如 string, number, object
|
||||
Flag string `yaml:"flag,omitempty"` // 命令行标志,如 "-u", "--url", "-p"
|
||||
Position *int `yaml:"position,omitempty"` // 位置参数的位置(从0开始)
|
||||
Format string `yaml:"format,omitempty"` // 参数格式: "flag", "positional", "combined" (flag=value), "template"
|
||||
Template string `yaml:"template,omitempty"` // 模板字符串,如 "{flag} {value}" 或 "{value}"
|
||||
Options []string `yaml:"options,omitempty"` // 可选值列表(用于枚举)
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -683,8 +728,8 @@ func Default() *Config {
|
||||
MaxTotalTokens: 120000,
|
||||
},
|
||||
Agent: AgentConfig{
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
|
||||
@@ -711,11 +756,11 @@ func Default() *Config {
|
||||
HybridWeight: 0.7,
|
||||
},
|
||||
Indexing: IndexingConfig{
|
||||
ChunkSize: 768, // 增加到 768,更好的上下文保持
|
||||
ChunkSize: 768, // 增加到 768,更好的上下文保持
|
||||
ChunkOverlap: 50,
|
||||
MaxChunksPerItem: 20, // 限制单个知识项最多 20 个块,避免消耗过多配额
|
||||
MaxRPM: 100, // 默认 100 RPM,避免 429 错误
|
||||
RateLimitDelayMs: 600, // 600ms 间隔,对应 100 RPM
|
||||
MaxChunksPerItem: 20, // 限制单个知识项最多 20 个块,避免消耗过多配额
|
||||
MaxRPM: 100, // 默认 100 RPM,避免 429 错误
|
||||
RateLimitDelayMs: 600, // 600ms 间隔,对应 100 RPM
|
||||
MaxRetries: 3,
|
||||
RetryDelayMs: 1000,
|
||||
},
|
||||
@@ -735,20 +780,20 @@ type KnowledgeConfig struct {
|
||||
// IndexingConfig 索引构建配置(用于控制知识库索引构建时的行为)
|
||||
type IndexingConfig struct {
|
||||
// 分块配置
|
||||
ChunkSize int `yaml:"chunk_size,omitempty" json:"chunk_size,omitempty"` // 每个块的最大 token 数(估算),默认 512
|
||||
ChunkOverlap int `yaml:"chunk_overlap,omitempty" json:"chunk_overlap,omitempty"` // 块之间的重叠 token 数,默认 50
|
||||
ChunkSize int `yaml:"chunk_size,omitempty" json:"chunk_size,omitempty"` // 每个块的最大 token 数(估算),默认 512
|
||||
ChunkOverlap int `yaml:"chunk_overlap,omitempty" json:"chunk_overlap,omitempty"` // 块之间的重叠 token 数,默认 50
|
||||
MaxChunksPerItem int `yaml:"max_chunks_per_item,omitempty" json:"max_chunks_per_item,omitempty"` // 单个知识项的最大块数量,0 表示不限制
|
||||
|
||||
// 速率限制配置(用于避免 API 速率限制)
|
||||
RateLimitDelayMs int `yaml:"rate_limit_delay_ms,omitempty" json:"rate_limit_delay_ms,omitempty"` // 请求间隔时间(毫秒),0 表示不使用固定延迟
|
||||
MaxRPM int `yaml:"max_rpm,omitempty" json:"max_rpm,omitempty"` // 每分钟最大请求数,0 表示不限制
|
||||
MaxRPM int `yaml:"max_rpm,omitempty" json:"max_rpm,omitempty"` // 每分钟最大请求数,0 表示不限制
|
||||
|
||||
// 重试配置(用于处理临时错误)
|
||||
MaxRetries int `yaml:"max_retries,omitempty" json:"max_retries,omitempty"` // 最大重试次数,默认 3
|
||||
RetryDelayMs int `yaml:"retry_delay_ms,omitempty" json:"retry_delay_ms,omitempty"` // 重试间隔(毫秒),默认 1000
|
||||
MaxRetries int `yaml:"max_retries,omitempty" json:"max_retries,omitempty"` // 最大重试次数,默认 3
|
||||
RetryDelayMs int `yaml:"retry_delay_ms,omitempty" json:"retry_delay_ms,omitempty"` // 重试间隔(毫秒),默认 1000
|
||||
|
||||
// 批处理配置(用于批量嵌入,当前未使用,保留扩展)
|
||||
BatchSize int `yaml:"batch_size,omitempty" json:"batch_size,omitempty"` // 批量处理大小,0 表示逐个处理
|
||||
BatchSize int `yaml:"batch_size,omitempty" json:"batch_size,omitempty"` // 批量处理大小,0 表示逐个处理
|
||||
}
|
||||
|
||||
// EmbeddingConfig 嵌入配置
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package einomcp
|
||||
|
||||
import "sync"
|
||||
|
||||
// ConversationHolder 在每次 DeepAgent 运行前写入会话 ID,供 MCP 工具桥接使用。
|
||||
type ConversationHolder struct {
|
||||
mu sync.RWMutex
|
||||
id string
|
||||
}
|
||||
|
||||
func (h *ConversationHolder) Set(id string) {
|
||||
h.mu.Lock()
|
||||
h.id = id
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *ConversationHolder) Get() string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.id
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package einomcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/eino-contrib/jsonschema"
|
||||
)
|
||||
|
||||
// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。
|
||||
type ExecutionRecorder func(executionID string)
|
||||
|
||||
// ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。
|
||||
func ToolsFromDefinitions(ag *agent.Agent, holder *ConversationHolder, defs []agent.Tool, rec ExecutionRecorder) ([]tool.BaseTool, error) {
|
||||
out := make([]tool.BaseTool, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
if d.Type != "function" || d.Function.Name == "" {
|
||||
continue
|
||||
}
|
||||
info, err := toolInfoFromDefinition(d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool %q: %w", d.Function.Name, err)
|
||||
}
|
||||
out = append(out, &mcpBridgeTool{
|
||||
info: info,
|
||||
name: d.Function.Name,
|
||||
agent: ag,
|
||||
holder: holder,
|
||||
record: rec,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toolInfoFromDefinition(d agent.Tool) (*schema.ToolInfo, error) {
|
||||
fn := d.Function
|
||||
raw, err := json.Marshal(fn.Parameters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var js jsonschema.Schema
|
||||
if len(raw) > 0 && string(raw) != "null" && string(raw) != "{}" {
|
||||
if err := json.Unmarshal(raw, &js); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if js.Type == "" {
|
||||
js.Type = string(schema.Object)
|
||||
}
|
||||
if js.Properties == nil && js.Type == string(schema.Object) {
|
||||
// 空参数对象
|
||||
}
|
||||
return &schema.ToolInfo{
|
||||
Name: fn.Name,
|
||||
Desc: fn.Description,
|
||||
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&js),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mcpBridgeTool struct {
|
||||
info *schema.ToolInfo
|
||||
name string
|
||||
agent *agent.Agent
|
||||
holder *ConversationHolder
|
||||
record ExecutionRecorder
|
||||
}
|
||||
|
||||
func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
_ = ctx
|
||||
return m.info, nil
|
||||
}
|
||||
|
||||
func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
_ = opts
|
||||
var args map[string]interface{}
|
||||
if argumentsInJSON != "" && argumentsInJSON != "null" {
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid tool arguments JSON: %w", err)
|
||||
}
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
conv := m.holder.Get()
|
||||
res, err := m.agent.ExecuteMCPToolForConversation(ctx, conv, m.name, args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res == nil {
|
||||
return "", nil
|
||||
}
|
||||
if res.ExecutionID != "" && m.record != nil {
|
||||
m.record(res.ExecutionID)
|
||||
}
|
||||
return res.Result, nil
|
||||
}
|
||||
+123
-25
@@ -19,6 +19,7 @@ import (
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
"cyberstrike-ai/internal/skills"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -77,7 +78,8 @@ type AgentHandler struct {
|
||||
knowledgeManager interface { // 知识库管理器接口
|
||||
LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error
|
||||
}
|
||||
skillsManager *skills.Manager // Skills管理器
|
||||
skillsManager *skills.Manager // Skills管理器
|
||||
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
|
||||
}
|
||||
|
||||
// NewAgentHandler 创建新的Agent处理器
|
||||
@@ -112,6 +114,11 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) {
|
||||
h.skillsManager = manager
|
||||
}
|
||||
|
||||
// SetAgentsMarkdownDir 设置 agents/*.md 子代理目录(绝对路径);空表示仅使用 config.yaml 中的 sub_agents。
|
||||
func (h *AgentHandler) SetAgentsMarkdownDir(absDir string) {
|
||||
h.agentsMarkdownDir = strings.TrimSpace(absDir)
|
||||
}
|
||||
|
||||
// ChatAttachment 聊天附件(用户上传的文件)
|
||||
type ChatAttachment struct {
|
||||
FileName string `json:"fileName"` // 文件名
|
||||
@@ -123,8 +130,8 @@ type ChatAttachment struct {
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||
}
|
||||
|
||||
@@ -315,7 +322,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
||||
|
||||
// 应用角色用户提示词和工具配置
|
||||
finalMessage := req.Message
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
|
||||
|
||||
// WebShell AI 助手模式:绑定当前连接,仅开放 webshell_* 工具并注入 connection_id
|
||||
@@ -484,6 +491,53 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, conversationI
|
||||
}
|
||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, nil)
|
||||
|
||||
useRobotMulti := h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.RobotUseMultiAgent
|
||||
if useRobotMulti {
|
||||
resultMA, errMA := multiagent.RunDeepAgent(
|
||||
ctx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
conversationID,
|
||||
finalMessage,
|
||||
agentHistoryMessages,
|
||||
roleTools,
|
||||
progressCallback,
|
||||
h.agentsMarkdownDir,
|
||||
)
|
||||
if errMA != nil {
|
||||
errMsg := "执行失败: " + errMA.Error()
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||
}
|
||||
return "", conversationID, errMA
|
||||
}
|
||||
if assistantMessageID != "" {
|
||||
mcpIDsJSON := ""
|
||||
if len(resultMA.MCPExecutionIDs) > 0 {
|
||||
jsonData, _ := json.Marshal(resultMA.MCPExecutionIDs)
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, err = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
resultMA.Response, mcpIDsJSON, assistantMessageID,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
if _, err = h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if resultMA.LastReActInput != "" || resultMA.LastReActOutput != "" {
|
||||
_ = h.db.SaveReActData(conversationID, resultMA.LastReActInput, resultMA.LastReActOutput)
|
||||
}
|
||||
return resultMA.Response, conversationID, nil
|
||||
}
|
||||
|
||||
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
|
||||
if err != nil {
|
||||
errMsg := "执行失败: " + err.Error()
|
||||
@@ -662,6 +716,14 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
||||
}
|
||||
}
|
||||
|
||||
// 子代理回复流式增量不落库;结束时合并为一条 eino_agent_reply
|
||||
if assistantMessageID != "" && eventType == "eino_agent_reply_stream_end" {
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "eino_agent_reply", message, data); err != nil {
|
||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 保存过程详情到数据库(排除response/done事件,它们会在后面单独处理)
|
||||
// 另外:response_start/response_delta 是模型流式增量,保存会导致过程详情膨胀,因此不落库。
|
||||
if assistantMessageID != "" &&
|
||||
@@ -671,7 +733,10 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
||||
eventType != "response_delta" &&
|
||||
eventType != "tool_result_delta" &&
|
||||
eventType != "thinking_stream_start" &&
|
||||
eventType != "thinking_stream_delta" {
|
||||
eventType != "thinking_stream_delta" &&
|
||||
eventType != "eino_agent_reply_stream_start" &&
|
||||
eventType != "eino_agent_reply_stream_delta" &&
|
||||
eventType != "eino_agent_reply_stream_end" {
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
|
||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||
}
|
||||
@@ -1499,7 +1564,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
|
||||
// 应用角色用户提示词和工具配置
|
||||
finalMessage := task.Message
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
var roleTools []string // 角色配置的工具列表
|
||||
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
|
||||
if queue.Role != "" && queue.Role != "默认" {
|
||||
if h.config.Roles != nil {
|
||||
@@ -1553,28 +1618,42 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
h.batchTaskManager.SetTaskCancel(queueID, cancel)
|
||||
// 使用队列配置的角色工具列表(如果为空,表示使用所有工具)
|
||||
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
|
||||
result, err := h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools, roleSkills)
|
||||
useBatchMulti := h.config != nil && h.config.MultiAgent.Enabled && h.config.MultiAgent.BatchUseMultiAgent
|
||||
var result *agent.AgentLoopResult
|
||||
var resultMA *multiagent.RunResult
|
||||
var runErr error
|
||||
if useBatchMulti {
|
||||
resultMA, runErr = multiagent.RunDeepAgent(ctx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir)
|
||||
} else {
|
||||
result, runErr = h.agent.AgentLoopWithProgress(ctx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools, roleSkills)
|
||||
}
|
||||
// 任务执行完成,清理取消函数
|
||||
h.batchTaskManager.SetTaskCancel(queueID, nil)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
if runErr != nil {
|
||||
// 检查是否是取消错误
|
||||
// 1. 直接检查是否是 context.Canceled(包括包装后的错误)
|
||||
// 2. 检查错误消息中是否包含"context canceled"或"cancelled"关键字
|
||||
// 3. 检查 result.Response 中是否包含取消相关的消息
|
||||
errStr := err.Error()
|
||||
isCancelled := errors.Is(err, context.Canceled) ||
|
||||
errStr := runErr.Error()
|
||||
partialResp := ""
|
||||
if result != nil {
|
||||
partialResp = result.Response
|
||||
} else if resultMA != nil {
|
||||
partialResp = resultMA.Response
|
||||
}
|
||||
isCancelled := errors.Is(runErr, context.Canceled) ||
|
||||
strings.Contains(strings.ToLower(errStr), "context canceled") ||
|
||||
strings.Contains(strings.ToLower(errStr), "context cancelled") ||
|
||||
(result != nil && result.Response != "" && (strings.Contains(result.Response, "任务已被取消") || strings.Contains(result.Response, "任务执行中断")))
|
||||
(partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断")))
|
||||
|
||||
if isCancelled {
|
||||
h.logger.Info("批量任务被取消", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||
// 如果result中有更具体的取消消息,使用它
|
||||
if result != nil && result.Response != "" && (strings.Contains(result.Response, "任务已被取消") || strings.Contains(result.Response, "任务执行中断")) {
|
||||
cancelMsg = result.Response
|
||||
// 如果执行结果中有更具体的取消消息,使用它
|
||||
if partialResp != "" && (strings.Contains(partialResp, "任务已被取消") || strings.Contains(partialResp, "任务执行中断")) {
|
||||
cancelMsg = partialResp
|
||||
}
|
||||
// 更新助手消息内容
|
||||
if assistantMessageID != "" {
|
||||
@@ -1601,11 +1680,15 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
|
||||
h.logger.Warn("保存取消任务的ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
}
|
||||
} else if resultMA != nil && (resultMA.LastReActInput != "" || resultMA.LastReActOutput != "") {
|
||||
if err := h.db.SaveReActData(conversationID, resultMA.LastReActInput, resultMA.LastReActOutput); err != nil {
|
||||
h.logger.Warn("保存取消任务的ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "cancelled", cancelMsg, "", conversationID)
|
||||
} else {
|
||||
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||
errorMsg := "执行失败: " + err.Error()
|
||||
h.logger.Error("批量任务执行失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(runErr))
|
||||
errorMsg := "执行失败: " + runErr.Error()
|
||||
// 更新助手消息内容
|
||||
if assistantMessageID != "" {
|
||||
if _, updateErr := h.db.Exec(
|
||||
@@ -1620,42 +1703,57 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
h.logger.Warn("保存错误详情失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", err.Error())
|
||||
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", runErr.Error())
|
||||
}
|
||||
} else {
|
||||
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||
|
||||
var resText string
|
||||
var mcpIDs []string
|
||||
var lastIn, lastOut string
|
||||
if useBatchMulti {
|
||||
resText = resultMA.Response
|
||||
mcpIDs = resultMA.MCPExecutionIDs
|
||||
lastIn = resultMA.LastReActInput
|
||||
lastOut = resultMA.LastReActOutput
|
||||
} else {
|
||||
resText = result.Response
|
||||
mcpIDs = result.MCPExecutionIDs
|
||||
lastIn = result.LastReActInput
|
||||
lastOut = result.LastReActOutput
|
||||
}
|
||||
|
||||
// 更新助手消息内容
|
||||
if assistantMessageID != "" {
|
||||
mcpIDsJSON := ""
|
||||
if len(result.MCPExecutionIDs) > 0 {
|
||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
||||
if len(mcpIDs) > 0 {
|
||||
jsonData, _ := json.Marshal(mcpIDs)
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
if _, updateErr := h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
result.Response,
|
||||
resText,
|
||||
mcpIDsJSON,
|
||||
assistantMessageID,
|
||||
); updateErr != nil {
|
||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||
// 如果更新失败,尝试创建新消息
|
||||
_, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs)
|
||||
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||
if err != nil {
|
||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果没有预先创建的助手消息,创建一个新的
|
||||
_, err = h.db.AddMessage(conversationID, "assistant", result.Response, result.MCPExecutionIDs)
|
||||
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||
if err != nil {
|
||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 保存ReAct数据
|
||||
if result.LastReActInput != "" || result.LastReActOutput != "" {
|
||||
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
|
||||
if lastIn != "" || lastOut != "" {
|
||||
if err := h.db.SaveReActData(conversationID, lastIn, lastOut); err != nil {
|
||||
h.logger.Warn("保存ReAct数据失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
} else {
|
||||
h.logger.Info("已保存ReAct数据", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||
@@ -1663,7 +1761,7 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
}
|
||||
|
||||
// 保存结果
|
||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", result.Response, "", conversationID)
|
||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, "completed", resText, "", conversationID)
|
||||
}
|
||||
|
||||
// 移动到下一个任务
|
||||
|
||||
+74
-21
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/knowledge"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
@@ -168,13 +169,14 @@ func (h *ConfigHandler) SetRobotRestarter(restarter RobotRestarter) {
|
||||
|
||||
// GetConfigResponse 获取配置响应
|
||||
type GetConfigResponse struct {
|
||||
OpenAI config.OpenAIConfig `json:"openai"`
|
||||
FOFA config.FofaConfig `json:"fofa"`
|
||||
MCP config.MCPConfig `json:"mcp"`
|
||||
Tools []ToolConfigInfo `json:"tools"`
|
||||
Agent config.AgentConfig `json:"agent"`
|
||||
Knowledge config.KnowledgeConfig `json:"knowledge"`
|
||||
Robots config.RobotsConfig `json:"robots,omitempty"`
|
||||
OpenAI config.OpenAIConfig `json:"openai"`
|
||||
FOFA config.FofaConfig `json:"fofa"`
|
||||
MCP config.MCPConfig `json:"mcp"`
|
||||
Tools []ToolConfigInfo `json:"tools"`
|
||||
Agent config.AgentConfig `json:"agent"`
|
||||
Knowledge config.KnowledgeConfig `json:"knowledge"`
|
||||
Robots config.RobotsConfig `json:"robots,omitempty"`
|
||||
MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"`
|
||||
}
|
||||
|
||||
// ToolConfigInfo 工具配置信息
|
||||
@@ -240,14 +242,37 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
subAgentCount := len(h.config.MultiAgent.SubAgents)
|
||||
agentsDir := strings.TrimSpace(h.config.AgentsDir)
|
||||
if agentsDir == "" {
|
||||
agentsDir = "agents"
|
||||
}
|
||||
if !filepath.IsAbs(agentsDir) {
|
||||
agentsDir = filepath.Join(filepath.Dir(h.configPath), agentsDir)
|
||||
}
|
||||
if load, err := agents.LoadMarkdownAgentsDir(agentsDir); err == nil {
|
||||
subAgentCount = len(agents.MergeYAMLAndMarkdown(h.config.MultiAgent.SubAgents, load.SubAgents))
|
||||
}
|
||||
multiPub := config.MultiAgentPublic{
|
||||
Enabled: h.config.MultiAgent.Enabled,
|
||||
DefaultMode: h.config.MultiAgent.DefaultMode,
|
||||
RobotUseMultiAgent: h.config.MultiAgent.RobotUseMultiAgent,
|
||||
BatchUseMultiAgent: h.config.MultiAgent.BatchUseMultiAgent,
|
||||
SubAgentCount: subAgentCount,
|
||||
}
|
||||
if strings.TrimSpace(multiPub.DefaultMode) == "" {
|
||||
multiPub.DefaultMode = "single"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetConfigResponse{
|
||||
OpenAI: h.config.OpenAI,
|
||||
FOFA: h.config.FOFA,
|
||||
MCP: h.config.MCP,
|
||||
Tools: tools,
|
||||
Agent: h.config.Agent,
|
||||
Knowledge: h.config.Knowledge,
|
||||
Robots: h.config.Robots,
|
||||
OpenAI: h.config.OpenAI,
|
||||
FOFA: h.config.FOFA,
|
||||
MCP: h.config.MCP,
|
||||
Tools: tools,
|
||||
Agent: h.config.Agent,
|
||||
Knowledge: h.config.Knowledge,
|
||||
Robots: h.config.Robots,
|
||||
MultiAgent: multiPub,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -499,13 +524,14 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
|
||||
// UpdateConfigRequest 更新配置请求
|
||||
type UpdateConfigRequest struct {
|
||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
||||
MCP *config.MCPConfig `json:"mcp,omitempty"`
|
||||
Tools []ToolEnableStatus `json:"tools,omitempty"`
|
||||
Agent *config.AgentConfig `json:"agent,omitempty"`
|
||||
Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"`
|
||||
Robots *config.RobotsConfig `json:"robots,omitempty"`
|
||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
||||
MCP *config.MCPConfig `json:"mcp,omitempty"`
|
||||
Tools []ToolEnableStatus `json:"tools,omitempty"`
|
||||
Agent *config.AgentConfig `json:"agent,omitempty"`
|
||||
Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"`
|
||||
Robots *config.RobotsConfig `json:"robots,omitempty"`
|
||||
MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"`
|
||||
}
|
||||
|
||||
// ToolEnableStatus 工具启用状态
|
||||
@@ -592,6 +618,23 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// 多代理标量(sub_agents 等仍由 config.yaml 维护)
|
||||
if req.MultiAgent != nil {
|
||||
h.config.MultiAgent.Enabled = req.MultiAgent.Enabled
|
||||
dm := strings.TrimSpace(req.MultiAgent.DefaultMode)
|
||||
if dm == "multi" || dm == "single" {
|
||||
h.config.MultiAgent.DefaultMode = dm
|
||||
}
|
||||
h.config.MultiAgent.RobotUseMultiAgent = req.MultiAgent.RobotUseMultiAgent
|
||||
h.config.MultiAgent.BatchUseMultiAgent = req.MultiAgent.BatchUseMultiAgent
|
||||
h.logger.Info("更新多代理配置",
|
||||
zap.Bool("enabled", h.config.MultiAgent.Enabled),
|
||||
zap.String("default_mode", h.config.MultiAgent.DefaultMode),
|
||||
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
|
||||
zap.Bool("batch_use_multi_agent", h.config.MultiAgent.BatchUseMultiAgent),
|
||||
)
|
||||
}
|
||||
|
||||
// 更新工具启用状态
|
||||
if req.Tools != nil {
|
||||
// 分离内部工具和外部工具
|
||||
@@ -910,6 +953,7 @@ func (h *ConfigHandler) saveConfig() error {
|
||||
updateFOFAConfig(root, h.config.FOFA)
|
||||
updateKnowledgeConfig(root, h.config.Knowledge)
|
||||
updateRobotsConfig(root, h.config.Robots)
|
||||
updateMultiAgentConfig(root, h.config.MultiAgent)
|
||||
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
|
||||
// 读取原始配置以保持向后兼容
|
||||
originalConfigs := make(map[string]map[string]bool)
|
||||
@@ -1119,6 +1163,15 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
|
||||
}
|
||||
|
||||
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
||||
root := doc.Content[0]
|
||||
maNode := ensureMap(root, "multi_agent")
|
||||
setBoolInMap(maNode, "enabled", cfg.Enabled)
|
||||
setStringInMap(maNode, "default_mode", cfg.DefaultMode)
|
||||
setBoolInMap(maNode, "robot_use_multi_agent", cfg.RobotUseMultiAgent)
|
||||
setBoolInMap(maNode, "batch_use_multi_agent", cfg.BatchUseMultiAgent)
|
||||
}
|
||||
|
||||
func ensureMap(parent *yaml.Node, path ...string) *yaml.Node {
|
||||
current := parent
|
||||
for _, key := range path {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var markdownAgentFilenameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.md$`)
|
||||
|
||||
// MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。
|
||||
type MarkdownAgentsHandler struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewMarkdownAgentsHandler dir 须为已解析的绝对路径。
|
||||
func NewMarkdownAgentsHandler(dir string) *MarkdownAgentsHandler {
|
||||
return &MarkdownAgentsHandler{dir: strings.TrimSpace(dir)}
|
||||
}
|
||||
|
||||
func (h *MarkdownAgentsHandler) safeJoin(filename string) (string, error) {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" || !markdownAgentFilenameRe.MatchString(filename) {
|
||||
return "", fmt.Errorf("非法文件名")
|
||||
}
|
||||
clean := filepath.Clean(filename)
|
||||
if clean != filename || strings.Contains(clean, "..") {
|
||||
return "", fmt.Errorf("非法文件名")
|
||||
}
|
||||
return filepath.Join(h.dir, clean), nil
|
||||
}
|
||||
|
||||
// existingOtherOrchestrator 若目录中已有别的主代理文件,返回其文件名;writingBasename 为当前正在写入的文件名时视为同一文件不冲突。
|
||||
func existingOtherOrchestrator(dir, writingBasename string) (other string, err error) {
|
||||
load, err := agents.LoadMarkdownAgentsDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if load.Orchestrator == nil {
|
||||
return "", nil
|
||||
}
|
||||
if strings.EqualFold(load.Orchestrator.Filename, writingBasename) {
|
||||
return "", nil
|
||||
}
|
||||
return load.Orchestrator.Filename, nil
|
||||
}
|
||||
|
||||
// ListMarkdownAgents GET /api/multi-agent/markdown-agents
|
||||
func (h *MarkdownAgentsHandler) ListMarkdownAgents(c *gin.Context) {
|
||||
if h.dir == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"agents": []any{}, "dir": "", "error": "未配置 agents 目录"})
|
||||
return
|
||||
}
|
||||
files, err := agents.LoadMarkdownAgentFiles(h.dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(files))
|
||||
for _, fa := range files {
|
||||
sub := fa.Config
|
||||
out = append(out, gin.H{
|
||||
"filename": fa.Filename,
|
||||
"id": sub.ID,
|
||||
"name": sub.Name,
|
||||
"description": sub.Description,
|
||||
"is_orchestrator": fa.IsOrchestrator,
|
||||
"kind": sub.Kind,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"agents": out, "dir": h.dir})
|
||||
}
|
||||
|
||||
// GetMarkdownAgent GET /api/multi-agent/markdown-agents/:filename
|
||||
func (h *MarkdownAgentsHandler) GetMarkdownAgent(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
path, err := h.safeJoin(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sub, err := agents.ParseMarkdownSubAgent(filename, string(b))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
isOrch := agents.IsOrchestratorMarkdown(filename, agents.FrontMatter{Kind: sub.Kind})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"filename": filename,
|
||||
"raw": string(b),
|
||||
"id": sub.ID,
|
||||
"name": sub.Name,
|
||||
"description": sub.Description,
|
||||
"tools": sub.RoleTools,
|
||||
"instruction": sub.Instruction,
|
||||
"bind_role": sub.BindRole,
|
||||
"max_iterations": sub.MaxIterations,
|
||||
"kind": sub.Kind,
|
||||
"is_orchestrator": isOrch,
|
||||
})
|
||||
}
|
||||
|
||||
type markdownAgentBody struct {
|
||||
Filename string `json:"filename"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tools []string `json:"tools"`
|
||||
Instruction string `json:"instruction"`
|
||||
BindRole string `json:"bind_role"`
|
||||
MaxIterations int `json:"max_iterations"`
|
||||
Kind string `json:"kind"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// CreateMarkdownAgent POST /api/multi-agent/markdown-agents
|
||||
func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) {
|
||||
if h.dir == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未配置 agents 目录"})
|
||||
return
|
||||
}
|
||||
var body markdownAgentBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filename := strings.TrimSpace(body.Filename)
|
||||
if filename == "" {
|
||||
if strings.EqualFold(strings.TrimSpace(body.Kind), "orchestrator") {
|
||||
filename = agents.OrchestratorMarkdownFilename
|
||||
} else {
|
||||
base := agents.SlugID(body.Name)
|
||||
if base == "" {
|
||||
base = "agent"
|
||||
}
|
||||
filename = base + ".md"
|
||||
}
|
||||
}
|
||||
path, err := h.safeJoin(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "文件已存在"})
|
||||
return
|
||||
}
|
||||
sub := config.MultiAgentSubConfig{
|
||||
ID: strings.TrimSpace(body.ID),
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Description: strings.TrimSpace(body.Description),
|
||||
Instruction: strings.TrimSpace(body.Instruction),
|
||||
RoleTools: body.Tools,
|
||||
BindRole: strings.TrimSpace(body.BindRole),
|
||||
MaxIterations: body.MaxIterations,
|
||||
Kind: strings.TrimSpace(body.Kind),
|
||||
}
|
||||
if strings.EqualFold(filepath.Base(path), agents.OrchestratorMarkdownFilename) && sub.Kind == "" {
|
||||
sub.Kind = "orchestrator"
|
||||
}
|
||||
if sub.ID == "" {
|
||||
sub.ID = agents.SlugID(sub.Name)
|
||||
}
|
||||
if sub.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
|
||||
return
|
||||
}
|
||||
var out []byte
|
||||
if strings.TrimSpace(body.Raw) != "" {
|
||||
out = []byte(body.Raw)
|
||||
} else {
|
||||
out, err = agents.BuildMarkdownFile(sub)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if want := agents.WantsMarkdownOrchestrator(filepath.Base(path), body.Kind, string(out)); want {
|
||||
other, oerr := existingOtherOrchestrator(h.dir, filepath.Base(path))
|
||||
if oerr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": oerr.Error()})
|
||||
return
|
||||
}
|
||||
if other != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("已存在主代理定义:%s,请先删除或取消其主代理标记", other)})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(h.dir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, out, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"filename": filepath.Base(path), "message": "已创建"})
|
||||
}
|
||||
|
||||
// UpdateMarkdownAgent PUT /api/multi-agent/markdown-agents/:filename
|
||||
func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
path, err := h.safeJoin(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var body markdownAgentBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sub := config.MultiAgentSubConfig{
|
||||
ID: strings.TrimSpace(body.ID),
|
||||
Name: strings.TrimSpace(body.Name),
|
||||
Description: strings.TrimSpace(body.Description),
|
||||
Instruction: strings.TrimSpace(body.Instruction),
|
||||
RoleTools: body.Tools,
|
||||
BindRole: strings.TrimSpace(body.BindRole),
|
||||
MaxIterations: body.MaxIterations,
|
||||
Kind: strings.TrimSpace(body.Kind),
|
||||
}
|
||||
if strings.EqualFold(filename, agents.OrchestratorMarkdownFilename) && sub.Kind == "" {
|
||||
sub.Kind = "orchestrator"
|
||||
}
|
||||
if sub.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
|
||||
return
|
||||
}
|
||||
if sub.ID == "" {
|
||||
sub.ID = agents.SlugID(sub.Name)
|
||||
}
|
||||
var out []byte
|
||||
if strings.TrimSpace(body.Raw) != "" {
|
||||
out = []byte(body.Raw)
|
||||
} else {
|
||||
out, err = agents.BuildMarkdownFile(sub)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if want := agents.WantsMarkdownOrchestrator(filename, body.Kind, string(out)); want {
|
||||
other, oerr := existingOtherOrchestrator(h.dir, filename)
|
||||
if oerr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": oerr.Error()})
|
||||
return
|
||||
}
|
||||
if other != "" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("已存在主代理定义:%s,请先删除或取消其主代理标记", other)})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, out, 0644); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已保存"})
|
||||
}
|
||||
|
||||
// DeleteMarkdownAgent DELETE /api/multi-agent/markdown-agents/:filename
|
||||
func (h *MarkdownAgentsHandler) DeleteMarkdownAgent(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
path, err := h.safeJoin(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MultiAgentLoopStream Eino DeepAgent 流式对话(需 config.multi_agent.enabled)。
|
||||
func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||
ev := StreamEvent{Type: "error", Message: "多代理未启用,请在设置或 config.yaml 中开启 multi_agent.enabled"}
|
||||
b, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
done := StreamEvent{Type: "done", Message: ""}
|
||||
db, _ := json.Marshal(done)
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", db)
|
||||
if flusher, ok := c.Writer.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
event := StreamEvent{Type: "error", Message: "请求参数错误: " + err.Error()}
|
||||
b, _ := json.Marshal(event)
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
clientDisconnected := false
|
||||
sendEvent := func(eventType, message string, data interface{}) {
|
||||
if clientDisconnected {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
clientDisconnected = true
|
||||
return
|
||||
default:
|
||||
}
|
||||
ev := StreamEvent{Type: eventType, Message: message, Data: data}
|
||||
b, _ := json.Marshal(ev)
|
||||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", b); err != nil {
|
||||
clientDisconnected = true
|
||||
return
|
||||
}
|
||||
if flusher, ok := c.Writer.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
} else {
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("收到 Eino DeepAgent 流式请求",
|
||||
zap.String("conversationId", req.ConversationID),
|
||||
)
|
||||
|
||||
prep, err := h.prepareMultiAgentSession(&req)
|
||||
if err != nil {
|
||||
sendEvent("error", err.Error(), nil)
|
||||
sendEvent("done", "", nil)
|
||||
return
|
||||
}
|
||||
if prep.CreatedNew {
|
||||
sendEvent("conversation", "会话已创建", map[string]interface{}{
|
||||
"conversationId": prep.ConversationID,
|
||||
})
|
||||
}
|
||||
|
||||
conversationID := prep.ConversationID
|
||||
assistantMessageID := prep.AssistantMessageID
|
||||
|
||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
defer cancelWithCause(nil)
|
||||
|
||||
if _, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
|
||||
var errorMsg string
|
||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
|
||||
sendEvent("error", errorMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"errorType": "task_already_running",
|
||||
})
|
||||
} else {
|
||||
errorMsg = "❌ 无法启动任务: " + err.Error()
|
||||
sendEvent("error", errorMsg, nil)
|
||||
}
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errorMsg, assistantMessageID)
|
||||
}
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
taskStatus := "completed"
|
||||
defer h.tasks.FinishTask(conversationID, taskStatus)
|
||||
|
||||
sendEvent("progress", "正在启动 Eino DeepAgent...", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
|
||||
result, runErr := multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
conversationID,
|
||||
prep.FinalMessage,
|
||||
prep.History,
|
||||
prep.RoleTools,
|
||||
progressCallback,
|
||||
h.agentsMarkdownDir,
|
||||
)
|
||||
|
||||
if runErr != nil {
|
||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||
cause := context.Cause(baseCtx)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
taskStatus = "cancelled"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", cancelMsg, assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||
}
|
||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
taskStatus = "failed"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
errMsg := "执行失败: " + runErr.Error()
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||
}
|
||||
sendEvent("error", errMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
if assistantMessageID != "" {
|
||||
mcpIDsJSON := ""
|
||||
if len(result.MCPExecutionIDs) > 0 {
|
||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, _ = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
result.Response,
|
||||
mcpIDsJSON,
|
||||
assistantMessageID,
|
||||
)
|
||||
}
|
||||
|
||||
if result.LastReActInput != "" || result.LastReActOutput != "" {
|
||||
if err := h.db.SaveReActData(conversationID, result.LastReActInput, result.LastReActOutput); err != nil {
|
||||
h.logger.Warn("保存 ReAct 数据失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent("response", result.Response, map[string]interface{}{
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_deep",
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
|
||||
// MultiAgentLoop Eino DeepAgent 非流式对话(与 POST /api/agent-loop 对齐,需 multi_agent.enabled)。
|
||||
func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
if h.config == nil || !h.config.MultiAgent.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "多代理未启用,请在 config.yaml 中设置 multi_agent.enabled: true"})
|
||||
return
|
||||
}
|
||||
|
||||
var req ChatRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("收到 Eino DeepAgent 非流式请求", zap.String("conversationId", req.ConversationID))
|
||||
|
||||
prep, err := h.prepareMultiAgentSession(&req)
|
||||
if err != nil {
|
||||
status, msg := multiAgentHTTPErrorStatus(err)
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
result, runErr := multiagent.RunDeepAgent(
|
||||
c.Request.Context(),
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.logger,
|
||||
prep.ConversationID,
|
||||
prep.FinalMessage,
|
||||
prep.History,
|
||||
prep.RoleTools,
|
||||
nil,
|
||||
h.agentsMarkdownDir,
|
||||
)
|
||||
if runErr != nil {
|
||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||
errMsg := "执行失败: " + runErr.Error()
|
||||
if prep.AssistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ? WHERE id = ?", errMsg, prep.AssistantMessageID)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
if prep.AssistantMessageID != "" {
|
||||
mcpIDsJSON := ""
|
||||
if len(result.MCPExecutionIDs) > 0 {
|
||||
jsonData, _ := json.Marshal(result.MCPExecutionIDs)
|
||||
mcpIDsJSON = string(jsonData)
|
||||
}
|
||||
_, _ = h.db.Exec(
|
||||
"UPDATE messages SET content = ?, mcp_execution_ids = ? WHERE id = ?",
|
||||
result.Response,
|
||||
mcpIDsJSON,
|
||||
prep.AssistantMessageID,
|
||||
)
|
||||
}
|
||||
|
||||
if result.LastReActInput != "" || result.LastReActOutput != "" {
|
||||
if err := h.db.SaveReActData(prep.ConversationID, result.LastReActInput, result.LastReActOutput); err != nil {
|
||||
h.logger.Warn("保存 ReAct 数据失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ChatResponse{
|
||||
Response: result.Response,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func multiAgentHTTPErrorStatus(err error) (int, string) {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "对话不存在"):
|
||||
return http.StatusNotFound, msg
|
||||
case strings.Contains(msg, "未找到该 WebShell"):
|
||||
return http.StatusBadRequest, msg
|
||||
case strings.Contains(msg, "附件最多"):
|
||||
return http.StatusBadRequest, msg
|
||||
case strings.Contains(msg, "保存用户消息失败"), strings.Contains(msg, "创建对话失败"):
|
||||
return http.StatusInternalServerError, msg
|
||||
case strings.Contains(msg, "保存上传文件失败"):
|
||||
return http.StatusInternalServerError, msg
|
||||
default:
|
||||
return http.StatusBadRequest, msg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// multiAgentPrepared 多代理请求在调用 Eino 前的会话与消息准备结果。
|
||||
type multiAgentPrepared struct {
|
||||
ConversationID string
|
||||
CreatedNew bool
|
||||
History []agent.ChatMessage
|
||||
FinalMessage string
|
||||
RoleTools []string
|
||||
AssistantMessageID string
|
||||
}
|
||||
|
||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPrepared, error) {
|
||||
if len(req.Attachments) > maxAttachments {
|
||||
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
||||
}
|
||||
|
||||
conversationID := strings.TrimSpace(req.ConversationID)
|
||||
createdNew := false
|
||||
if conversationID == "" {
|
||||
title := safeTruncateString(req.Message, 50)
|
||||
var conv *database.Conversation
|
||||
var err error
|
||||
if strings.TrimSpace(req.WebShellConnectionID) != "" {
|
||||
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title)
|
||||
} else {
|
||||
conv, err = h.db.CreateConversation(title)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建对话失败: %w", err)
|
||||
}
|
||||
conversationID = conv.ID
|
||||
createdNew = true
|
||||
} else {
|
||||
if _, err := h.db.GetConversation(conversationID); err != nil {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
}
|
||||
}
|
||||
|
||||
agentHistoryMessages, err := h.loadHistoryFromReActData(conversationID)
|
||||
if err != nil {
|
||||
historyMessages, getErr := h.db.GetMessages(conversationID)
|
||||
if getErr != nil {
|
||||
agentHistoryMessages = []agent.ChatMessage{}
|
||||
} else {
|
||||
agentHistoryMessages = make([]agent.ChatMessage, 0, len(historyMessages))
|
||||
for _, msg := range historyMessages {
|
||||
agentHistoryMessages = append(agentHistoryMessages, agent.ChatMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalMessage := req.Message
|
||||
var roleTools []string
|
||||
if req.WebShellConnectionID != "" {
|
||||
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
|
||||
if errConn != nil || conn == nil {
|
||||
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
|
||||
return nil, fmt.Errorf("未找到该 WebShell 连接")
|
||||
}
|
||||
remark := conn.Remark
|
||||
if remark == "" {
|
||||
remark = conn.URL
|
||||
}
|
||||
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base、list_skills、read_skill。请根据用户输入决定下一步:若仅为问候、闲聊或简单问题,直接简短回复即可,不必调用工具;当用户明确需要执行命令、列目录、读写文件、记录漏洞或检索知识库/查看 Skills 等操作时再调用上述工具。\n\n用户请求:%s",
|
||||
conn.ID, remark, conn.ID, req.Message)
|
||||
roleTools = []string{
|
||||
builtin.ToolWebshellExec,
|
||||
builtin.ToolWebshellFileList,
|
||||
builtin.ToolWebshellFileRead,
|
||||
builtin.ToolWebshellFileWrite,
|
||||
builtin.ToolRecordVulnerability,
|
||||
builtin.ToolListKnowledgeRiskTypes,
|
||||
builtin.ToolSearchKnowledgeBase,
|
||||
builtin.ToolListSkills,
|
||||
builtin.ToolReadSkill,
|
||||
}
|
||||
} else if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil {
|
||||
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
|
||||
if role.UserPrompt != "" {
|
||||
finalMessage = role.UserPrompt + "\n\n" + req.Message
|
||||
}
|
||||
roleTools = role.Tools
|
||||
}
|
||||
}
|
||||
|
||||
var savedPaths []string
|
||||
if len(req.Attachments) > 0 {
|
||||
var aerr error
|
||||
savedPaths, aerr = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
||||
if aerr != nil {
|
||||
return nil, fmt.Errorf("保存上传文件失败: %w", aerr)
|
||||
}
|
||||
}
|
||||
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
|
||||
|
||||
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||
if _, err = h.db.AddMessage(conversationID, "user", userContent, nil); err != nil {
|
||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("保存用户消息失败: %w", err)
|
||||
}
|
||||
|
||||
assistantMsg, aerr := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
|
||||
var assistantMessageID string
|
||||
if aerr != nil {
|
||||
h.logger.Warn("创建助手消息占位失败", zap.Error(aerr))
|
||||
} else if assistantMsg != nil {
|
||||
assistantMessageID = assistantMsg.ID
|
||||
}
|
||||
|
||||
return &multiAgentPrepared{
|
||||
ConversationID: conversationID,
|
||||
CreatedNew: createdNew,
|
||||
History: agentHistoryMessages,
|
||||
FinalMessage: finalMessage,
|
||||
RoleTools: roleTools,
|
||||
AssistantMessageID: assistantMessageID,
|
||||
}, nil
|
||||
}
|
||||
@@ -1481,6 +1481,91 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/multi-agent": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"summary": "发送消息并获取 AI 回复(Eino DeepAgent,非流式)",
|
||||
"description": "与 `POST /api/agent-loop` 请求体相同,但由 **CloudWeGo Eino DeepAgent** 执行多代理编排。**前提**:`multi_agent.enabled: true`(可在设置页或 `config.yaml` 开启);未启用时返回 404 JSON。请求体支持 `webshellConnectionId`(与单代理 WebShell 助手一致)。",
|
||||
"operationId": "sendMessageMultiAgent",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要发送的消息(必需)",
|
||||
},
|
||||
"conversationId": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "对话 ID(可选,不提供则新建)",
|
||||
},
|
||||
"role": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "角色名称(可选)",
|
||||
},
|
||||
"webshellConnectionId": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "WebShell 连接 ID(可选,与 agent-loop 行为一致)",
|
||||
},
|
||||
},
|
||||
"required": []string{"message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "成功,响应格式同 /api/agent-loop",
|
||||
},
|
||||
"400": map[string]interface{}{"description": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"404": map[string]interface{}{"description": "多代理未启用或对话不存在"},
|
||||
"500": map[string]interface{}{"description": "执行失败"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/multi-agent/stream": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"summary": "发送消息并获取 AI 回复(Eino DeepAgent,SSE)",
|
||||
"description": "与 `POST /api/agent-loop/stream` 类似,事件类型兼容;由 Eino DeepAgent 执行。**前提**:`multi_agent.enabled: true`;路由常注册,未启用时仍返回 200 SSE,流内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||
"operationId": "sendMessageMultiAgentStream",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"message": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"required": []string{"message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "text/event-stream(SSE)",
|
||||
"content": map[string]interface{}{
|
||||
"text/event-stream": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "SSE 流",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/agent-loop/cancel": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
// Package multiagent 使用 CloudWeGo Eino 的 DeepAgent(adk/prebuilt/deep)编排多代理,MCP 工具经 einomcp 桥接到现有 Agent。
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
|
||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/prebuilt/deep"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RunResult 与单 Agent 循环结果字段对齐,便于复用存储与 SSE 收尾逻辑。
|
||||
type RunResult struct {
|
||||
Response string
|
||||
MCPExecutionIDs []string
|
||||
LastReActInput string
|
||||
LastReActOutput string
|
||||
}
|
||||
|
||||
// RunDeepAgent 使用 Eino DeepAgent 执行一轮对话(流式事件通过 progress 回调输出)。
|
||||
func RunDeepAgent(
|
||||
ctx context.Context,
|
||||
appCfg *config.Config,
|
||||
ma *config.MultiAgentConfig,
|
||||
ag *agent.Agent,
|
||||
logger *zap.Logger,
|
||||
conversationID string,
|
||||
userMessage string,
|
||||
history []agent.ChatMessage,
|
||||
roleTools []string,
|
||||
progress func(eventType, message string, data interface{}),
|
||||
agentsMarkdownDir string,
|
||||
) (*RunResult, error) {
|
||||
if appCfg == nil || ma == nil || ag == nil {
|
||||
return nil, fmt.Errorf("multiagent: 配置或 Agent 为空")
|
||||
}
|
||||
|
||||
effectiveSubs := ma.SubAgents
|
||||
var orch *agents.OrchestratorMarkdown
|
||||
if strings.TrimSpace(agentsMarkdownDir) != "" {
|
||||
load, merr := agents.LoadMarkdownAgentsDir(agentsMarkdownDir)
|
||||
if merr != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("加载 agents 目录 Markdown 失败,沿用 config 中的 sub_agents", zap.Error(merr))
|
||||
}
|
||||
} else {
|
||||
effectiveSubs = agents.MergeYAMLAndMarkdown(ma.SubAgents, load.SubAgents)
|
||||
orch = load.Orchestrator
|
||||
}
|
||||
}
|
||||
if ma.WithoutGeneralSubAgent && len(effectiveSubs) == 0 {
|
||||
return nil, fmt.Errorf("multi_agent.without_general_sub_agent 为 true 时,必须在 multi_agent.sub_agents 或 agents 目录 Markdown 中配置至少一个子代理")
|
||||
}
|
||||
|
||||
holder := &einomcp.ConversationHolder{}
|
||||
holder.Set(conversationID)
|
||||
|
||||
var mcpIDsMu sync.Mutex
|
||||
var mcpIDs []string
|
||||
recorder := func(id string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
mcpIDsMu.Lock()
|
||||
mcpIDs = append(mcpIDs, id)
|
||||
mcpIDsMu.Unlock()
|
||||
}
|
||||
|
||||
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
|
||||
snapshotMCPIDs := func() []string {
|
||||
mcpIDsMu.Lock()
|
||||
defer mcpIDsMu.Unlock()
|
||||
out := make([]string, len(mcpIDs))
|
||||
copy(out, mcpIDs)
|
||||
return out
|
||||
}
|
||||
|
||||
mainDefs := ag.ToolsForRole(roleTools)
|
||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Minute,
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 300 * time.Second,
|
||||
KeepAlive: 300 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||
APIKey: appCfg.OpenAI.APIKey,
|
||||
BaseURL: strings.TrimSuffix(appCfg.OpenAI.BaseURL, "/"),
|
||||
Model: appCfg.OpenAI.Model,
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
|
||||
deepMaxIter := ma.MaxIteration
|
||||
if deepMaxIter <= 0 {
|
||||
deepMaxIter = appCfg.Agent.MaxIterations
|
||||
}
|
||||
if deepMaxIter <= 0 {
|
||||
deepMaxIter = 40
|
||||
}
|
||||
|
||||
subDefaultIter := ma.SubAgentMaxIterations
|
||||
if subDefaultIter <= 0 {
|
||||
subDefaultIter = 20
|
||||
}
|
||||
|
||||
subAgents := make([]adk.Agent, 0, len(effectiveSubs))
|
||||
for _, sub := range effectiveSubs {
|
||||
id := strings.TrimSpace(sub.ID)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("multi_agent.sub_agents 中存在空的 id")
|
||||
}
|
||||
name := strings.TrimSpace(sub.Name)
|
||||
if name == "" {
|
||||
name = id
|
||||
}
|
||||
desc := strings.TrimSpace(sub.Description)
|
||||
if desc == "" {
|
||||
desc = fmt.Sprintf("Specialist agent %s for penetration testing workflow.", id)
|
||||
}
|
||||
instr := strings.TrimSpace(sub.Instruction)
|
||||
if instr == "" {
|
||||
instr = "你是 CyberStrikeAI 中的专业子代理,在授权渗透测试场景下协助完成用户委托的子任务。优先使用可用工具获取证据,回答简洁专业。"
|
||||
}
|
||||
|
||||
roleTools := sub.RoleTools
|
||||
bind := strings.TrimSpace(sub.BindRole)
|
||||
if bind != "" && appCfg.Roles != nil {
|
||||
if r, ok := appCfg.Roles[bind]; ok && r.Enabled {
|
||||
if len(roleTools) == 0 && len(r.Tools) > 0 {
|
||||
roleTools = r.Tools
|
||||
}
|
||||
if len(r.Skills) > 0 {
|
||||
var b strings.Builder
|
||||
b.WriteString(instr)
|
||||
b.WriteString("\n\n本角色推荐通过 list_skills / read_skill 按需加载的 Skills:")
|
||||
for i, s := range r.Skills {
|
||||
if i > 0 {
|
||||
b.WriteString("、")
|
||||
}
|
||||
b.WriteString(s)
|
||||
}
|
||||
b.WriteString("。")
|
||||
instr = b.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q ChatModel: %w", id, err)
|
||||
}
|
||||
|
||||
subDefs := ag.ToolsForRole(roleTools)
|
||||
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
|
||||
}
|
||||
|
||||
subMax := sub.MaxIterations
|
||||
if subMax <= 0 {
|
||||
subMax = subDefaultIter
|
||||
}
|
||||
|
||||
sa, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
|
||||
Name: id,
|
||||
Description: desc,
|
||||
Instruction: instr,
|
||||
Model: subModel,
|
||||
ToolsConfig: adk.ToolsConfig{
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: subTools,
|
||||
},
|
||||
EmitInternalEvents: true,
|
||||
},
|
||||
MaxIterations: subMax,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q: %w", id, err)
|
||||
}
|
||||
subAgents = append(subAgents, sa)
|
||||
}
|
||||
|
||||
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Deep 主模型: %w", err)
|
||||
}
|
||||
|
||||
// 与 deep.Config.Name 一致。子代理的 assistant 正文也会经 EmitInternalEvents 流出,若全部当主回复会重复(编排器总结 + 子代理原文)。
|
||||
orchestratorName := "cyberstrike-deep"
|
||||
orchDescription := "Coordinates specialist agents and MCP tools for authorized security testing."
|
||||
orchInstruction := strings.TrimSpace(ma.OrchestratorInstruction)
|
||||
if orch != nil {
|
||||
if strings.TrimSpace(orch.EinoName) != "" {
|
||||
orchestratorName = strings.TrimSpace(orch.EinoName)
|
||||
}
|
||||
if d := strings.TrimSpace(orch.Description); d != "" {
|
||||
orchDescription = d
|
||||
}
|
||||
if ins := strings.TrimSpace(orch.Instruction); ins != "" {
|
||||
orchInstruction = ins
|
||||
}
|
||||
}
|
||||
da, err := deep.New(ctx, &deep.Config{
|
||||
Name: orchestratorName,
|
||||
Description: orchDescription,
|
||||
ChatModel: mainModel,
|
||||
Instruction: orchInstruction,
|
||||
SubAgents: subAgents,
|
||||
WithoutGeneralSubAgent: ma.WithoutGeneralSubAgent,
|
||||
WithoutWriteTodos: ma.WithoutWriteTodos,
|
||||
MaxIteration: deepMaxIter,
|
||||
ToolsConfig: adk.ToolsConfig{
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: mainTools,
|
||||
},
|
||||
EmitInternalEvents: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deep.New: %w", err)
|
||||
}
|
||||
|
||||
msgs := historyToMessages(history)
|
||||
msgs = append(msgs, schema.UserMessage(userMessage))
|
||||
|
||||
runner := adk.NewRunner(ctx, adk.RunnerConfig{
|
||||
Agent: da,
|
||||
EnableStreaming: true,
|
||||
})
|
||||
iter := runner.Run(ctx, msgs)
|
||||
|
||||
streamsMainAssistant := func(agent string) bool {
|
||||
return agent == "" || agent == orchestratorName
|
||||
}
|
||||
|
||||
var lastAssistant string
|
||||
var reasoningStreamSeq int64
|
||||
var einoSubReplyStreamSeq int64
|
||||
toolEmitSeen := make(map[string]struct{})
|
||||
for {
|
||||
ev, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
if ev.Err != nil {
|
||||
if progress != nil {
|
||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
return nil, ev.Err
|
||||
}
|
||||
if ev.AgentName != "" && progress != nil {
|
||||
progress("progress", fmt.Sprintf("[Eino] %s", ev.AgentName), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": ev.AgentName,
|
||||
})
|
||||
}
|
||||
if ev.Output == nil || ev.Output.MessageOutput == nil {
|
||||
continue
|
||||
}
|
||||
mv := ev.Output.MessageOutput
|
||||
|
||||
if mv.IsStreaming && mv.MessageStream != nil {
|
||||
streamHeaderSent := false
|
||||
var reasoningStreamID string
|
||||
var toolStreamFragments []schema.ToolCall
|
||||
var subAssistantBuf strings.Builder
|
||||
var subReplyStreamID string
|
||||
for {
|
||||
chunk, rerr := mv.MessageStream.Recv()
|
||||
if rerr != nil {
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Warn("eino stream recv", zap.Error(rerr))
|
||||
}
|
||||
break
|
||||
}
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||
if reasoningStreamID == "" {
|
||||
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
||||
progress("thinking_stream_start", " ", map[string]interface{}{
|
||||
"streamId": reasoningStreamID,
|
||||
"source": "eino",
|
||||
"einoAgent": ev.AgentName,
|
||||
})
|
||||
}
|
||||
progress("thinking_stream_delta", chunk.ReasoningContent, map[string]interface{}{
|
||||
"streamId": reasoningStreamID,
|
||||
})
|
||||
}
|
||||
if chunk.Content != "" {
|
||||
if progress != nil && streamsMainAssistant(ev.AgentName) {
|
||||
if !streamHeaderSent {
|
||||
progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||
})
|
||||
streamHeaderSent = true
|
||||
}
|
||||
progress("response_delta", chunk.Content, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
})
|
||||
lastAssistant += chunk.Content
|
||||
} else if !streamsMainAssistant(ev.AgentName) {
|
||||
if progress != nil {
|
||||
if subReplyStreamID == "" {
|
||||
subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1))
|
||||
progress("eino_agent_reply_stream_start", "", map[string]interface{}{
|
||||
"streamId": subReplyStreamID,
|
||||
"einoAgent": ev.AgentName,
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
progress("eino_agent_reply_stream_delta", chunk.Content, map[string]interface{}{
|
||||
"streamId": subReplyStreamID,
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
}
|
||||
subAssistantBuf.WriteString(chunk.Content)
|
||||
}
|
||||
}
|
||||
// 收集流式 tool_calls 全部分片;arguments 在最后一帧常为 "",需按 index/id 合并后才能展示 subagent_type/description。
|
||||
if len(chunk.ToolCalls) > 0 {
|
||||
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
||||
}
|
||||
}
|
||||
if subAssistantBuf.Len() > 0 && progress != nil {
|
||||
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
||||
if subReplyStreamID != "" {
|
||||
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
||||
"streamId": subReplyStreamID,
|
||||
"einoAgent": ev.AgentName,
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
} else {
|
||||
progress("eino_agent_reply", s, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": ev.AgentName,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
var lastToolChunk *schema.Message
|
||||
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
||||
lastToolChunk = &schema.Message{ToolCalls: merged}
|
||||
}
|
||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, conversationID, progress, toolEmitSeen)
|
||||
continue
|
||||
}
|
||||
|
||||
msg, gerr := mv.GetMessage()
|
||||
if gerr != nil || msg == nil {
|
||||
continue
|
||||
}
|
||||
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, conversationID, progress, toolEmitSeen)
|
||||
|
||||
if mv.Role == schema.Assistant {
|
||||
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
progress("thinking", strings.TrimSpace(msg.ReasoningContent), map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoAgent": ev.AgentName,
|
||||
})
|
||||
}
|
||||
body := strings.TrimSpace(msg.Content)
|
||||
if body != "" {
|
||||
if streamsMainAssistant(ev.AgentName) {
|
||||
if progress != nil {
|
||||
progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||
})
|
||||
progress("response_delta", body, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
})
|
||||
}
|
||||
lastAssistant += body
|
||||
} else if progress != nil {
|
||||
progress("eino_agent_reply", body, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": ev.AgentName,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mv.Role == schema.Tool && progress != nil {
|
||||
toolName := msg.ToolName
|
||||
if toolName == "" {
|
||||
toolName = mv.ToolName
|
||||
}
|
||||
preview := msg.Content
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": true,
|
||||
"result": msg.Content,
|
||||
"resultPreview": preview,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": ev.AgentName,
|
||||
"source": "eino",
|
||||
}
|
||||
if msg.ToolCallID != "" {
|
||||
data["toolCallId"] = msg.ToolCallID
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||
}
|
||||
}
|
||||
|
||||
mcpIDsMu.Lock()
|
||||
ids := append([]string(nil), mcpIDs...)
|
||||
mcpIDsMu.Unlock()
|
||||
|
||||
histJSON, _ := json.Marshal(msgs)
|
||||
cleaned := strings.TrimSpace(lastAssistant)
|
||||
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
||||
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
|
||||
out := &RunResult{
|
||||
Response: cleaned,
|
||||
MCPExecutionIDs: ids,
|
||||
LastReActInput: string(histJSON),
|
||||
LastReActOutput: cleaned,
|
||||
}
|
||||
if out.Response == "" {
|
||||
out.Response = "(Eino DeepAgent 已完成,但未捕获到助手文本输出。请查看过程详情或日志。)"
|
||||
out.LastReActOutput = out.Response
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func historyToMessages(history []agent.ChatMessage) []adk.Message {
|
||||
if len(history) == 0 {
|
||||
return nil
|
||||
}
|
||||
const maxTurns = 40
|
||||
start := 0
|
||||
if len(history) > maxTurns {
|
||||
start = len(history) - maxTurns
|
||||
}
|
||||
out := make([]adk.Message, 0, len(history[start:]))
|
||||
for _, h := range history[start:] {
|
||||
switch h.Role {
|
||||
case "user":
|
||||
if strings.TrimSpace(h.Content) != "" {
|
||||
out = append(out, schema.UserMessage(h.Content))
|
||||
}
|
||||
case "assistant":
|
||||
if strings.TrimSpace(h.Content) == "" && len(h.ToolCalls) > 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(h.Content) != "" {
|
||||
out = append(out, schema.AssistantMessage(h.Content, nil))
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeStreamingToolCallFragments 将流式多帧的 ToolCall 按 index 合并 arguments(与 schema.concatToolCalls 行为一致)。
|
||||
func mergeStreamingToolCallFragments(fragments []schema.ToolCall) []schema.ToolCall {
|
||||
if len(fragments) == 0 {
|
||||
return nil
|
||||
}
|
||||
m, err := schema.ConcatMessages([]*schema.Message{{ToolCalls: fragments}})
|
||||
if err != nil || m == nil {
|
||||
return fragments
|
||||
}
|
||||
return m.ToolCalls
|
||||
}
|
||||
|
||||
// mergeMessageToolCalls 非流式路径上若仍带分片式 tool_calls,合并后再上报 UI。
|
||||
func mergeMessageToolCalls(msg *schema.Message) *schema.Message {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 {
|
||||
return msg
|
||||
}
|
||||
m, err := schema.ConcatMessages([]*schema.Message{msg})
|
||||
if err != nil || m == nil {
|
||||
return msg
|
||||
}
|
||||
out := *msg
|
||||
out.ToolCalls = m.ToolCalls
|
||||
return &out
|
||||
}
|
||||
|
||||
// toolCallStableID 用于流式阶段去重;OpenAI 流式常先给 index 后补 id。
|
||||
func toolCallStableID(tc schema.ToolCall) string {
|
||||
if tc.ID != "" {
|
||||
return tc.ID
|
||||
}
|
||||
if tc.Index != nil {
|
||||
return fmt.Sprintf("idx:%d", *tc.Index)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toolCallDisplayName 避免前端「未知工具」:DeepAgent 内置 task 等可能延迟写入 function.name。
|
||||
func toolCallDisplayName(tc schema.ToolCall) string {
|
||||
if n := strings.TrimSpace(tc.Function.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
if n := strings.TrimSpace(tc.Type); n != "" && !strings.EqualFold(n, "function") {
|
||||
return n
|
||||
}
|
||||
return "task"
|
||||
}
|
||||
|
||||
// toolCallsSignatureFlush 用于去重键;无 id/index 时用占位 pos,避免流末帧缺 id 时整条工具事件丢失。
|
||||
func toolCallsSignatureFlush(msg *schema.Message) string {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(msg.ToolCalls))
|
||||
for i, tc := range msg.ToolCalls {
|
||||
id := toolCallStableID(tc)
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("pos:%d", i)
|
||||
}
|
||||
parts = append(parts, id+"|"+toolCallDisplayName(tc))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// toolCallsRichSignature 用于去重:同一次流式已上报后,紧随其后的非流式消息常带相同 tool_calls。
|
||||
func toolCallsRichSignature(msg *schema.Message) string {
|
||||
base := toolCallsSignatureFlush(msg)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(msg.ToolCalls))
|
||||
for _, tc := range msg.ToolCalls {
|
||||
id := toolCallStableID(tc)
|
||||
arg := tc.Function.Arguments
|
||||
if len(arg) > 240 {
|
||||
arg = arg[:240]
|
||||
}
|
||||
parts = append(parts, id+":"+arg)
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return base + "|" + strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func tryEmitToolCallsOnce(msg *schema.Message, agentName, conversationID string, progress func(string, string, interface{}), seen map[string]struct{}) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil {
|
||||
return
|
||||
}
|
||||
if toolCallsSignatureFlush(msg) == "" {
|
||||
return
|
||||
}
|
||||
sig := agentName + "\x1e" + toolCallsRichSignature(msg)
|
||||
if _, ok := seen[sig]; ok {
|
||||
return
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
emitToolCallsFromMessage(msg, agentName, conversationID, progress)
|
||||
}
|
||||
|
||||
func emitToolCallsFromMessage(msg *schema.Message, agentName, conversationID string, progress func(string, string, interface{})) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil {
|
||||
return
|
||||
}
|
||||
progress("tool_calls_detected", fmt.Sprintf("检测到 %d 个工具调用", len(msg.ToolCalls)), map[string]interface{}{
|
||||
"count": len(msg.ToolCalls),
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoAgent": agentName,
|
||||
})
|
||||
for idx, tc := range msg.ToolCalls {
|
||||
argStr := strings.TrimSpace(tc.Function.Arguments)
|
||||
if argStr == "" && len(tc.Extra) > 0 {
|
||||
if b, mErr := json.Marshal(tc.Extra); mErr == nil {
|
||||
argStr = string(b)
|
||||
}
|
||||
}
|
||||
var argsObj map[string]interface{}
|
||||
if argStr != "" {
|
||||
if uErr := json.Unmarshal([]byte(argStr), &argsObj); uErr != nil || argsObj == nil {
|
||||
argsObj = map[string]interface{}{"_raw": argStr}
|
||||
}
|
||||
}
|
||||
display := toolCallDisplayName(tc)
|
||||
toolCallID := tc.ID
|
||||
if toolCallID == "" && tc.Index != nil {
|
||||
toolCallID = fmt.Sprintf("eino-stream-%d", *tc.Index)
|
||||
}
|
||||
progress("tool_call", fmt.Sprintf("正在调用工具: %s", display), map[string]interface{}{
|
||||
"toolName": display,
|
||||
"arguments": argStr,
|
||||
"argumentsObj": argsObj,
|
||||
"toolCallId": toolCallID,
|
||||
"index": idx + 1,
|
||||
"total": len(msg.ToolCalls),
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoAgent": agentName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// dedupeRepeatedParagraphs 去掉完全相同的连续/重复段落,缓解多代理各自复述同一列表。
|
||||
func dedupeRepeatedParagraphs(s string, minLen int) string {
|
||||
if s == "" || minLen <= 0 {
|
||||
return s
|
||||
}
|
||||
paras := strings.Split(s, "\n\n")
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range paras {
|
||||
t := strings.TrimSpace(p)
|
||||
if len(t) < minLen {
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
if seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n\n"))
|
||||
}
|
||||
|
||||
// dedupeParagraphsByLineFingerprint 去掉「正文行集合相同」的重复段落(开场白略不同也会合并),缓解多代理各写一遍目录清单。
|
||||
func dedupeParagraphsByLineFingerprint(s string, minParaLen int) string {
|
||||
if s == "" || minParaLen <= 0 {
|
||||
return s
|
||||
}
|
||||
paras := strings.Split(s, "\n\n")
|
||||
var out []string
|
||||
seen := make(map[string]bool)
|
||||
for _, p := range paras {
|
||||
t := strings.TrimSpace(p)
|
||||
if len(t) < minParaLen {
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
fp := paragraphLineFingerprint(t)
|
||||
// 指纹仅在「≥4 条非空行」时有效;单行/短段落长回复(如自我介绍)fp 为空,必须保留,否则会误删全文并触发「未捕获到助手文本」占位。
|
||||
if fp == "" {
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
if seen[fp] {
|
||||
continue
|
||||
}
|
||||
seen[fp] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n\n"))
|
||||
}
|
||||
|
||||
func paragraphLineFingerprint(t string) string {
|
||||
lines := strings.Split(t, "\n")
|
||||
norm := make([]string, 0, len(lines))
|
||||
for _, L := range lines {
|
||||
s := strings.TrimSpace(L)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
norm = append(norm, s)
|
||||
}
|
||||
if len(norm) < 4 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(norm)
|
||||
return strings.Join(norm, "\x1e")
|
||||
}
|
||||
Reference in New Issue
Block a user