Spaces:
Running
Running
Nathan Brake
commited on
Some re-arranging to make MCP work, add some doc and tests (#3)
Browse files* Some re-arranging to make MCP work, add some doc and tests
* prompt grammar fix
* Add Phoenix info
* Updates from code review
- .gitignore +4 -0
- README.md +55 -8
- pyproject.toml +8 -1
- src/surf_spot_finder/agents/smolagents.py +49 -35
- src/surf_spot_finder/cli.py +10 -8
- src/surf_spot_finder/config.py +11 -5
- src/surf_spot_finder/tracing.py +1 -1
- tests/integration/agents/test_integration_smolagents.py +67 -0
- tests/unit/agents/test_load_smolagents.py +0 -27
- tests/unit/agents/test_unit_smolagents.py +103 -0
.gitignore
CHANGED
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
1 |
# Byte-compiled / optimized / DLL files
|
2 |
__pycache__/
|
3 |
*.py[cod]
|
|
|
1 |
+
# Custom gitignores
|
2 |
+
uv.lock
|
3 |
+
|
4 |
+
|
5 |
# Byte-compiled / optimized / DLL files
|
6 |
__pycache__/
|
7 |
*.py[cod]
|
README.md
CHANGED
@@ -9,20 +9,49 @@
|
|
9 |
</picture>
|
10 |
</p>
|
11 |
|
12 |
-
|
|
|
|
|
13 |
|
14 |
-
|
|
|
|
|
|
|
|
|
|
|
15 |
|
16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
|
18 |
### Built with
|
19 |
-
-
|
20 |
-
-
|
21 |
-
|
22 |
-
|
|
|
23 |
|
24 |
-
|
|
|
|
|
|
|
|
|
25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
|
27 |
## How it Works
|
28 |
|
@@ -36,8 +65,26 @@ This blueprint guides you to ...
|
|
36 |
- Disk space:
|
37 |
|
38 |
- **Dependencies**:
|
|
|
39 |
- Dependencies listed in `pyproject.toml`
|
40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
|
42 |
## Troubleshooting
|
43 |
|
|
|
9 |
</picture>
|
10 |
</p>
|
11 |
|
12 |
+
Many Large Language Model (LLM) capabilities are unlocked when they are given access to tools and given control of their
|
13 |
+
own runtime and execution path. However, it's important that as they are given greater capabilities, they are properly
|
14 |
+
evaluated and controlled.
|
15 |
|
16 |
+
In this Blueprint, we demonstrate an AI agent designed for an extremely specific task (some refer to this as a "Vertical Agent")
|
17 |
+
that is given the web and searching access it needs to find an answer the same way you would find the answer as a human.
|
18 |
+
|
19 |
+
This agent is designed for help in finding the next great surf spot near you: the agent is provided with a location, a distance,
|
20 |
+
a timestamp, and it's able to independently search and browse the web to recommend the best spot to you along with the
|
21 |
+
relevant information!
|
22 |
|
23 |
+
Although this exact use-case may not be useful to you directly, the framework we provide here is intended to be easily
|
24 |
+
adapted to the Agent use case you have in mind.
|
25 |
+
|
26 |
+
This implementation uses the [smolagents](https://huggingface.co/docs/smolagents/index) library for Agentic capabilities, alongside
|
27 |
+
of the increasingly Model Context Protocol (MCP) which allows for a standard access communication standard for a large number of tools.
|
28 |
+
|
29 |
+
📘 To explore this project further and discover other Blueprints, visit the [**Blueprints Hub**](https://developer-hub.mozilla.ai/).
|
30 |
|
31 |
### Built with
|
32 |
+

|
33 |
+
[](https://huggingface.co/docs/smolagents/index)
|
34 |
+
|
35 |
+
|
36 |
+
## 🚀 Quick Start
|
37 |
|
38 |
+
### 1️⃣ Clone the Project
|
39 |
+
```bash
|
40 |
+
git clone https://github.com/mozilla-ai/surf-spot-finder.git
|
41 |
+
cd surf-spot-finder
|
42 |
+
```
|
43 |
|
44 |
+
### 2️⃣ Update submodule and install dependencies
|
45 |
+
```bash
|
46 |
+
pip install -e . # Install root project dependencies
|
47 |
+
```
|
48 |
+
|
49 |
+
### 3️⃣ Run
|
50 |
+
|
51 |
+
```bash
|
52 |
+
export OPENAI_API_KEY=yourkeyhere
|
53 |
+
surf-spot-finder --location="Pittsburgh Pennsylvania" --date="2025-03-11 22:00" --max-driving-hours=5 --model-id="openai/o1" --api-key-var="OPENAI_API_KEY"
|
54 |
+
```
|
55 |
|
56 |
## How it Works
|
57 |
|
|
|
65 |
- Disk space:
|
66 |
|
67 |
- **Dependencies**:
|
68 |
+
- Docker
|
69 |
- Dependencies listed in `pyproject.toml`
|
70 |
|
71 |
+
## Run Tests
|
72 |
+
|
73 |
+
```bash
|
74 |
+
pip install -e .[tests]
|
75 |
+
```
|
76 |
+
|
77 |
+
### Unit Tests
|
78 |
+
|
79 |
+
```bash
|
80 |
+
pytest
|
81 |
+
```
|
82 |
+
|
83 |
+
### Integration Tests
|
84 |
+
|
85 |
+
```bash
|
86 |
+
INTEGRATION_TESTS=Y pytest # Requires docker and OPENAI_API_KEY
|
87 |
+
```
|
88 |
|
89 |
## Troubleshooting
|
90 |
|
pyproject.toml
CHANGED
@@ -34,6 +34,11 @@ tests = [
|
|
34 |
"pytest-sugar>=0.9.6",
|
35 |
]
|
36 |
|
|
|
|
|
|
|
|
|
|
|
37 |
[project.urls]
|
38 |
Documentation = "https://mozilla-ai.github.io/surf-spot-finder/"
|
39 |
Issues = "https://github.com/mozilla-ai/surf-spot-finder/issues"
|
@@ -47,4 +52,6 @@ namespaces = false
|
|
47 |
[tool.setuptools_scm]
|
48 |
|
49 |
[project.scripts]
|
50 |
-
|
|
|
|
|
|
34 |
"pytest-sugar>=0.9.6",
|
35 |
]
|
36 |
|
37 |
+
# TODO maybe we don't want to keep this, or we want to swap this to Lumigator SDK
|
38 |
+
tracing = [
|
39 |
+
"arize-phoenix>=8.12.1",
|
40 |
+
]
|
41 |
+
|
42 |
[project.urls]
|
43 |
Documentation = "https://mozilla-ai.github.io/surf-spot-finder/"
|
44 |
Issues = "https://github.com/mozilla-ai/surf-spot-finder/issues"
|
|
|
52 |
[tool.setuptools_scm]
|
53 |
|
54 |
[project.scripts]
|
55 |
+
surf-spot-finder = "surf_spot_finder.cli:main"
|
56 |
+
# TODO maybe this would be lumigator
|
57 |
+
start-phoenix = "phoenix.server.main:main"
|
src/surf_spot_finder/agents/smolagents.py
CHANGED
@@ -8,53 +8,67 @@ if TYPE_CHECKING:
|
|
8 |
|
9 |
|
10 |
@logger.catch(reraise=True)
|
11 |
-
def
|
12 |
-
|
13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
CodeAgent,
|
15 |
-
ToolCollection,
|
16 |
DuckDuckGoSearchTool,
|
17 |
-
VisitWebpageTool,
|
18 |
LiteLLMModel,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
)
|
|
|
20 |
from mcp import StdioServerParameters
|
21 |
|
22 |
model = LiteLLMModel(
|
23 |
model_id=model_id,
|
24 |
-
|
|
|
25 |
)
|
26 |
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
# https://huggingface.co/docs/smolagents/v1.10.0/en/reference/tools#smolagents.ToolCollection.from_mcp
|
38 |
-
with ToolCollection.from_mcp(server_parameters) as tool_collection:
|
39 |
-
agent = CodeAgent(
|
40 |
-
tools=[
|
41 |
-
*tool_collection.tools,
|
42 |
-
DuckDuckGoSearchTool(),
|
43 |
-
VisitWebpageTool(),
|
44 |
-
],
|
45 |
-
model=model,
|
46 |
-
add_base_tools=True,
|
47 |
-
additional_authorized_imports=["json"],
|
48 |
-
)
|
49 |
-
else:
|
50 |
-
logger.debug(
|
51 |
-
"GOOGLE_MAPS_api_key_var not set, running without Google Maps tool"
|
52 |
-
)
|
53 |
agent = CodeAgent(
|
54 |
-
tools=[
|
|
|
|
|
|
|
55 |
model=model,
|
56 |
-
add_base_tools=
|
57 |
-
additional_authorized_imports=["json"],
|
58 |
)
|
|
|
59 |
|
60 |
return agent
|
|
|
8 |
|
9 |
|
10 |
@logger.catch(reraise=True)
|
11 |
+
def run_smolagent(
|
12 |
+
model_id: str,
|
13 |
+
prompt: str,
|
14 |
+
api_key_var: Optional[str] = None,
|
15 |
+
api_base: Optional[str] = None,
|
16 |
+
) -> "CodeAgent":
|
17 |
+
"""
|
18 |
+
Create and configure a Smolagents CodeAgent with the specified model.
|
19 |
+
See https://docs.litellm.ai/docs/providers for details on available LiteLLM providers.
|
20 |
+
Args:
|
21 |
+
model_id (str): Model identifier using LiteLLM syntax (e.g., 'openai/o1', 'anthropic/claude-3-sonnet')
|
22 |
+
prompt (str): Prompt to provide to the model
|
23 |
+
api_key_var (Optional[str]): Name of environment variable containing the API key
|
24 |
+
api_base (Optional[str]): Custom API base URL, if needed for non-default endpoints
|
25 |
+
|
26 |
+
Returns:
|
27 |
+
CodeAgent: Configured agent ready to process requests
|
28 |
+
|
29 |
+
Example:
|
30 |
+
>>> agent = run_smolagent("anthropic/claude-3-haiku", "my prompt here", "ANTHROPIC_API_KEY", None, None)
|
31 |
+
>>> agent.run("Find surf spots near San Diego")
|
32 |
+
"""
|
33 |
+
from smolagents import ( # pylint: disable=import-outside-toplevel
|
34 |
CodeAgent,
|
|
|
35 |
DuckDuckGoSearchTool,
|
|
|
36 |
LiteLLMModel,
|
37 |
+
ToolCollection,
|
38 |
+
)
|
39 |
+
|
40 |
+
model = LiteLLMModel(
|
41 |
+
model_id=model_id,
|
42 |
+
api_base=api_base if api_base else None,
|
43 |
+
api_key=os.environ[api_key_var] if api_key_var else None,
|
44 |
)
|
45 |
+
|
46 |
from mcp import StdioServerParameters
|
47 |
|
48 |
model = LiteLLMModel(
|
49 |
model_id=model_id,
|
50 |
+
api_base=api_base if api_base else None,
|
51 |
+
api_key=os.environ[api_key_var] if api_key_var else None,
|
52 |
)
|
53 |
|
54 |
+
# We could easily use any of the MCPs at https://github.com/modelcontextprotocol/servers
|
55 |
+
# or at https://glama.ai/mcp/servers
|
56 |
+
# or at https://smithery.ai/
|
57 |
+
server_parameters = StdioServerParameters(
|
58 |
+
command="docker",
|
59 |
+
args=["run", "-i", "--rm", "mcp/fetch"],
|
60 |
+
env={**os.environ},
|
61 |
+
)
|
62 |
+
# https://huggingface.co/docs/smolagents/v1.10.0/en/reference/tools#smolagents.ToolCollection.from_mcp
|
63 |
+
with ToolCollection.from_mcp(server_parameters) as tool_collection:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
agent = CodeAgent(
|
65 |
+
tools=[
|
66 |
+
*tool_collection.tools,
|
67 |
+
DuckDuckGoSearchTool(),
|
68 |
+
],
|
69 |
model=model,
|
70 |
+
add_base_tools=False, # Turn this on if you want to let it run python code as it sees fit
|
|
|
71 |
)
|
72 |
+
agent.run(prompt)
|
73 |
|
74 |
return agent
|
src/surf_spot_finder/cli.py
CHANGED
@@ -7,7 +7,7 @@ from surf_spot_finder.config import (
|
|
7 |
Config,
|
8 |
DEFAULT_PROMPT,
|
9 |
)
|
10 |
-
from surf_spot_finder.agents.smolagents import
|
11 |
from surf_spot_finder.tracing import setup_tracing
|
12 |
|
13 |
|
@@ -20,6 +20,7 @@ def find_surf_spot(
|
|
20 |
api_key_var: Optional[str] = None,
|
21 |
prompt: str = DEFAULT_PROMPT,
|
22 |
json_tracer: bool = True,
|
|
|
23 |
):
|
24 |
logger.info("Loading config")
|
25 |
config = Config(
|
@@ -30,21 +31,22 @@ def find_surf_spot(
|
|
30 |
api_key_var=api_key_var,
|
31 |
prompt=prompt,
|
32 |
json_tracer=json_tracer,
|
|
|
33 |
)
|
34 |
|
35 |
-
logger.info("Loading agent")
|
36 |
-
agent = load_smolagent(config.model_id, config.api_key_var)
|
37 |
-
|
38 |
logger.info("Setting up tracing")
|
39 |
-
setup_tracing(project_name="
|
40 |
|
41 |
logger.info("Running agent")
|
42 |
-
|
43 |
-
config.
|
|
|
|
|
|
|
44 |
LOCATION=config.location,
|
45 |
MAX_DRIVING_HOURS=config.max_driving_hours,
|
46 |
DATE=config.date,
|
47 |
-
)
|
48 |
)
|
49 |
|
50 |
|
|
|
7 |
Config,
|
8 |
DEFAULT_PROMPT,
|
9 |
)
|
10 |
+
from surf_spot_finder.agents.smolagents import run_smolagent
|
11 |
from surf_spot_finder.tracing import setup_tracing
|
12 |
|
13 |
|
|
|
20 |
api_key_var: Optional[str] = None,
|
21 |
prompt: str = DEFAULT_PROMPT,
|
22 |
json_tracer: bool = True,
|
23 |
+
api_base: Optional[str] = None,
|
24 |
):
|
25 |
logger.info("Loading config")
|
26 |
config = Config(
|
|
|
31 |
api_key_var=api_key_var,
|
32 |
prompt=prompt,
|
33 |
json_tracer=json_tracer,
|
34 |
+
api_base=api_base,
|
35 |
)
|
36 |
|
|
|
|
|
|
|
37 |
logger.info("Setting up tracing")
|
38 |
+
setup_tracing(project_name="surf-spot-finder", json_tracer=config.json_tracer)
|
39 |
|
40 |
logger.info("Running agent")
|
41 |
+
run_smolagent(
|
42 |
+
model_id=config.model_id,
|
43 |
+
api_key_var=config.api_key_var,
|
44 |
+
api_base=config.api_base,
|
45 |
+
prompt=config.prompt.format(
|
46 |
LOCATION=config.location,
|
47 |
MAX_DRIVING_HOURS=config.max_driving_hours,
|
48 |
DATE=config.date,
|
49 |
+
),
|
50 |
)
|
51 |
|
52 |
|
src/surf_spot_finder/config.py
CHANGED
@@ -1,26 +1,32 @@
|
|
1 |
from typing import Annotated, Optional
|
2 |
from pydantic import AfterValidator, BaseModel, FutureDatetime, PositiveInt
|
|
|
3 |
|
|
|
4 |
|
5 |
DEFAULT_PROMPT = (
|
6 |
"What will be the best surf spot around {LOCATION}"
|
7 |
-
", in a
|
8 |
-
", at {DATE}?"
|
|
|
|
|
|
|
9 |
)
|
10 |
|
11 |
|
12 |
-
def validate_prompt(value):
|
13 |
-
for placeholder in ("{LOCATION}", "{MAX_DRIVING_HOURS}"):
|
14 |
if placeholder not in value:
|
15 |
raise ValueError(f"prompt must contain {placeholder}")
|
16 |
return value
|
17 |
|
18 |
|
19 |
class Config(BaseModel):
|
20 |
-
prompt:
|
21 |
location: str
|
22 |
max_driving_hours: PositiveInt
|
23 |
date: FutureDatetime
|
24 |
model_id: str
|
25 |
api_key_var: Optional[str] = None
|
26 |
json_tracer: bool = True
|
|
|
|
1 |
from typing import Annotated, Optional
|
2 |
from pydantic import AfterValidator, BaseModel, FutureDatetime, PositiveInt
|
3 |
+
from datetime import datetime
|
4 |
|
5 |
+
CURRENT_DATE = datetime.now().strftime("%Y-%m-%d")
|
6 |
|
7 |
DEFAULT_PROMPT = (
|
8 |
"What will be the best surf spot around {LOCATION}"
|
9 |
+
", in a {MAX_DRIVING_HOURS} hour driving radius"
|
10 |
+
", at {DATE}? it is currently "
|
11 |
+
+ CURRENT_DATE
|
12 |
+
+ ". find me the best surf spot and the"
|
13 |
+
" up to date weather forecast for that day."
|
14 |
)
|
15 |
|
16 |
|
17 |
+
def validate_prompt(value) -> str:
|
18 |
+
for placeholder in ("{LOCATION}", "{MAX_DRIVING_HOURS}", "{DATE}"):
|
19 |
if placeholder not in value:
|
20 |
raise ValueError(f"prompt must contain {placeholder}")
|
21 |
return value
|
22 |
|
23 |
|
24 |
class Config(BaseModel):
|
25 |
+
prompt: Annotated[str, AfterValidator(validate_prompt)]
|
26 |
location: str
|
27 |
max_driving_hours: PositiveInt
|
28 |
date: FutureDatetime
|
29 |
model_id: str
|
30 |
api_key_var: Optional[str] = None
|
31 |
json_tracer: bool = True
|
32 |
+
api_base: Optional[str] = None
|
src/surf_spot_finder/tracing.py
CHANGED
@@ -24,7 +24,7 @@ class JsonFileSpanExporter(SpanExporter):
|
|
24 |
pass
|
25 |
|
26 |
|
27 |
-
def setup_tracing(project_name: str, json_tracer: bool
|
28 |
"""
|
29 |
Set up tracing configuration based on the selected mode.
|
30 |
|
|
|
24 |
pass
|
25 |
|
26 |
|
27 |
+
def setup_tracing(project_name: str, json_tracer: bool) -> TracerProvider:
|
28 |
"""
|
29 |
Set up tracing configuration based on the selected mode.
|
30 |
|
tests/integration/agents/test_integration_smolagents.py
ADDED
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import pytest
|
3 |
+
from unittest.mock import patch
|
4 |
+
|
5 |
+
from surf_spot_finder.agents.smolagents import run_smolagent
|
6 |
+
|
7 |
+
# TODO I'd rather not use openai
|
8 |
+
INTEGRATION_MODEL = "openai/gpt-3.5-turbo"
|
9 |
+
API_KEY_VAR = "OPENAI_API_KEY"
|
10 |
+
|
11 |
+
|
12 |
+
@pytest.mark.skipif(
|
13 |
+
"INTEGRATION_TESTS" not in os.environ,
|
14 |
+
reason="Integration tests require INTEGRATION_TESTS env var",
|
15 |
+
)
|
16 |
+
def test_smolagent_integration():
|
17 |
+
"""
|
18 |
+
Full integration test of the smolagent functionality.
|
19 |
+
|
20 |
+
Requires:
|
21 |
+
- Docker to be running
|
22 |
+
- OPENAI_API_KEY in environment variables
|
23 |
+
- INTEGRATION_TESTS env var to be set
|
24 |
+
"""
|
25 |
+
with patch("smolagents.CodeAgent") as MockCodeAgent:
|
26 |
+
# Create a mock agent that returns itself from run()
|
27 |
+
mock_agent = MockCodeAgent.return_value
|
28 |
+
mock_agent.run.return_value = mock_agent
|
29 |
+
|
30 |
+
# Run the agent
|
31 |
+
result = run_smolagent(
|
32 |
+
INTEGRATION_MODEL,
|
33 |
+
"Find popular surf spots in California",
|
34 |
+
api_key_var=API_KEY_VAR,
|
35 |
+
)
|
36 |
+
|
37 |
+
# Verify the agent was created and run
|
38 |
+
MockCodeAgent.assert_called_once()
|
39 |
+
mock_agent.run.assert_called_once_with("Find popular surf spots in California")
|
40 |
+
assert result is mock_agent
|
41 |
+
|
42 |
+
|
43 |
+
@pytest.mark.skipif(
|
44 |
+
"INTEGRATION_TESTS" not in os.environ,
|
45 |
+
reason="Full integration tests require INTEGRATION_TESTS env var",
|
46 |
+
)
|
47 |
+
def test_smolagent_real_execution():
|
48 |
+
"""
|
49 |
+
Tests the actual execution of the agent against real APIs.
|
50 |
+
|
51 |
+
WARNING: This will make actual API calls and incur costs.
|
52 |
+
Only run when explicitly needed for full system testing.
|
53 |
+
|
54 |
+
Requires:
|
55 |
+
- Docker to be running
|
56 |
+
- OPENAI_API_KEY in environment variables
|
57 |
+
- INTEGRATION_TESTS env var to be set
|
58 |
+
"""
|
59 |
+
# Run with a simple, inexpensive request
|
60 |
+
agent = run_smolagent(
|
61 |
+
INTEGRATION_MODEL,
|
62 |
+
"What are three popular surf spots in California?",
|
63 |
+
api_key_var=API_KEY_VAR,
|
64 |
+
)
|
65 |
+
|
66 |
+
# Basic verification that we got an agent back
|
67 |
+
assert agent is not None
|
tests/unit/agents/test_load_smolagents.py
DELETED
@@ -1,27 +0,0 @@
|
|
1 |
-
from surf_spot_finder.agents.smolagents import load_smolagent
|
2 |
-
|
3 |
-
|
4 |
-
def test_google_maps_tool(monkeypatch):
|
5 |
-
monkeypatch.setenv("GEMINI_API_KEY", "FOO")
|
6 |
-
|
7 |
-
no_google_maps_agent = load_smolagent("gemini/gemini-2.0-flash", "GEMINI_API_KEY")
|
8 |
-
assert sorted(list(no_google_maps_agent.tools.keys())) == [
|
9 |
-
"final_answer",
|
10 |
-
"visit_webpage",
|
11 |
-
"web_search",
|
12 |
-
]
|
13 |
-
|
14 |
-
monkeypatch.setenv("GOOGLE_MAPS_API_KEY", "BAR")
|
15 |
-
google_maps_agent = load_smolagent("gemini/gemini-2.0-flash", "GEMINI_API_KEY")
|
16 |
-
assert sorted(list(google_maps_agent.tools.keys())) == [
|
17 |
-
"final_answer",
|
18 |
-
"maps_directions",
|
19 |
-
"maps_distance_matrix",
|
20 |
-
"maps_elevation",
|
21 |
-
"maps_geocode",
|
22 |
-
"maps_place_details",
|
23 |
-
"maps_reverse_geocode",
|
24 |
-
"maps_search_places",
|
25 |
-
"visit_webpage",
|
26 |
-
"web_search",
|
27 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/unit/agents/test_unit_smolagents.py
ADDED
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import pytest
|
3 |
+
from unittest.mock import patch, MagicMock
|
4 |
+
|
5 |
+
from surf_spot_finder.agents.smolagents import run_smolagent
|
6 |
+
|
7 |
+
|
8 |
+
@pytest.fixture
|
9 |
+
def mock_smolagents_imports():
|
10 |
+
"""Mock the smolagents imports to avoid actual instantiation."""
|
11 |
+
mock_code_agent = MagicMock()
|
12 |
+
mock_ddg_tool = MagicMock()
|
13 |
+
mock_litellm_model = MagicMock()
|
14 |
+
mock_tool_collection = MagicMock()
|
15 |
+
|
16 |
+
# Configure the mock tool collection to work as a context manager
|
17 |
+
mock_tool_collection.from_mcp.return_value.__enter__.return_value = (
|
18 |
+
mock_tool_collection
|
19 |
+
)
|
20 |
+
mock_tool_collection.from_mcp.return_value.__exit__.return_value = None
|
21 |
+
mock_tool_collection.tools = ["mock_tool"]
|
22 |
+
|
23 |
+
with patch.dict(
|
24 |
+
"sys.modules",
|
25 |
+
{
|
26 |
+
"smolagents": MagicMock(
|
27 |
+
CodeAgent=mock_code_agent,
|
28 |
+
DuckDuckGoSearchTool=mock_ddg_tool,
|
29 |
+
LiteLLMModel=mock_litellm_model,
|
30 |
+
ToolCollection=mock_tool_collection,
|
31 |
+
),
|
32 |
+
"mcp": MagicMock(
|
33 |
+
StdioServerParameters=MagicMock(),
|
34 |
+
),
|
35 |
+
},
|
36 |
+
):
|
37 |
+
yield {
|
38 |
+
"CodeAgent": mock_code_agent,
|
39 |
+
"DuckDuckGoSearchTool": mock_ddg_tool,
|
40 |
+
"LiteLLMModel": mock_litellm_model,
|
41 |
+
"ToolCollection": mock_tool_collection,
|
42 |
+
}
|
43 |
+
|
44 |
+
|
45 |
+
@pytest.mark.usefixtures("mock_smolagents_imports")
|
46 |
+
def test_run_smolagent_with_api_key_var():
|
47 |
+
"""Test smolagent creation with an API key from environment variable."""
|
48 |
+
# The patch.dict(os.environ, {"TEST_API_KEY": "test-key-12345"})
|
49 |
+
# is a testing construct that temporarily modifies the environment variables
|
50 |
+
# for the duration of the test.
|
51 |
+
# some tests use TEST_API_KEY while others don't
|
52 |
+
with patch.dict(os.environ, {"TEST_API_KEY": "test-key-12345"}):
|
53 |
+
from smolagents import CodeAgent, LiteLLMModel
|
54 |
+
|
55 |
+
run_smolagent("openai/gpt-4", "Test prompt", api_key_var="TEST_API_KEY")
|
56 |
+
|
57 |
+
LiteLLMModel.assert_called()
|
58 |
+
model_call_kwargs = LiteLLMModel.call_args[1]
|
59 |
+
assert model_call_kwargs["model_id"] == "openai/gpt-4"
|
60 |
+
assert model_call_kwargs["api_key"] == "test-key-12345"
|
61 |
+
assert model_call_kwargs["api_base"] is None
|
62 |
+
|
63 |
+
CodeAgent.assert_called_once()
|
64 |
+
CodeAgent.return_value.run.assert_called_once_with("Test prompt")
|
65 |
+
|
66 |
+
|
67 |
+
@pytest.mark.usefixtures("mock_smolagents_imports")
|
68 |
+
def test_run_smolagent_with_custom_api_base():
|
69 |
+
"""Test smolagent creation with a custom API base."""
|
70 |
+
with patch.dict(os.environ, {"TEST_API_KEY": "test-key-12345"}):
|
71 |
+
from smolagents import LiteLLMModel
|
72 |
+
|
73 |
+
# Act
|
74 |
+
run_smolagent(
|
75 |
+
"anthropic/claude-3-sonnet",
|
76 |
+
"Test prompt",
|
77 |
+
api_key_var="TEST_API_KEY",
|
78 |
+
api_base="https://custom-api.example.com",
|
79 |
+
)
|
80 |
+
last_call = LiteLLMModel.call_args_list[-1]
|
81 |
+
|
82 |
+
assert last_call[1]["model_id"] == "anthropic/claude-3-sonnet"
|
83 |
+
assert last_call[1]["api_key"] == "test-key-12345"
|
84 |
+
assert last_call[1]["api_base"] == "https://custom-api.example.com"
|
85 |
+
|
86 |
+
|
87 |
+
@pytest.mark.usefixtures("mock_smolagents_imports")
|
88 |
+
def test_run_smolagent_without_api_key():
|
89 |
+
"""You should be able to run the smolagent without an API key."""
|
90 |
+
from smolagents import LiteLLMModel
|
91 |
+
|
92 |
+
run_smolagent("ollama_chat/deepseek-r1", "Test prompt")
|
93 |
+
|
94 |
+
last_call = LiteLLMModel.call_args_list[-1]
|
95 |
+
assert last_call[1]["model_id"] == "ollama_chat/deepseek-r1"
|
96 |
+
assert last_call[1]["api_key"] is None
|
97 |
+
|
98 |
+
|
99 |
+
def test_run_smolagent_environment_error():
|
100 |
+
"""Test that passing a bad api_key_var throws an error"""
|
101 |
+
with patch.dict(os.environ, {}, clear=True):
|
102 |
+
with pytest.raises(KeyError, match="MISSING_KEY"):
|
103 |
+
run_smolagent("test-model", "Test prompt", api_key_var="MISSING_KEY")
|