From 251d97a3d7ae81203636c620a00c8e2b22757f7f Mon Sep 17 00:00:00 2001 From: Pavel Rykov Date: Tue, 27 Jan 2026 20:13:11 +0300 Subject: [PATCH 01/13] isSystemTool added to tools --- sgr_agent_core/base_tool.py | 1 + sgr_agent_core/tools/adapt_plan_tool.py | 2 ++ sgr_agent_core/tools/clarification_tool.py | 2 ++ sgr_agent_core/tools/create_report_tool.py | 2 ++ sgr_agent_core/tools/final_answer_tool.py | 2 ++ sgr_agent_core/tools/generate_plan_tool.py | 2 ++ sgr_agent_core/tools/reasoning_tool.py | 2 ++ 7 files changed, 13 insertions(+) diff --git a/sgr_agent_core/base_tool.py b/sgr_agent_core/base_tool.py index 57207532..e69d592f 100644 --- a/sgr_agent_core/base_tool.py +++ b/sgr_agent_core/base_tool.py @@ -29,6 +29,7 @@ class BaseTool(BaseModel, ToolRegistryMixin): tool_name: ClassVar[str] = None description: ClassVar[str] = None + isSystemTool: ClassVar[bool] = False # Optional: Pydantic model for this tool's config; agent.get_tool_config(tool_class) returns it config_model: ClassVar[type[BaseModel] | None] = None # If set, agent config attribute to merge as base (e.g. "search") when resolving tool config diff --git a/sgr_agent_core/tools/adapt_plan_tool.py b/sgr_agent_core/tools/adapt_plan_tool.py index c53a690f..4c9248e1 100644 --- a/sgr_agent_core/tools/adapt_plan_tool.py +++ b/sgr_agent_core/tools/adapt_plan_tool.py @@ -14,6 +14,8 @@ class AdaptPlanTool(BaseTool): """Adapt a research plan based on new findings.""" + isSystemTool = True + reasoning: str = Field(description="Why plan needs adaptation based on new data") original_goal: str = Field(description="Original research goal") new_goal: str = Field(description="Updated research goal") diff --git a/sgr_agent_core/tools/clarification_tool.py b/sgr_agent_core/tools/clarification_tool.py index 1fdbf17e..c4eee313 100644 --- a/sgr_agent_core/tools/clarification_tool.py +++ b/sgr_agent_core/tools/clarification_tool.py @@ -17,6 +17,8 @@ class ClarificationTool(BaseTool): Keep all fields concise - brief reasoning, short terms, and clear questions. """ + isSystemTool = True + reasoning: str = Field(description="Why clarification is needed (1-2 sentences MAX)", max_length=200) unclear_terms: list[str] = Field( description="List of unclear terms (brief, 1-3 words each)", diff --git a/sgr_agent_core/tools/create_report_tool.py b/sgr_agent_core/tools/create_report_tool.py index aa42c618..f14e6b83 100644 --- a/sgr_agent_core/tools/create_report_tool.py +++ b/sgr_agent_core/tools/create_report_tool.py @@ -26,6 +26,8 @@ class CreateReportTool(BaseTool): Citations must be integrated directly into sentences, not just listed at the end. """ + isSystemTool = True + reasoning: str = Field(description="Why ready to create report now") title: str = Field(description="Report title") user_request_language_reference: str = Field( diff --git a/sgr_agent_core/tools/final_answer_tool.py b/sgr_agent_core/tools/final_answer_tool.py index 7c5b7af6..f87b3cd3 100644 --- a/sgr_agent_core/tools/final_answer_tool.py +++ b/sgr_agent_core/tools/final_answer_tool.py @@ -23,6 +23,8 @@ class FinalAnswerTool(BaseTool): Usage: Call after you are ready to finalize your work and provide the final answer to the user. """ + isSystemTool = True + reasoning: str = Field(description="Why task is now complete and how answer was verified") completed_steps: list[str] = Field( description="Summary of completed steps including verification", min_length=1, max_length=5 diff --git a/sgr_agent_core/tools/generate_plan_tool.py b/sgr_agent_core/tools/generate_plan_tool.py index ba74005a..a00ab05b 100644 --- a/sgr_agent_core/tools/generate_plan_tool.py +++ b/sgr_agent_core/tools/generate_plan_tool.py @@ -17,6 +17,8 @@ class GeneratePlanTool(BaseTool): Useful to split complex request into manageable steps. """ + isSystemTool = True + reasoning: str = Field(description="Justification for research approach") research_goal: str = Field(description="Primary research objective") planned_steps: list[str] = Field(description="List of 3-4 planned steps", min_length=3, max_length=4) diff --git a/sgr_agent_core/tools/reasoning_tool.py b/sgr_agent_core/tools/reasoning_tool.py index 6084e2b1..c57e0c1c 100644 --- a/sgr_agent_core/tools/reasoning_tool.py +++ b/sgr_agent_core/tools/reasoning_tool.py @@ -13,6 +13,8 @@ class ReasoningTool(BaseTool): Usage: Required tool. Use this tool before any other tool execution """ + isSystemTool = True + # Reasoning chain - step-by-step thinking process (helps stabilize model) reasoning_steps: list[str] = Field( description="Step-by-step reasoning (brief, 1 sentence each)", From 4e53971d1dc9523141c3d691cbcdba70969c1927 Mon Sep 17 00:00:00 2001 From: Pavel Rykov Date: Tue, 27 Jan 2026 20:21:46 +0300 Subject: [PATCH 02/13] tools filtering: add rank-bm25 dependency --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9d7b900d..28108bd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dependencies = [ "uvicorn>=0.35.0", "fastmcp>=2.12.4", "jambo>=0.1.3.post2", + # Tools filtering + "rank-bm25>=0.2.2", ] [project.urls] From 3e78a6c6ec0390d7bed490598440e81371764921 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Wed, 11 Feb 2026 19:01:01 +0000 Subject: [PATCH 03/13] feat(progressive_discovery): dynamic tool discovery agent Add agent and utilities for dynamic tool selection to minimize LLM context size. - Introduce progressive_discovery agent for discovering and filtering tools - Add search tool and filter service modules - Provide config and usage examples - Include new tests for progressive discovery workflow --- examples/progressive_discovery/README.md | 51 +++ examples/progressive_discovery/__init__.py | 0 .../progressive_discovery/config.yaml.example | 53 +++ .../progressive_discovery_agent.py | 80 +++++ .../services/__init__.py | 0 .../services/tool_filter_service.py | 84 +++++ .../progressive_discovery/tools/__init__.py | 0 .../tools/search_tools_tool.py | 53 +++ tests/test_progressive_discovery.py | 302 ++++++++++++++++++ 9 files changed, 623 insertions(+) create mode 100644 examples/progressive_discovery/README.md create mode 100644 examples/progressive_discovery/__init__.py create mode 100644 examples/progressive_discovery/config.yaml.example create mode 100644 examples/progressive_discovery/progressive_discovery_agent.py create mode 100644 examples/progressive_discovery/services/__init__.py create mode 100644 examples/progressive_discovery/services/tool_filter_service.py create mode 100644 examples/progressive_discovery/tools/__init__.py create mode 100644 examples/progressive_discovery/tools/search_tools_tool.py create mode 100644 tests/test_progressive_discovery.py diff --git a/examples/progressive_discovery/README.md b/examples/progressive_discovery/README.md new file mode 100644 index 00000000..84c12e2c --- /dev/null +++ b/examples/progressive_discovery/README.md @@ -0,0 +1,51 @@ +# Progressive Tool Discovery + +Example agent demonstrating dynamic tool discovery for SGR Agent Core. + +## Problem + +When using multiple MCP servers (Jira, Confluence, GitHub, GDrive), each adds dozens of tools. With ~60 tools the LLM context becomes bloated — local models can't handle it, and paid APIs waste tokens on irrelevant tool descriptions. + +## Solution + +The agent starts with a minimal set of **system tools** (reasoning, planning, clarification, final answer) and dynamically discovers additional tools via `SearchToolsTool`. + +``` +User query → Agent reasons → needs web search → calls SearchToolsTool("search the web") +→ WebSearchTool discovered and added to active toolkit → Agent uses WebSearchTool +``` + +### How it works + +1. **Init**: Toolkit is split into system tools (`isSystemTool=True`) and discoverable tools +2. **Runtime**: Only system tools + already discovered tools are sent to LLM +3. **Discovery**: Agent calls `SearchToolsTool` with a natural language query +4. **Matching**: `ToolFilterService` uses BM25 ranking + regex keyword overlap to find relevant tools +5. **Activation**: Matched tools are added to the active toolkit for subsequent calls + +### Key components + +| Component | Description | +| --------------------------- | ------------------------------------------------------------- | +| `ProgressiveDiscoveryAgent` | Agent subclass that manages system/discovered tool split | +| `SearchToolsTool` | Meta-tool for discovering new tools by capability description | +| `ToolFilterService` | Stateless BM25 + regex matching service | + +## Usage + +```bash +cp config.yaml.example config.yaml +# Edit config.yaml with your API key and MCP servers +sgr --config-file config.yaml +``` + +## Architecture + +``` +ProgressiveDiscoveryAgent +├── self.toolkit = [ReasoningTool, SearchToolsTool, ...] (system tools) +├── context.custom_context["all_tools"] = [WebSearchTool, ...] (discoverable) +└── context.custom_context["discovered_tools"] = [] (accumulates at runtime) +``` + +`_get_active_tools()` returns `system_tools + discovered_tools` — used by both `_prepare_tools()` and `_prepare_context()`. diff --git a/examples/progressive_discovery/__init__.py b/examples/progressive_discovery/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/progressive_discovery/config.yaml.example b/examples/progressive_discovery/config.yaml.example new file mode 100644 index 00000000..553bc54e --- /dev/null +++ b/examples/progressive_discovery/config.yaml.example @@ -0,0 +1,53 @@ +# Progressive Discovery Agent Configuration +# +# This agent starts with minimal system tools and dynamically discovers +# additional tools as needed via SearchToolsTool (BM25 + regex matching). +# +# Useful when you have many MCP servers with dozens of tools — keeps +# the LLM context small and focused. + +llm: + model: "gpt-4o" + base_url: "https://api.openai.com/v1" + api_key: "sk-..." + temperature: 0.1 + max_tokens: 16000 + +execution: + max_iterations: 15 + max_clarifications: 2 + +prompts: + system_prompt_path: "examples/progressive_discovery/prompts/system_prompt.txt" + +# MCP servers provide additional tools that will be discoverable +# (not loaded into context until agent searches for them) +# +# mcp: +# servers: +# - name: "jira" +# command: "npx" +# args: ["-y", "@anthropic/jira-mcp-server"] +# env: +# JIRA_URL: "https://your-org.atlassian.net" +# JIRA_TOKEN: "your-token" +# +# - name: "github" +# command: "npx" +# args: ["-y", "@anthropic/github-mcp-server"] +# env: +# GITHUB_TOKEN: "your-token" + +agents: + progressive_discovery: + base_class: "examples.progressive_discovery.progressive_discovery_agent.ProgressiveDiscoveryAgent" + tools: + - "sgr_agent_core.tools.reasoning_tool.ReasoningTool" + - "sgr_agent_core.tools.clarification_tool.ClarificationTool" + - "sgr_agent_core.tools.generate_plan_tool.GeneratePlanTool" + - "sgr_agent_core.tools.adapt_plan_tool.AdaptPlanTool" + - "sgr_agent_core.tools.create_report_tool.CreateReportTool" + - "sgr_agent_core.tools.final_answer_tool.FinalAnswerTool" + # Add any non-system tools here — they will be discoverable, not loaded by default + # - "sgr_agent_core.tools.web_search_tool.WebSearchTool" + # - "sgr_agent_core.tools.extract_page_content_tool.ExtractPageContentTool" diff --git a/examples/progressive_discovery/progressive_discovery_agent.py b/examples/progressive_discovery/progressive_discovery_agent.py new file mode 100644 index 00000000..1e7475aa --- /dev/null +++ b/examples/progressive_discovery/progressive_discovery_agent.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Type + +from openai import AsyncOpenAI, pydantic_function_tool + +from sgr_agent_core.agent_definition import AgentConfig +from sgr_agent_core.agents.sgr_tool_calling_agent import SGRToolCallingAgent +from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.services.prompt_loader import PromptLoader + +from .tools.search_tools_tool import SearchToolsTool + + +class ProgressiveDiscoveryAgent(SGRToolCallingAgent): + """Agent that starts with minimal system tools and dynamically discovers + additional tools via SearchToolsTool. + + On init, splits the toolkit into: + - system tools (isSystemTool=True) -> self.toolkit (always available) + - non-system tools -> stored in context.custom_context["all_tools"] + + SearchToolsTool is automatically added if not already present. + Discovered tools accumulate in context.custom_context["discovered_tools"]. + """ + + name: str = "progressive_discovery_agent" + + def __init__( + self, + task_messages: list, + openai_client: AsyncOpenAI, + agent_config: AgentConfig, + toolkit: list[Type[BaseTool]], + def_name: str | None = None, + **kwargs: dict, + ): + system_tools = [t for t in toolkit if getattr(t, "isSystemTool", False)] + non_system_tools = [t for t in toolkit if not getattr(t, "isSystemTool", False)] + + if SearchToolsTool not in system_tools: + system_tools.append(SearchToolsTool) + + super().__init__( + task_messages=task_messages, + openai_client=openai_client, + agent_config=agent_config, + toolkit=system_tools, + def_name=def_name, + **kwargs, + ) + + if self._context.custom_context is None: + self._context.custom_context = {} + self._context.custom_context["all_tools"] = non_system_tools + self._context.custom_context["discovered_tools"] = [] + + def _get_active_tools(self) -> list[Type[BaseTool]]: + """Return system tools + discovered tools.""" + discovered = [] + if isinstance(self._context.custom_context, dict): + discovered = self._context.custom_context.get("discovered_tools", []) + return list(self.toolkit) + list(discovered) + + async def _prepare_tools(self) -> list[dict]: + """Override to return only active tools (system + discovered).""" + active_tools = self._get_active_tools() + if self._context.iteration >= self.config.execution.max_iterations: + raise RuntimeError("Max iterations reached") + return [pydantic_function_tool(tool, name=tool.tool_name) for tool in active_tools] + + async def _prepare_context(self) -> list[dict]: + """Override to pass only active tools to system prompt.""" + active_tools = self._get_active_tools() + return [ + {"role": "system", "content": PromptLoader.get_system_prompt(active_tools, self.config.prompts)}, + *self.task_messages, + {"role": "user", "content": PromptLoader.get_initial_user_request(self.task_messages, self.config.prompts)}, + *self.conversation, + ] diff --git a/examples/progressive_discovery/services/__init__.py b/examples/progressive_discovery/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/progressive_discovery/services/tool_filter_service.py b/examples/progressive_discovery/services/tool_filter_service.py new file mode 100644 index 00000000..a5df2525 --- /dev/null +++ b/examples/progressive_discovery/services/tool_filter_service.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from rank_bm25 import BM25Okapi + +if TYPE_CHECKING: + from sgr_agent_core.base_tool import BaseTool + + +class ToolFilterService: + """Stateless service for filtering tools by relevance to a query. + + Uses BM25 ranking + regex keyword overlap to find tools matching a + query. + """ + + @classmethod + def filter_tools( + cls, + query: str, + tools: list[type[BaseTool]], + bm25_threshold: float = 0.1, + ) -> list[type[BaseTool]]: + """Filter tools by relevance to query using BM25 + regex. + + Args: + query: Natural language description of needed capability. + tools: Full list of available tool classes. + bm25_threshold: Minimum BM25 score to consider a tool relevant. + + Returns: + List of tool classes matching the query. + """ + if not query or not query.strip() or not tools: + return list(tools) + + query_lower = query.strip().lower() + + tool_documents = [] + for tool in tools: + tool_name = (tool.tool_name or tool.__name__).lower() + tool_description = (tool.description or "").lower() + tool_documents.append(f"{tool_name} {tool_description}") + + tokenized_docs = [doc.split() for doc in tool_documents] + bm25 = BM25Okapi(tokenized_docs) + + query_tokens = query_lower.split() + scores = bm25.get_scores(query_tokens) + + query_words = set(re.findall(r"\b\w+\b", query_lower)) + + filtered = [] + for i, tool in enumerate(tools): + bm25_score = scores[i] + + tool_name = (tool.tool_name or tool.__name__).lower() + tool_description = (tool.description or "").lower() + tool_words = set(re.findall(r"\b\w+\b", f"{tool_name} {tool_description}")) + has_regex_match = bool(query_words & tool_words) + + if bm25_score > bm25_threshold or has_regex_match: + filtered.append(tool) + + return filtered + + @classmethod + def get_tool_summaries(cls, tools: list[type[BaseTool]]) -> str: + """Format tool list for LLM output. + + Args: + tools: List of tool classes to summarize. + + Returns: + Formatted string with tool names and descriptions. + """ + lines = [] + for i, tool in enumerate(tools, start=1): + name = tool.tool_name or tool.__name__ + desc = tool.description or "" + lines.append(f"{i}. {name}: {desc}") + return "\n".join(lines) diff --git a/examples/progressive_discovery/tools/__init__.py b/examples/progressive_discovery/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/progressive_discovery/tools/search_tools_tool.py b/examples/progressive_discovery/tools/search_tools_tool.py new file mode 100644 index 00000000..7fbd0c6b --- /dev/null +++ b/examples/progressive_discovery/tools/search_tools_tool.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import Field + +from sgr_agent_core.base_tool import BaseTool + +from ..services.tool_filter_service import ToolFilterService + +if TYPE_CHECKING: + from sgr_agent_core.agent_definition import AgentConfig + from sgr_agent_core.models import AgentContext + + +class SearchToolsTool(BaseTool): + """Search for available tools by capability description. + + Use this tool when you need a capability that is not in your current + toolkit. Describe what you need in natural language and matching + tools will be added to your active toolkit for subsequent use. + """ + + isSystemTool = True + + query: str = Field(description="Natural language description of the capability you need (e.g. 'search the web')") + + async def __call__(self, context: AgentContext, config: AgentConfig, **kwargs) -> str: + custom = context.custom_context + if not isinstance(custom, dict): + return "Error: custom_context is not initialized as dict" + + all_tools = custom.get("all_tools", []) + if not all_tools: + return "No additional tools available for discovery." + + discovered = custom.setdefault("discovered_tools", []) + + matched = ToolFilterService.filter_tools(self.query, all_tools) + + already_discovered_names = {t.tool_name for t in discovered} + new_tools = [t for t in matched if t.tool_name not in already_discovered_names] + + if not new_tools: + return f"No new tools found for query '{self.query}'. Already discovered: {already_discovered_names}" + + discovered.extend(new_tools) + + summary = ToolFilterService.get_tool_summaries(new_tools) + return ( + f"Found {len(new_tools)} new tool(s) for '{self.query}':\n{summary}\n\n" + "These tools are now available in your toolkit. You can use them in subsequent steps." + ) diff --git a/tests/test_progressive_discovery.py b/tests/test_progressive_discovery.py new file mode 100644 index 00000000..5f88b252 --- /dev/null +++ b/tests/test_progressive_discovery.py @@ -0,0 +1,302 @@ +"""Tests for Progressive Tool Discovery example. + +Covers ToolFilterService, SearchToolsTool, ProgressiveDiscoveryAgent, +and isSystemTool in core BaseTool. +""" + +import pytest +from pydantic import Field + +from examples.progressive_discovery.progressive_discovery_agent import ProgressiveDiscoveryAgent +from examples.progressive_discovery.services.tool_filter_service import ToolFilterService +from examples.progressive_discovery.tools.search_tools_tool import SearchToolsTool +from sgr_agent_core import PromptsConfig +from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.models import AgentContext +from sgr_agent_core.tools import ( + AdaptPlanTool, + ClarificationTool, + CreateReportTool, + FinalAnswerTool, + GeneratePlanTool, + ReasoningTool, +) +from tests.conftest import create_test_agent + +# --- Test helpers --- + + +class DummySearchTool(BaseTool): + """Search the web for information.""" + + query: str = Field(default="test") + + async def __call__(self, context, config, **kwargs): + return "search result" + + +class DummyExtractTool(BaseTool): + """Extract content from a web page URL.""" + + url: str = Field(default="http://example.com") + + async def __call__(self, context, config, **kwargs): + return "extracted content" + + +class DummyDatabaseTool(BaseTool): + """Query a SQL database and return results.""" + + sql: str = Field(default="SELECT 1") + + async def __call__(self, context, config, **kwargs): + return "db result" + + +# --- Phase 4.1: ToolFilterService --- + + +class TestToolFilterService: + """Tests for ToolFilterService.""" + + def test_empty_query_returns_all_tools(self): + """Empty query should return all tools unfiltered.""" + tools = [DummySearchTool, DummyExtractTool, DummyDatabaseTool] + result = ToolFilterService.filter_tools("", tools) + assert result == tools + + def test_empty_tools_returns_empty(self): + """Empty tools list should return empty.""" + result = ToolFilterService.filter_tools("search", []) + assert result == [] + + def test_none_query_returns_all_tools(self): + """None-like query should return all tools.""" + tools = [DummySearchTool, DummyExtractTool] + result = ToolFilterService.filter_tools(" ", tools) + assert result == tools + + def test_bm25_match_finds_relevant_tool(self): + """BM25 should find tools with matching descriptions.""" + tools = [DummySearchTool, DummyDatabaseTool] + result = ToolFilterService.filter_tools("search the web for information", tools) + assert DummySearchTool in result + + def test_regex_match_finds_tool_by_keyword(self): + """Regex should find tools with overlapping keywords.""" + tools = [DummySearchTool, DummyExtractTool, DummyDatabaseTool] + result = ToolFilterService.filter_tools("extract content from page", tools) + assert DummyExtractTool in result + + def test_no_matches_returns_empty(self): + """Query with no matches should return empty list.""" + tools = [DummySearchTool, DummyExtractTool] + result = ToolFilterService.filter_tools("zzzzxyznonexistent", tools) + assert result == [] + + def test_get_tool_summaries_format(self): + """Tool summaries should be numbered and include name + description.""" + tools = [DummySearchTool, DummyExtractTool] + summary = ToolFilterService.get_tool_summaries(tools) + assert "1. dummysearchtool:" in summary + assert "2. dummyextracttool:" in summary + assert "Search the web" in summary + + +# --- Phase 4.2: SearchToolsTool --- + + +class TestSearchToolsTool: + """Tests for SearchToolsTool.""" + + def test_is_system_tool(self): + """SearchToolsTool must be marked as system tool.""" + assert SearchToolsTool.isSystemTool is True + + @pytest.mark.asyncio + async def test_finds_tools_and_adds_to_discovered(self): + """Should find matching tools and add them to discovered_tools.""" + context = AgentContext() + context.custom_context = { + "all_tools": [DummySearchTool, DummyExtractTool], + "discovered_tools": [], + } + tool = SearchToolsTool(query="search the web") + result = await tool(context, config=None) + + assert DummySearchTool in context.custom_context["discovered_tools"] + assert "Found" in result + + @pytest.mark.asyncio + async def test_deduplication_on_repeated_call(self): + """Should not add already discovered tools again.""" + context = AgentContext() + context.custom_context = { + "all_tools": [DummySearchTool], + "discovered_tools": [DummySearchTool], + } + tool = SearchToolsTool(query="search the web") + result = await tool(context, config=None) + + assert context.custom_context["discovered_tools"].count(DummySearchTool) == 1 + assert "No new tools found" in result + + @pytest.mark.asyncio + async def test_error_on_invalid_context(self): + """Should return error if custom_context is not a dict.""" + context = AgentContext() + context.custom_context = None + tool = SearchToolsTool(query="search") + result = await tool(context, config=None) + + assert "Error" in result + + @pytest.mark.asyncio + async def test_no_tools_available(self): + """Should return message when no tools available for discovery.""" + context = AgentContext() + context.custom_context = { + "all_tools": [], + "discovered_tools": [], + } + tool = SearchToolsTool(query="anything") + result = await tool(context, config=None) + + assert "No additional tools" in result + + +# --- Phase 4.3: ProgressiveDiscoveryAgent --- + + +class TestProgressiveDiscoveryAgent: + """Tests for ProgressiveDiscoveryAgent.""" + + def test_init_splits_toolkit(self): + """Init should separate system and non-system tools.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool, DummyExtractTool], + ) + + # System tools in toolkit + assert ReasoningTool in agent.toolkit + assert FinalAnswerTool in agent.toolkit + assert SearchToolsTool in agent.toolkit + + # Non-system tools in custom_context + all_tools = agent._context.custom_context["all_tools"] + assert DummySearchTool in all_tools + assert DummyExtractTool in all_tools + + # Non-system tools NOT in toolkit + assert DummySearchTool not in agent.toolkit + assert DummyExtractTool not in agent.toolkit + + def test_search_tools_tool_always_in_system(self): + """SearchToolsTool should be added even if not in original toolkit.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool], + ) + assert SearchToolsTool in agent.toolkit + + def test_get_active_tools_returns_system_plus_discovered(self): + """_get_active_tools should return system + discovered tools.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool], + ) + + # Initially only system tools + active = agent._get_active_tools() + assert DummySearchTool not in active + assert ReasoningTool in active + + # After discovery + agent._context.custom_context["discovered_tools"].append(DummySearchTool) + active = agent._get_active_tools() + assert DummySearchTool in active + + @pytest.mark.asyncio + async def test_prepare_tools_returns_only_active(self): + """_prepare_tools should return pydantic_function_tool only for active + tools.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool, DummyExtractTool], + ) + + tools = await agent._prepare_tools() + tool_names = {t["function"]["name"] for t in tools} + + # System tools present + assert ReasoningTool.tool_name in tool_names + assert FinalAnswerTool.tool_name in tool_names + assert SearchToolsTool.tool_name in tool_names + + # Non-system tools not present (not discovered yet) + assert DummySearchTool.tool_name not in tool_names + assert DummyExtractTool.tool_name not in tool_names + + @pytest.mark.asyncio + async def test_prepare_context_uses_active_tools(self): + """_prepare_context should pass active tools to system prompt.""" + prompts = PromptsConfig( + system_prompt_str="Tools: {available_tools}", + initial_user_request_str="Test", + clarification_response_str="Test", + ) + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool], + prompts_config=prompts, + ) + + context = await agent._prepare_context() + system_msg = context[0]["content"] + + # System tools mentioned + assert ReasoningTool.tool_name in system_msg + + # Non-system tools NOT mentioned (not discovered yet) + assert DummySearchTool.tool_name not in system_msg + + +# --- Phase 4.4: Core isSystemTool --- + + +class TestIsSystemTool: + """Tests for isSystemTool ClassVar on BaseTool.""" + + def test_base_tool_default_is_false(self): + """BaseTool.isSystemTool should default to False.""" + assert BaseTool.isSystemTool is False + + def test_subclass_can_set_true(self): + """Subclass should be able to set isSystemTool = True.""" + + class MySystemTool(BaseTool): + isSystemTool = True + + assert MySystemTool.isSystemTool is True + + def test_subclass_inherits_false(self): + """Subclass without override should inherit False.""" + + class MyRegularTool(BaseTool): + pass + + assert MyRegularTool.isSystemTool is False + + def test_core_system_tools_marked(self): + """All core system tools should have isSystemTool = True.""" + system_tools = [ + ReasoningTool, + ClarificationTool, + FinalAnswerTool, + GeneratePlanTool, + AdaptPlanTool, + CreateReportTool, + ] + for tool in system_tools: + assert tool.isSystemTool is True, f"{tool.__name__} should have isSystemTool=True" From c315083bdcc79e5c3023e6f73e6afa21c08bddd0 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Wed, 11 Feb 2026 19:02:11 +0000 Subject: [PATCH 04/13] fix(logging): remove redundant string concatenations in warning messages and error formatting for clarity --- sgr_agent_core/agent_config.py | 4 ++-- sgr_agent_core/services/tool_instantiator.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sgr_agent_core/agent_config.py b/sgr_agent_core/agent_config.py index 3969eb88..4b40d5b3 100644 --- a/sgr_agent_core/agent_config.py +++ b/sgr_agent_core/agent_config.py @@ -70,7 +70,7 @@ def _definitions_from_dict(cls, data: dict) -> Self: # Check for agents that will be overridden overridden = set(cls._instance.agents.keys()) & set(custom_agents.keys()) if overridden: - logger.warning(f"Loaded agents will override existing agents: " f"{', '.join(sorted(overridden))}") + logger.warning(f"Loaded agents will override existing agents: {', '.join(sorted(overridden))}") cls._instance.agents.update(custom_agents) @@ -90,7 +90,7 @@ def _definitions_from_dict(cls, data: dict) -> Self: # Check for tools that will be overridden overridden_tools = set(cls._instance.tools.keys()) & set(custom_tools.keys()) if overridden_tools: - logger.warning(f"Loaded tools will override existing tools: " f"{', '.join(sorted(overridden_tools))}") + logger.warning(f"Loaded tools will override existing tools: {', '.join(sorted(overridden_tools))}") cls._instance.tools.update(custom_tools) return cls._instance diff --git a/sgr_agent_core/services/tool_instantiator.py b/sgr_agent_core/services/tool_instantiator.py index 25bfb243..086b7672 100644 --- a/sgr_agent_core/services/tool_instantiator.py +++ b/sgr_agent_core/services/tool_instantiator.py @@ -391,7 +391,7 @@ def build_model(self, content: str) -> "BaseTool": except ValidationError as e: for err in e.errors(): self.errors.append( - f"pydantic validation error - type: {err['type']} - " f"field: {err['loc']} - {err['msg']}" + f"pydantic validation error - type: {err['type']} - field: {err['loc']} - {err['msg']}" ) raise ValueError("Failed to build model") from e except JSONDecodeError as e: From d296231f0e8e931d3546414f75748f10dd313c21 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Fri, 13 Feb 2026 11:18:59 +0000 Subject: [PATCH 05/13] refactor(config.yaml.example): update tool and mcp server definitions for clarity and discoverability --- .../progressive_discovery/config.yaml.example | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/examples/progressive_discovery/config.yaml.example b/examples/progressive_discovery/config.yaml.example index 553bc54e..9ae25990 100644 --- a/examples/progressive_discovery/config.yaml.example +++ b/examples/progressive_discovery/config.yaml.example @@ -24,30 +24,35 @@ prompts: # (not loaded into context until agent searches for them) # # mcp: -# servers: -# - name: "jira" -# command: "npx" -# args: ["-y", "@anthropic/jira-mcp-server"] -# env: -# JIRA_URL: "https://your-org.atlassian.net" -# JIRA_TOKEN: "your-token" -# -# - name: "github" -# command: "npx" -# args: ["-y", "@anthropic/github-mcp-server"] -# env: -# GITHUB_TOKEN: "your-token" +# mcpServers: +# jira: +# url: "https://your-jira-mcp-server.com/mcp" +# github: +# url: "https://your-github-mcp-server.com/mcp" + +# Tool Definitions +tools: + # Core system tools (base_class defaults to sgr_agent_core.tools.*) + reasoning_tool: + clarification_tool: + generate_plan_tool: + adapt_plan_tool: + create_report_tool: + final_answer_tool: + # Non-system tools — will be discoverable, not loaded into context by default + web_search_tool: + extract_page_content_tool: agents: progressive_discovery: base_class: "examples.progressive_discovery.progressive_discovery_agent.ProgressiveDiscoveryAgent" tools: - - "sgr_agent_core.tools.reasoning_tool.ReasoningTool" - - "sgr_agent_core.tools.clarification_tool.ClarificationTool" - - "sgr_agent_core.tools.generate_plan_tool.GeneratePlanTool" - - "sgr_agent_core.tools.adapt_plan_tool.AdaptPlanTool" - - "sgr_agent_core.tools.create_report_tool.CreateReportTool" - - "sgr_agent_core.tools.final_answer_tool.FinalAnswerTool" - # Add any non-system tools here — they will be discoverable, not loaded by default - # - "sgr_agent_core.tools.web_search_tool.WebSearchTool" - # - "sgr_agent_core.tools.extract_page_content_tool.ExtractPageContentTool" + - "reasoning_tool" + - "clarification_tool" + - "generate_plan_tool" + - "adapt_plan_tool" + - "create_report_tool" + - "final_answer_tool" + # Non-system tools — discoverable via SearchToolsTool + - "web_search_tool" + - "extract_page_content_tool" From 4b37d9fc5a94010c7552e7917b6bc4e4417fcf0f Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Fri, 13 Feb 2026 11:21:34 +0000 Subject: [PATCH 06/13] test(progressive_discovery): remove phase comments and add tests ensuring system tools are never filtered and always included in active tools --- tests/test_progressive_discovery.py | 90 +++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/tests/test_progressive_discovery.py b/tests/test_progressive_discovery.py index 5f88b252..6e7d3b21 100644 --- a/tests/test_progressive_discovery.py +++ b/tests/test_progressive_discovery.py @@ -53,9 +53,6 @@ async def __call__(self, context, config, **kwargs): return "db result" -# --- Phase 4.1: ToolFilterService --- - - class TestToolFilterService: """Tests for ToolFilterService.""" @@ -103,9 +100,6 @@ def test_get_tool_summaries_format(self): assert "Search the web" in summary -# --- Phase 4.2: SearchToolsTool --- - - class TestSearchToolsTool: """Tests for SearchToolsTool.""" @@ -165,9 +159,6 @@ async def test_no_tools_available(self): assert "No additional tools" in result -# --- Phase 4.3: ProgressiveDiscoveryAgent --- - - class TestProgressiveDiscoveryAgent: """Tests for ProgressiveDiscoveryAgent.""" @@ -262,7 +253,86 @@ async def test_prepare_context_uses_active_tools(self): assert DummySearchTool.tool_name not in system_msg -# --- Phase 4.4: Core isSystemTool --- +class TestSystemToolsNeverFiltered: + """Tests that isSystemTool tools are always available and never subject to + filtering.""" + + def test_system_tools_not_in_all_tools(self): + """System tools must never end up in all_tools (the filterable + pool).""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, ClarificationTool, DummySearchTool, DummyDatabaseTool], + ) + + all_tools = agent._context.custom_context["all_tools"] + for tool in [ReasoningTool, FinalAnswerTool, ClarificationTool, SearchToolsTool]: + assert tool not in all_tools, f"System tool {tool.__name__} should not be in filterable pool" + + def test_non_system_tools_only_in_all_tools(self): + """Only non-system tools should be in the filterable pool.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool, DummyExtractTool, DummyDatabaseTool], + ) + + all_tools = agent._context.custom_context["all_tools"] + assert set(all_tools) == {DummySearchTool, DummyExtractTool, DummyDatabaseTool} + + def test_system_tools_persist_after_search_with_no_results(self): + """System tools must remain active even when search finds nothing.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool], + ) + + active = agent._get_active_tools() + assert ReasoningTool in active + assert FinalAnswerTool in active + assert SearchToolsTool in active + + @pytest.mark.asyncio + async def test_system_tools_persist_after_discovery(self): + """System tools must remain in active toolkit after discovering new + tools.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool, DummyExtractTool], + ) + + # Simulate discovery + tool = SearchToolsTool(query="search the web") + await tool(agent._context, config=None) + + active = agent._get_active_tools() + # System tools still there + assert ReasoningTool in active + assert FinalAnswerTool in active + assert SearchToolsTool in active + # Discovered tool also there + assert DummySearchTool in active + + @pytest.mark.asyncio + async def test_prepare_tools_always_includes_system_tools(self): + """_prepare_tools must always include system tools regardless of + discovered state.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, GeneratePlanTool, DummySearchTool, DummyDatabaseTool], + ) + + tools = await agent._prepare_tools() + tool_names = {t["function"]["name"] for t in tools} + + # All system tools present + assert ReasoningTool.tool_name in tool_names + assert FinalAnswerTool.tool_name in tool_names + assert GeneratePlanTool.tool_name in tool_names + assert SearchToolsTool.tool_name in tool_names + + # Non-system tools absent (not yet discovered) + assert DummySearchTool.tool_name not in tool_names + assert DummyDatabaseTool.tool_name not in tool_names class TestIsSystemTool: From 1237807dc2da62993008c57e0e39ad6261ed55a1 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Fri, 13 Feb 2026 15:35:40 +0000 Subject: [PATCH 07/13] refactor(SystemBaseTool): introduce SystemBaseTool base class for system tools and update all core system tools to inherit it to unify system tool identification and improve filtering logic --- examples/progressive_discovery/README.md | 2 +- .../progressive_discovery_agent.py | 8 +++---- .../tools/search_tools_tool.py | 6 ++--- sgr_agent_core/__init__.py | 3 ++- sgr_agent_core/base_tool.py | 9 +++++++- sgr_agent_core/tools/__init__.py | 3 ++- sgr_agent_core/tools/adapt_plan_tool.py | 6 ++--- sgr_agent_core/tools/clarification_tool.py | 6 ++--- sgr_agent_core/tools/create_report_tool.py | 6 ++--- sgr_agent_core/tools/final_answer_tool.py | 6 ++--- sgr_agent_core/tools/generate_plan_tool.py | 6 ++--- sgr_agent_core/tools/reasoning_tool.py | 6 ++--- tests/test_progressive_discovery.py | 23 ++++++++++++------- 13 files changed, 46 insertions(+), 44 deletions(-) diff --git a/examples/progressive_discovery/README.md b/examples/progressive_discovery/README.md index 84c12e2c..d4cd5ed3 100644 --- a/examples/progressive_discovery/README.md +++ b/examples/progressive_discovery/README.md @@ -17,7 +17,7 @@ User query → Agent reasons → needs web search → calls SearchToolsTool("sea ### How it works -1. **Init**: Toolkit is split into system tools (`isSystemTool=True`) and discoverable tools +1. **Init**: Toolkit is split into system tools (subclasses of `SystemBaseTool`) and discoverable tools 2. **Runtime**: Only system tools + already discovered tools are sent to LLM 3. **Discovery**: Agent calls `SearchToolsTool` with a natural language query 4. **Matching**: `ToolFilterService` uses BM25 ranking + regex keyword overlap to find relevant tools diff --git a/examples/progressive_discovery/progressive_discovery_agent.py b/examples/progressive_discovery/progressive_discovery_agent.py index 1e7475aa..f43ae934 100644 --- a/examples/progressive_discovery/progressive_discovery_agent.py +++ b/examples/progressive_discovery/progressive_discovery_agent.py @@ -6,7 +6,7 @@ from sgr_agent_core.agent_definition import AgentConfig from sgr_agent_core.agents.sgr_tool_calling_agent import SGRToolCallingAgent -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import BaseTool, SystemBaseTool from sgr_agent_core.services.prompt_loader import PromptLoader from .tools.search_tools_tool import SearchToolsTool @@ -17,7 +17,7 @@ class ProgressiveDiscoveryAgent(SGRToolCallingAgent): additional tools via SearchToolsTool. On init, splits the toolkit into: - - system tools (isSystemTool=True) -> self.toolkit (always available) + - system tools (subclasses of SystemBaseTool) -> self.toolkit (always available) - non-system tools -> stored in context.custom_context["all_tools"] SearchToolsTool is automatically added if not already present. @@ -35,8 +35,8 @@ def __init__( def_name: str | None = None, **kwargs: dict, ): - system_tools = [t for t in toolkit if getattr(t, "isSystemTool", False)] - non_system_tools = [t for t in toolkit if not getattr(t, "isSystemTool", False)] + system_tools = [t for t in toolkit if issubclass(t, SystemBaseTool)] + non_system_tools = [t for t in toolkit if not issubclass(t, SystemBaseTool)] if SearchToolsTool not in system_tools: system_tools.append(SearchToolsTool) diff --git a/examples/progressive_discovery/tools/search_tools_tool.py b/examples/progressive_discovery/tools/search_tools_tool.py index 7fbd0c6b..4fc89f5f 100644 --- a/examples/progressive_discovery/tools/search_tools_tool.py +++ b/examples/progressive_discovery/tools/search_tools_tool.py @@ -4,7 +4,7 @@ from pydantic import Field -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import SystemBaseTool from ..services.tool_filter_service import ToolFilterService @@ -13,7 +13,7 @@ from sgr_agent_core.models import AgentContext -class SearchToolsTool(BaseTool): +class SearchToolsTool(SystemBaseTool): """Search for available tools by capability description. Use this tool when you need a capability that is not in your current @@ -21,8 +21,6 @@ class SearchToolsTool(BaseTool): tools will be added to your active toolkit for subsequent use. """ - isSystemTool = True - query: str = Field(description="Natural language description of the capability you need (e.g. 'search the web')") async def __call__(self, context: AgentContext, config: AgentConfig, **kwargs) -> str: diff --git a/sgr_agent_core/__init__.py b/sgr_agent_core/__init__.py index 2c9c2995..5f3a9198 100644 --- a/sgr_agent_core/__init__.py +++ b/sgr_agent_core/__init__.py @@ -21,7 +21,7 @@ from sgr_agent_core.agent_factory import AgentFactory from sgr_agent_core.agents import * # noqa: F403 from sgr_agent_core.base_agent import BaseAgent -from sgr_agent_core.base_tool import BaseTool, MCPBaseTool +from sgr_agent_core.base_tool import BaseTool, MCPBaseTool, SystemBaseTool from sgr_agent_core.models import ( AgentContext, AgentStatesEnum, @@ -40,6 +40,7 @@ # Base classes "BaseAgent", "BaseTool", + "SystemBaseTool", "MCPBaseTool", # Models "AgentStatesEnum", diff --git a/sgr_agent_core/base_tool.py b/sgr_agent_core/base_tool.py index e69d592f..477b9fb7 100644 --- a/sgr_agent_core/base_tool.py +++ b/sgr_agent_core/base_tool.py @@ -20,7 +20,7 @@ class ToolRegistryMixin: def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) - if cls.__name__ not in ("BaseTool", "MCPBaseTool"): + if cls.__name__ not in ("BaseTool", "MCPBaseTool", "SystemBaseTool"): ToolRegistry.register(cls, name=cls.tool_name) @@ -45,6 +45,13 @@ def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) +class SystemBaseTool(BaseTool): + """Base class for system tools that are always available and never + filtered.""" + + isSystemTool: ClassVar[bool] = True + + class MCPBaseTool(BaseTool): """Base model for MCP Tool schema.""" diff --git a/sgr_agent_core/tools/__init__.py b/sgr_agent_core/tools/__init__.py index 786e5182..3f0be69a 100644 --- a/sgr_agent_core/tools/__init__.py +++ b/sgr_agent_core/tools/__init__.py @@ -1,4 +1,4 @@ -from sgr_agent_core.base_tool import BaseTool, MCPBaseTool +from sgr_agent_core.base_tool import BaseTool, MCPBaseTool, SystemBaseTool from sgr_agent_core.next_step_tool import ( NextStepToolsBuilder, NextStepToolStub, @@ -18,6 +18,7 @@ # Base classes "BaseTool", "MCPBaseTool", + "SystemBaseTool", "NextStepToolStub", "ToolNameSelectorStub", "NextStepToolsBuilder", diff --git a/sgr_agent_core/tools/adapt_plan_tool.py b/sgr_agent_core/tools/adapt_plan_tool.py index 4c9248e1..4bce2e77 100644 --- a/sgr_agent_core/tools/adapt_plan_tool.py +++ b/sgr_agent_core/tools/adapt_plan_tool.py @@ -4,18 +4,16 @@ from pydantic import Field -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import SystemBaseTool if TYPE_CHECKING: from sgr_agent_core.agent_config import AgentConfig from sgr_agent_core.models import AgentContext -class AdaptPlanTool(BaseTool): +class AdaptPlanTool(SystemBaseTool): """Adapt a research plan based on new findings.""" - isSystemTool = True - reasoning: str = Field(description="Why plan needs adaptation based on new data") original_goal: str = Field(description="Original research goal") new_goal: str = Field(description="Updated research goal") diff --git a/sgr_agent_core/tools/clarification_tool.py b/sgr_agent_core/tools/clarification_tool.py index c4eee313..f06f1fb4 100644 --- a/sgr_agent_core/tools/clarification_tool.py +++ b/sgr_agent_core/tools/clarification_tool.py @@ -4,21 +4,19 @@ from pydantic import Field -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import SystemBaseTool from sgr_agent_core.models import AgentContext, AgentStatesEnum if TYPE_CHECKING: from sgr_agent_core.agent_config import AgentConfig -class ClarificationTool(BaseTool): +class ClarificationTool(SystemBaseTool): """Ask clarifying questions when facing an ambiguous request. Keep all fields concise - brief reasoning, short terms, and clear questions. """ - isSystemTool = True - reasoning: str = Field(description="Why clarification is needed (1-2 sentences MAX)", max_length=200) unclear_terms: list[str] = Field( description="List of unclear terms (brief, 1-3 words each)", diff --git a/sgr_agent_core/tools/create_report_tool.py b/sgr_agent_core/tools/create_report_tool.py index f14e6b83..abbdf1b9 100644 --- a/sgr_agent_core/tools/create_report_tool.py +++ b/sgr_agent_core/tools/create_report_tool.py @@ -8,7 +8,7 @@ from pydantic import Field -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import SystemBaseTool if TYPE_CHECKING: from sgr_agent_core.agent_definition import AgentConfig @@ -18,7 +18,7 @@ logger.setLevel(logging.INFO) -class CreateReportTool(BaseTool): +class CreateReportTool(SystemBaseTool): """Create a comprehensive detailed report with citations as a final step of research. @@ -26,8 +26,6 @@ class CreateReportTool(BaseTool): Citations must be integrated directly into sentences, not just listed at the end. """ - isSystemTool = True - reasoning: str = Field(description="Why ready to create report now") title: str = Field(description="Report title") user_request_language_reference: str = Field( diff --git a/sgr_agent_core/tools/final_answer_tool.py b/sgr_agent_core/tools/final_answer_tool.py index f87b3cd3..612f23ac 100644 --- a/sgr_agent_core/tools/final_answer_tool.py +++ b/sgr_agent_core/tools/final_answer_tool.py @@ -5,7 +5,7 @@ from pydantic import Field -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import SystemBaseTool from sgr_agent_core.models import AgentStatesEnum if TYPE_CHECKING: @@ -16,15 +16,13 @@ logger.setLevel(logging.INFO) -class FinalAnswerTool(BaseTool): +class FinalAnswerTool(SystemBaseTool): """Finalize a task and complete agent execution after all steps are completed. Usage: Call after you are ready to finalize your work and provide the final answer to the user. """ - isSystemTool = True - reasoning: str = Field(description="Why task is now complete and how answer was verified") completed_steps: list[str] = Field( description="Summary of completed steps including verification", min_length=1, max_length=5 diff --git a/sgr_agent_core/tools/generate_plan_tool.py b/sgr_agent_core/tools/generate_plan_tool.py index a00ab05b..c14ea7de 100644 --- a/sgr_agent_core/tools/generate_plan_tool.py +++ b/sgr_agent_core/tools/generate_plan_tool.py @@ -4,21 +4,19 @@ from pydantic import Field -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import SystemBaseTool if TYPE_CHECKING: from sgr_agent_core.agent_definition import AgentConfig from sgr_agent_core.models import AgentContext -class GeneratePlanTool(BaseTool): +class GeneratePlanTool(SystemBaseTool): """Generate a research plan. Useful to split complex request into manageable steps. """ - isSystemTool = True - reasoning: str = Field(description="Justification for research approach") research_goal: str = Field(description="Primary research objective") planned_steps: list[str] = Field(description="List of 3-4 planned steps", min_length=3, max_length=4) diff --git a/sgr_agent_core/tools/reasoning_tool.py b/sgr_agent_core/tools/reasoning_tool.py index c57e0c1c..65abea5f 100644 --- a/sgr_agent_core/tools/reasoning_tool.py +++ b/sgr_agent_core/tools/reasoning_tool.py @@ -2,10 +2,10 @@ from pydantic import Field -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import SystemBaseTool -class ReasoningTool(BaseTool): +class ReasoningTool(SystemBaseTool): """Agent core logic determines the next reasoning step with adaptive planning by schema-guided-reasoning capabilities. Keep all text fields concise and focused. @@ -13,8 +13,6 @@ class ReasoningTool(BaseTool): Usage: Required tool. Use this tool before any other tool execution """ - isSystemTool = True - # Reasoning chain - step-by-step thinking process (helps stabilize model) reasoning_steps: list[str] = Field( description="Step-by-step reasoning (brief, 1 sentence each)", diff --git a/tests/test_progressive_discovery.py b/tests/test_progressive_discovery.py index 6e7d3b21..473602c8 100644 --- a/tests/test_progressive_discovery.py +++ b/tests/test_progressive_discovery.py @@ -1,7 +1,7 @@ """Tests for Progressive Tool Discovery example. Covers ToolFilterService, SearchToolsTool, ProgressiveDiscoveryAgent, -and isSystemTool in core BaseTool. +and SystemBaseTool. """ import pytest @@ -11,7 +11,7 @@ from examples.progressive_discovery.services.tool_filter_service import ToolFilterService from examples.progressive_discovery.tools.search_tools_tool import SearchToolsTool from sgr_agent_core import PromptsConfig -from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.base_tool import BaseTool, SystemBaseTool from sgr_agent_core.models import AgentContext from sgr_agent_core.tools import ( AdaptPlanTool, @@ -336,19 +336,24 @@ async def test_prepare_tools_always_includes_system_tools(self): class TestIsSystemTool: - """Tests for isSystemTool ClassVar on BaseTool.""" + """Tests for isSystemTool ClassVar on BaseTool and SystemBaseTool.""" def test_base_tool_default_is_false(self): """BaseTool.isSystemTool should default to False.""" assert BaseTool.isSystemTool is False - def test_subclass_can_set_true(self): - """Subclass should be able to set isSystemTool = True.""" + def test_system_base_tool_is_true(self): + """SystemBaseTool.isSystemTool should be True.""" + assert SystemBaseTool.isSystemTool is True - class MySystemTool(BaseTool): - isSystemTool = True + def test_subclass_of_system_base_tool_inherits_true(self): + """Subclass of SystemBaseTool should inherit isSystemTool = True.""" + + class MySystemTool(SystemBaseTool): + pass assert MySystemTool.isSystemTool is True + assert issubclass(MySystemTool, SystemBaseTool) def test_subclass_inherits_false(self): """Subclass without override should inherit False.""" @@ -357,9 +362,10 @@ class MyRegularTool(BaseTool): pass assert MyRegularTool.isSystemTool is False + assert not issubclass(MyRegularTool, SystemBaseTool) def test_core_system_tools_marked(self): - """All core system tools should have isSystemTool = True.""" + """All core system tools should inherit from SystemBaseTool.""" system_tools = [ ReasoningTool, ClarificationTool, @@ -369,4 +375,5 @@ def test_core_system_tools_marked(self): CreateReportTool, ] for tool in system_tools: + assert issubclass(tool, SystemBaseTool), f"{tool.__name__} should inherit from SystemBaseTool" assert tool.isSystemTool is True, f"{tool.__name__} should have isSystemTool=True" From c7db38a044634f49b210209d01782c92d03b7d18 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Fri, 13 Feb 2026 15:40:20 +0000 Subject: [PATCH 08/13] fix(base_tool): ensure tool_name and description are only set if not explicitly defined in subclass to prevent unintended overrides --- sgr_agent_core/base_tool.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sgr_agent_core/base_tool.py b/sgr_agent_core/base_tool.py index 477b9fb7..eaeff51a 100644 --- a/sgr_agent_core/base_tool.py +++ b/sgr_agent_core/base_tool.py @@ -40,8 +40,10 @@ async def __call__(self, context: AgentContext, config: AgentConfig, **kwargs) - raise NotImplementedError("Execute method must be implemented by subclass") def __init_subclass__(cls, **kwargs) -> None: - cls.tool_name = cls.tool_name or cls.__name__.lower() - cls.description = cls.description or cls.__doc__ or "" + if "tool_name" not in cls.__dict__: + cls.tool_name = cls.__name__.lower() + if "description" not in cls.__dict__: + cls.description = cls.__doc__ or "" super().__init_subclass__(**kwargs) From 0cf35bee0ada61fe5d17a199bb528cf9eaa82c7a Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Sun, 15 Feb 2026 08:28:53 +0000 Subject: [PATCH 09/13] refactor(tool_filter_service): optimize tool word extraction by using precomputed documents to improve efficiency --- .../progressive_discovery/services/tool_filter_service.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/progressive_discovery/services/tool_filter_service.py b/examples/progressive_discovery/services/tool_filter_service.py index a5df2525..55b85a6a 100644 --- a/examples/progressive_discovery/services/tool_filter_service.py +++ b/examples/progressive_discovery/services/tool_filter_service.py @@ -56,9 +56,7 @@ def filter_tools( for i, tool in enumerate(tools): bm25_score = scores[i] - tool_name = (tool.tool_name or tool.__name__).lower() - tool_description = (tool.description or "").lower() - tool_words = set(re.findall(r"\b\w+\b", f"{tool_name} {tool_description}")) + tool_words = set(re.findall(r"\b\w+\b", tool_documents[i])) has_regex_match = bool(query_words & tool_words) if bm25_score > bm25_threshold or has_regex_match: From 948e87c12d952f09dd8a906972a751d088c2a4a3 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Sun, 15 Feb 2026 08:31:02 +0000 Subject: [PATCH 10/13] refactor(progressive_discovery): replace dict-based custom_context with ProgressiveDiscoveryContext model for type safety and clarity --- examples/progressive_discovery/models.py | 20 +++++++++ .../progressive_discovery_agent.py | 17 +++----- .../tools/search_tools_tool.py | 16 +++---- tests/test_progressive_discovery.py | 43 +++++++++---------- 4 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 examples/progressive_discovery/models.py diff --git a/examples/progressive_discovery/models.py b/examples/progressive_discovery/models.py new file mode 100644 index 00000000..95592ab6 --- /dev/null +++ b/examples/progressive_discovery/models.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field + +from sgr_agent_core.base_tool import BaseTool + + +class ProgressiveDiscoveryContext(BaseModel): + """Typed context for progressive discovery agent. + + Stores tool lists used by the discovery mechanism instead of raw + dict access on custom_context. + """ + + model_config = {"arbitrary_types_allowed": True} + + all_tools: list[type[BaseTool]] = Field( + default_factory=list, description="Full list of non-system tools available for discovery" + ) + discovered_tools: list[type[BaseTool]] = Field( + default_factory=list, description="Tools discovered so far via SearchToolsTool" + ) diff --git a/examples/progressive_discovery/progressive_discovery_agent.py b/examples/progressive_discovery/progressive_discovery_agent.py index f43ae934..3f543b71 100644 --- a/examples/progressive_discovery/progressive_discovery_agent.py +++ b/examples/progressive_discovery/progressive_discovery_agent.py @@ -9,6 +9,7 @@ from sgr_agent_core.base_tool import BaseTool, SystemBaseTool from sgr_agent_core.services.prompt_loader import PromptLoader +from .models import ProgressiveDiscoveryContext from .tools.search_tools_tool import SearchToolsTool @@ -18,10 +19,10 @@ class ProgressiveDiscoveryAgent(SGRToolCallingAgent): On init, splits the toolkit into: - system tools (subclasses of SystemBaseTool) -> self.toolkit (always available) - - non-system tools -> stored in context.custom_context["all_tools"] + - non-system tools -> stored in context.custom_context.all_tools SearchToolsTool is automatically added if not already present. - Discovered tools accumulate in context.custom_context["discovered_tools"]. + Discovered tools accumulate in context.custom_context.discovered_tools. """ name: str = "progressive_discovery_agent" @@ -50,17 +51,13 @@ def __init__( **kwargs, ) - if self._context.custom_context is None: - self._context.custom_context = {} - self._context.custom_context["all_tools"] = non_system_tools - self._context.custom_context["discovered_tools"] = [] + self._context.custom_context = ProgressiveDiscoveryContext( + all_tools=non_system_tools, + ) def _get_active_tools(self) -> list[Type[BaseTool]]: """Return system tools + discovered tools.""" - discovered = [] - if isinstance(self._context.custom_context, dict): - discovered = self._context.custom_context.get("discovered_tools", []) - return list(self.toolkit) + list(discovered) + return list(self.toolkit) + list(self._context.custom_context.discovered_tools) async def _prepare_tools(self) -> list[dict]: """Override to return only active tools (system + discovered).""" diff --git a/examples/progressive_discovery/tools/search_tools_tool.py b/examples/progressive_discovery/tools/search_tools_tool.py index 4fc89f5f..d463613f 100644 --- a/examples/progressive_discovery/tools/search_tools_tool.py +++ b/examples/progressive_discovery/tools/search_tools_tool.py @@ -6,6 +6,7 @@ from sgr_agent_core.base_tool import SystemBaseTool +from ..models import ProgressiveDiscoveryContext from ..services.tool_filter_service import ToolFilterService if TYPE_CHECKING: @@ -25,24 +26,21 @@ class SearchToolsTool(SystemBaseTool): async def __call__(self, context: AgentContext, config: AgentConfig, **kwargs) -> str: custom = context.custom_context - if not isinstance(custom, dict): - return "Error: custom_context is not initialized as dict" + if not isinstance(custom, ProgressiveDiscoveryContext): + return "Error: custom_context is not initialized as ProgressiveDiscoveryContext" - all_tools = custom.get("all_tools", []) - if not all_tools: + if not custom.all_tools: return "No additional tools available for discovery." - discovered = custom.setdefault("discovered_tools", []) + matched = ToolFilterService.filter_tools(self.query, custom.all_tools) - matched = ToolFilterService.filter_tools(self.query, all_tools) - - already_discovered_names = {t.tool_name for t in discovered} + already_discovered_names = {t.tool_name for t in custom.discovered_tools} new_tools = [t for t in matched if t.tool_name not in already_discovered_names] if not new_tools: return f"No new tools found for query '{self.query}'. Already discovered: {already_discovered_names}" - discovered.extend(new_tools) + custom.discovered_tools.extend(new_tools) summary = ToolFilterService.get_tool_summaries(new_tools) return ( diff --git a/tests/test_progressive_discovery.py b/tests/test_progressive_discovery.py index 473602c8..2e5a69b3 100644 --- a/tests/test_progressive_discovery.py +++ b/tests/test_progressive_discovery.py @@ -7,6 +7,7 @@ import pytest from pydantic import Field +from examples.progressive_discovery.models import ProgressiveDiscoveryContext from examples.progressive_discovery.progressive_discovery_agent import ProgressiveDiscoveryAgent from examples.progressive_discovery.services.tool_filter_service import ToolFilterService from examples.progressive_discovery.tools.search_tools_tool import SearchToolsTool @@ -111,33 +112,33 @@ def test_is_system_tool(self): async def test_finds_tools_and_adds_to_discovered(self): """Should find matching tools and add them to discovered_tools.""" context = AgentContext() - context.custom_context = { - "all_tools": [DummySearchTool, DummyExtractTool], - "discovered_tools": [], - } + context.custom_context = ProgressiveDiscoveryContext( + all_tools=[DummySearchTool, DummyExtractTool], + ) tool = SearchToolsTool(query="search the web") result = await tool(context, config=None) - assert DummySearchTool in context.custom_context["discovered_tools"] + assert DummySearchTool in context.custom_context.discovered_tools assert "Found" in result @pytest.mark.asyncio async def test_deduplication_on_repeated_call(self): """Should not add already discovered tools again.""" context = AgentContext() - context.custom_context = { - "all_tools": [DummySearchTool], - "discovered_tools": [DummySearchTool], - } + context.custom_context = ProgressiveDiscoveryContext( + all_tools=[DummySearchTool], + discovered_tools=[DummySearchTool], + ) tool = SearchToolsTool(query="search the web") result = await tool(context, config=None) - assert context.custom_context["discovered_tools"].count(DummySearchTool) == 1 + assert context.custom_context.discovered_tools.count(DummySearchTool) == 1 assert "No new tools found" in result @pytest.mark.asyncio async def test_error_on_invalid_context(self): - """Should return error if custom_context is not a dict.""" + """Should return error if custom_context is not + ProgressiveDiscoveryContext.""" context = AgentContext() context.custom_context = None tool = SearchToolsTool(query="search") @@ -149,10 +150,7 @@ async def test_error_on_invalid_context(self): async def test_no_tools_available(self): """Should return message when no tools available for discovery.""" context = AgentContext() - context.custom_context = { - "all_tools": [], - "discovered_tools": [], - } + context.custom_context = ProgressiveDiscoveryContext() tool = SearchToolsTool(query="anything") result = await tool(context, config=None) @@ -175,9 +173,8 @@ def test_init_splits_toolkit(self): assert SearchToolsTool in agent.toolkit # Non-system tools in custom_context - all_tools = agent._context.custom_context["all_tools"] - assert DummySearchTool in all_tools - assert DummyExtractTool in all_tools + assert DummySearchTool in agent._context.custom_context.all_tools + assert DummyExtractTool in agent._context.custom_context.all_tools # Non-system tools NOT in toolkit assert DummySearchTool not in agent.toolkit @@ -204,7 +201,7 @@ def test_get_active_tools_returns_system_plus_discovered(self): assert ReasoningTool in active # After discovery - agent._context.custom_context["discovered_tools"].append(DummySearchTool) + agent._context.custom_context.discovered_tools.append(DummySearchTool) active = agent._get_active_tools() assert DummySearchTool in active @@ -265,9 +262,10 @@ def test_system_tools_not_in_all_tools(self): toolkit=[ReasoningTool, FinalAnswerTool, ClarificationTool, DummySearchTool, DummyDatabaseTool], ) - all_tools = agent._context.custom_context["all_tools"] for tool in [ReasoningTool, FinalAnswerTool, ClarificationTool, SearchToolsTool]: - assert tool not in all_tools, f"System tool {tool.__name__} should not be in filterable pool" + assert ( + tool not in agent._context.custom_context.all_tools + ), f"System tool {tool.__name__} should not be in filterable pool" def test_non_system_tools_only_in_all_tools(self): """Only non-system tools should be in the filterable pool.""" @@ -276,8 +274,7 @@ def test_non_system_tools_only_in_all_tools(self): toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool, DummyExtractTool, DummyDatabaseTool], ) - all_tools = agent._context.custom_context["all_tools"] - assert set(all_tools) == {DummySearchTool, DummyExtractTool, DummyDatabaseTool} + assert set(agent._context.custom_context.all_tools) == {DummySearchTool, DummyExtractTool, DummyDatabaseTool} def test_system_tools_persist_after_search_with_no_results(self): """System tools must remain active even when search finds nothing.""" From 35008c2951ea92c7e82d19f59aff128a9c40a954 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Sun, 15 Feb 2026 08:31:15 +0000 Subject: [PATCH 11/13] refactor(config.yaml.example): remove unused prompts section to simplify example configuration --- examples/progressive_discovery/config.yaml.example | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/progressive_discovery/config.yaml.example b/examples/progressive_discovery/config.yaml.example index 9ae25990..243c8d0a 100644 --- a/examples/progressive_discovery/config.yaml.example +++ b/examples/progressive_discovery/config.yaml.example @@ -17,9 +17,6 @@ execution: max_iterations: 15 max_clarifications: 2 -prompts: - system_prompt_path: "examples/progressive_discovery/prompts/system_prompt.txt" - # MCP servers provide additional tools that will be discoverable # (not loaded into context until agent searches for them) # From 643d69230001a6785a8e67cad9f33f7a49f28f55 Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Wed, 18 Feb 2026 14:20:27 +0000 Subject: [PATCH 12/13] chore(config): remove commented-out tool definitions from progressive_discovery example config to clean up and simplify the file --- examples/progressive_discovery/config.yaml.example | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/examples/progressive_discovery/config.yaml.example b/examples/progressive_discovery/config.yaml.example index 243c8d0a..9cbd91a4 100644 --- a/examples/progressive_discovery/config.yaml.example +++ b/examples/progressive_discovery/config.yaml.example @@ -27,19 +27,6 @@ execution: # github: # url: "https://your-github-mcp-server.com/mcp" -# Tool Definitions -tools: - # Core system tools (base_class defaults to sgr_agent_core.tools.*) - reasoning_tool: - clarification_tool: - generate_plan_tool: - adapt_plan_tool: - create_report_tool: - final_answer_tool: - # Non-system tools — will be discoverable, not loaded into context by default - web_search_tool: - extract_page_content_tool: - agents: progressive_discovery: base_class: "examples.progressive_discovery.progressive_discovery_agent.ProgressiveDiscoveryAgent" From 44c98ab41314cd06901ea88e90512de4eecbea8f Mon Sep 17 00:00:00 2001 From: Nikita Matsko Date: Wed, 18 Feb 2026 14:29:02 +0000 Subject: [PATCH 13/13] refactor(progressive_discovery): unify context usage by replacing custom_context with direct context fields and update related code and tests accordingly --- examples/progressive_discovery/README.md | 6 ++- examples/progressive_discovery/models.py | 13 +++---- .../progressive_discovery_agent.py | 8 ++-- .../tools/search_tools_tool.py | 13 +++---- tests/test_progressive_discovery.py | 39 ++++++++++--------- 5 files changed, 41 insertions(+), 38 deletions(-) diff --git a/examples/progressive_discovery/README.md b/examples/progressive_discovery/README.md index d4cd5ed3..3cfe1217 100644 --- a/examples/progressive_discovery/README.md +++ b/examples/progressive_discovery/README.md @@ -44,8 +44,10 @@ sgr --config-file config.yaml ``` ProgressiveDiscoveryAgent ├── self.toolkit = [ReasoningTool, SearchToolsTool, ...] (system tools) -├── context.custom_context["all_tools"] = [WebSearchTool, ...] (discoverable) -└── context.custom_context["discovered_tools"] = [] (accumulates at runtime) +├── context.all_tools = [WebSearchTool, ...] (discoverable) +└── context.discovered_tools = [] (accumulates at runtime) ``` +`context` is a `ProgressiveDiscoveryContext(AgentContext)` — extends the base context with discovery-specific fields. + `_get_active_tools()` returns `system_tools + discovered_tools` — used by both `_prepare_tools()` and `_prepare_context()`. diff --git a/examples/progressive_discovery/models.py b/examples/progressive_discovery/models.py index 95592ab6..5d4c89d4 100644 --- a/examples/progressive_discovery/models.py +++ b/examples/progressive_discovery/models.py @@ -1,17 +1,16 @@ -from pydantic import BaseModel, Field +from pydantic import Field from sgr_agent_core.base_tool import BaseTool +from sgr_agent_core.models import AgentContext -class ProgressiveDiscoveryContext(BaseModel): - """Typed context for progressive discovery agent. +class ProgressiveDiscoveryContext(AgentContext): + """Extended agent context for progressive discovery. - Stores tool lists used by the discovery mechanism instead of raw - dict access on custom_context. + Inherits all standard AgentContext fields (iteration, state, + searches, etc.) and adds tool lists used by the discovery mechanism. """ - model_config = {"arbitrary_types_allowed": True} - all_tools: list[type[BaseTool]] = Field( default_factory=list, description="Full list of non-system tools available for discovery" ) diff --git a/examples/progressive_discovery/progressive_discovery_agent.py b/examples/progressive_discovery/progressive_discovery_agent.py index 3f543b71..0d56407c 100644 --- a/examples/progressive_discovery/progressive_discovery_agent.py +++ b/examples/progressive_discovery/progressive_discovery_agent.py @@ -19,10 +19,10 @@ class ProgressiveDiscoveryAgent(SGRToolCallingAgent): On init, splits the toolkit into: - system tools (subclasses of SystemBaseTool) -> self.toolkit (always available) - - non-system tools -> stored in context.custom_context.all_tools + - non-system tools -> stored in context.all_tools SearchToolsTool is automatically added if not already present. - Discovered tools accumulate in context.custom_context.discovered_tools. + Discovered tools accumulate in context.discovered_tools. """ name: str = "progressive_discovery_agent" @@ -51,13 +51,13 @@ def __init__( **kwargs, ) - self._context.custom_context = ProgressiveDiscoveryContext( + self._context = ProgressiveDiscoveryContext( all_tools=non_system_tools, ) def _get_active_tools(self) -> list[Type[BaseTool]]: """Return system tools + discovered tools.""" - return list(self.toolkit) + list(self._context.custom_context.discovered_tools) + return list(self.toolkit) + list(self._context.discovered_tools) async def _prepare_tools(self) -> list[dict]: """Override to return only active tools (system + discovered).""" diff --git a/examples/progressive_discovery/tools/search_tools_tool.py b/examples/progressive_discovery/tools/search_tools_tool.py index d463613f..519c2b2a 100644 --- a/examples/progressive_discovery/tools/search_tools_tool.py +++ b/examples/progressive_discovery/tools/search_tools_tool.py @@ -25,22 +25,21 @@ class SearchToolsTool(SystemBaseTool): query: str = Field(description="Natural language description of the capability you need (e.g. 'search the web')") async def __call__(self, context: AgentContext, config: AgentConfig, **kwargs) -> str: - custom = context.custom_context - if not isinstance(custom, ProgressiveDiscoveryContext): - return "Error: custom_context is not initialized as ProgressiveDiscoveryContext" + if not isinstance(context, ProgressiveDiscoveryContext): + return "Error: context is not initialized as ProgressiveDiscoveryContext" - if not custom.all_tools: + if not context.all_tools: return "No additional tools available for discovery." - matched = ToolFilterService.filter_tools(self.query, custom.all_tools) + matched = ToolFilterService.filter_tools(self.query, context.all_tools) - already_discovered_names = {t.tool_name for t in custom.discovered_tools} + already_discovered_names = {t.tool_name for t in context.discovered_tools} new_tools = [t for t in matched if t.tool_name not in already_discovered_names] if not new_tools: return f"No new tools found for query '{self.query}'. Already discovered: {already_discovered_names}" - custom.discovered_tools.extend(new_tools) + context.discovered_tools.extend(new_tools) summary = ToolFilterService.get_tool_summaries(new_tools) return ( diff --git a/tests/test_progressive_discovery.py b/tests/test_progressive_discovery.py index 2e5a69b3..ac667046 100644 --- a/tests/test_progressive_discovery.py +++ b/tests/test_progressive_discovery.py @@ -111,36 +111,33 @@ def test_is_system_tool(self): @pytest.mark.asyncio async def test_finds_tools_and_adds_to_discovered(self): """Should find matching tools and add them to discovered_tools.""" - context = AgentContext() - context.custom_context = ProgressiveDiscoveryContext( + context = ProgressiveDiscoveryContext( all_tools=[DummySearchTool, DummyExtractTool], ) tool = SearchToolsTool(query="search the web") result = await tool(context, config=None) - assert DummySearchTool in context.custom_context.discovered_tools + assert DummySearchTool in context.discovered_tools assert "Found" in result @pytest.mark.asyncio async def test_deduplication_on_repeated_call(self): """Should not add already discovered tools again.""" - context = AgentContext() - context.custom_context = ProgressiveDiscoveryContext( + context = ProgressiveDiscoveryContext( all_tools=[DummySearchTool], discovered_tools=[DummySearchTool], ) tool = SearchToolsTool(query="search the web") result = await tool(context, config=None) - assert context.custom_context.discovered_tools.count(DummySearchTool) == 1 + assert context.discovered_tools.count(DummySearchTool) == 1 assert "No new tools found" in result @pytest.mark.asyncio async def test_error_on_invalid_context(self): - """Should return error if custom_context is not + """Should return error if context is not ProgressiveDiscoveryContext.""" context = AgentContext() - context.custom_context = None tool = SearchToolsTool(query="search") result = await tool(context, config=None) @@ -149,8 +146,7 @@ async def test_error_on_invalid_context(self): @pytest.mark.asyncio async def test_no_tools_available(self): """Should return message when no tools available for discovery.""" - context = AgentContext() - context.custom_context = ProgressiveDiscoveryContext() + context = ProgressiveDiscoveryContext() tool = SearchToolsTool(query="anything") result = await tool(context, config=None) @@ -160,6 +156,15 @@ async def test_no_tools_available(self): class TestProgressiveDiscoveryAgent: """Tests for ProgressiveDiscoveryAgent.""" + def test_context_is_progressive_discovery_context(self): + """Agent context must be ProgressiveDiscoveryContext, not base + AgentContext.""" + agent = create_test_agent( + ProgressiveDiscoveryAgent, + toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool], + ) + assert isinstance(agent._context, ProgressiveDiscoveryContext) + def test_init_splits_toolkit(self): """Init should separate system and non-system tools.""" agent = create_test_agent( @@ -172,9 +177,9 @@ def test_init_splits_toolkit(self): assert FinalAnswerTool in agent.toolkit assert SearchToolsTool in agent.toolkit - # Non-system tools in custom_context - assert DummySearchTool in agent._context.custom_context.all_tools - assert DummyExtractTool in agent._context.custom_context.all_tools + # Non-system tools in context + assert DummySearchTool in agent._context.all_tools + assert DummyExtractTool in agent._context.all_tools # Non-system tools NOT in toolkit assert DummySearchTool not in agent.toolkit @@ -201,7 +206,7 @@ def test_get_active_tools_returns_system_plus_discovered(self): assert ReasoningTool in active # After discovery - agent._context.custom_context.discovered_tools.append(DummySearchTool) + agent._context.discovered_tools.append(DummySearchTool) active = agent._get_active_tools() assert DummySearchTool in active @@ -263,9 +268,7 @@ def test_system_tools_not_in_all_tools(self): ) for tool in [ReasoningTool, FinalAnswerTool, ClarificationTool, SearchToolsTool]: - assert ( - tool not in agent._context.custom_context.all_tools - ), f"System tool {tool.__name__} should not be in filterable pool" + assert tool not in agent._context.all_tools, f"System tool {tool.__name__} should not be in filterable pool" def test_non_system_tools_only_in_all_tools(self): """Only non-system tools should be in the filterable pool.""" @@ -274,7 +277,7 @@ def test_non_system_tools_only_in_all_tools(self): toolkit=[ReasoningTool, FinalAnswerTool, DummySearchTool, DummyExtractTool, DummyDatabaseTool], ) - assert set(agent._context.custom_context.all_tools) == {DummySearchTool, DummyExtractTool, DummyDatabaseTool} + assert set(agent._context.all_tools) == {DummySearchTool, DummyExtractTool, DummyDatabaseTool} def test_system_tools_persist_after_search_with_no_results(self): """System tools must remain active even when search finds nothing."""