diff --git a/internal/agents/markdown.go b/internal/agents/markdown.go new file mode 100644 index 00000000..b3aa8a0f --- /dev/null +++ b/internal/agents/markdown.go @@ -0,0 +1,526 @@ +// 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" + +// OrchestratorPlanExecuteMarkdownFilename plan_execute 模式主代理(规划侧)专用 Markdown 文件名。 +const OrchestratorPlanExecuteMarkdownFilename = "orchestrator-plan-execute.md" + +// OrchestratorSupervisorMarkdownFilename supervisor 模式主代理专用 Markdown 文件名。 +const OrchestratorSupervisorMarkdownFilename = "orchestrator-supervisor.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 // Deep 主代理 + OrchestratorPlanExecute *OrchestratorMarkdown // plan_execute 规划主代理 + OrchestratorSupervisor *OrchestratorMarkdown // supervisor 监督主代理 + FileEntries []FileAgent // 含主代理与所有子代理,供管理 API 列表 +} + +// OrchestratorMarkdownKind 按固定文件名返回主代理类型:deep、plan_execute、supervisor;否则返回空。 +func OrchestratorMarkdownKind(filename string) string { + base := filepath.Base(strings.TrimSpace(filename)) + switch { + case strings.EqualFold(base, OrchestratorPlanExecuteMarkdownFilename): + return "plan_execute" + case strings.EqualFold(base, OrchestratorSupervisorMarkdownFilename): + return "supervisor" + case strings.EqualFold(base, OrchestratorMarkdownFilename): + return "deep" + default: + return "" + } +} + +// IsOrchestratorMarkdown 判断该文件是否占用 **Deep** 主代理槽位:orchestrator.md、或 kind: orchestrator(不含 plan_execute / supervisor 专用文件名)。 +func IsOrchestratorMarkdown(filename string, fm FrontMatter) bool { + base := filepath.Base(strings.TrimSpace(filename)) + switch OrchestratorMarkdownKind(base) { + case "plan_execute", "supervisor": + return false + } + if strings.EqualFold(base, OrchestratorMarkdownFilename) { + return true + } + return strings.EqualFold(strings.TrimSpace(fm.Kind), "orchestrator") +} + +// IsOrchestratorLikeMarkdown 是否应在前端/API 中显示为「主代理类」文件。 +func IsOrchestratorLikeMarkdown(filename string, kind string) bool { + if OrchestratorMarkdownKind(filename) != "" { + return true + } + return IsOrchestratorMarkdown(filename, FrontMatter{Kind: kind}) +} + +// WantsMarkdownOrchestrator 保存前判断是否会把该文件作为主代理(用于唯一性校验)。 +func WantsMarkdownOrchestrator(filename string, kindField string, raw string) bool { + base := filepath.Base(strings.TrimSpace(filename)) + if OrchestratorMarkdownKind(base) != "" { + return true + } + if strings.EqualFold(strings.TrimSpace(kindField), "orchestrator") { + return true + } + 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 目录:拆出 Deep / plan_execute / supervisor 主代理各至多一个,及其余子代理。 +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) + } + switch OrchestratorMarkdownKind(n) { + case "plan_execute": + if out.OrchestratorPlanExecute != nil { + return nil, fmt.Errorf("agents: 仅能定义一个 %s,已有 %s", OrchestratorPlanExecuteMarkdownFilename, out.OrchestratorPlanExecute.Filename) + } + orch, err := orchestratorFromParsed(n, fm, body) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + out.OrchestratorPlanExecute = orch + out.FileEntries = append(out.FileEntries, FileAgent{ + Filename: n, + Config: orchestratorConfigFromOrchestrator(orch), + IsOrchestrator: true, + }) + continue + case "supervisor": + if out.OrchestratorSupervisor != nil { + return nil, fmt.Errorf("agents: 仅能定义一个 %s,已有 %s", OrchestratorSupervisorMarkdownFilename, out.OrchestratorSupervisor.Filename) + } + orch, err := orchestratorFromParsed(n, fm, body) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + out.OrchestratorSupervisor = orch + out.FileEntries = append(out.FileEntries, FileAgent{ + Filename: n, + Config: orchestratorConfigFromOrchestrator(orch), + IsOrchestrator: true, + }) + continue + } + 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 OrchestratorMarkdownKind(filename) != "" { + orch, err := orchestratorFromParsed(filename, fm, body) + if err != nil { + return config.MultiAgentSubConfig{}, err + } + return orchestratorConfigFromOrchestrator(orch), nil + } + 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 +} diff --git a/internal/agents/markdown_orchestrator_test.go b/internal/agents/markdown_orchestrator_test.go new file mode 100644 index 00000000..9ea7474d --- /dev/null +++ b/internal/agents/markdown_orchestrator_test.go @@ -0,0 +1,97 @@ +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") + } +} + +func TestLoadMarkdownAgentsDir_ModeOrchestratorsCoexist(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0644); err != nil { + t.Fatal(err) + } + } + write(OrchestratorMarkdownFilename, "---\nname: Deep\n---\n\ndeep\n") + write(OrchestratorPlanExecuteMarkdownFilename, "---\nname: PE\n---\n\npe\n") + write(OrchestratorSupervisorMarkdownFilename, "---\nname: SV\n---\n\nsv\n") + write("worker.md", "---\nid: worker\nname: Worker\n---\n\nw\n") + + load, err := LoadMarkdownAgentsDir(dir) + if err != nil { + t.Fatal(err) + } + if load.Orchestrator == nil || load.Orchestrator.Instruction != "deep" { + t.Fatalf("deep: %+v", load.Orchestrator) + } + if load.OrchestratorPlanExecute == nil || load.OrchestratorPlanExecute.Instruction != "pe" { + t.Fatalf("pe: %+v", load.OrchestratorPlanExecute) + } + if load.OrchestratorSupervisor == nil || load.OrchestratorSupervisor.Instruction != "sv" { + t.Fatalf("sv: %+v", load.OrchestratorSupervisor) + } + if len(load.SubAgents) != 1 || load.SubAgents[0].ID != "worker" { + t.Fatalf("subs: %+v", load.SubAgents) + } +} diff --git a/internal/einomcp/holder.go b/internal/einomcp/holder.go new file mode 100644 index 00000000..fe56b442 --- /dev/null +++ b/internal/einomcp/holder.go @@ -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 +} diff --git a/internal/einomcp/mcp_tools.go b/internal/einomcp/mcp_tools.go new file mode 100644 index 00000000..edff81b4 --- /dev/null +++ b/internal/einomcp/mcp_tools.go @@ -0,0 +1,214 @@ +package einomcp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "cyberstrike-ai/internal/agent" + "cyberstrike-ai/internal/security" + + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" + "github.com/eino-contrib/jsonschema" +) + +// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。 +// toolCallID 来自 Eino compose.GetToolCallID,用于与 reduction 后的展示结果关联。 +type ExecutionRecorder func(executionID, toolCallID string) + +// ToolErrorPrefix 用于把内部 MCP 执行结果中的 IsError 标记传递到多代理上层。 +// Eino 工具通道目前只支持返回字符串,因此通过前缀标识,随后在多代理 runner 中解析为 success/isError。 +const ToolErrorPrefix = "__CYBERSTRIKE_AI_TOOL_ERROR__\n" + +// ToolsFromDefinitions 将单 Agent 使用的 OpenAI 风格工具定义转为 Eino InvokableTool,执行时走 Agent 的 MCP 路径。 +// invokeNotify 可选:与 runEinoADKAgentLoop 共享,在 InvokableRun 返回时触发 UI 与 pending 清理(与 ADK Tool 事件去重)。 +// einoAgentName 为该套工具所属 ChatModelAgent 的 Name(主代理或子代理 id),用于 SSE 上的 einoAgent 字段。 +func ToolsFromDefinitions( + ag *agent.Agent, + holder *ConversationHolder, + defs []agent.Tool, + rec ExecutionRecorder, + toolOutputChunk func(toolName, toolCallID, chunk string), + invokeNotify *ToolInvokeNotifyHolder, + einoAgentName string, +) ([]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, + chunk: toolOutputChunk, + invokeNotify: invokeNotify, + einoAgentName: strings.TrimSpace(einoAgentName), + }) + } + 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 + chunk func(toolName, toolCallID, chunk string) + invokeNotify *ToolInvokeNotifyHolder + einoAgentName string +} + +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) (out string, err error) { + _ = opts + toolCallID := compose.GetToolCallID(ctx) + defer func() { + if m.invokeNotify == nil { + return + } + tid := strings.TrimSpace(toolCallID) + if tid == "" { + return + } + success := err == nil && !strings.HasPrefix(out, ToolErrorPrefix) + body := out + if err != nil { + success = false + } else if strings.HasPrefix(out, ToolErrorPrefix) { + success = false + body = strings.TrimPrefix(out, ToolErrorPrefix) + } + m.invokeNotify.Fire(tid, m.name, m.einoAgentName, success, body, err) + }() + return runMCPToolInvocation(ctx, m.agent, m.holder, m.name, argumentsInJSON, m.record, m.chunk) +} + +// runMCPToolInvocation 与 mcpBridgeTool.InvokableRun 共用。 +func runMCPToolInvocation( + ctx context.Context, + ag *agent.Agent, + holder *ConversationHolder, + toolName string, + argumentsInJSON string, + record ExecutionRecorder, + chunk func(toolName, toolCallID, chunk string), +) (string, error) { + var args map[string]interface{} + if argumentsInJSON != "" && argumentsInJSON != "null" { + if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil { + // Return soft error (nil error) so the eino graph continues and the LLM can self-correct, + // instead of a hard error that terminates the iteration loop. + return ToolErrorPrefix + fmt.Sprintf( + "Invalid tool arguments JSON: %s\n\nPlease ensure the arguments are a valid JSON object "+ + "(double-quoted keys, matched braces, no trailing commas) and retry.\n\n"+ + "(工具参数 JSON 解析失败:%s。请确保 arguments 是合法的 JSON 对象并重试。)", + err.Error(), err.Error()), nil + } + } + if args == nil { + args = map[string]interface{}{} + } + + if chunk != nil { + toolCallID := compose.GetToolCallID(ctx) + if toolCallID != "" { + if existing, ok := ctx.Value(security.ToolOutputCallbackCtxKey).(security.ToolOutputCallback); ok && existing != nil { + ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) { + existing(c) + if strings.TrimSpace(c) == "" { + return + } + chunk(toolName, toolCallID, c) + })) + } else { + ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) { + if strings.TrimSpace(c) == "" { + return + } + chunk(toolName, toolCallID, c) + })) + } + } + } + + res, err := ag.ExecuteMCPToolForConversation(ctx, holder.Get(), toolName, args) + if err != nil { + return "", err + } + if res == nil { + return "", nil + } + if res.ExecutionID != "" && record != nil { + record(res.ExecutionID, compose.GetToolCallID(ctx)) + } + if res.IsError { + return ToolErrorPrefix + res.Result, nil + } + return res.Result, nil +} + +// UnknownToolReminderHandler 供 compose.ToolsNodeConfig.UnknownToolsHandler 使用: +// 模型请求了未注册的工具名时,返回一个「软错误」工具结果(nil error), +// 让模型在同一轮继续自我修正,避免触发 run-loop 级别的 full rerun。 +// 不进行名称猜测或映射,避免误执行。 +func UnknownToolReminderHandler() func(ctx context.Context, name, input string) (string, error) { + return func(ctx context.Context, name, input string) (string, error) { + _ = ctx + _ = input + requested := strings.TrimSpace(name) + // Return a soft tool-result error so the graph keeps running and the LLM + // can correct tool name/arguments within the same run. + return ToolErrorPrefix + unknownToolReminderText(requested), nil + } +} + +func unknownToolReminderText(requested string) string { + if requested == "" { + requested = "(empty)" + } + return fmt.Sprintf(`The tool name %q is not registered for this agent. + +Please retry using only names that appear in the tool definitions for this turn (exact match, case-sensitive). Do not invent or rename tools; adjust your plan and continue. + +(工具 %q 未注册:请仅使用本回合上下文中给出的工具名称,须完全一致;请勿自行改写或猜测名称,并继续后续步骤。)`, requested, requested) +} diff --git a/internal/einomcp/mcp_tools_test.go b/internal/einomcp/mcp_tools_test.go new file mode 100644 index 00000000..078c8c04 --- /dev/null +++ b/internal/einomcp/mcp_tools_test.go @@ -0,0 +1,16 @@ +package einomcp + +import ( + "strings" + "testing" +) + +func TestUnknownToolReminderText(t *testing.T) { + s := unknownToolReminderText("bad_tool") + if !strings.Contains(s, "bad_tool") { + t.Fatalf("expected requested name in message: %s", s) + } + if strings.Contains(s, "Tools currently available") { + t.Fatal("unified message must not list tool names") + } +} diff --git a/internal/einomcp/tool_invoke_notify.go b/internal/einomcp/tool_invoke_notify.go new file mode 100644 index 00000000..a776a7bc --- /dev/null +++ b/internal/einomcp/tool_invoke_notify.go @@ -0,0 +1,39 @@ +package einomcp + +import "sync" + +// ToolInvokeNotifyHolder 由 Eino run loop 与 MCP/execute 桥共享;Fire 在工具原始返回时触发。 +// UI 的 tool_result 须等 ADK schema.Tool 事件(reduction 后正文),不在此 holder 的回调里推送。 +type ToolInvokeNotifyHolder struct { + mu sync.RWMutex + fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) +} + +// NewToolInvokeNotifyHolder 创建可在 ToolsFromDefinitions 与 run loop 之间共享的 holder。 +func NewToolInvokeNotifyHolder() *ToolInvokeNotifyHolder { + return &ToolInvokeNotifyHolder{} +} + +// Set 由 runEinoADKAgentLoop 在开始消费 iter 之前调用;可多次覆盖(通常仅一次)。 +func (h *ToolInvokeNotifyHolder) Set(fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)) { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + h.fn = fn +} + +// Fire 由 mcpBridgeTool 在工具调用返回时调用;若尚未 Set 或 toolCallID 为空则忽略。 +func (h *ToolInvokeNotifyHolder) Fire(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) { + if h == nil { + return + } + h.mu.RLock() + fn := h.fn + h.mu.RUnlock() + if fn == nil { + return + } + fn(toolCallID, toolName, einoAgent, success, content, invokeErr) +} diff --git a/internal/termout/startup.go b/internal/termout/startup.go new file mode 100644 index 00000000..00209721 --- /dev/null +++ b/internal/termout/startup.go @@ -0,0 +1,67 @@ +package termout + +import ( + "fmt" + "os" + "strings" +) + +// StartupWebUIOptions configures the startup Web UI banner. +type StartupWebUIOptions struct { + Scheme string + Port int + SelfSigned bool + HTTPRedirect bool +} + +// PrintConfigCreated prints a short notice when config.yaml is bootstrapped. +func PrintConfigCreated() { + s := New(os.Stdout) + s.Println("") + s.Println(s.Green("✔ ") + s.Bold("已创建 config.yaml") + s.Dim("(来自 config.example.yaml)")) + s.BlankLine() +} + +// PrintStartupWebUI prints a colored startup banner for the Web UI. +func PrintStartupWebUI(opts StartupWebUIOptions) { + s := New(os.Stdout) + scheme := opts.Scheme + if scheme == "" { + scheme = "http" + } + port := opts.Port + if port <= 0 { + port = 8080 + } + url := fmt.Sprintf("%s://127.0.0.1:%d/", scheme, port) + + s.BlankLine() + s.Println(s.Bold(s.Cyan("CYBERSTRIKE AI")) + s.Dim(" / secure workspace")) + s.Println(s.Dim(strings.Repeat("─", 60))) + s.Println(s.Green("● ONLINE") + " " + s.Bold(s.White(url))) + if opts.SelfSigned { + s.Println(s.Dim(" TLS ") + s.Yellow("self-signed") + s.Dim(" · accept the browser warning once")) + } + if opts.HTTPRedirect { + s.Println(s.Dim(" Redirect ") + fmt.Sprintf("http://127.0.0.1:%d/ → HTTPS", port)) + } + s.BlankLine() +} + +// PrintBootstrapAdminCredentials prints the initial admin password banner. +func PrintBootstrapAdminCredentials(password string) { + password = strings.TrimSpace(password) + if password == "" { + return + } + + s := New(os.Stdout) + s.Println(s.Bold(s.Yellow("ADMIN SETUP REQUIRED"))) + s.Println(s.Dim(strings.Repeat("─", 60))) + s.Println(s.Dim(" Username ") + s.Bold(s.White("admin"))) + s.Println(s.Dim(" Password ") + s.Bold(s.Yellow(password))) + s.BlankLine() + s.Println(s.Yellow(" ! ") + s.White("Store this password securely. It is shown only once.")) + s.Println(s.Dim(" Change it in Settings immediately after signing in.")) + s.BlankLine() +} diff --git a/internal/termout/startup_test.go b/internal/termout/startup_test.go new file mode 100644 index 00000000..f01b610c --- /dev/null +++ b/internal/termout/startup_test.go @@ -0,0 +1,76 @@ +package termout + +import ( + "strings" + "testing" +) + +func TestDisplayWidthEmoji(t *testing.T) { + if got := displayWidth("🚀"); got != 2 { + t.Fatalf("displayWidth(emoji) = %d, want 2", got) + } + if got := displayWidth("ab"); got != 2 { + t.Fatalf("displayWidth(ab) = %d, want 2", got) + } +} + +func TestDisplayWidthIgnoresANSI(t *testing.T) { + s := New(nil) + colored := s.Bold("admin") + if got := displayWidth(colored); got != 5 { + t.Fatalf("displayWidth colored = %d, want 5", got) + } +} + +func TestPadRightDisplay(t *testing.T) { + got := padRightDisplay("pwd", 10) + if displayWidth(got) != 10 { + t.Fatalf("padded width = %d, want 10", displayWidth(got)) + } +} + +func TestColorDisabledWithoutTTY(t *testing.T) { + s := New(nil) + if s.enabled { + t.Fatal("expected colors disabled for nil writer") + } + if got := s.Cyan("x"); got != "x" { + t.Fatalf("Cyan without TTY = %q, want plain text", got) + } +} + +func TestPrintBootstrapAdminCredentialsEmpty(t *testing.T) { + PrintBootstrapAdminCredentials(" ") +} + +func TestPrintStartupWebUIOptions(t *testing.T) { + PrintStartupWebUI(StartupWebUIOptions{ + Scheme: "https", + Port: 8080, + SelfSigned: true, + HTTPRedirect: true, + }) +} + +func TestBoxRowAlignedWidth(t *testing.T) { + s := New(nil) + rows := []string{ + s.Bold("CyberStrikeAI") + s.White(" is ready"), + s.Dim("Web UI ") + s.Bold("https://127.0.0.1:8080/"), + } + inner := maxDisplayWidth(rows...) + for _, row := range rows { + line := s.boxRow(inner, row) + if !strings.Contains(line, "│") { + t.Fatalf("box row missing border: %q", line) + } + } +} + +func TestMaxDisplayWidth(t *testing.T) { + short := "abc" + long := "https://127.0.0.1:8080/" + if got := maxDisplayWidth(short, long); got != displayWidth(long) { + t.Fatalf("maxDisplayWidth = %d, want %d", got, displayWidth(long)) + } +} diff --git a/internal/termout/style.go b/internal/termout/style.go new file mode 100644 index 00000000..2a805de3 --- /dev/null +++ b/internal/termout/style.go @@ -0,0 +1,108 @@ +package termout + +import ( + "fmt" + "io" + "os" + "strings" +) + +const ( + codeReset = "\033[0m" + codeBold = "\033[1m" + codeDim = "\033[2m" + codeRed = "\033[31m" + codeGreen = "\033[32m" + codeYellow = "\033[33m" + codeBlue = "\033[34m" + codeCyan = "\033[36m" + codeWhite = "\033[97m" +) + +// Style wraps ANSI styling with TTY / NO_COLOR awareness. +type Style struct { + out io.Writer + enabled bool +} + +// New creates a Style writing to out (typically os.Stdout). +func New(out io.Writer) *Style { + return &Style{out: out, enabled: colorEnabled(out)} +} + +func colorEnabled(w io.Writer) bool { + if strings.TrimSpace(os.Getenv("NO_COLOR")) != "" { + return false + } + force := strings.TrimSpace(os.Getenv("FORCE_COLOR")) + if force == "1" || strings.EqualFold(force, "true") || strings.EqualFold(force, "yes") { + return true + } + f, ok := w.(*os.File) + if !ok { + return false + } + stat, err := f.Stat() + if err != nil { + return false + } + return stat.Mode()&os.ModeCharDevice != 0 +} + +func (s *Style) paint(code, text string) string { + if !s.enabled || text == "" { + return text + } + return code + text + codeReset +} + +func (s *Style) Bold(text string) string { return s.paint(codeBold, text) } +func (s *Style) Dim(text string) string { return s.paint(codeDim, text) } +func (s *Style) Red(text string) string { return s.paint(codeRed, text) } +func (s *Style) Green(text string) string { return s.paint(codeGreen, text) } +func (s *Style) Yellow(text string) string { return s.paint(codeYellow, text) } +func (s *Style) Blue(text string) string { return s.paint(codeBlue, text) } +func (s *Style) Cyan(text string) string { return s.paint(codeCyan, text) } +func (s *Style) White(text string) string { return s.paint(codeWhite, text) } + +func (s *Style) Println(text string) { + _, _ = fmt.Fprintln(s.out, text) +} + +func (s *Style) Printf(format string, args ...interface{}) { + _, _ = fmt.Fprintf(s.out, format, args...) +} + +func (s *Style) BlankLine() { + s.Println("") +} + +func (s *Style) boxTop(innerWidth int) string { + return s.Cyan("╭" + strings.Repeat("─", innerWidth+2) + "╮") +} + +func (s *Style) boxBottom(innerWidth int) string { + return s.Cyan("╰" + strings.Repeat("─", innerWidth+2) + "╯") +} + +func (s *Style) boxRow(innerWidth int, content string) string { + return s.Cyan("│ ") + padRightDisplay(content, innerWidth) + s.Cyan(" │") +} + +func (s *Style) printBox(rows []string, minInner, maxInner int) { + inner := maxDisplayWidth(rows...) + if inner < minInner { + inner = minInner + } + if maxInner > 0 && inner > maxInner { + inner = maxInner + } + + s.BlankLine() + s.Println(s.boxTop(inner)) + for _, row := range rows { + s.Println(s.boxRow(inner, row)) + } + s.Println(s.boxBottom(inner)) + s.BlankLine() +} diff --git a/internal/termout/width.go b/internal/termout/width.go new file mode 100644 index 00000000..9ea812bf --- /dev/null +++ b/internal/termout/width.go @@ -0,0 +1,73 @@ +package termout + +import ( + "regexp" + "strings" + "unicode/utf8" + + "golang.org/x/text/width" +) + +var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// displayWidth returns the terminal display width of text, ignoring ANSI codes. +func displayWidth(text string) int { + plain := ansiEscapeRe.ReplaceAllString(text, "") + w := 0 + for _, r := range plain { + w += runeDisplayWidth(r) + } + return w +} + +func runeDisplayWidth(r rune) int { + if r == utf8.RuneError { + return 0 + } + // Most emoji / symbols render as double-width in modern terminals. + if isEmojiLikeRune(r) { + return 2 + } + switch width.LookupRune(r).Kind() { + case width.EastAsianWide, width.EastAsianFullwidth: + return 2 + default: + return 1 + } +} + +func isEmojiLikeRune(r rune) bool { + switch { + case r >= 0x1F300 && r <= 0x1FAFF: // pictographs / emoji + return true + case r >= 0x2600 && r <= 0x27BF: // misc symbols + return true + case r >= 0x2300 && r <= 0x23FF: // misc technical (⌚ etc.) + return true + case r >= 0x2B50 && r <= 0x2B55: + return true + default: + return false + } +} + +func padRightDisplay(text string, target int) string { + if target <= 0 { + return "" + } + gap := target - displayWidth(text) + if gap <= 0 { + return text + } + return text + strings.Repeat(" ", gap) +} + +func maxDisplayWidth(rows ...string) int { + max := 0 + for _, row := range rows { + if w := displayWidth(row); w > max { + max = w + } + } + return max +}