a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

add web_search tool (tavily) so phi can ground recency claims

phi was confidently posting "this week" / "same week" framings about
old news because it had no way to check the open web. tavily fits the
shape: 1k free searches/month, fast, and time_range bounds the window
so phi can't accidentally cite stale articles.

tool: web_search(query, time_range, topic, max_results) — Annotated
params with descriptions the LLM sees. operational instruction nudges
phi to pass time_range BEFORE asserting recency, not after.

requires TAVILY_API_KEY env var (set as fly secret in prod).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+106 -1
+2
src/bot/agent.py
··· 42 42 check_services checks nate's infrastructure, not yours. only use during reflection or when explicitly asked about services. 43 43 44 44 check_relays reads relay-eval. modes: no args = fleet snapshot; name="<host>" = coverage history for that relay (narrow with since/until for precision); transitions=True = fleet-wide status-change log (best for "when did X happen"). use headlines verbatim — no theories about cause. 45 + 46 + web_search hits the open web (Tavily). use BEFORE asserting anything is recent / current / "this week" — pass time_range="week" so results are bounded. for old-news pattern matching ("same week," "just announced"), search first, post second. headlines without timestamps aren't evidence of timing. 45 47 """.strip() 46 48 47 49
+6
src/bot/config.py
··· 56 56 default=None, description="The search engine ID for the Google API" 57 57 ) 58 58 59 + # Tavily web search — grounds phi against the open web for currency 60 + # checks and source-finding. Free tier covers 1k searches/month. 61 + tavily_api_key: str | None = Field( 62 + default=None, description="API key for Tavily web search" 63 + ) 64 + 59 65 # TurboPuffer configuration 60 66 turbopuffer_api_key: str | None = Field( 61 67 default=None, description="The API key for the TurboPuffer API"
+98 -1
src/bot/tools/search.py
··· 1 - """Search tools — bluesky posts, cosmik network, trending.""" 1 + """Search tools — bluesky posts, cosmik network, trending, open web.""" 2 2 3 3 from datetime import date 4 + from typing import Annotated, Literal 4 5 5 6 import httpx 7 + from pydantic import Field 6 8 from pydantic_ai import RunContext 7 9 10 + from bot.config import settings 8 11 from bot.core.atproto_client import bot_client 9 12 from bot.tools._helpers import PhiDeps, _relative_age 10 13 ··· 76 79 return "\n\n".join(lines) 77 80 except Exception as e: 78 81 return f"network search failed: {e}" 82 + 83 + @agent.tool 84 + async def web_search( 85 + ctx: RunContext[PhiDeps], 86 + query: Annotated[ 87 + str, 88 + Field(description="Search query — natural language."), 89 + ], 90 + time_range: Annotated[ 91 + Literal["day", "week", "month", "year"] | None, 92 + Field( 93 + description=( 94 + "Bound results to a time window relative to today. " 95 + "Use this BEFORE asserting recency in a post — " 96 + "e.g. set 'week' before claiming something happened " 97 + "this week. Without it, results may include stale items." 98 + ) 99 + ), 100 + ] = None, 101 + topic: Annotated[ 102 + Literal["general", "news"] | None, 103 + Field( 104 + description=( 105 + "'news' optimizes for recent journalism, 'general' for " 106 + "evergreen content. Default: general." 107 + ) 108 + ), 109 + ] = None, 110 + max_results: Annotated[ 111 + int, 112 + Field(description="How many results to return. Default 5."), 113 + ] = 5, 114 + ) -> str: 115 + """Search the open web via Tavily. 116 + 117 + Use to ground claims about the world outside atproto — current 118 + events, primary sources, official statements, technical docs. 119 + For atproto posts use search_posts; for the cosmik network use 120 + search_network. 121 + 122 + IMPORTANT: if you're about to assert something is recent, current, 123 + or 'this week,' pass time_range first. headlines without dates 124 + aren't evidence of when something happened.""" 125 + if not settings.tavily_api_key: 126 + return "web_search unavailable: TAVILY_API_KEY not set" 127 + 128 + body: dict = { 129 + "query": query, 130 + "max_results": max_results, 131 + "search_depth": "basic", 132 + } 133 + if time_range: 134 + body["time_range"] = time_range 135 + if topic: 136 + body["topic"] = topic 137 + 138 + try: 139 + async with httpx.AsyncClient(timeout=20) as http: 140 + r = await http.post( 141 + "https://api.tavily.com/search", 142 + headers={ 143 + "Authorization": f"Bearer {settings.tavily_api_key}", 144 + "Content-Type": "application/json", 145 + }, 146 + json=body, 147 + ) 148 + r.raise_for_status() 149 + data = r.json() 150 + except Exception as e: 151 + return f"web search failed: {e}" 152 + 153 + results = data.get("results", []) 154 + if not results: 155 + return f"no web results for '{query}'" 156 + 157 + scope_parts = [] 158 + if time_range: 159 + scope_parts.append(f"time_range={time_range}") 160 + if topic: 161 + scope_parts.append(f"topic={topic}") 162 + scope = f" ({', '.join(scope_parts)})" if scope_parts else "" 163 + 164 + lines = [f"web results for '{query}'{scope}:"] 165 + for i, r_item in enumerate(results, 1): 166 + title = r_item.get("title", "untitled") 167 + url = r_item.get("url", "") 168 + content = (r_item.get("content") or "").strip() 169 + lines.append("") 170 + lines.append(f"{i}. {title}") 171 + if url: 172 + lines.append(f" {url}") 173 + if content: 174 + lines.append(f" {content[:400]}") 175 + return "\n".join(lines) 79 176 80 177 @agent.tool 81 178 async def get_trending(ctx: RunContext[PhiDeps]) -> str: