Skip to content

Commit 4f289d4

Browse files
Add web search grounding feature
Implement Google Custom Search API integration for real-time web grounding. Tools can now return dict data directly to the model instead of client-bound frames.
1 parent 5da7f36 commit 4f289d4

8 files changed

Lines changed: 168 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies = [
1313
"pydantic>=2.10.0",
1414
"pydantic-settings>=2.6.0",
1515
"python-dotenv>=1.0.0",
16+
"httpx>=0.27.0",
1617
]
1718

1819
[project.optional-dependencies]

src/actions/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,5 +125,5 @@ async def tool_fn(
125125
self,
126126
ctx: "ConnectionContext",
127127
**kwargs: Any,
128-
) -> OutboundFrame:
128+
) -> OutboundFrame | dict[str, Any]:
129129
pass

src/actions/server/web_search.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Search the web for up-to-date information using Google Custom Search API."""
2+
3+
import logging
4+
from typing import TYPE_CHECKING, Any
5+
6+
import httpx
7+
8+
from src.actions.base import ServerEventEmitter, register_server_event
9+
from src.schema.frames import OutboundFrame
10+
11+
if TYPE_CHECKING:
12+
from src.engine.connection_context import ConnectionContext
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
@register_server_event("WEB_SEARCH")
18+
class WebSearchEmitter(ServerEventEmitter):
19+
"""Search the web for up-to-date information."""
20+
21+
event_type = "WEB_SEARCH"
22+
description = (
23+
"Search the web for up-to-date information. Use proactively when "
24+
"the user asks about recent events, weather, prices, or time-sensitive topics."
25+
)
26+
parameters = {
27+
"query": {"type": "string", "description": "The search query"},
28+
"num_results": {
29+
"type": "integer",
30+
"description": "Number of results (default: 5, max: 10)",
31+
},
32+
}
33+
34+
async def tool_fn( # type: ignore[override]
35+
self, _ctx: "ConnectionContext", **kwargs: Any
36+
) -> OutboundFrame | dict[str, Any]:
37+
from src.config import get_settings
38+
39+
settings = get_settings()
40+
api_key = settings.google_search_api_key
41+
engine_id = settings.google_search_engine_id
42+
43+
if not api_key or not engine_id:
44+
logger.warning("Google Search API credentials not configured")
45+
return {"error": "Search not configured", "results": []}
46+
47+
query = kwargs.get("query", "")
48+
if not query:
49+
return {"error": "No search query provided", "results": []}
50+
51+
num_results = min(kwargs.get("num_results", 5), 10)
52+
53+
async with httpx.AsyncClient() as client:
54+
try:
55+
response = await client.get(
56+
"https://www.googleapis.com/customsearch/v1",
57+
params={
58+
"key": api_key,
59+
"cx": engine_id,
60+
"q": query,
61+
"num": num_results,
62+
},
63+
timeout=10.0,
64+
)
65+
response.raise_for_status()
66+
data = response.json()
67+
except httpx.HTTPError as e:
68+
logger.exception(f"Search API error: {e}")
69+
return {"error": f"Search failed: {e}", "results": []}
70+
71+
items = data.get("items", [])
72+
results = [
73+
{
74+
"title": item.get("title", ""),
75+
"link": item.get("link", ""),
76+
"snippet": item.get("snippet", ""),
77+
}
78+
for item in items
79+
]
80+
81+
return {"query": query, "results": results}

src/agent/runner.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ async def _handle_function_call(
268268
function_call: The FunctionCall to process
269269
270270
Returns:
271-
OutboundFrame from the tool execution
271+
OutboundFrame from the tool execution, or None if result sent to model
272272
"""
273273
from src.actions.base import get_server_event_emitter
274274

@@ -284,8 +284,19 @@ async def _handle_function_call(
284284
return None
285285

286286
try:
287-
frame = await emitter.tool_fn(self.ctx, **args)
288-
return frame
287+
result = await emitter.tool_fn(self.ctx, **args)
288+
289+
if isinstance(result, dict):
290+
response = types.FunctionResponse(name=tool_name, response=result)
291+
content = types.Content(
292+
role="user",
293+
parts=[types.Part(function_response=response)],
294+
)
295+
if self.adk_queue:
296+
self.adk_queue.send_content(content)
297+
return None
298+
299+
return result
289300
except (TypeError, ValueError, KeyError) as e:
290301
logger.exception(f"Error executing tool {tool_name}: {e}")
291302
return OutboundFrame(

src/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class Settings(BaseSettings):
1414
session_grace_minutes: int = 10
1515
app_name: str = "jemmie"
1616
log_level: str = "INFO"
17+
google_search_api_key: str | None = None
18+
google_search_engine_id: str | None = None
1719

1820

1921
@lru_cache

src/prompts/jemmie.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
7. **Ending Call (END_CALL)**:
3939
- When the user wants to verbally end the interaction (e.g. "I gotta go", "hang up", "bye bye"), gracefully say goodbye and immediately invoke `end_call` to automatically hang up for them.
4040
41+
8. **Web Search (WEB_SEARCH)**:
42+
- When asked about current events, weather, sports, stock prices, or anything time-sensitive, proactively use `web_search` to get accurate, up-to-date information.
43+
- After searching, naturally incorporate findings: "According to recent reports..."
44+
4145
## Personality
4246
4347
- Warm, approachable, genuinely helpful, and incredibly empathetic.

tests/test_actions.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,66 @@ def test_get_server_event_emitter_case_insensitive() -> None:
192192
def test_get_server_event_emitter_not_found() -> None:
193193
emitter = get_server_event_emitter("nonexistent_tool")
194194
assert emitter is None
195+
196+
197+
def test_web_search_registered() -> None:
198+
emitter = get_server_event_emitter("web_search")
199+
assert emitter is not None
200+
assert emitter.event_type == "WEB_SEARCH"
201+
202+
203+
def test_web_search_tool_definition() -> None:
204+
tools = get_all_server_tools()
205+
web_search_tool = next((t for t in tools if t["name"] == "web_search"), None)
206+
assert web_search_tool is not None
207+
assert "query" in web_search_tool["parameters"]["properties"]
208+
assert "num_results" in web_search_tool["parameters"]["properties"]
209+
210+
211+
@pytest.mark.asyncio
212+
async def test_web_search_no_credentials(mock_websocket: MockWebSocket) -> None:
213+
from src.actions.server.web_search import WebSearchEmitter
214+
215+
emitter = WebSearchEmitter()
216+
ctx = ConnectionContext(
217+
device_id="test-device",
218+
session_id="test-session",
219+
websocket=mock_websocket,
220+
)
221+
222+
result = await emitter.tool_fn(ctx, query="test query")
223+
assert isinstance(result, dict)
224+
assert result["error"] == "Search not configured"
225+
assert result["results"] == []
226+
227+
228+
@pytest.mark.asyncio
229+
async def test_web_search_empty_query(
230+
mock_websocket: MockWebSocket, monkeypatch: pytest.MonkeyPatch
231+
) -> None:
232+
from src.actions.server.web_search import WebSearchEmitter
233+
from src.config import get_settings
234+
235+
monkeypatch.setenv("GOOGLE_SEARCH_API_KEY", "test-key")
236+
monkeypatch.setenv("GOOGLE_SEARCH_ENGINE_ID", "test-id")
237+
get_settings.cache_clear()
238+
239+
emitter = WebSearchEmitter()
240+
ctx = ConnectionContext(
241+
device_id="test-device",
242+
session_id="test-session",
243+
websocket=mock_websocket,
244+
)
245+
246+
result = await emitter.tool_fn(ctx, query="")
247+
assert isinstance(result, dict)
248+
assert result["error"] == "No search query provided"
249+
assert result["results"] == []
250+
251+
252+
def test_web_search_returns_dict_not_frame() -> None:
253+
"""Verify web_search returns a dict (model-bound), not OutboundFrame (client-bound)."""
254+
emitter = get_server_event_emitter("web_search")
255+
assert emitter is not None
256+
tool_def = emitter.get_tool_definition()
257+
assert tool_def["name"] == "web_search"

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)