From 21d19ac73cc40a75bfcf2b34d972b682d903f20f Mon Sep 17 00:00:00 2001 From: farhoud Date: Tue, 16 Dec 2025 18:05:41 +0330 Subject: [PATCH 1/4] Remove deprecated agent module --- src/scouter/agent/__init__.py | 0 src/scouter/agent/agent.py | 69 ----------------------------------- src/scouter/agent/mcp.py | 27 -------------- src/scouter/agent/tools.py | 53 --------------------------- 4 files changed, 149 deletions(-) delete mode 100644 src/scouter/agent/__init__.py delete mode 100644 src/scouter/agent/agent.py delete mode 100644 src/scouter/agent/mcp.py delete mode 100644 src/scouter/agent/tools.py diff --git a/src/scouter/agent/__init__.py b/src/scouter/agent/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/scouter/agent/agent.py b/src/scouter/agent/agent.py deleted file mode 100644 index d3e1861..0000000 --- a/src/scouter/agent/agent.py +++ /dev/null @@ -1,69 +0,0 @@ -import json - -from scouter.agent.tools import get_tools -from scouter.config.llm import ( - DEFAULT_MODEL, - call_with_rate_limit, - get_scouter_client, -) - - -def handle_tool_calls(response, tools, client, messages): - if not response.choices[0].message.tool_calls: - return response.choices[0].message.content, messages - - # Append assistant message with tool calls - messages.append(response.choices[0].message) - - for tool_call in response.choices[0].message.tool_calls: - tool_name = tool_call.function.name - args = json.loads(tool_call.function.arguments) - for tool in tools: - if tool["function"]["name"] == tool_name: - result = tool["callable"](**args) - # Append tool result message - messages.append( - { - "role": "tool", - "tool_call_id": tool_call.id, - "content": json.dumps([r.model_dump() for r in result]) - if isinstance(result, list) - else json.dumps(result), - } - ) - break - - # Follow-up call for multi-turn - follow_up = call_with_rate_limit( - client, - model=DEFAULT_MODEL, - messages=messages, # type: ignore[arg-type] - tools=[t["function"] for t in tools], - tool_choice="auto", - ) - return handle_tool_calls(follow_up, tools, client, messages) - - -def search_agent(query: str, hints: str = ""): - client = get_scouter_client() - tools = get_tools() - openai_tools = [t["function"] for t in tools] - system_content = ( - "You are a search agent. Use the available tools to answer the user's query." - ) - if hints: - system_content += f"\n\nHints:\n{hints}" - messages = [ - {"role": "system", "content": system_content}, - {"role": "user", "content": query}, - ] - response = call_with_rate_limit( - client, - model=DEFAULT_MODEL, - messages=messages, # type: ignore[arg-type] - tools=openai_tools, - tool_choice="auto", - max_tokens=200, - ) - final_response, _ = handle_tool_calls(response, tools, client, messages) - return final_response diff --git a/src/scouter/agent/mcp.py b/src/scouter/agent/mcp.py deleted file mode 100644 index b499682..0000000 --- a/src/scouter/agent/mcp.py +++ /dev/null @@ -1,27 +0,0 @@ -from fastmcp import FastMCP - -from .agent import search_agent - -app = FastMCP("Scouter Agent") - - -@app.tool() -def search_knowledge_graph(query: str, hints: str = "") -> str: - """Search the knowledge graph for information related to the query using semantic search. - - This tool allows LLMs to retrieve relevant documents and knowledge from the Scouter knowledge graph. - It performs vector-based semantic search, returning the most relevant results. - - Args: - query: The search query string to find relevant information - hints: Optional hints to guide the search agent - - Returns: - A response string containing search results and analysis - - """ - return search_agent(query, hints) - - -if __name__ == "__main__": - app.run() diff --git a/src/scouter/agent/tools.py b/src/scouter/agent/tools.py deleted file mode 100644 index f73b8fd..0000000 --- a/src/scouter/agent/tools.py +++ /dev/null @@ -1,53 +0,0 @@ -import ast - -from neo4j_graphrag.retrievers import VectorRetriever -from pydantic import BaseModel, Field - -from scouter.config.llm import get_neo4j_driver, get_neo4j_embedder -from scouter.shared.domain_models import VectorSearchResult - - -class SemanticSearchParams(BaseModel): - query_text: str = Field(description="exact user query") - top_k: int = Field(default=10, description="Number of results to return (1-20)") - filters: dict | None = Field(default=None, description="Optional filters") - effective_search_ratio: float = Field( - default=1.0, description="Search pool multiplier for better accuracy" - ) - - -def _get_vector_search_tool(): - retriever = VectorRetriever( - driver=get_neo4j_driver(), - index_name="chunkEmbedding", - embedder=get_neo4j_embedder(), - ) - - def semantic_search(**kwargs): - params = SemanticSearchParams(**kwargs) - raw_results = retriever.search(**params.model_dump()) - items = raw_results.items - return [ - VectorSearchResult( - node_id=data.get("id", "unknown"), - score=data.get("score", 0.0), - content=data.get("text", ""), - metadata=data, - ) - for result in items - for data in [ast.literal_eval(result.content)] - ] - - return { - "type": "function", - "function": { - "name": "semantic_search", - "description": "find relative information based on cosine similarity", - "parameters": SemanticSearchParams.model_json_schema(), - }, - "callable": semantic_search, - } - - -def get_tools(): - return [_get_vector_search_tool()] From 79c34bb4b8eb0be84b03a8d770b3ebae83774f12 Mon Sep 17 00:00:00 2001 From: farhoud Date: Tue, 16 Dec 2025 18:05:43 +0330 Subject: [PATCH 2/4] Improve LLM config validation and provider support --- src/scouter/config/llm.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/scouter/config/llm.py b/src/scouter/config/llm.py index ca0d95f..c5467c9 100644 --- a/src/scouter/config/llm.py +++ b/src/scouter/config/llm.py @@ -24,27 +24,31 @@ class ClientConfig(BaseSettings): env: str = "test" @model_validator(mode="after") - def set_provider_defaults(self): + def validate_and_set_provider_defaults(self): + # Validate provider + supported_providers = ["openai", "openrouter"] + if self.provider not in supported_providers: + msg = f"Unsupported provider '{self.provider}'. Supported providers: {', '.join(supported_providers)}" + raise ValueError(msg) + + # Set provider-specific defaults if self.provider == "openrouter": self.api_base = self.api_base or "https://openrouter.ai/api/v1" self.api_key = self.api_key or os.getenv("OPENROUTER_API_KEY") - else: + elif self.provider == "openai": self.api_key = self.api_key or os.getenv("OPENAI_API_KEY") + + # Validate API key is set if not self.api_key: - msg = f"API key required for provider {self.provider}" + msg = f"API key required for provider '{self.provider}'. Set {'OPENROUTER_API_KEY' if self.provider == 'openrouter' else 'OPENAI_API_KEY'} environment variable." raise ValueError(msg) + + # Validate environment if self.env not in ["development", "production", "test"]: msg = "env must be one of: development, production, test" raise ValueError(msg) - return self - def __init__(self, **data): - super().__init__(**data) - if self.provider == "openrouter": - self.api_base = self.api_base or "https://openrouter.ai/api/v1" - self.api_key = os.getenv("OPENROUTER_API_KEY", self.api_key) - # Map model if needed, e.g., self.model = "openai/gpt-3.5-turbo" - self.env = os.getenv("SCOUTER_ENV", self.env) + return self def get_client_config(provider: str = "openai") -> ClientConfig: @@ -80,8 +84,8 @@ def get_neo4j_driver() -> neo4j.Driver: @lru_cache(maxsize=1) -def get_neo4j_llm() -> OpenAILLM: - config = get_client_config("openrouter") +def get_neo4j_llm(provider: str = "openrouter") -> OpenAILLM: + config = get_client_config(provider) return OpenAILLM(config.model, api_key=config.api_key, base_url=config.api_base) From b42d94ac6ef3173f084f91a04ac0032d1ff68485 Mon Sep 17 00:00:00 2001 From: farhoud Date: Tue, 16 Dec 2025 18:05:46 +0330 Subject: [PATCH 3/4] Refactor agent functionality and add AgentConfig --- src/scouter/llmcore/__init__.py | 11 ++- src/scouter/llmcore/agent.py | 120 +++++++++++++++++++++++++++----- src/scouter/llmcore/client.py | 16 ++++- src/scouter/llmcore/tools.py | 6 +- 4 files changed, 129 insertions(+), 24 deletions(-) diff --git a/src/scouter/llmcore/__init__.py b/src/scouter/llmcore/__init__.py index af1a775..521f1bb 100644 --- a/src/scouter/llmcore/__init__.py +++ b/src/scouter/llmcore/__init__.py @@ -1,4 +1,11 @@ -from .agent import AgentRun, LLMStep, ToolStep, run_agent +from .agent import ( + AgentConfig, + AgentRun, + LLMStep, + ToolStep, + create_agent, + run_agent, +) from .client import ChatCompletionOptions, LLMConfig, call_llm, create_llm_client from .exceptions import ( AgentError, @@ -34,6 +41,7 @@ from .utils import retry_loop __all__ = [ + "AgentConfig", "AgentError", "AgentRun", "ChatCompletion", @@ -57,6 +65,7 @@ "ToolExecutionError", "ToolStep", "call_llm", + "create_agent", "create_instruction", "create_llm_client", "create_tool", diff --git a/src/scouter/llmcore/agent.py b/src/scouter/llmcore/agent.py index 1fbaa82..5cb4352 100644 --- a/src/scouter/llmcore/agent.py +++ b/src/scouter/llmcore/agent.py @@ -9,9 +9,10 @@ from .client import ChatCompletionOptions, call_llm from .exceptions import InvalidRunStateError -from .flow import Flow, LLMStep, ToolCall, ToolStep +from .flow import Flow, InputStep, LLMStep, ToolCall, ToolStep from .memory import MemoryFunction, full_history_memory -from .tools import run_tool +from .messages import create_instruction +from .tools import lookup_tool, run_tool if TYPE_CHECKING: from collections.abc import Callable, Iterable @@ -26,6 +27,33 @@ logger = logging.getLogger(__name__) +# Constants +TUPLE_INSTRUCTION_LENGTH = 2 + + +# Type for flexible instruction specification +InstructionType = ( + str # Just system prompt + | tuple[str, str] # (system_prompt, user_prompt) + | list["ChatCompletionMessageParam"] # Full message list + | None # No instructions +) + + +@dataclass +class AgentConfig: + """Configuration for agent creation.""" + + name: str = "default" + provider: str = "openai" + model: str = "gpt-4o-mini" + temperature: float = 0.7 + max_tokens: int | None = None + instructions: InstructionType = None + tools: list[str] | None = None # Tool names + memory_function: MemoryFunction = full_history_memory + continue_condition: Callable[[AgentRun], bool] | None = None + @dataclass class AgentRun: @@ -34,9 +62,6 @@ class AgentRun: ) flows: list[Flow] = field(default_factory=list) memory_function: MemoryFunction = field(default=full_history_memory) - agents: dict[str, Callable[[], AgentRun]] = field( - default_factory=dict - ) # For multi-agent: factory functions def add_flow(self, flow: Flow) -> None: """Add a flow to the run.""" @@ -46,19 +71,6 @@ def get_context(self) -> list[ChatCompletionMessageParam]: """Get configurable memory context instead of flat history.""" return self.memory_function(self) - def run_sub_agent(self, agent_id: str) -> Flow: - """Run a sub-agent within this run, returning its flow.""" - if agent_id not in self.agents: - msg = f"Agent {agent_id} not registered" - raise ValueError(msg) - flow = Flow(id=f"{agent_id}_{len(self.flows)}", agent_id=agent_id) - flow.mark_running() - self.add_flow(flow) - # TODO: Integrate with run_agent for actual execution - # For now, placeholder: assume sub_run executes and adds steps to flow - flow.mark_completed() - return flow - @property def total_usage( self, @@ -200,3 +212,75 @@ async def execute_single_tool(tc: ChatCompletionMessageToolCall): current_flow.add_step(ToolStep(calls=success)) current_flow.mark_completed() logger.info("Agent run completed with %d total flows", len(run.flows)) + + +def _process_instructions( + instructions: InstructionType, +) -> list[ChatCompletionMessageParam]: + """Convert instruction specification to message list.""" + if instructions is None: + return [] + if isinstance(instructions, str): + # Just system prompt + return [{"role": "system", "content": instructions}] + if ( + isinstance(instructions, tuple) + and len(instructions) == TUPLE_INSTRUCTION_LENGTH + ): + return create_instruction(instructions[0], instructions[1]) + if isinstance(instructions, list): + # Full message list + return instructions + + msg = f"Invalid instruction format: {type(instructions)}" + raise ValueError(msg) + + +def create_agent(config: AgentConfig) -> AgentRun: + """Create an agent from configuration.""" + # Start with default continue condition if none specified + continue_cond = config.continue_condition + if continue_cond is None: + continue_cond = default_continue_condition_factory() + + return AgentRun( + memory_function=config.memory_function, continue_condition=continue_cond + ) + + +async def run_agent( + agent: AgentRun, + config: AgentConfig, + messages: list[ChatCompletionMessageParam] | None = None, + **options, +) -> AgentRun: + """Run an agent with configuration.""" + input_messages = messages or [] + + # Get tools from registry + tools = None + if config.tools: + tools = [lookup_tool(name).openai_tool_spec() for name in config.tools] + + # Process instructions and combine with input messages + instruction_messages = _process_instructions(config.instructions) + all_messages = instruction_messages + input_messages + + # Add initial messages as InputStep to the agent + initial_flow = Flow(id="initial", agent_id=config.name) + initial_flow.add_step(InputStep(input=all_messages)) + agent.add_flow(initial_flow) + + # Build options dict, only including max_tokens if set + flow_options = {"temperature": config.temperature, **options} + if config.max_tokens is not None: + flow_options["max_tokens"] = config.max_tokens # type: ignore[assignment] + + await run_flow( + agent, + model=config.model, + tools=tools, + options=ChatCompletionOptions(**flow_options), + ) + + return agent diff --git a/src/scouter/llmcore/client.py b/src/scouter/llmcore/client.py index fd8793e..1c0c7ad 100644 --- a/src/scouter/llmcore/client.py +++ b/src/scouter/llmcore/client.py @@ -45,9 +45,21 @@ class LLMConfig: @staticmethod def load_from_env() -> "LLMConfig": + provider = os.getenv("LLM_PROVIDER", "openai") + if provider == "openrouter": + api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") + base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") + elif provider == "openai": + api_key = os.getenv("OPENAI_API_KEY") + base_url = os.getenv("OPENAI_BASE_URL") + else: + # Default to openai for backward compatibility + api_key = os.getenv("OPENAI_API_KEY") + base_url = os.getenv("OPENAI_BASE_URL") + return LLMConfig( - api_key=os.getenv("OPENAI_API_KEY"), - base_url=os.getenv("OPENAI_BASE_URL"), + api_key=api_key, + base_url=base_url, ) diff --git a/src/scouter/llmcore/tools.py b/src/scouter/llmcore/tools.py index 5673db8..10e1c1e 100644 --- a/src/scouter/llmcore/tools.py +++ b/src/scouter/llmcore/tools.py @@ -19,7 +19,7 @@ class Tool(BaseModel): name: str description: str - handler: Callable[[BaseModel], BaseModel | str] + handler: Callable[..., BaseModel | str] # Auto-filled fields parameters_schema: dict = Field(default_factory=dict) @@ -94,7 +94,7 @@ def openai_tool_spec(self) -> ChatCompletionToolParam: def create_tool( - name: str, description: str, handler: Callable[[BaseModel], BaseModel | str] + name: str, description: str, handler: Callable[..., BaseModel | str] ) -> Tool: """ Creates a Pydantic Tool instance. @@ -108,7 +108,7 @@ def tool(name: str | None = None, description: str | None = None): The decorated function MUST take a Pydantic model and return a Pydantic model or a string. """ - def decorator(func: Callable[[BaseModel], BaseModel | str]): + def decorator(func: Callable[..., BaseModel | str]): tool_name = name or func.__name__ tool_desc = description or (func.__doc__ or "No description.").strip() From ae761a0340042d737bf2932c7b12e600b83cd623 Mon Sep 17 00:00:00 2001 From: farhoud Date: Tue, 16 Dec 2025 18:05:48 +0330 Subject: [PATCH 4/4] Add MCP and search modules --- src/scouter/mcp/__init__.py | 7 ++++ src/scouter/search/__init__.py | 0 src/scouter/search/agent.py | 61 ++++++++++++++++++++++++++++++++++ src/scouter/search/mcp.py | 21 ++++++++++++ src/scouter/search/tools.py | 48 ++++++++++++++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 src/scouter/mcp/__init__.py create mode 100644 src/scouter/search/__init__.py create mode 100644 src/scouter/search/agent.py create mode 100644 src/scouter/search/mcp.py create mode 100644 src/scouter/search/tools.py diff --git a/src/scouter/mcp/__init__.py b/src/scouter/mcp/__init__.py new file mode 100644 index 0000000..440b196 --- /dev/null +++ b/src/scouter/mcp/__init__.py @@ -0,0 +1,7 @@ +from fastmcp import FastMCP + +app = FastMCP("Scouter") + + +if __name__ == "__main__": + app.run() diff --git a/src/scouter/search/__init__.py b/src/scouter/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/scouter/search/agent.py b/src/scouter/search/agent.py new file mode 100644 index 0000000..4982a52 --- /dev/null +++ b/src/scouter/search/agent.py @@ -0,0 +1,61 @@ +from scouter.llmcore import AgentConfig, AgentRun, create_agent, run_agent +from scouter.llmcore.types import ( + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) + + +def get_search_agent() -> tuple[AgentRun, AgentConfig]: + """Get a pre-configured search agent for knowledge graph queries.""" + config = AgentConfig( + name="search", + provider="openai", # Use OpenAI for search (more deterministic) + model="gpt-4o-mini", + temperature=0.0, # Deterministic for search + instructions=( + "You are a search agent specialized in retrieving information from a knowledge graph. " + "Use the semantic_search tool to find relevant information based on the user's query. " + "Analyze the search results and provide a comprehensive answer.", + "What information are you looking for?", + ), + tools=["semantic_search"], + max_tokens=1000, # Allow longer responses for search results + ) + agent = create_agent(config) + return agent, config + + +async def search_knowledge_graph(query: str, hints: str = "") -> str: + """Search the knowledge graph for information related to the query. + + Args: + query: The search query string + hints: Optional hints to guide the search + + Returns: + A response string containing search results and analysis + """ + agent, config = get_search_agent() + + # Prepare messages with hints if provided + base_instructions = ( + "You are a search agent specialized in retrieving information from a knowledge graph. " + "Use the semantic_search tool to find relevant information based on the user's query. " + "Analyze the search results and provide a comprehensive answer." + ) + + system_content = base_instructions + if hints: + system_content += f"\n\nAdditional hints: {hints}" + + messages: list[ChatCompletionMessageParam] = [ + ChatCompletionSystemMessageParam(role="system", content=system_content), + ChatCompletionUserMessageParam(role="user", content=query), + ] + + # Run the agent + result_agent = await run_agent(agent, config, messages) + + # Extract the final response + return result_agent.last_output diff --git a/src/scouter/search/mcp.py b/src/scouter/search/mcp.py new file mode 100644 index 0000000..553e538 --- /dev/null +++ b/src/scouter/search/mcp.py @@ -0,0 +1,21 @@ +from scouter.mcp import app + +from .agent import search_knowledge_graph + + +@app.tool() +async def search_knowledge_graph_tool(query: str, hints: str = "") -> str: + """Search the knowledge graph for information related to the query using semantic search. + + This tool allows LLMs to retrieve relevant documents and knowledge from the Scouter knowledge graph. + It performs vector-based semantic search, returning the most relevant results with analysis. + + Args: + query: The search query string to find relevant information + hints: Optional hints to guide the search agent + + Returns: + A response string containing search results and analysis + + """ + return await search_knowledge_graph(query, hints) diff --git a/src/scouter/search/tools.py b/src/scouter/search/tools.py new file mode 100644 index 0000000..b9779b5 --- /dev/null +++ b/src/scouter/search/tools.py @@ -0,0 +1,48 @@ +import ast + +from neo4j_graphrag.retrievers import VectorRetriever +from pydantic import BaseModel, Field + +from scouter.config.llm import get_neo4j_driver, get_neo4j_embedder +from scouter.llmcore import tool +from scouter.shared.domain_models import VectorSearchResult + + +class SemanticSearchParams(BaseModel): + query_text: str = Field(description="exact user query") + top_k: int = Field(default=10, description="Number of results to return (1-20)") + filters: dict | None = Field(default=None, description="Optional filters") + effective_search_ratio: float = Field( + default=1.0, description="Search pool multiplier for better accuracy" + ) + + +class SearchResults(BaseModel): + results: list[VectorSearchResult] + + +@tool("semantic_search") +def semantic_search(params: SemanticSearchParams) -> SearchResults: + """Find relevant information based on cosine similarity search.""" + # Cast to the expected parameter type + search_params = SemanticSearchParams(**params.model_dump()) + + retriever = VectorRetriever( + driver=get_neo4j_driver(), + index_name="chunkEmbedding", + embedder=get_neo4j_embedder(), + ) + + raw_results = retriever.search(**search_params.model_dump()) + items = raw_results.items + results = [ + VectorSearchResult( + node_id=data.get("id", "unknown"), + score=data.get("score", 0.0), + content=data.get("text", ""), + metadata=data, + ) + for result in items + for data in [ast.literal_eval(result.content)] + ] + return SearchResults(results=results)