| """Assistant tools. |
| |
| Two tools, exposed via LangChain's @tool decorator so either assistant can call |
| them through the standard tool-calling interface: |
| |
| - calculator: safe arithmetic. Evaluated by walking a parsed AST (NOT Python's |
| eval), so it can never execute arbitrary code. |
| - web_search: Tavily web search, returning a short text digest of top hits. |
| |
| `TOOLS` is the list bound to each model. `run_tool_call` executes a single |
| tool call (by name + args) and returns its string result. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import ast |
| import operator |
|
|
| from langchain_core.tools import tool |
|
|
| from src.config import settings |
| from src.observability import annotate_span, observe |
|
|
| |
|
|
| |
| |
| |
| _ALLOWED_BINOPS = { |
| 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, |
| } |
| _ALLOWED_UNARYOPS = { |
| ast.UAdd: operator.pos, |
| ast.USub: operator.neg, |
| } |
|
|
|
|
| def _eval_node(node: ast.AST) -> float: |
| """Recursively evaluate a whitelisted arithmetic AST node.""" |
| if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): |
| return node.value |
| if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_BINOPS: |
| return _ALLOWED_BINOPS[type(node.op)]( |
| _eval_node(node.left), _eval_node(node.right) |
| ) |
| if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_UNARYOPS: |
| return _ALLOWED_UNARYOPS[type(node.op)](_eval_node(node.operand)) |
| raise ValueError("Only basic arithmetic (+ - * / // % **) is allowed.") |
|
|
|
|
| @tool |
| def calculator(expression: str) -> str: |
| """Evaluate a basic arithmetic expression and return the result. |
| |
| Supports + - * / // % ** and parentheses on numbers only. Use this for any |
| math instead of computing it yourself. |
| """ |
| try: |
| tree = ast.parse(expression, mode="eval") |
| result = _eval_node(tree.body) |
| return str(result) |
| except Exception as exc: |
| return f"Calculator error: {exc}" |
|
|
|
|
| |
|
|
|
|
| @tool |
| def web_search(query: str) -> str: |
| """Search the web for current information and return a short text digest. |
| |
| Use this for facts that may be recent, niche, or beyond your training data. |
| """ |
| if not settings.tavily_api_key: |
| return "Web search is unavailable (TAVILY_API_KEY is not configured)." |
|
|
| |
| |
| from tavily import TavilyClient |
|
|
| try: |
| client = TavilyClient(api_key=settings.tavily_api_key) |
| resp = client.search(query=query, max_results=3) |
| results = resp.get("results", []) |
| if not results: |
| return "No web results found." |
| lines = [ |
| f"- {r.get('title', 'untitled')}: {r.get('content', '').strip()}" |
| for r in results |
| ] |
| return "\n".join(lines) |
| except Exception as exc: |
| return f"Web search error: {exc}" |
|
|
|
|
| |
| TOOLS = [calculator, web_search] |
|
|
| |
| TOOLS_BY_NAME = {t.name: t for t in TOOLS} |
|
|
|
|
| @observe(as_type="tool", name="tool_call") |
| def run_tool_call(name: str, args: dict) -> str: |
| """Execute one tool call by name and return its result as a string.""" |
| annotate_span(metadata={"tool": name, "args": args}) |
| tool_obj = TOOLS_BY_NAME.get(name) |
| if tool_obj is None: |
| return f"Unknown tool: {name}" |
| return str(tool_obj.invoke(args)) |
|
|