diff --git a/.env.example b/.env.example index 526a8ca..0fa0841 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,10 @@ LLM_API_KEY=sk-xxxxx LLM_MODEL=gpt-4o LLM_FALLBACK_MODEL=gpt-4o-mini +# ---- LLM 视觉模型(多模态路由:含图片的请求路由到此模型) ---- +# 不配置则图片路由回主模型;仅当启用多模态能力时设置。 +# LLM_VISION_MODEL=gpt-4o-mini + # Provider = anthropic:Anthropic Messages /v1/messages(tool_use.input 为结构化对象,工具循环更稳) # ANTHROPIC_BASE_URL=https://api.anthropic.com # ANTHROPIC_API_KEY=sk-ant-xxxxx @@ -70,5 +74,18 @@ THINK_SILENCE_TIMEOUT_SEC=120 WS_MAX_CONNECTIONS=1000 SESSION_IDLE_TIMEOUT_MIN=30 +# ---- MCP 服务器(可选,JSON 数组字符串;每台 server 含 name/command/args/url/enabled) ---- +# stdio 子进程示例: +# MCP_SERVERS_JSON=[{"name":"calc","command":"mcp-calc","args":["--stdio"],"enabled":true}] +# SSE 端点示例: +# MCP_SERVERS_JSON=[{"name":"weather","url":"http://localhost:3001/sse","enabled":true}] + +# ---- 本地执行沙箱(exec/terminal 工具;默认关闭,授权放权) ---- +# 只有 Enabled=true 时 agent 才可执行命令;仍需命令白名单与根目录边界。 +# EXEC_SANDBOX_ENABLED=false +# EXEC_SANDBOX_COMMANDS=git,ls,cat,pwd +# EXEC_SANDBOX_ROOTS=./data +# EXEC_SANDBOX_ENV=PATH + # ---- 跨域白名单 ---- ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 diff --git a/cmd/cyrene/mcp.go b/cmd/cyrene/mcp.go new file mode 100644 index 0000000..03b40da --- /dev/null +++ b/cmd/cyrene/mcp.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// mcpConnectTimeout bounds each MCP server's connect+discover handshake so a hung +// server degrades to a warning instead of blocking startup. +const mcpConnectTimeout = 15 * time.Second + +// wireMCP reads cfg.MCP.ServersJSON, connects to every enabled server, registers +// its discovered tools into the capability registry (KindTool so the agent can +// call them), and surfaces a KindMCP capability so the admin plugin list +// enumerates the server. A server that fails to connect or is unconfigured +// degrades to a warning and never blocks startup. +func (a *app) wireMCP() { + log := logger.WithSource(a.log, "cmd/cyrene") + servers, err := a.mcpServers() + if err != nil { + log.Warn("mcp config parse skipped", "error", err) + return + } + if len(servers) == 0 { + log.Debug("no mcp servers configured") + return + } + for _, srv := range servers { + if !srv.Enabled { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout) + err := a.registerMCPServer(ctx, srv) + cancel() + if err != nil { + log.Warn("mcp server skipped", "name", srv.Name, "error", err) + } + } +} + +// mcpServers parses the configured MCP servers from cfg.MCP.ServersJSON. +func (a *app) mcpServers() ([]config.MCPServer, error) { + raw := strings.TrimSpace(a.cfg.MCP.ServersJSON) + if raw == "" { + return nil, nil + } + var servers []config.MCPServer + if err := json.Unmarshal([]byte(raw), &servers); err != nil { + return nil, err + } + return servers, nil +} + +// registerMCPServer connects to one server, registers its tools, and publishes a +// KindMCP capability under "mcp:". It returns the first error so callers +// can classify it; on partial registration it cleans up what it added. +func (a *app) registerMCPServer(ctx context.Context, srv config.MCPServer) error { + log := logger.WithSource(a.log, "cmd/cyrene") + reg := mcp.NewRegistry(a.caps, mcp.MCPConfig{ + Name: srv.Name, + Command: srv.Command, + Args: srv.Args, + URL: srv.URL, + }, mcp.WithLogger(a.log)) + if err := reg.Register(ctx); err != nil { + _ = reg.Unregister(context.Background()) + return err + } + if err := a.caps.Register(registry.Capability{ + Name: "mcp:" + srv.Name, + Kind: registry.KindMCP, + Description: "MCP server " + srv.Name, + Value: reg, + }); err != nil { + log.Warn("mcp capability register skipped", "name", srv.Name, "error", err) + } + a.mcpRegs = append(a.mcpRegs, reg) + log.Info("mcp server registered", "name", srv.Name) + return nil +} diff --git a/cmd/cyrene/skills.go b/cmd/cyrene/skills.go new file mode 100644 index 0000000..84975ee --- /dev/null +++ b/cmd/cyrene/skills.go @@ -0,0 +1,36 @@ +package main + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// defaultSkillsDir is the on-disk directory of skill YAML definitions. It is +// optional: a missing directory skips skill loading with a warning. +const defaultSkillsDir = "configs/skills" + +// wireSkills loads skill definitions from configs/skills and registers each into +// the capability registry as a KindSkill capability so the plugin ecosystem can +// enumerate them. Load+register is the M5 deliverable; Runner.Run (executing a +// skill end to end) lands in a later milestone and currently returns ErrRunPending. +func (a *app) wireSkills() { + log := logger.WithSource(a.log, "cmd/cyrene") + list, err := skills.NewLoader(a.log).LoadDir(defaultSkillsDir) + if err != nil { + log.Warn("skills load skipped", "dir", defaultSkillsDir, "error", err) + return + } + if len(list) == 0 { + log.Debug("no skills defined", "dir", defaultSkillsDir) + return + } + registered := 0 + for _, s := range list { + if err := skills.Register(a.caps, s); err != nil { + log.Warn("skill register skipped", "skill", s.Name(), "error", err) + continue + } + registered++ + } + log.Info("skills registered", "registered", registered, "loaded", len(list)) +} diff --git a/cmd/cyrene/terminal_tool.go b/cmd/cyrene/terminal_tool.go new file mode 100644 index 0000000..0c3abe9 --- /dev/null +++ b/cmd/cyrene/terminal_tool.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec" +) + +// terminalToolName is the registered name of the exec/terminal tool. +const terminalToolName = "terminal" + +// terminalTool is an sdk.Tool that runs a command through the grant-based exec +// sandbox (exec.Provider). It is registered even when the sandbox is closed: +// Execute then returns exec.ErrExecutionDisabled, so the agent gains the ability +// to run code only after the owner explicitly enables the sandbox (default off). +type terminalTool struct { + prov exec.Provider + log *slog.Logger +} + +// Name returns the tool identifier. +func (t *terminalTool) Name() string { return terminalToolName } + +// Description explains what the tool does. +func (t *terminalTool) Description() string { + return "Run a terminal command through the sandboxed exec provider and return its stdout, stderr and exit code." +} + +// Parameters returns the input JSON Schema. +func (t *terminalTool) Parameters() (map[string]any, error) { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "The command line to run; split into argv by whitespace (no shell interpretation).", + }, + "workdir": map[string]any{ + "type": "string", + "description": "Optional working directory, confined to an allowed sandbox root.", + }, + "timeout": map[string]any{ + "type": "number", + "description": "Optional timeout in seconds (default 30).", + }, + }, + "required": []any{"command"}, + }, nil +} + +// Execute runs the command via the exec provider and returns its outcome. When +// the sandbox is closed the provider returns exec.ErrExecutionDisabled, which the +// dispatcher wraps into a tool error safe to feed back to the LLM. +func (t *terminalTool) Execute(ctx context.Context, args any) (any, error) { + m, ok := args.(map[string]any) + if !ok { + return nil, fmt.Errorf("terminal: args must be an object") + } + cmd, _ := m["command"].(string) + argv := strings.Fields(cmd) + if len(argv) == 0 { + return nil, fmt.Errorf("terminal: command is required") + } + if t.log != nil { + t.log.Debug("terminal tool invoked", "cmd", strings.Join(argv, " ")) + } + req := exec.ExecRequest{Command: argv, Timeout: timeoutArg(m["timeout"])} + if wd, ok := m["workdir"].(string); ok { + req.WorkDir = wd + } + res, err := t.prov.Execute(ctx, req) + if err != nil { + return nil, err + } + return map[string]any{ + "stdout": res.Stdout, + "stderr": res.Stderr, + "exit": res.ExitCode, + "truncated": res.Truncated, + }, nil +} + +// timeoutArg converts the optional timeout argument (seconds) to a duration. A +// missing or non-positive value returns zero, letting the provider apply its own +// default. +func timeoutArg(v any) time.Duration { + switch n := v.(type) { + case float64: + if n > 0 { + return time.Duration(n * float64(time.Second)) + } + case int: + if n > 0 { + return time.Duration(n) * time.Second + } + } + return 0 +} + +// wireTerminalTool registers the terminal tool with the dispatcher so the agent +// can issue sandboxed terminal commands. It no-ops (with a warning) when no exec +// provider is wired, which keeps callers safe. +func (a *app) wireTerminalTool() { + log := logger.WithSource(a.log, "cmd/cyrene") + if a.execProv == nil { + log.Warn("terminal tool skipped: no exec provider") + return + } + if err := a.dispatcher.Register(&terminalTool{ + prov: a.execProv, + log: logger.WithSource(a.log, "cmd/cyrene"), + }); err != nil { + log.Warn("terminal tool register skipped", "error", err) + return + } + log.Info("terminal tool registered", "sandbox_enabled", a.cfg.ExecSandbox.Enabled) +} diff --git a/cmd/cyrene/vision.go b/cmd/cyrene/vision.go new file mode 100644 index 0000000..397a3cd --- /dev/null +++ b/cmd/cyrene/vision.go @@ -0,0 +1,42 @@ +package main + +import ( + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision" +) + +// visionToolName and ocrToolName are the registered names of the vision and OCR +// tools. They are bound to the tool registration here and advertised to the +// agent by the tool dispatcher. +const ( + visionToolName = "vision" + ocrToolName = "ocr" +) + +// wireVision registers the vision and OCR tools and the vision model routing. +// +// The vision adapter is an OpenAI-compatible /chat/completions endpoint on the +// same base URL as the chat LLM (LLM_BASE_URL), using the dedicated model +// (LLM_VISION_MODEL) when set. When no base URL is configured the tools are +// skipped with a warning so startup never blocks; the model router is still +// bound to the vision model (in wireRuntime) whenever one is configured. +func (a *app) wireVision() { + log := logger.WithSource(a.log, "cmd/cyrene") + if strings.TrimSpace(a.cfg.LLM.BaseURL) == "" { + log.Warn("vision/ocr tools skipped: no LLM base url configured") + return + } + adapter := vision.NewOpenAICompatible(a.cfg.LLM, + vision.WithModel(a.cfg.LLM.VisionModel), + vision.WithLogger(a.log)) + if err := vision.RegisterTools(a.caps, + vision.NewVisionTool(visionToolName, adapter), + vision.NewOCRTool(ocrToolName, adapter), + ); err != nil { + log.Warn("vision/ocr tool register skipped", "error", err) + return + } + log.Info("vision/ocr tools registered", "vision_model", a.cfg.LLM.VisionModel) +} diff --git a/cmd/cyrene/voice.go b/cmd/cyrene/voice.go new file mode 100644 index 0000000..fed3fa5 --- /dev/null +++ b/cmd/cyrene/voice.go @@ -0,0 +1,18 @@ +package main + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" +) + +// wireVoice builds the voice STT/TTS degrading chain from config and stores it +// on the app for the WebSocket voice_input/voice_output paths to consume. The +// chain degrades cloud → local → edge-tts/espeak → silent, so a missing endpoint +// or API key never blocks startup (the design accepts the silent fallback). +func (a *app) wireVoice() { + a.voice = voice.New(a.cfg.Voice) + logger.WithSource(a.log, "cmd/cyrene").Info("voice chain ready", + "tts_provider", a.cfg.Voice.TTSProvider, + "stt_provider", a.cfg.Voice.STTProvider, + "dashscope_configured", a.cfg.Voice.DashscopeAPIKey != "") +} diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 7fa5591..6209c76 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -12,6 +12,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" @@ -30,6 +31,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist" @@ -79,7 +81,10 @@ type app struct { loop *loop.Loop scheduler *orchestration.Scheduler tasks *task.Manager + execProv exec.Provider iot *iot.Manager + voice *voice.Chain + mcpRegs []*mcp.Registry sessions *session.SessionManager automation *automations.Service backthink *backthink.Backthink @@ -251,7 +256,8 @@ func (a *app) wireRuntime() error { a.log.Warn("llm adapter unavailable", "error", aerr) adapter = nil } - router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM)) + router := model.NewRouter(model.DefaultModelForProvider(a.cfg.LLM), + model.WithVisionModel(a.cfg.LLM.VisionModel)) dispatcher, derr := tool.New(a.caps, tool.WithDefaultTimeout(defaultToolTimeout)) if derr != nil { @@ -271,6 +277,7 @@ func (a *app) wireRuntime() error { a.log.Warn("io tool register skipped", "tool", t.Name(), "error", rerr) } } + a.wireVision() guards := guardrail.New() instructions := a.defaultInstructions() @@ -326,6 +333,7 @@ func (a *app) wireAuxiliary() error { if err != nil { return fmt.Errorf("exec provider: %w", err) } + a.execProv = provider tmgr, err := task.New(a.cfg, provider, a.log, task.WithBus(a.bus)) if err != nil { return fmt.Errorf("task manager: %w", err) @@ -365,6 +373,11 @@ func (a *app) wireAuxiliary() error { return fmt.Errorf("backthink: %w", err) } a.backthink = bt + + a.wireVoice() + a.wireMCP() + a.wireSkills() + a.wireTerminalTool() return nil } @@ -473,6 +486,9 @@ func (a *app) close(ctx context.Context) { if a.gw != nil { a.gw.Close() } + for _, reg := range a.mcpRegs { + _ = reg.Unregister(ctx) + } if a.tasks != nil { _ = a.tasks.Close() } diff --git a/internal/config/config.go b/internal/config/config.go index 2ecfb46..673df31 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ type Config struct { Background BackgroundConfig WSHub WSHubConfig ExecSandbox ExecSandbox + MCP MCPConfig } // ServerConfig holds the single gateway listener settings. @@ -69,6 +70,10 @@ type LLMConfig struct { Model string `env:"LLM_MODEL"` Fallback string `env:"LLM_FALLBACK_MODEL"` + // VisionModel is the dedicated model used for image/vision requests. When + // set, the model router routes image content to it (see model.Router.WithVisionModel). + VisionModel string `env:"LLM_VISION_MODEL"` + // Anthropic native provider, used when Provider=anthropic. Independent // config block so each vendor is a self-contained config. AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"` @@ -110,6 +115,24 @@ type WSHubConfig struct { SessionIdleTimeout int `env:"SESSION_IDLE_TIMEOUT_MIN" default:"30"` } +// MCPConfig holds Model Context Protocol server settings. The reflection-based +// loader only supports scalars and []string, so servers are supplied as a JSON +// array string (ServersJSON, env MCP_SERVERS_JSON) and parsed at startup by the +// MCP wiring (cmd/cyrene/mcp.go). +type MCPConfig struct { + ServersJSON string `env:"MCP_SERVERS_JSON"` +} + +// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or +// an SSE endpoint (URL). Enabled gates activation at startup. +type MCPServer struct { + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args"` + URL string `json:"url"` + Enabled bool `json:"enabled"` +} + // Load reads configuration, merging an optional .env file (development only) // with process environment variables. Process environment variables always take // precedence over .env values, which take precedence over code defaults.