diff --git a/agentic-framework/AGENTS.md b/agentic-framework/AGENTS.md new file mode 100644 index 0000000..8836129 --- /dev/null +++ b/agentic-framework/AGENTS.md @@ -0,0 +1,368 @@ +# Agents Guide + +This guide is for both external LLMs working within this codebase and developers building new agents and tools. + +## Project Overview + +This is an educational **LangChain + MCP framework** for building agentic systems in Python 3.12+. + +### Key Technologies + +- **LangChain**: LLM orchestration framework +- **LangGraph**: Stateful agent workflows with checkpoints +- **MCP (Model Context Protocol)**: External tool integration +- **Typer**: CLI interface +- **Rich**: Terminal formatting + +## Architecture + +### Directory Structure + +``` +src/agentic_framework/ +├── core/ # Agent implementations +│ ├── __init__.py # Exports only base classes (no concrete agents) +│ ├── langgraph_agent.py # Reusable LangGraphMCPAgent base +│ ├── simple_agent.py # Basic LLM agent (no tools) +│ ├── chef_agent.py # Recipe finder with web search +│ ├── travel_agent.py # Flight search via Kiwi MCP +│ ├── news_agent.py # AI news via web-fetch MCP +│ ├── travel_coordinator_agent.py # Multi-agent orchestration +│ └── developer_agent.py # Codebase exploration agent +├── interfaces/ # Abstract base classes +│ └── base.py # Agent and Tool ABCs +├── mcp/ # Model Context Protocol +│ ├── config.py # MCP server configurations +│ └── provider.py # MCP client and session management +├── tools/ # Tool implementations +│ ├── codebase_explorer.py # Code navigation tools +│ ├── code_searcher.py # ripgrep wrapper +│ ├── web_search.py # Tavily search +│ └── example.py # Demo tools +├── constants.py # Project-wide constants +├── registry.py # Agent discovery and registration +└── cli.py # CLI interface +``` + +## Core Concepts + +### Agent + +Base class defined in `interfaces/base.py`: + +```python +class Agent(ABC): + @abstractmethod + async def run( + self, + input_data: Union[str, List[BaseMessage]], + config: Optional[Dict[str, Any]] = None, + ) -> Union[str, BaseMessage]: + """Run the agent with the given input.""" + + @abstractmethod + def get_tools(self) -> List[Any]: + """Return available tools for this agent.""" +``` + +### Tool + +Base class defined in `interfaces/base.py`: + +```python +class Tool(ABC): + @property + @abstractmethod + def name(self) -> str: + """The name of the tool.""" + + @property + @abstractmethod + def description(self) -> str: + """A description of what the tool does.""" + + @abstractmethod + def invoke(self, input_str: str) -> Any: + """Execute the tool logic.""" +``` + +### Agent Registry + +Central registration system in `registry.py`: + +```python +@AgentRegistry.register("agent-name", mcp_servers=["server1", "server2"]) +class MyAgent(Agent): + # Implementation +``` + +**Registry Methods:** +- `AgentRegistry.list_agents()` - List all registered agents +- `AgentRegistry.get(name)` - Get agent class by name +- `AgentRegistry.get_mcp_servers(name)` - Get allowed MCP servers for an agent +- `AgentRegistry.discover_agents()` - Auto-discover agents in `core/` package +- `AgentRegistry.set_strict_registration(strict=True)` - Enable strict mode (raises error on duplicate registrations) +- `AgentRegistry.register(name, mcp_servers, override=False)` - Register an agent with optional override flag + +### MCP (Model Context Protocol) + +External tool integration managed by `MCPProvider`. + +**Available MCP Servers** (see `mcp/config.py`): +- `kiwi-com-flight-search` - Flight search +- `tinyfish` - AI assistant +- `web-fetch` - Web content fetching +- `tavily` - Web search + +## Available Agents + +### simple +Basic LLM assistant with no tools. Minimal example. + +### chef +Recipe finder using local web search tool + Tavily MCP. + +### travel +Flight search assistant using Kiwi MCP. + +### news +AI news aggregator using web-fetch MCP. + +### travel-coordinator +Orchestrates 3 specialist agents (flight, city intel, reviewer) with MCP tools. + +### developer +Codebase exploration agent with specialized local tools and webfetch MCP: +- `find_files` - Fast file search via fd +- `discover_structure` - Directory tree exploration +- `get_file_outline` - Extract class/function signatures +- `read_file_fragment` - Read specific line ranges +- `code_search` - Fast pattern search via ripgrep +- `webfetch` (MCP) - Web content fetching + +## Using Existing Agents + +### CLI Usage + +```bash +# List all available agents +agentic-run list + +# View detailed information about an agent +agentic-run info developer + +# Run an agent +agentic-run developer --input "Find all files with 'agent' in the name" + +# With timeout override +agentic-run travel --input "BCN to LIS next week" --timeout 120 +``` + +### Programmatic Usage + +```python +from agentic_framework.registry import AgentRegistry +from agentic_framework.mcp import MCPProvider + +# Get agent class +agent_cls = AgentRegistry.get("developer") + +# Without MCP +agent = agent_cls() +result = await agent.run("Explain the project structure") + +# With MCP (if agent supports it) +provider = MCPProvider(server_names=["webfetch"]) +async with provider.tool_session() as mcp_tools: + agent = agent_cls(initial_mcp_tools=mcp_tools) + result = await agent.run("Search for...") +``` + +## Building New Agents + +### Simple Agent (No Tools) + +```python +from agentic_framework.interfaces.base import Agent +from agentic_framework.registry import AgentRegistry + +@AgentRegistry.register("my-simple-agent", mcp_servers=None) +class MySimpleAgent(Agent): + async def run(self, input_data, config=None): + return f"Response to: {input_data}" + + def get_tools(self): + return [] +``` + +### LangGraph Agent with Local Tools + +```python +from langchain_core.tools import StructuredTool +from agentic_framework.core.langgraph_agent import LangGraphMCPAgent +from agentic_framework.registry import AgentRegistry + +@AgentRegistry.register("my-agent", mcp_servers=None) +class MyAgent(LangGraphMCPAgent): + @property + def system_prompt(self) -> str: + return "You are a helpful assistant specialized in X." + + def local_tools(self) -> Sequence[Any]: + # Add your tools here + return [ + StructuredTool.from_function( + func=my_function, + name="my_tool", + description="Description of what the tool does", + ) + ] +``` + +### LangGraph Agent with MCP Tools + +```python +@AgentRegistry.register("my-agent", mcp_servers=["web-fetch", "tavily"]) +class MyAgent(LangGraphMCPAgent): + @property + def system_prompt(self) -> str: + return "You have access to web tools." + + def local_tools(self) -> Sequence[Any]: + return [] # No local tools, just MCP +``` + +## Building New Tools + +### Simple Tool + +```python +from agentic_framework.interfaces.base import Tool + +class MyTool(Tool): + @property + def name(self) -> str: + return "my_tool" + + @property + def description(self) -> str: + return "Description of what the tool does." + + def invoke(self, input_str: str) -> Any: + # Tool logic here + return f"Result: {input_str}" +``` + +### Codebase Explorer Tool + +```python +from agentic_framework.tools.codebase_explorer import CodebaseExplorer, Tool + +class MyExplorerTool(CodebaseExplorer, Tool): + @property + def name(self) -> str: + return "my_explorer" + + @property + def description(self) -> str: + return "Description of the explorer tool." + + def invoke(self, input_str: str) -> Any: + # Use self.root_dir for project root + # Tool logic here + return result +``` + +### Export from tools/__init__.py + +Add your tool to `tools/__init__.py`: + +```python +from .my_tool import MyTool + +__all__ = [ + # existing... + "MyTool", +] +``` + +## Patterns and Conventions + +### Agent Registration + +- Always use `@AgentRegistry.register(name, mcp_servers)` decorator +- `mcp_servers=None` means no MCP access +- `mcp_servers=[]` or `mcp_servers=["server1"]` for MCP access + +### System Prompts + +- Define as a property named `system_prompt` +- Keep prompts concise and focused +- Include instructions on when/how to use tools + +### Tool Initialization + +- Tools in `local_tools()` should be initialized once (not per-call) +- Use `StructuredTool.from_function()` to wrap functions for LangChain +- Tool names should be snake_case + +### Codebase Tools Usage + +- Use `find_files` when you need to locate files by name/pattern +- Use `discover_structure` for project layout overview +- Use `get_file_outline` to skim file contents before reading +- Use `read_file_fragment` to read specific lines (format: `path:start:end`) +- Use `code_search` for fast global pattern matching + +### Async/Await + +- All agent `run()` methods are async +- Use `await` when calling agent methods +- MCP operations use async context managers + +### Thread IDs + +- LangGraph uses thread IDs for checkpointing +- Provide unique thread_ids for concurrent agent runs +- Format: `"1"`, `"agent:1"`, etc. + +### Error Handling + +- Tools should return error messages as strings, not raise exceptions +- CLI provides error reporting with `--verbose` flag +- MCP connection errors are handled via `MCPConnectionError` + +## Testing + +```python +# Test agent discovery +def test_my_agent_registered(): + AgentRegistry.discover_agents() + assert "my-agent" in AgentRegistry.list_agents() + +# Test agent behavior (use monkeypatch for external dependencies) +def test_my_agent_run(monkeypatch): + # Mock external dependencies + monkeypatch.setattr("module.Class", mock_class) + agent = MyAgent() + result = await agent.run("test") + assert "expected" in result +``` + +## Constants + +- `BASE_DIR` - Project root directory +- `LOGS_DIR` - Logs directory (logs/) +- Default timeout: 600 seconds +- Connection timeout: 15 seconds + +## Notes for External LLMs + +When working with this codebase: + +1. **Agent Discovery**: All agents are auto-registered in `AgentRegistry` +2. **MCP Access**: Check `AgentRegistry.get_mcp_servers(name)` before using MCP +3. **Tool Conventions**: Tools return strings for errors, not exceptions +4. **Codebase Navigation**: The developer agent has specialized tools for code exploration +5. **Model Selection**: The default model name may be a placeholder - check environment variables +6. **File Patterns**: Ignore patterns include `.git`, `__pycache__`, `node_modules`, `.venv`, etc. diff --git a/agentic-framework/TODO.md b/agentic-framework/TODO.md new file mode 100644 index 0000000..b395c10 --- /dev/null +++ b/agentic-framework/TODO.md @@ -0,0 +1,138 @@ +# TODO + +This file tracks bugs, missing features, gaps, and improvement suggestions. + +## Bugs + +### None currently identified + +## Missing Features + +### Testing + +- [x] Add test file for `developer_agent.py` (`tests/test_developer_agent.py`) +- [ ] Add integration tests for CLI commands +- [ ] Add end-to-end tests for MCP server connections +- [ ] Add tests for concurrent agent execution (multiple thread IDs) + +### Documentation + +- [ ] Add inline documentation (docstrings) for all public APIs +- [ ] Add examples for each agent in AGENTS.md +- [ ] Add contribution guidelines +- [ ] Add troubleshooting section for common MCP connection issues + +### CLI + +- [x] Add command to view agent details (system prompt, tools, MCP servers) +- [ ] Add command to list available MCP servers and their status +- [ ] Add interactive mode for multi-turn conversations +- [ ] Add streaming output support for long-running responses + +### Tools + +- [ ] Add file editing tool to `CodebaseExplorer` tools +- [ ] Add git integration tools (status, diff, log) +- [ ] Add shell execution tool with safety guards +- [ ] Add test runner tool (pytest wrapper) + +## Gaps + +### Registry + +- [x] Registry doesn't validate duplicate registrations (silent overwrite) +- [ ] No way to unregister an agent +- [ ] No agent metadata (version, author, description) + +### MCP Provider + +- [ ] No connection health check/status API +- [ ] No way to list available MCP servers without initializing provider +- [ ] Fallback behavior on server failure could be improved +- [ ] No caching of MCP tool schemas + +### Agent Base Classes + +- [ ] `LangGraphMCPAgent` doesn't expose model configuration for customization +- [ ] No shared state management between coordinated agents +- [ ] No built-in rate limiting for tool usage + +## Improvement Suggestions + +### Code Quality + +- [x] The `CalculatorTool` uses `eval()` which is dangerous - should use `ast.literal_eval` or a proper parser +- [ ] Type hints could be more complete throughout +- [ ] Some imports are scattered and could be organized better + +### Performance + +- [ ] Consider adding LRU cache for tool results +- [ ] MCP tool loading could be parallelized (with anyio task identity fixes) +- [ ] File search results could be paginated for large codebases + +### Usability + +- [ ] Add environment variable validation at startup +- [ ] Add configuration file support (e.g., `.agentic-framework.toml`) +- [ ] Add agent aliases/shortcuts +- [ ] Better error messages for missing external tools (fd, rg, fzf) + +### Architecture + +- [ ] Consider splitting `CodebaseExplorer` tools into separate files +- [ ] Could add a plugin system for third-party agents/tools +- [ ] Consider separating agent definitions from the registry decorator +- [ ] Add support for LangChain's tool call retry middleware + +## Potential Future Enhancements + +### Agent Capabilities + +- [ ] Add memory management agents (conversation history) +- [ ] Add planning agents (task decomposition) +- [ ] Add reflection agents (self-critique and improvement) +- [ ] Add multi-modal support (image, audio, document parsing) + +### Integration + +- [ ] Add LangSmith tracing integration +- [ ] Add support for custom LLM providers +- [ ] Add support for vector stores (RAG capabilities) +- [ ] Add support for databases (SQL tools) + +### Observability + +- [ ] Add metrics collection (tool usage, response times) +- [ ] Add structured logging format +- [ ] Add agent execution visualization +- [ ] Add cost tracking for LLM API calls + +## Environment Setup Notes + +### External Tool Dependencies + +The developer agent requires these external tools: +- `fd` - Fast file search +- `rg` (ripgrep) - Fast content search +- `fzf` - Fuzzy finder (optional, with fallback) + +Installation: +```bash +# macOS +brew install fd ripgrep fzf + +# Ubuntu/Debian +apt install fd-find ripgrep fzf +``` + +### API Keys Required + +- `TAVILY_API_KEY` - For Tavily MCP server +- `TINYFISH_API_KEY` - For TinyFish MCP server (optional) + +Note: Missing keys produce warnings but don't prevent agent startup. + +## Deprecated / Legacy + +None currently. diff --git a/agentic-framework/src/agentic_framework/cli.py b/agentic-framework/src/agentic_framework/cli.py index abc7422..6d8832d 100644 --- a/agentic-framework/src/agentic_framework/cli.py +++ b/agentic-framework/src/agentic_framework/cli.py @@ -1,7 +1,7 @@ import asyncio import logging import traceback -from typing import Any, Type +from typing import Any, Callable, Type import typer from dotenv import load_dotenv @@ -97,6 +97,69 @@ def list_agents() -> None: ) +@app.command(name="info") +def agent_info(agent_name: str = typer.Argument(..., help="Name of the agent to inspect.")) -> None: + """Show detailed information about an agent.""" + agent_cls = AgentRegistry.get(agent_name) + if not agent_cls: + console.print(f"[bold red]Error:[/bold red] Agent '{agent_name}' not found.") + console.print("[yellow]Tip:[/yellow] Use 'list' command to see all available agents.") + raise typer.Exit(code=1) + + console.print(f"[bold cyan]Agent Details:[/bold cyan] {agent_name}\n") + + # Agent class name + console.print(f"[bold]Class:[/bold] {agent_cls.__name__}") + + # Module + console.print(f"[bold]Module:[/bold] {agent_cls.__module__}") + + # MCP servers + mcp_servers = AgentRegistry.get_mcp_servers(agent_name) + if mcp_servers is None: + console.print("[bold]MCP Servers:[/bold] None (no MCP access)") + elif mcp_servers: + console.print(f"[bold]MCP Servers:[/bold] {', '.join(mcp_servers)}") + else: + console.print("[bold]MCP Servers:[/bold] (configured but empty list)") + + # Create agent instance first (needed for system prompt and tools) + agent = None + try: + agent = agent_cls(initial_mcp_tools=[]) # type: ignore[call-arg] + except Exception as e: + console.print(f"[yellow]Warning:[/yellow] Could not instantiate agent: {e}") + + # System prompt (if available) - need to instantiate to access the property + console.print("\n[bold]System Prompt:[/bold]") + if agent and hasattr(agent, "system_prompt"): + try: + system_prompt = agent.system_prompt + console.print(system_prompt) + except Exception as e: + console.print(f"[dim](Could not access system prompt: {e})[/dim]") + else: + console.print("[dim](No system prompt defined)[/dim]") + + # Tools info + console.print("\n[bold]Tools:[/bold]") + if agent: + try: + tools = agent.get_tools() + + if not tools: + console.print(" No tools configured") + else: + for tool in tools: + tool_name = getattr(tool, "name", tool.__class__.__name__) + tool_desc = getattr(tool, "description", "(no description)") + console.print(f" - [green]{tool_name}[/green]: {tool_desc}") + except Exception as e: + console.print(f" [dim](Could not list tools: {e})[/dim]") + else: + console.print(" [dim](Could not instantiate agent to list tools)[/dim]") + + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, @@ -109,7 +172,7 @@ def main( console.print("[bold yellow]No command provided. Use --help to see available commands.[/bold yellow]") -def create_agent_command(agent_name: str): +def create_agent_command(agent_name: str) -> Callable[[str, int], None]: def command( input_text: str = typer.Option(..., "--input", "-i", help="Input text for the agent."), timeout_sec: int = typer.Option( diff --git a/agentic-framework/src/agentic_framework/registry.py b/agentic-framework/src/agentic_framework/registry.py index 730bb49..26fd313 100644 --- a/agentic-framework/src/agentic_framework/registry.py +++ b/agentic-framework/src/agentic_framework/registry.py @@ -1,6 +1,7 @@ import importlib +import logging import pkgutil -from typing import Dict, List, Optional, Type +from typing import Callable, Dict, List, Optional, Type from agentic_framework.interfaces.base import Agent @@ -8,23 +9,72 @@ # import (agent modules do "from agentic_framework.registry import AgentRegistry"). # Must be a package whose __init__.py does NOT import concrete agent modules. _AGENTS_PACKAGE_NAME = "agentic_framework.core" +_logger = logging.getLogger(__name__) + + +class DuplicateAgentRegistrationError(Exception): + """Raised when attempting to register an agent with a name that's already in use.""" + + def __init__(self, name: str, existing: Type[Agent], new: Type[Agent]): + self.name = name + self.existing = existing + self.new = new + super().__init__( + f"Agent '{name}' is already registered with class {existing.__name__}. " + f"Attempted to register with {new.__name__}." + ) class AgentRegistry: _registry: Dict[str, Type[Agent]] = {} _mcp_servers: Dict[str, Optional[List[str]]] = {} + _strict_registration: bool = False # If True, duplicates raise an error + + @classmethod + def set_strict_registration(cls, strict: bool = True) -> None: + """Set whether duplicate registrations should raise an error. + + When strict=True, attempting to register an agent with an existing name + will raise DuplicateAgentRegistrationError. When strict=False (default), + a warning is logged and the existing registration is overwritten. + """ + cls._strict_registration = strict @classmethod - def register(cls, name: str, mcp_servers: Optional[List[str]] = None): + def register( + cls, + name: str, + mcp_servers: Optional[List[str]] = None, + *, + override: bool = False, + ) -> Callable[[Type[Agent]], Type[Agent]]: """Decorator to register an agent class and its allowed MCP servers. - mcp_servers: list of keys from mcp.config.DEFAULT_MCP_SERVERS this agent may use. - None or [] means the agent has no MCP access. + Args: + name: The agent name to register. + mcp_servers: list of keys from mcp.config.DEFAULT_MCP_SERVERS this agent may use. + None or [] means the agent has no MCP access. + override: If True, allow overwriting an existing registration even in strict mode. + + Raises: + DuplicateAgentRegistrationError: If strict_registration is True and the name + is already registered (unless override=True). """ - def decorator(agent_cls: Type[Agent]): + def decorator(agent_cls: Type[Agent]) -> Type[Agent]: + if name in cls._registry and not override: + existing_cls = cls._registry[name] + if cls._strict_registration: + raise DuplicateAgentRegistrationError(name, existing_cls, agent_cls) + _logger.warning( + "Duplicate agent registration: '%s' already registered with %s, overwriting with %s", + name, + existing_cls.__name__, + agent_cls.__name__, + ) cls._registry[name] = agent_cls cls._mcp_servers[name] = mcp_servers + _logger.debug("Registered agent '%s' with class %s", name, agent_cls.__name__) return agent_cls return decorator diff --git a/agentic-framework/src/agentic_framework/tools/example.py b/agentic-framework/src/agentic_framework/tools/example.py index 61ff80e..7b9cd2b 100644 --- a/agentic-framework/src/agentic_framework/tools/example.py +++ b/agentic-framework/src/agentic_framework/tools/example.py @@ -1,8 +1,32 @@ +import ast +import operator +from typing import Any + from agentic_framework.interfaces.base import Tool +# Allowed operators for safe math evaluation +_ALLOWED_OPERATORS: dict[type, Any] = { # type: ignore[misc] + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.Pow: operator.pow, + ast.USub: operator.neg, + ast.UAdd: operator.pos, +} +_ALLOWED_FUNCTIONS: dict[str, Any] = { # type: ignore[misc] + "abs": abs, + "round": round, + "min": min, + "max": max, + "sum": sum, +} + class CalculatorTool(Tool): - """A simple calculator tool.""" + """A simple calculator tool with safe math evaluation.""" @property def name(self) -> str: @@ -10,16 +34,59 @@ def name(self) -> str: @property def description(self) -> str: - return "Evaluate a mathematical expression. Input should be a valid Python expression string." + return ( + "Evaluate a mathematical expression. Supports basic operators " + "(+, -, *, /, //, %, **) and functions (abs, round, min, max, sum)." + ) def invoke(self, input_str: str) -> str: - """Evaluate a mathematical expression.""" + """Evaluate a mathematical expression safely.""" try: - # WARNING: eval is dangerous, this is just for demo purposes - return str(eval(input_str)) + result = self._eval_safe(input_str) + return str(result) except Exception as e: return f"Error: {e}" + def _eval_safe(self, expr: str) -> Any: + """Safely evaluate a mathematical expression using AST parsing.""" + node = ast.parse(expr, mode="eval") + return self._eval_node(node.body) + + def _eval_node(self, node: ast.AST) -> Any: + """Recursively evaluate AST nodes with safe operations only.""" + if isinstance(node, ast.Constant): + return node.value + elif isinstance(node, ast.BinOp): + left = self._eval_node(node.left) + right = self._eval_node(node.right) + op_type = type(node.op) + if op_type not in _ALLOWED_OPERATORS: + raise ValueError(f"Operator {op_type.__name__} is not allowed") + op_func = _ALLOWED_OPERATORS[op_type] # type: ignore[index] + return op_func(left, right) # type: ignore[operator] + elif isinstance(node, ast.UnaryOp): + operand = self._eval_node(node.operand) + op_type = type(node.op) # type: ignore[assignment] + if op_type not in _ALLOWED_OPERATORS: + raise ValueError(f"Operator {op_type.__name__} is not allowed") + op_func = _ALLOWED_OPERATORS[op_type] # type: ignore[index] + return op_func(operand) # type: ignore[operator] + elif isinstance(node, ast.Call): + func_name = getattr(node.func, "id", "") + if func_name not in _ALLOWED_FUNCTIONS: + raise ValueError(f"Function {func_name} is not allowed") + args = [self._eval_node(arg) for arg in node.args] + func = _ALLOWED_FUNCTIONS[func_name] # type: ignore[index] + return func(*args) # type: ignore[operator] + elif isinstance(node, ast.List): + return [self._eval_node(item) for item in node.elts] + elif isinstance(node, ast.Tuple): + return tuple(self._eval_node(item) for item in node.elts) + elif isinstance(node, ast.Num): # Python < 3.8 + return node.n + else: + raise ValueError(f"Unsupported expression: {ast.dump(node)}") + class WeatherTool(Tool): """A mock weather tool.""" diff --git a/agentic-framework/tests/test_developer_agent.py b/agentic-framework/tests/test_developer_agent.py new file mode 100644 index 0000000..505bd18 --- /dev/null +++ b/agentic-framework/tests/test_developer_agent.py @@ -0,0 +1,91 @@ +import asyncio +from types import SimpleNamespace + +from agentic_framework.core.developer_agent import DeveloperAgent + + +class DummyGraph: + async def ainvoke(self, payload, config): + return {"messages": [SimpleNamespace(content="done")]} + + +def test_developer_agent_system_prompt(monkeypatch): + monkeypatch.setattr("agentic_framework.core.langgraph_agent.ChatOpenAI", lambda **kwargs: object()) + monkeypatch.setattr("agentic_framework.core.langgraph_agent.create_agent", lambda **kwargs: DummyGraph()) + + agent = DeveloperAgent(initial_mcp_tools=[]) + + assert "Principal Software Engineer" in agent.system_prompt + assert "discover_structure" in agent.system_prompt + assert "find_files" in agent.system_prompt + assert "get_file_outline" in agent.system_prompt + assert "read_file_fragment" in agent.system_prompt + assert "code_search" in agent.system_prompt + + +def test_developer_agent_local_tools_count(monkeypatch): + monkeypatch.setattr("agentic_framework.core.langgraph_agent.ChatOpenAI", lambda **kwargs: object()) + monkeypatch.setattr("agentic_framework.core.langgraph_agent.create_agent", lambda **kwargs: DummyGraph()) + + agent = DeveloperAgent(initial_mcp_tools=[]) + + tools = agent.get_tools() + assert len(tools) == 5 + + tool_names = {tool.name for tool in tools} + expected_names = {"discover_structure", "find_files", "get_file_outline", "read_file_fragment", "code_search"} + assert tool_names == expected_names + + +def test_developer_agent_tool_descriptions(monkeypatch): + monkeypatch.setattr("agentic_framework.core.langgraph_agent.ChatOpenAI", lambda **kwargs: object()) + monkeypatch.setattr("agentic_framework.core.langgraph_agent.create_agent", lambda **kwargs: DummyGraph()) + + agent = DeveloperAgent(initial_mcp_tools=[]) + + tools = agent.get_tools() + tools_by_name = {tool.name: tool for tool in tools} + + assert "files and directories recursively" in tools_by_name["discover_structure"].description.lower() + assert "file search by name" in tools_by_name["find_files"].description.lower() + outline_desc = tools_by_name["get_file_outline"].description.lower() + assert "class" in outline_desc and "function" in outline_desc + assert "specific line range" in tools_by_name["read_file_fragment"].description.lower() + assert "ripgrep" in tools_by_name["code_search"].description.lower() + + +def test_developer_agent_run(monkeypatch): + monkeypatch.setattr("agentic_framework.core.langgraph_agent.ChatOpenAI", lambda **kwargs: object()) + monkeypatch.setattr("agentic_framework.core.langgraph_agent.create_agent", lambda **kwargs: DummyGraph()) + + agent = DeveloperAgent(initial_mcp_tools=[]) + + result = asyncio.run(agent.run("Find all files related to agents")) + assert result == "done" + + +def test_developer_agent_with_mcp_tools(monkeypatch): + monkeypatch.setattr("agentic_framework.core.langgraph_agent.ChatOpenAI", lambda **kwargs: object()) + monkeypatch.setattr("agentic_framework.core.langgraph_agent.create_agent", lambda **kwargs: DummyGraph()) + + # Simulate MCP tools + class MockMCPTool: + name = "webfetch" + description = "Fetch web content" + + agent = DeveloperAgent(initial_mcp_tools=[MockMCPTool()]) + + # MCP tools are added during initialization (_ensure_initialized is called during run) + # We can verify the agent was initialized with MCP tools + assert agent._initial_mcp_tools is not None + assert len(agent._initial_mcp_tools) == 1 + assert agent._initial_mcp_tools[0].name == "webfetch" + + # After running, tools should include both local and MCP tools + asyncio.run(agent.run("test")) + tools = agent.get_tools() + + # Should have 5 local tools + 1 MCP tool + assert len(tools) == 6 + tool_names = {tool.name for tool in tools} + assert "webfetch" in tool_names diff --git a/agentic-framework/tests/test_registry.py b/agentic-framework/tests/test_registry.py index 4fc2935..de3e758 100644 --- a/agentic-framework/tests/test_registry.py +++ b/agentic-framework/tests/test_registry.py @@ -11,6 +11,7 @@ def test_registry_discovers_core_agents(): assert "travel" in agents assert "news" in agents assert "travel-coordinator" in agents + assert "developer" in agents def test_registry_register_get_and_mcp_servers(): @@ -29,3 +30,91 @@ def get_tools(self): finally: AgentRegistry._registry.pop("test-agent", None) AgentRegistry._mcp_servers.pop("test-agent", None) + + +def test_registry_duplicate_registration_warns_by_default(): + """By default, duplicate registrations should log a warning but allow the override.""" + + @AgentRegistry.register("dup-test", mcp_servers=None) + class TestAgent1(Agent): + async def run(self, input_data, config=None): + return "ok1" + + def get_tools(self): + return [] + + @AgentRegistry.register("dup-test", mcp_servers=None) + class TestAgent2(Agent): + async def run(self, input_data, config=None): + return "ok2" + + def get_tools(self): + return [] + + try: + # Second registration should have overwritten the first + assert AgentRegistry.get("dup-test") is TestAgent2 + finally: + AgentRegistry._registry.pop("dup-test", None) + AgentRegistry._mcp_servers.pop("dup-test", None) + + +def test_registry_duplicate_registration_strict_mode_raises(): + """In strict mode, duplicate registrations should raise an error.""" + AgentRegistry.set_strict_registration(True) + + @AgentRegistry.register("dup-strict-test", mcp_servers=None) + class TestAgent1(Agent): + async def run(self, input_data, config=None): + return "ok1" + + def get_tools(self): + return [] + + try: + # This should raise DuplicateAgentRegistrationError + @AgentRegistry.register("dup-strict-test", mcp_servers=None) + class TestAgent2(Agent): + async def run(self, input_data, config=None): + return "ok2" + + def get_tools(self): + return [] + + assert False, "Expected DuplicateAgentRegistrationError to be raised" + except Exception as e: + assert "DuplicateAgentRegistrationError" in str(type(e).__name__) + assert "dup-strict-test" in str(e) + finally: + AgentRegistry._registry.pop("dup-strict-test", None) + AgentRegistry._mcp_servers.pop("dup-strict-test", None) + AgentRegistry.set_strict_registration(False) # Reset to default + + +def test_registry_duplicate_registration_with_override_flag(): + """The override flag should allow duplicate registration even in strict mode.""" + AgentRegistry.set_strict_registration(True) + + @AgentRegistry.register("dup-override-test", mcp_servers=None) + class TestAgent1(Agent): + async def run(self, input_data, config=None): + return "ok1" + + def get_tools(self): + return [] + + @AgentRegistry.register("dup-override-test", mcp_servers=None, override=True) + class TestAgent2(Agent): + async def run(self, input_data, config=None): + return "ok2" + + def get_tools(self): + return [] + + try: + # Should have succeeded due to override=True + assert AgentRegistry.get("dup-override-test") is TestAgent2 + finally: + AgentRegistry._registry.pop("dup-override-test", None) + AgentRegistry._mcp_servers.pop("dup-override-test", None) + AgentRegistry.set_strict_registration(False) # Reset to default diff --git a/agentic-framework/tests/test_tools.py b/agentic-framework/tests/test_tools.py index ec4ae2c..738a022 100644 --- a/agentic-framework/tests/test_tools.py +++ b/agentic-framework/tests/test_tools.py @@ -28,3 +28,35 @@ def search(self, query): result = tool.invoke("langchain") assert result["query"] == "langchain" + + +def test_calculator_blocks_dangerous_operations(): + tool = CalculatorTool() + # Test that function calls are blocked + assert "not allowed" in tool.invoke("__import__('os')") + assert "not allowed" in tool.invoke("print('hello')") + # Test that attribute access is blocked (returns error message) + result = tool.invoke("open('/etc/passwd').read()") + assert "Error" in result + # Test that empty expressions are blocked + result2 = tool.invoke("") + assert "Error" in result2 + + +def test_calculator_safe_operations(): + tool = CalculatorTool() + # Test all supported operations + assert tool.invoke("2 + 2") == "4" + assert tool.invoke("10 - 3") == "7" + assert tool.invoke("3 * 4") == "12" + assert tool.invoke("15 / 3") == "5.0" + assert tool.invoke("15 // 4") == "3" + assert tool.invoke("15 % 4") == "3" + assert tool.invoke("2 ** 3") == "8" + assert tool.invoke("-5") == "-5" + assert tool.invoke("+5") == "5" + assert tool.invoke("abs(-5)") == "5" + assert tool.invoke("round(3.7)") == "4" + assert tool.invoke("min(1, 5, 3)") == "1" + assert tool.invoke("max(1, 5, 3)") == "5" + assert tool.invoke("sum([1, 2, 3])") == "6"