diff --git a/examples/stack_overflow_agent/run.py b/examples/stack_overflow_agent/run.py new file mode 100644 index 0000000..f810bd4 --- /dev/null +++ b/examples/stack_overflow_agent/run.py @@ -0,0 +1,23 @@ +import os +from rich import print +from rich.panel import Panel +from dotenv import load_dotenv + +from vantage import run_yaml_agent, save_trace_png +from .tools import StackOverflowToolAgent + +def main() -> None: + load_dotenv() + base_dir = os.path.dirname(os.path.abspath(__file__)) + cfg_path = os.path.join(base_dir, "stack_overflow_agent.yaml") + + prompt = "How do I reverse a list in Python?" + resp = run_yaml_agent(cfg_path, "stack_overflow_agent", prompt, tools=[StackOverflowToolAgent()]) + print(Panel(resp.content, title="[bold][green]Final Response[/green][/bold]", border_style="green")) + + # Save a visual trace + save_trace_png(resp.trace, "examples/stack_overflow_agent/trace.png") + print("\n[bold][green]Visual trace saved to examples/stack_overflow_agent/trace.png[/green][/bold]") + +if __name__ == "__main__": + main() diff --git a/examples/stack_overflow_agent/stack_overflow_agent.yaml b/examples/stack_overflow_agent/stack_overflow_agent.yaml new file mode 100644 index 0000000..caef912 --- /dev/null +++ b/examples/stack_overflow_agent/stack_overflow_agent.yaml @@ -0,0 +1,10 @@ +agents: + stack_overflow_agent: + model: groq/llama-3.3-70b-versatile + system_prompt: > + You are a programming assistant. + When the user asks a programming question, call the stack_overflow_tool. + tools: [stack_overflow_tool] + response_schema: + answer: string + message: string diff --git a/examples/stack_overflow_agent/tools.py b/examples/stack_overflow_agent/tools.py new file mode 100644 index 0000000..4a5779c --- /dev/null +++ b/examples/stack_overflow_agent/tools.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +from vantage.tools.stack_overflow_tool import StackOverflowTool + +class StackOverflowToolAgent(StackOverflowTool): + pass diff --git a/examples/stack_overflow_agent/trace.png b/examples/stack_overflow_agent/trace.png new file mode 100644 index 0000000..ff7acdd Binary files /dev/null and b/examples/stack_overflow_agent/trace.png differ diff --git a/src/vantage/tools/__init__.py b/src/vantage/tools/__init__.py index 2f968d0..25da4b7 100644 --- a/src/vantage/tools/__init__.py +++ b/src/vantage/tools/__init__.py @@ -1,5 +1,6 @@ from .calculator import Calculator from .weather_tool import WeatherTool +from .stack_overflow_tool import StackOverflowTool -__all__ = ["Calculator", "WeatherTool"] +__all__ = ["Calculator", "WeatherTool", "StackOverflowTool"] diff --git a/src/vantage/tools/stack_overflow_tool.py b/src/vantage/tools/stack_overflow_tool.py new file mode 100644 index 0000000..f40c47a --- /dev/null +++ b/src/vantage/tools/stack_overflow_tool.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any, Dict +from urllib.parse import quote + +import httpx + +from ..core.bases import ToolBase + +_DEFAULT_TIMEOUT = 10.0 +_RESPONSE_SNIPPET_LENGTH = 200 + + +class StackOverflowTool(ToolBase): + def __init__(self, timeout: float = _DEFAULT_TIMEOUT) -> None: + if timeout <= 0: + raise ValueError("timeout must be positive") + self._timeout = timeout + + @property + def name(self) -> str: + return "stack_overflow_tool" + + @property + def description(self) -> str: + return "Search Stack Overflow for programming questions and return the top answer snippet." + + def input_schema(self) -> Dict[str, Any]: + return { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Programming question to search on Stack Overflow.", + }, + }, + "required": ["query"], + "additionalProperties": False, + } + + def execute(self, **kwargs: Any) -> str: + query = str(kwargs.get("query", "")).strip() + if not query: + raise ValueError("query is required") + url = f"https://api.stackexchange.com/2.3/search/advanced?order=desc&sort=relevance&q={quote(query)}&site=stackoverflow&filter=!9_bDDxJY5" + with httpx.Client(timeout=self._timeout) as client: + resp = client.get(url) + resp.raise_for_status() + items = resp.json().get("items", []) + if not items: + return "No results found." + question_id = items[0]["question_id"] + answer_url = f"https://api.stackexchange.com/2.3/questions/{question_id}/answers?order=desc&sort=votes&site=stackoverflow&filter=withbody" + answer_resp = client.get(answer_url) + answer_resp.raise_for_status() + answers = answer_resp.json().get("items", []) + if not answers: + return f"No answers found for: {items[0]['title']}" + # Return a snippet of the top answer + print(answers) + body = answers[0].get("body", "") + return body + + + diff --git a/tests/tools/test_stack_overflow_tool.py b/tests/tools/test_stack_overflow_tool.py new file mode 100644 index 0000000..5169641 --- /dev/null +++ b/tests/tools/test_stack_overflow_tool.py @@ -0,0 +1,60 @@ +"""Tests for StackOverflowTool.""" +from __future__ import annotations + +import httpx +import pytest +import respx + +from vantage.tools.stack_overflow_tool import StackOverflowTool + +@respx.mock +def test_execute_returns_answer_snippet() -> None: + tool = StackOverflowTool() + # Mock search response + respx.get("https://api.stackexchange.com/2.3/search/advanced").mock( + return_value=httpx.Response(200, json={ + "items": [{"question_id": 123, "title": "How to reverse a list in Python?"}] + }) + ) + # Mock answer response + respx.get("https://api.stackexchange.com/2.3/questions/123/answers").mock( + return_value=httpx.Response(200, json={ + "items": [{"body": "

You can use list[::-1] to reverse a list.

"}] + }) + ) + result = tool.execute(query="How to reverse a list in Python?") + assert "reverse a list" in result + assert "list[::-1]" in result + +@respx.mock +def test_execute_no_results() -> None: + tool = StackOverflowTool() + respx.get("https://api.stackexchange.com/2.3/search/advanced").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + result = tool.execute(query="some totally random query") + assert result == "No results found." + +@respx.mock +def test_execute_no_answers() -> None: + tool = StackOverflowTool() + respx.get("https://api.stackexchange.com/2.3/search/advanced").mock( + return_value=httpx.Response(200, json={ + "items": [{"question_id": 456, "title": "Unanswered question"}] + }) + ) + respx.get("https://api.stackexchange.com/2.3/questions/456/answers").mock( + return_value=httpx.Response(200, json={"items": []}) + ) + result = tool.execute(query="Unanswered question") + assert result.startswith("No answers found for:") + +def test_execute_raises_on_empty_query() -> None: + tool = StackOverflowTool() + with pytest.raises(ValueError, match="query is required"): + tool.execute(query="") + +def test_execute_raises_on_blank_query() -> None: + tool = StackOverflowTool() + with pytest.raises(ValueError, match="query is required"): + tool.execute(query=" ")