diff --git a/backend/apps/agents/manager/prompt/tool_catalog.py b/backend/apps/agents/manager/prompt/tool_catalog.py index d6640c2f..dfb41235 100644 --- a/backend/apps/agents/manager/prompt/tool_catalog.py +++ b/backend/apps/agents/manager/prompt/tool_catalog.py @@ -1,5 +1,6 @@ import logging -from typing import List, Optional, Set +import os +from typing import Dict, List, Optional, Set, Union from typeguard import typechecked @@ -23,6 +24,22 @@ FULL_TOOLS = [ ] +@typechecked +def resolve_builtin_tools_option() -> Union[List[str], Dict[str, str]]: + """The SDK `tools` option (CLI `--tools`), the base set of built-in tools. + + Default: the full claude_code preset, which ships every preset built-in's schema. With + OSW_TOOL_MANIFEST=1, an explicit FULL_TOOLS list instead, ONLY the built-ins OpenSwarm exposes, + which prunes the ~9 preset extras nothing here references (Cron*/Monitor/Task*/PushNotification/ + RemoteTrigger/etc) for ~940 schema tokens/turn, cache-stable. MCP tools ride mcp_servers, so + SpawnAgent/browser/schedule/skill/web/user MCPs are untouched; ToolSearch stays in FULL_TOOLS so + deferred loading survives (live-proven: the model still ToolSearch-loads + calls MCP tools under + the manifest). Flag-gated pending a real-app soak before default-on.""" + if os.environ.get("OSW_TOOL_MANIFEST") == "1": + return list(FULL_TOOLS) + return {"type": "preset", "preset": "claude_code"} + + @typechecked def get_denied_tool_names(tool: ToolDefinition) -> Set[str]: """Return the set of MCP sub-tool names whose permission is 'deny'.""" diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index 81d086b2..e58e72e8 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -25,7 +25,7 @@ from backend.apps.agents.manager.configure_provider_env import configure_provide from backend.apps.agents.manager.session.workspace_git import ensure_cwd_git_repo from backend.apps.agents.manager.session.history_compaction import build_history_prefix, get_branch_messages from backend.apps.agents.manager.prompt.compose_turn_system_prompt import compose_turn_system_prompt -from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names +from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names, resolve_builtin_tools_option from backend.apps.agents.manager.prompt.prompt_context import resolve_mode from backend.apps.agents.manager.run.run_options_helpers import ( pre_send_context_guard, set_framework_overhead, register_web_mcp_server, @@ -194,11 +194,8 @@ class RunOptions(AgentManagerProtocol): options_kwargs["mcp_servers"] = mcp_servers mcp_json_len = len(json.dumps({"mcpServers": mcp_servers})) logger.info(f"[MCP-DEBUG] mcp_servers passed to SDK: {list(mcp_servers.keys())}, JSON length={mcp_json_len}") - # claude_code preset for BOTH system_prompt and tools so the CLI's deferred-tools scaffolding survives. Raw string would replace it. - options_kwargs["tools"] = { - "type": "preset", - "preset": "claude_code", - } + # Built-in tool surface (preset vs pruned FULL_TOOLS manifest); see resolve_builtin_tools_option. system_prompt keeps the preset regardless. + options_kwargs["tools"] = resolve_builtin_tools_option() # exclude_dynamic_sections=True moves cwd/git/OS grounding out of the cached prefix and into the first user message, unlocks Anthropic prompt cache (~80% input-token cut, 13-31% faster TTFT). Trade-off: grounding freezes at turn 1. if composed_prompt: options_kwargs["system_prompt"] = { diff --git a/backend/tests/test_tool_manifest.py b/backend/tests/test_tool_manifest.py new file mode 100644 index 00000000..67c0ce39 --- /dev/null +++ b/backend/tests/test_tool_manifest.py @@ -0,0 +1,46 @@ +"""OSW_TOOL_MANIFEST gate: default ships the full claude_code preset; flag-on ships an explicit +FULL_TOOLS list that prunes dead preset built-ins WITHOUT dropping anything OpenSwarm uses. The +manifest MUST keep every built-in in effective_allowed's source set + ToolSearch (so deferred MCP +loading survives); MCP tools ride mcp_servers, not this list.""" + +import os + +from backend.apps.agents.manager.prompt.tool_catalog import ( + FULL_TOOLS, + resolve_builtin_tools_option, +) + + +def test_default_is_the_full_preset(monkeypatch): + monkeypatch.delenv("OSW_TOOL_MANIFEST", raising=False) + assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"} + + +def test_flag_on_is_the_explicit_full_tools_manifest(monkeypatch): + monkeypatch.setenv("OSW_TOOL_MANIFEST", "1") + out = resolve_builtin_tools_option() + assert isinstance(out, list) + assert out == list(FULL_TOOLS) + # A fresh copy, never the module list itself (a caller mutation must not poison FULL_TOOLS). + assert out is not FULL_TOOLS + + +def test_manifest_keeps_toolsearch_so_deferred_mcp_loading_survives(monkeypatch): + monkeypatch.setenv("OSW_TOOL_MANIFEST", "1") + out = resolve_builtin_tools_option() + assert "ToolSearch" in out + + +def test_manifest_keeps_every_core_builtin_openswarm_exposes(monkeypatch): + # These are the built-ins the agent actually uses; none may vanish from the manifest. + monkeypatch.setenv("OSW_TOOL_MANIFEST", "1") + out = resolve_builtin_tools_option() + for core in ("Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion"): + assert core in out + + +def test_only_the_exact_flag_value_flips_it(monkeypatch): + monkeypatch.setenv("OSW_TOOL_MANIFEST", "true") # not "1" + assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"} + monkeypatch.setenv("OSW_TOOL_MANIFEST", "0") + assert resolve_builtin_tools_option() == {"type": "preset", "preset": "claude_code"}