From 8949a2575b87506486a72494fb11cf1699c52678 Mon Sep 17 00:00:00 2001 From: wenjie Date: Wed, 11 Mar 2026 19:57:59 +0800 Subject: [PATCH 001/117] Add exec allow_remote config support in web settings (#1363) - default tools.exec.allow_remote to true when omitted in config loading - preserve allow_remote in OpenClaw config migration and API updates - expose allow_remote in the web config form with i18n strings - add backend and config tests covering the new default behavior --- pkg/config/config_test.go | 23 +++++ pkg/config/defaults.go | 2 +- .../sources/openclaw/openclaw_config.go | 1 + .../sources/openclaw/openclaw_config_test.go | 14 +++ web/backend/api/config.go | 17 ++++ web/backend/api/config_test.go | 88 +++++++++++++++++++ .../src/components/config/config-page.tsx | 5 ++ .../src/components/config/config-sections.tsx | 7 ++ .../src/components/config/form-model.ts | 8 ++ web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + 11 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 web/backend/api/config_test.go diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 8baf3e6fd..7ec0e90ba 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { } } +func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true") + } +} + func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { } } +func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when unset in config file") + } +} + func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 56cc95375..50f9d58ac 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -427,7 +427,7 @@ func DefaultConfig() *Config { Enabled: true, }, EnableDenyPatterns: true, - AllowRemote: false, + AllowRemote: true, TimeoutSeconds: 60, }, Skills: SkillsToolsConfig{ diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index e272d17a9..e95c2f3ec 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -1111,6 +1111,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig { Exec: config.ExecConfig{ EnableDenyPatterns: c.Exec.EnableDenyPatterns, CustomDenyPatterns: c.Exec.CustomDenyPatterns, + AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote, }, } } diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 3a7d0c686..802693825 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config_test.go +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -290,6 +290,20 @@ func TestConvertToPicoClaw(t *testing.T) { } } +func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) { + cfg := (&PicoClawConfig{ + Tools: ToolsConfig{ + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, + }, + }).ToStandardConfig() + + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true") + } +} + func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") diff --git a/web/backend/api/config.go b/web/backend/api/config.go index e261f43dc..091e3fbae 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -48,6 +48,9 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } + if execAllowRemoteOmitted(body) { + cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote + } if errs := validateConfig(&cfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -68,6 +71,20 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } +func execAllowRemoteOmitted(body []byte) bool { + var raw struct { + Tools *struct { + Exec *struct { + AllowRemote *bool `json:"allow_remote"` + } `json:"exec"` + } `json:"tools"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return false + } + return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil +} + // handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396). // Only the fields present in the request body will be updated; all other fields remain unchanged. // diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go new file mode 100644 index 000000000..29811e37e --- /dev/null +++ b/web/backend/api/config_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_key": "sk-default" + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config") + } +} + +func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_key": "sk-default" + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.ModelList[0].APIBase; got != "" { + t.Fatalf("model_list[0].api_base = %q, want empty string", got) + } +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index c2d502079..d7e1aa1b5 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -189,6 +189,11 @@ export function ConfigPage() { session: { dm_scope: dmScope, }, + tools: { + exec: { + allow_remote: form.allowRemote, + }, + }, heartbeat: { enabled: form.heartbeatEnabled, interval: heartbeatInterval, diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 340ece333..90813be2a 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -63,6 +63,13 @@ export function AgentDefaultsSection({ } /> + onFieldChange("allowRemote", checked)} + /> + export interface CoreConfigForm { workspace: string restrictToWorkspace: boolean + allowRemote: boolean maxTokens: string maxToolIterations: string summarizeMessageThreshold: string @@ -54,6 +55,7 @@ export const DM_SCOPE_OPTIONS = [ export const EMPTY_FORM: CoreConfigForm = { workspace: "", restrictToWorkspace: true, + allowRemote: true, maxTokens: "32768", maxToolIterations: "50", summarizeMessageThreshold: "20", @@ -103,6 +105,8 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { const session = asRecord(root.session) const heartbeat = asRecord(root.heartbeat) const devices = asRecord(root.devices) + const tools = asRecord(root.tools) + const exec = asRecord(tools.exec) return { workspace: asString(defaults.workspace) || EMPTY_FORM.workspace, @@ -110,6 +114,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { defaults.restrict_to_workspace === undefined ? EMPTY_FORM.restrictToWorkspace : asBool(defaults.restrict_to_workspace), + allowRemote: + exec.allow_remote === undefined + ? EMPTY_FORM.allowRemote + : asBool(exec.allow_remote), maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens), maxToolIterations: asNumberString( defaults.max_tool_iterations, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 875387c8a..b88b5c924 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -429,6 +429,8 @@ "workspace_hint": "Base directory for agent file operations.", "restrict_workspace": "Restrict to Workspace", "restrict_workspace_hint": "Only allow file operations inside workspace.", + "allow_remote": "Allow Remote Shell Execution", + "allow_remote_hint": "When enabled, shell commands can also run for remote sessions or non-local contexts. When disabled, shell execution stays limited to local safe contexts.", "max_tokens": "Max Tokens", "max_tokens_hint": "Upper token limit per model response.", "max_tool_iterations": "Max Tool Iterations", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e40ad625b..12833cbf5 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -429,6 +429,8 @@ "workspace_hint": "智能体执行文件读写操作时使用的基础目录。", "restrict_workspace": "限制工作目录访问", "restrict_workspace_hint": "仅允许在工作目录内执行文件操作。", + "allow_remote": "允许远程执行 Shell 命令", + "allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行 shell 命令;关闭后,仅允许本地安全上下文执行。", "max_tokens": "最大 Token 数", "max_tokens_hint": "单次模型响应允许的最大 Token 数。", "max_tool_iterations": "最大工具迭代次数", From 4a8a2e9c23588ed26eddd35ce4e5d688fe9f621f Mon Sep 17 00:00:00 2001 From: wenjie Date: Wed, 11 Mar 2026 20:23:37 +0800 Subject: [PATCH 002/117] chore(frontend): update pnpm-lock.yaml (#1368) --- web/frontend/pnpm-lock.yaml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 08f13a0b9..9de3354a1 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - '@hono/node-server': 1.19.11 - express-rate-limit: 8.3.1 - hono: 4.12.7 - importers: .: @@ -71,8 +66,8 @@ importers: specifier: ^4.0.1 version: 4.0.1 shadcn: - specifier: ^3.8.5 - version: 3.8.5(@types/node@24.11.0)(typescript@5.9.3) + specifier: ^4.0.5 + version: 4.0.5(@types/node@24.11.0)(typescript@5.9.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -521,7 +516,7 @@ packages: resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} engines: {node: '>=18.14.1'} peerDependencies: - hono: 4.12.7 + hono: ^4 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -3485,8 +3480,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@3.8.5: - resolution: {integrity: sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==} + shadcn@4.0.5: + resolution: {integrity: sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg==} hasBin: true shebang-command@2.0.0: @@ -7518,7 +7513,7 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@3.8.5(@types/node@24.11.0)(typescript@5.9.3): + shadcn@4.0.5(@types/node@24.11.0)(typescript@5.9.3): dependencies: '@antfu/ni': 25.0.0 '@babel/core': 7.29.0 From 9b0a48ac6d0df8ad1862c6f88648cc4dbde4bd50 Mon Sep 17 00:00:00 2001 From: Congregalis <2056843452@qq.com> Date: Thu, 12 Mar 2026 01:06:48 +0800 Subject: [PATCH 003/117] fix(agent): initialize MCP in direct agent mode (#1361) --- pkg/agent/loop.go | 132 ++++------------------------- pkg/agent/loop_mcp.go | 184 +++++++++++++++++++++++++++++++++++++++++ pkg/agent/loop_test.go | 50 +++++++++++ 3 files changed, 252 insertions(+), 114 deletions(-) create mode 100644 pkg/agent/loop_mcp.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bee8d91a7..28e549ce0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -25,7 +25,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/mcp" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" @@ -48,6 +47,7 @@ type AgentLoop struct { mediaStore media.MediaStore transcriber voice.Transcriber cmdRegistry *commands.Registry + mcp mcpRuntime } // processOptions configures how a message is processed @@ -239,119 +239,8 @@ func registerSharedTools( func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) - - // Initialize MCP servers for all agents - if al.cfg.Tools.IsToolEnabled("mcp") { - mcpManager := mcp.NewManager() - // Ensure MCP connections are cleaned up on exit, regardless of initialization success - // This fixes resource leak when LoadFromMCPConfig partially succeeds then fails - defer func() { - if err := mcpManager.Close(); err != nil { - logger.ErrorCF("agent", "Failed to close MCP manager", - map[string]any{ - "error": err.Error(), - }) - } - }() - - defaultAgent := al.registry.GetDefaultAgent() - var workspacePath string - if defaultAgent != nil && defaultAgent.Workspace != "" { - workspacePath = defaultAgent.Workspace - } else { - workspacePath = al.cfg.WorkspacePath() - } - - if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { - logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", - map[string]any{ - "error": err.Error(), - }) - } else { - // Register MCP tools for all agents - servers := mcpManager.GetServers() - uniqueTools := 0 - totalRegistrations := 0 - agentIDs := al.registry.ListAgentIDs() - agentCount := len(agentIDs) - - for serverName, conn := range servers { - uniqueTools += len(conn.Tools) - for _, tool := range conn.Tools { - for _, agentID := range agentIDs { - agent, ok := al.registry.GetAgent(agentID) - if !ok { - continue - } - - mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) - - if al.cfg.Tools.MCP.Discovery.Enabled { - agent.Tools.RegisterHidden(mcpTool) - } else { - agent.Tools.Register(mcpTool) - } - - totalRegistrations++ - logger.DebugCF("agent", "Registered MCP tool", - map[string]any{ - "agent_id": agentID, - "server": serverName, - "tool": tool.Name, - "name": mcpTool.Name(), - }) - } - } - } - logger.InfoCF("agent", "MCP tools registered successfully", - map[string]any{ - "server_count": len(servers), - "unique_tools": uniqueTools, - "total_registrations": totalRegistrations, - "agent_count": agentCount, - }) - - // Initializes Discovery Tools only if enabled by configuration - if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { - useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 - useRegex := al.cfg.Tools.MCP.Discovery.UseRegex - - // Fail fast: If discovery is enabled but no search method is turned on - if !useBM25 && !useRegex { - return fmt.Errorf( - "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", - ) - } - - ttl := al.cfg.Tools.MCP.Discovery.TTL - if ttl <= 0 { - ttl = 5 // Default value - } - - maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults - if maxSearchResults <= 0 { - maxSearchResults = 5 // Default value - } - - logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ - "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, - }) - - for _, agentID := range agentIDs { - agent, ok := al.registry.GetAgent(agentID) - if !ok { - continue - } - - if useRegex { - agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) - } - if useBM25 { - agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) - } - } - } - } + if err := al.ensureMCPInitialized(ctx); err != nil { + return err } for al.running.Load() { @@ -431,6 +320,17 @@ func (al *AgentLoop) Stop() { // Close releases resources held by agent session stores. Call after Stop. func (al *AgentLoop) Close() { + mcpManager := al.mcp.takeManager() + + if mcpManager != nil { + if err := mcpManager.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": err.Error(), + }) + } + } + al.registry.Close() } @@ -619,6 +519,10 @@ func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, content, sessionKey, channel, chatID string, ) (string, error) { + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + msg := bus.InboundMessage{ Channel: channel, SenderID: "cron", diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go new file mode 100644 index 000000000..2795db52a --- /dev/null +++ b/pkg/agent/loop_mcp.go @@ -0,0 +1,184 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/mcp" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type mcpRuntime struct { + initOnce sync.Once + mu sync.Mutex + manager *mcp.Manager + initErr error +} + +func (r *mcpRuntime) setManager(manager *mcp.Manager) { + r.mu.Lock() + r.manager = manager + r.initErr = nil + r.mu.Unlock() +} + +func (r *mcpRuntime) setInitErr(err error) { + r.mu.Lock() + r.initErr = err + r.mu.Unlock() +} + +func (r *mcpRuntime) getInitErr() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.initErr +} + +func (r *mcpRuntime) takeManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + manager := r.manager + r.manager = nil + return manager +} + +func (r *mcpRuntime) hasManager() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager != nil +} + +// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct +// agent mode share the same initialization path. +func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { + if !al.cfg.Tools.IsToolEnabled("mcp") { + return nil + } + + al.mcp.initOnce.Do(func() { + mcpManager := mcp.NewManager() + + defaultAgent := al.registry.GetDefaultAgent() + workspacePath := al.cfg.WorkspacePath() + if defaultAgent != nil && defaultAgent.Workspace != "" { + workspacePath = defaultAgent.Workspace + } + + if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", + map[string]any{ + "error": err.Error(), + }) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + // Register MCP tools for all agents + servers := mcpManager.GetServers() + uniqueTools := 0 + totalRegistrations := 0 + agentIDs := al.registry.ListAgentIDs() + agentCount := len(agentIDs) + + for serverName, conn := range servers { + uniqueTools += len(conn.Tools) + for _, tool := range conn.Tools { + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) + + if al.cfg.Tools.MCP.Discovery.Enabled { + agent.Tools.RegisterHidden(mcpTool) + } else { + agent.Tools.Register(mcpTool) + } + + totalRegistrations++ + logger.DebugCF("agent", "Registered MCP tool", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "tool": tool.Name, + "name": mcpTool.Name(), + }) + } + } + } + logger.InfoCF("agent", "MCP tools registered successfully", + map[string]any{ + "server_count": len(servers), + "unique_tools": uniqueTools, + "total_registrations": totalRegistrations, + "agent_count": agentCount, + }) + + // Initializes Discovery Tools only if enabled by configuration + if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { + useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 + useRegex := al.cfg.Tools.MCP.Discovery.UseRegex + + // Fail fast: If discovery is enabled but no search method is turned on + if !useBM25 && !useRegex { + al.mcp.setInitErr(fmt.Errorf( + "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", + )) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + ttl := al.cfg.Tools.MCP.Discovery.TTL + if ttl <= 0 { + ttl = 5 // Default value + } + + maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults + if maxSearchResults <= 0 { + maxSearchResults = 5 // Default value + } + + logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ + "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, + }) + + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + if useRegex { + agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) + } + if useBM25 { + agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) + } + } + } + + al.mcp.setManager(mcpManager) + }) + + return al.mcp.getInitErr() +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2e456fa60..cab82e176 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -770,6 +770,56 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } +func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{ + Enabled: true, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() + + if al.mcp.hasManager() { + t.Fatal("expected MCP manager to be nil before first direct processing") + } + + _, err = al.ProcessDirectWithChannel( + context.Background(), + "hello", + "session-1", + "cli", + "direct", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + if !al.mcp.hasManager() { + t.Fatal("expected MCP manager to be initialized in direct agent mode") + } +} + func TestTargetReasoningChannelID_AllChannels(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { From fcf954573618201e1a87ebf6d2987b0ef0e1d587 Mon Sep 17 00:00:00 2001 From: XYSK-lilong007 <267018309+XYSK-lilong007@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:21:44 +0800 Subject: [PATCH 004/117] fix: use native textarea scrolling for raw config editor --- .../src/components/config/raw-json-panel.tsx | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/web/frontend/src/components/config/raw-json-panel.tsx b/web/frontend/src/components/config/raw-json-panel.tsx index f67bd89f5..5f195f744 100644 --- a/web/frontend/src/components/config/raw-json-panel.tsx +++ b/web/frontend/src/components/config/raw-json-panel.tsx @@ -22,7 +22,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" -import { ScrollArea } from "@/components/ui/scroll-area" import { Textarea } from "@/components/ui/textarea" export function RawJsonPanel() { @@ -142,17 +141,15 @@ export function RawJsonPanel() { )}
- -