From f3ba962fd05b394331ffd446c931a9206a20eb92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 17:58:33 +0100 Subject: [PATCH] fix(subagents): explain browser tool profile filtering --- CHANGELOG.md | 1 + docs/tools/browser.md | 18 +++++ docs/tools/index.md | 6 ++ docs/tools/subagents.md | 24 +++++- ...tools.create-openclaw-coding-tools.test.ts | 35 ++++++++ .../test-helpers/fast-openclaw-tools.ts | 1 + src/agents/tool-catalog.test.ts | 1 + src/agents/tools-effective-inventory.test.ts | 44 ++++++++++ src/agents/tools-effective-inventory.ts | 81 ++++++++++++++++++- src/agents/tools-effective-inventory.types.ts | 7 ++ src/auto-reply/status.tools.test.ts | 34 ++++++++ src/auto-reply/status.ts | 6 ++ 12 files changed, 256 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afbaaf9f9e0..5a6c822f798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Telegram/STT: frame inbound voice-note transcripts as machine-generated, untrusted text in agent context while preserving raw transcript mention detection. Closes #33360. Thanks @smartchainark. +- Subagents/browser: show an actionable `/tools` notice when browser automation is configured but filtered out by the active tool profile, and document that coding-profile agents should use `tools.alsoAllow: ["browser"]` rather than subagent allowlists alone. - Control UI/Quick Settings: persist the assistant avatar override to browser local storage (mirroring the user avatar) so uploaded image data URLs no longer fail config validation with "Too big: expected string to have <=200 characters". Also lift the gateway-side `ui.assistant.avatar` length cap to match the user avatar size budget for non-UI clients writing the field directly. Thanks @BunsDev. - Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532. - ACP/OpenCode: update the bundled acpx runtime to 0.6.0 and cover the OpenCode ACP bind path in Docker live tests. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index cb4471e1c46..19f8c12081b 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -69,6 +69,24 @@ Browser config changes require a Gateway restart so the plugin can re-register i ## Agent guidance +Tool-profile note: `tools.profile: "coding"` includes `web_search` and +`web_fetch`, but it does not include the full `browser` tool. If the agent or a +spawned sub-agent should use browser automation, add browser at the profile +stage: + +```json5 +{ + tools: { + profile: "coding", + alsoAllow: ["browser"], + }, +} +``` + +For a single agent, use `agents.list[].tools.alsoAllow: ["browser"]`. +`tools.subagents.tools.allow: ["browser"]` alone is not enough because sub-agent +policy is applied after profile filtering. + The browser plugin ships two levels of agent guidance: - The `browser` tool description carries the compact always-on contract: pick diff --git a/docs/tools/index.md b/docs/tools/index.md index 16c154fe328..53842f0c7f0 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -143,6 +143,12 @@ Per-agent override: `agents.list[].tools.profile`. | `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` | | `minimal` | `session_status` only | +`coding` includes lightweight web tools (`web_search`, `web_fetch`, `x_search`) +but not the full browser-control tool. Browser automation can drive real +sessions and logged-in profiles, so add it explicitly with +`tools.alsoAllow: ["browser"]` or a per-agent +`agents.list[].tools.alsoAllow: ["browser"]`. + The `coding` and `messaging` profiles also allow configured bundle MCP tools under the plugin key `bundle-mcp`. Add `tools.deny: ["bundle-mcp"]` when you want a profile to keep its normal built-ins but hide all configured MCP tools. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 70331775d85..2626aec1353 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -305,7 +305,11 @@ Announce payloads include a stats line at the end (even when wrapped): ## Tool Policy (sub-agent tools) -By default, sub-agents get **all tools except session tools** and system tools: +Sub-agents use the same profile and tool-policy pipeline as the parent or target +agent first. After that, OpenClaw applies the sub-agent restriction layer. + +With no restrictive `tools.profile`, sub-agents get **all tools except session +tools** and system tools: - `sessions_list` - `sessions_history` @@ -341,6 +345,24 @@ Override via config: } ``` +`tools.subagents.tools.allow` is a final allow-only filter. It can narrow the +already-resolved tool set, but it cannot add back a tool removed by +`tools.profile`. For example, `tools.profile: "coding"` includes +`web_search`/`web_fetch`, but not the `browser` tool. To let coding-profile +sub-agents use browser automation, add browser at the profile stage: + +```json5 +{ + tools: { + profile: "coding", + alsoAllow: ["browser"], + }, +} +``` + +Use per-agent `agents.list[].tools.alsoAllow: ["browser"]` when only one agent +should get browser automation. + ## Concurrency Sub-agents use a dedicated in-process queue lane: diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index 1ace0b9c64e..f7d90fac9ab 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -353,6 +353,41 @@ describe("createOpenClawCodingTools", () => { expect(names.has("browser")).toBe(false); }); + it("keeps browser out of coding-profile subagents unless profile-stage alsoAllow adds it", () => { + const baseConfig = { + browser: { enabled: true }, + plugins: { entries: { browser: { enabled: true } } }, + tools: { profile: "coding" }, + } as OpenClawConfig; + const codingSubagent = createOpenClawCodingTools({ + sessionKey: "agent:main:subagent:test", + config: baseConfig, + }); + const codingNames = new Set(codingSubagent.map((tool) => tool.name)); + expect(codingNames.has("browser")).toBe(false); + + const subagentAllowOnly = createOpenClawCodingTools({ + sessionKey: "agent:main:subagent:test", + config: { + ...baseConfig, + tools: { + profile: "coding", + subagents: { tools: { allow: ["browser"] } }, + }, + } as OpenClawConfig, + }); + expect(subagentAllowOnly.some((tool) => tool.name === "browser")).toBe(false); + + const profileStageAlsoAllow = createOpenClawCodingTools({ + sessionKey: "agent:main:subagent:test", + config: { + ...baseConfig, + tools: { profile: "coding", alsoAllow: ["browser"] }, + } as OpenClawConfig, + }); + expect(profileStageAlsoAllow.some((tool) => tool.name === "browser")).toBe(true); + }); + it("can keep message available when a cron route needs it under the coding profile", () => { const codingTools = createOpenClawCodingTools({ config: { tools: { profile: "coding" } }, diff --git a/src/agents/test-helpers/fast-openclaw-tools.ts b/src/agents/test-helpers/fast-openclaw-tools.ts index 3ff7b3d7200..bfaff6c89f7 100644 --- a/src/agents/test-helpers/fast-openclaw-tools.ts +++ b/src/agents/test-helpers/fast-openclaw-tools.ts @@ -30,6 +30,7 @@ const coreTools = [ stubActionTool("sessions_spawn", ["spawn", "handoff"]), stubActionTool("subagents", ["list", "show"]), stubActionTool("session_status", ["get", "show"]), + stubActionTool("browser", ["status", "snapshot"]), stubTool("tts"), stubTool("image_generate"), stubTool("video_generate"), diff --git a/src/agents/tool-catalog.test.ts b/src/agents/tool-catalog.test.ts index 0a47f1663fd..c14f90a4bc8 100644 --- a/src/agents/tool-catalog.test.ts +++ b/src/agents/tool-catalog.test.ts @@ -13,6 +13,7 @@ describe("tool-catalog", () => { expect(policy!.allow).toContain("music_generate"); expect(policy!.allow).toContain("video_generate"); expect(policy!.allow).toContain("update_plan"); + expect(policy!.allow).not.toContain("browser"); }); it("includes bundle MCP tools in coding and messaging profile policies", () => { diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index b531b9be3e5..2ecc856fb00 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -262,6 +262,50 @@ describe("resolveEffectiveToolInventory", () => { expect(result.profile).toBe("coding"); }); + it("adds an actionable notice when configured browser is filtered by the tool profile", async () => { + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [ + mockTool({ name: "web_fetch", label: "Web Fetch", description: "Fetch web content" }), + ], + effectivePolicy: { profile: "coding" }, + }); + + const result = resolveEffectiveToolInventory({ + cfg: { + browser: { enabled: true }, + plugins: { entries: { browser: { enabled: true } } }, + } as never, + }); + + expect(result.notices).toEqual([ + { + id: "browser-filtered-by-profile", + severity: "info", + message: + 'Browser is configured, but the current tool profile does not include the browser tool. Add tools.alsoAllow: ["browser"] or agents.list[].tools.alsoAllow: ["browser"]; tools.subagents.tools.allow alone cannot add it back after profile filtering.', + }, + ]); + }); + + it("does not add a browser profile notice when browser is already available", async () => { + const { resolveEffectiveToolInventory } = await loadHarness({ + tools: [ + mockTool({ name: "browser", label: "Browser", description: "Control browser" }), + mockTool({ name: "web_fetch", label: "Web Fetch", description: "Fetch web content" }), + ], + effectivePolicy: { profile: "coding" }, + }); + + const result = resolveEffectiveToolInventory({ + cfg: { + browser: { enabled: true }, + plugins: { entries: { browser: { enabled: true } } }, + } as never, + }); + + expect(result.notices).toBeUndefined(); + }); + it("passes resolved model compat into effective tool creation", async () => { const createToolsMock = vi.fn(() => [ mockTool({ name: "exec", label: "Exec", description: "Run shell commands" }), diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index f43f915a5c7..7c173b08830 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -12,7 +12,9 @@ import { createOpenClawCodingTools } from "./pi-tools.js"; import { resolveEffectiveToolPolicy } from "./pi-tools.policy.js"; import { summarizeToolDescriptionText } from "./tool-description-summary.js"; import { resolveToolDisplay } from "./tool-display.js"; +import { normalizeToolName } from "./tool-policy.js"; import type { + EffectiveToolInventoryNotice, EffectiveToolInventoryEntry, EffectiveToolInventoryGroup, EffectiveToolInventoryResult, @@ -70,6 +72,82 @@ function groupLabel(source: EffectiveToolSource): string { } } +function listIncludesTool(list: string[] | undefined, toolName: string): boolean { + if (!Array.isArray(list)) { + return false; + } + const normalizedToolName = normalizeToolName(toolName); + return list.some((entry) => normalizeToolName(entry) === normalizedToolName); +} + +function policyDeniesTool(policy: { deny?: string[] } | undefined, toolName: string): boolean { + return ( + listIncludesTool(policy?.deny, toolName) || + listIncludesTool(policy?.deny, "group:ui") || + listIncludesTool(policy?.deny, "group:openclaw") + ); +} + +function hasExplicitBrowserIntent(cfg: OpenClawConfig): boolean { + return cfg.browser?.enabled !== false && Boolean(cfg.browser || cfg.plugins?.entries?.browser); +} + +function buildToolInventoryNotices(params: { + cfg: OpenClawConfig; + profile: string; + entries: EffectiveToolInventoryEntry[]; + effectivePolicy: ReturnType; +}): EffectiveToolInventoryNotice[] | undefined { + const hasBrowserTool = params.entries.some((entry) => normalizeToolName(entry.id) === "browser"); + if (hasBrowserTool || !hasExplicitBrowserIntent(params.cfg)) { + return undefined; + } + + const browserDenied = [ + params.effectivePolicy.globalPolicy, + params.effectivePolicy.globalProviderPolicy, + params.effectivePolicy.agentPolicy, + params.effectivePolicy.agentProviderPolicy, + ].some((policy) => policyDeniesTool(policy, "browser")); + if (browserDenied) { + return [ + { + id: "browser-denied-by-policy", + severity: "info", + message: + "Browser is configured, but this session does not expose the browser tool because tool policy denies it. Remove the browser deny entry to use browser automation.", + }, + ]; + } + + if (params.profile !== "full") { + return [ + { + id: "browser-filtered-by-profile", + severity: "info", + message: + 'Browser is configured, but the current tool profile does not include the browser tool. Add tools.alsoAllow: ["browser"] or agents.list[].tools.alsoAllow: ["browser"]; tools.subagents.tools.allow alone cannot add it back after profile filtering.', + }, + ]; + } + + if ( + Array.isArray(params.cfg.plugins?.allow) && + !listIncludesTool(params.cfg.plugins.allow, "browser") + ) { + return [ + { + id: "browser-plugin-not-allowed", + severity: "warning", + message: + 'Browser is configured, but plugins.allow does not include browser. Add "browser" to plugins.allow or remove the restrictive plugin allowlist.', + }, + ]; + } + + return undefined; +} + function disambiguateLabels(entries: EffectiveToolInventoryEntry[]): EffectiveToolInventoryEntry[] { const counts = new Map(); for (const entry of entries) { @@ -170,6 +248,7 @@ export function resolveEffectiveToolInventory( }) .toSorted((a, b) => a.label.localeCompare(b.label)), ); + const notices = buildToolInventoryNotices({ cfg: params.cfg, profile, entries, effectivePolicy }); const groupsBySource = new Map(); for (const entry of entries) { const tools = groupsBySource.get(entry.source) ?? []; @@ -192,5 +271,5 @@ export function resolveEffectiveToolInventory( }) .filter((group): group is EffectiveToolInventoryGroup => group !== null); - return { agentId, profile, groups }; + return { agentId, profile, groups, ...(notices ? { notices } : {}) }; } diff --git a/src/agents/tools-effective-inventory.types.ts b/src/agents/tools-effective-inventory.types.ts index afb389cdd17..6b3f4b76ee2 100644 --- a/src/agents/tools-effective-inventory.types.ts +++ b/src/agents/tools-effective-inventory.types.ts @@ -19,10 +19,17 @@ export type EffectiveToolInventoryGroup = { tools: EffectiveToolInventoryEntry[]; }; +export type EffectiveToolInventoryNotice = { + id: string; + severity: "info" | "warning"; + message: string; +}; + export type EffectiveToolInventoryResult = { agentId: string; profile: string; groups: EffectiveToolInventoryGroup[]; + notices?: EffectiveToolInventoryNotice[]; }; export type ResolveEffectiveToolInventoryParams = { diff --git a/src/auto-reply/status.tools.test.ts b/src/auto-reply/status.tools.test.ts index 169e4ddf28d..d83efafd458 100644 --- a/src/auto-reply/status.tools.test.ts +++ b/src/auto-reply/status.tools.test.ts @@ -72,6 +72,40 @@ describe("tools product copy", () => { expect(text).not.toContain("unavailable right now"); }); + it("renders effective tool inventory notices", () => { + const text = buildToolsMessage({ + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "web_fetch", + label: "Web Fetch", + description: "Fetch web content", + rawDescription: "Fetch web content", + source: "core", + }, + ], + }, + ], + notices: [ + { + id: "browser-filtered-by-profile", + severity: "info", + message: + 'Browser is configured, but the current tool profile does not include the browser tool. Add tools.alsoAllow: ["browser"].', + }, + ], + }); + + expect(text).toContain("Notes"); + expect(text).toContain('Add tools.alsoAllow: ["browser"].'); + }); + it("keeps detailed descriptions in verbose mode", () => { const text = buildToolsMessage( { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 32829db34d5..25532a1ef4e 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -97,5 +97,11 @@ export function buildToolsMessage( } else { lines.push("", "Use /tools verbose for descriptions."); } + if (result.notices?.length) { + lines.push("", "Notes"); + for (const notice of result.notices) { + lines.push(` ${notice.message}`); + } + } return lines.join("\n"); }