Compare commits

..

6 Commits

Author SHA1 Message Date
Alvis
eba805f787 Update docs: fast tools, routecheck service, commute tool
- Request flow: add fast_tool_runner.run_matching() to pre-flight gather
- New Fast Tools section: WeatherTool + CommuteTool table, extension guide
- New routecheck section: captcha UI, internal API, proxy requirements
- Services table: add routecheck:8090
- Files tree: add fast_tools.py, routecheck/, updated .env note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:10:30 +00:00
Alvis
32089ed596 Add routecheck service and CommuteTool fast tool
routecheck/ — FastAPI service (port 8090):
  - Image captcha (PIL: arithmetic problem, noise, wave distortion)
  - POST /api/captcha/new + /api/captcha/solve → short-lived token
  - GET /api/route?from=lat,lon&to=lat,lon&token=... → Yandex Routing API
  - Internal bypass via INTERNAL_TOKEN env var (for CommuteTool)
  - HTTPS proxy forwarded to reach Yandex API from container

CommuteTool (fast_tools.py):
  - Matches commute/traffic/arrival time queries
  - Calls routecheck /api/route with ROUTECHECK_TOKEN
  - Hardcoded route: Balashikha home → Moscow center
  - Returns traffic-adjusted travel time + delay annotation

Needs: YANDEX_ROUTING_KEY + ROUTECHECK_TOKEN in .env

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:08:48 +00:00
Alvis
d2ca1926f8 WeatherTool: use Russian query for Celsius sources
'погода Балашиха сейчас' returns Russian weather sites (gismeteo,
meteotrend) that report in °C, vs English queries which return
Fahrenheit snippets that the model misreads as Celsius.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:25:53 +00:00
Alvis
af181ba7ec Rename RealTimeSearchTool → WeatherTool, fetch Balashikha weather via SearXNG
WeatherTool queries SearXNG with a fixed 'weather Balashikha Moscow now'
query instead of passing the user message as-is. SearXNG has external
internet access and returns snippets with actual current conditions.
Direct wttr.in fetch not possible — deepagents container has no external
internet routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:40:10 +00:00
Alvis
f5fc2e9bfb Introduce FastTools: pre-flight classifier + context enrichment
New fast_tools.py module:
- FastTool base class (matches + run interface)
- RealTimeSearchTool: SearXNG search for weather/news/prices/scores
- FastToolRunner: classifier that checks all tools, runs matching
  ones concurrently and returns combined context

Router accepts FastToolRunner; any_matches() forces medium tier
before LLM classification (replaces _MEDIUM_FORCE_PATTERNS regex).

agent.py: _REALTIME_RE and _searxng_search_async removed; pre-flight
gather now includes fast_tool_runner.run_matching() alongside URL
fetch and memory retrieval.

To add a new fast tool: subclass FastTool, add to the list in agent.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:18:44 +00:00
Alvis
436299f7e2 Add real-time query handling: pre-search enrichment + routing fix
- router.py: add _MEDIUM_FORCE_PATTERNS to block weather/news/price
  queries from light tier regardless of LLM classification
- agent.py: add _REALTIME_RE and _searxng_search_async(); real-time
  queries now run SearXNG search concurrently with URL fetch + memory
  retrieval, injecting snippets into medium system prompt
- tests/use_cases/weather_now.md: use case test for weather queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 05:08:08 +00:00
10 changed files with 723 additions and 17 deletions

View File

@@ -56,7 +56,9 @@ Autonomous personal assistant with a multi-channel gateway. Three-tier model rou
5. Parallel IO (asyncio.gather): 5. Parallel IO (asyncio.gather):
a. _fetch_urls_from_message() — Crawl4AI fetches any URLs in message a. _fetch_urls_from_message() — Crawl4AI fetches any URLs in message
b. _retrieve_memories() — openmemory semantic search for context b. _retrieve_memories() — openmemory semantic search for context
6. router.route() with enriched history (url_context + memories as system msgs) c. _fast_tool_runner.run_matching() — FastTools (weather, commute) if pattern matches
6. router.route() with enriched history (url_context + fast_context + memories)
- fast tool match → force medium (real-time data, no point routing to light)
- if URL content fetched and tier=light → upgrade to medium - if URL content fetched and tier=light → upgrade to medium
7. Invoke agent for tier with url_context + memories in system prompt 7. Invoke agent for tier with url_context + memories in system prompt
8. Token streaming: 8. Token streaming:
@@ -90,6 +92,33 @@ Adolf uses LangChain's tool interface but only the complex agent actually invoke
Complex tier is locked out unless the message starts with `/think` — any LLM classification of "complex" is downgraded to medium. Complex tier is locked out unless the message starts with `/think` — any LLM classification of "complex" is downgraded to medium.
## Fast Tools (`fast_tools.py`)
Pre-flight tools that run concurrently with URL fetch and memory retrieval before any LLM call. Each tool has two methods:
- `matches(message) → bool` — regex classifier; also used by `Router` to force medium tier
- `run(message) → str` — async fetch returning a context block injected into system prompt
`FastToolRunner` holds all tools. `any_matches()` is called by the Router at step 0a; `run_matching()` is called in the pre-flight `asyncio.gather` in `run_agent_task()`.
| Tool | Pattern | Source | Context returned |
|------|---------|--------|-----------------|
| `WeatherTool` | weather/forecast/temperature/snow/rain | SearXNG `"погода Балашиха сейчас"` | Current conditions in °C from Russian weather sites |
| `CommuteTool` | commute/traffic/arrival/пробки | `routecheck:8090/api/route` (Yandex Routing API) | Drive time with/without traffic, Balashikha→Moscow |
**To add a new fast tool:** subclass `FastTool` in `fast_tools.py`, implement `name`/`matches`/`run`, add an instance to `_fast_tool_runner` in `agent.py`.
## routecheck Service (`routecheck/`)
Local web service on port 8090. Exists because Yandex Routing API free tier requires a web UI that uses the API.
**Web UI** (`http://localhost:8090`): PIL-generated arithmetic captcha → lat/lon form → travel time result.
**Internal API**: `GET /api/route?from=lat,lon&to=lat,lon&token=ROUTECHECK_TOKEN` — bypasses captcha, used by `CommuteTool`. The `ROUTECHECK_TOKEN` shared secret is set in `.env` and passed to both `routecheck` and `deepagents` containers.
Yandex API calls are routed through the host HTTPS proxy (`host.docker.internal:56928`) since the container has no direct external internet access.
**Requires** `.env`: `YANDEX_ROUTING_KEY` (free from `developer.tech.yandex.ru`) + `ROUTECHECK_TOKEN`.
## Crawl4AI Integration ## Crawl4AI Integration
Crawl4AI runs as a Docker service (`crawl4ai:11235`) providing JS-rendered, bot-bypass page fetching. Crawl4AI runs as a Docker service (`crawl4ai:11235`) providing JS-rendered, bot-bypass page fetching.
@@ -135,20 +164,24 @@ Conversation history is keyed by session_id (5-turn buffer).
``` ```
adolf/ adolf/
├── docker-compose.yml Services: bifrost, deepagents, openmemory, grammy, crawl4ai, cli (profile:tools) ├── docker-compose.yml Services: bifrost, deepagents, openmemory, grammy, crawl4ai, routecheck, cli
├── Dockerfile deepagents container (Python 3.12) ├── Dockerfile deepagents container (Python 3.12)
├── Dockerfile.cli CLI container (python:3.12-slim + rich) ├── Dockerfile.cli CLI container (python:3.12-slim + rich)
├── agent.py FastAPI gateway, run_agent_task, Crawl4AI pre-fetch, memory pipeline, /stream/ SSE ├── agent.py FastAPI gateway, run_agent_task, Crawl4AI pre-fetch, fast tools, memory pipeline
├── fast_tools.py FastTool base, FastToolRunner, WeatherTool, CommuteTool
├── channels.py Channel registry + deliver() + pending_replies ├── channels.py Channel registry + deliver() + pending_replies
├── router.py Router class — regex + LLM tier classification ├── router.py Router class — regex + LLM tier classification, FastToolRunner integration
├── vram_manager.py VRAMManager — flush/prewarm/poll Ollama VRAM ├── vram_manager.py VRAMManager — flush/prewarm/poll Ollama VRAM
├── agent_factory.py _DirectModel (medium) / create_deep_agent (complex) ├── agent_factory.py _DirectModel (medium) / create_deep_agent (complex)
├── cli.py Interactive CLI REPL — Rich Live streaming + Markdown render ├── cli.py Interactive CLI REPL — Rich Live streaming + Markdown render
├── wiki_research.py Batch wiki research pipeline (uses /message + SSE) ├── wiki_research.py Batch wiki research pipeline (uses /message + SSE)
├── .env TELEGRAM_BOT_TOKEN, ROUTECHECK_TOKEN, YANDEX_ROUTING_KEY (not committed)
├── routecheck/
│ ├── app.py FastAPI: image captcha + /api/route Yandex proxy
│ └── Dockerfile
├── tests/ ├── tests/
│ ├── integration/ Standalone integration test scripts (common.py + test_*.py) │ ├── integration/ Standalone integration test scripts (common.py + test_*.py)
│ └── use_cases/ Claude Code skill markdown files — Claude acts as user + evaluator │ └── use_cases/ Claude Code skill markdown files — Claude acts as user + evaluator
├── .env TELEGRAM_BOT_TOKEN (not committed)
├── openmemory/ ├── openmemory/
│ ├── server.py FastMCP + mem0: add_memory, search_memory, get_all_memories │ ├── server.py FastMCP + mem0: add_memory, search_memory, get_all_memories
│ └── Dockerfile │ └── Dockerfile

View File

@@ -48,8 +48,10 @@ Channel adapter → POST /message {text, session_id, channel, user_id}
→ asyncio.gather( → asyncio.gather(
_fetch_urls_from_message() ← Crawl4AI, concurrent _fetch_urls_from_message() ← Crawl4AI, concurrent
_retrieve_memories() ← openmemory search, concurrent _retrieve_memories() ← openmemory search, concurrent
_fast_tool_runner.run_matching() ← FastTools (weather, commute), concurrent
) )
→ router.route() → tier decision (light/medium/complex) → router.route() → tier decision (light/medium/complex)
fast tool match → force medium
if URL content fetched → upgrade light→medium if URL content fetched → upgrade light→medium
→ invoke agent for tier via Bifrost (url_context + memories in system prompt) → invoke agent for tier via Bifrost (url_context + memories in system prompt)
deepagents:8000 → bifrost:8080/v1 → ollama:11436 deepagents:8000 → bifrost:8080/v1 → ollama:11436
@@ -112,6 +114,7 @@ Session IDs: `tg-<chat_id>` for Telegram, `cli-<username>` for CLI. Conversation
| `openmemory` | 8765 | FastMCP server + mem0 memory tools (Qdrant-backed) | | `openmemory` | 8765 | FastMCP server + mem0 memory tools (Qdrant-backed) |
| `grammy` | 3001 | grammY Telegram bot + `/send` HTTP endpoint | | `grammy` | 3001 | grammY Telegram bot + `/send` HTTP endpoint |
| `crawl4ai` | 11235 | JS-rendered page fetching | | `crawl4ai` | 11235 | JS-rendered page fetching |
| `routecheck` | 8090 | Local routing web service — image captcha UI + Yandex Routing API backend |
| `cli` | — | Interactive CLI container (`profiles: [tools]`), Rich streaming display | | `cli` | — | Interactive CLI container (`profiles: [tools]`), Rich streaming display |
External (from `openai/` stack, host ports): External (from `openai/` stack, host ports):
@@ -134,6 +137,25 @@ Crawl4AI is embedded at all levels of the pipeline:
MCP tools from openmemory (`add_memory`, `search_memory`, `get_all_memories`) are **excluded** from agent tools — memory management is handled outside the agent loop. MCP tools from openmemory (`add_memory`, `search_memory`, `get_all_memories`) are **excluded** from agent tools — memory management is handled outside the agent loop.
### Fast Tools (`fast_tools.py`)
Pre-flight tools that run before the LLM in the `asyncio.gather` alongside URL fetch and memory retrieval. Each tool has a regex `matches()` classifier and an async `run()` that returns a context string injected into the system prompt. The router uses `FastToolRunner.any_matches()` to force medium tier when a tool matches.
| Tool | Trigger | Data source |
|------|---------|-------------|
| `WeatherTool` | weather/forecast/temperature keywords | SearXNG query `"погода Балашиха сейчас"` — Russian sources return °C |
| `CommuteTool` | commute/traffic/arrival time keywords | `routecheck:8090/api/route` — Yandex Routing API, Balashikha→Moscow center |
To add a new fast tool: subclass `FastTool` in `fast_tools.py`, add an instance to `_fast_tool_runner` in `agent.py`.
### `routecheck` service (`routecheck/app.py`)
Local web service that exposes Yandex Routing API behind an image captcha. Two access paths:
- **Web UI** (`localhost:8090`): solve PIL-generated arithmetic captcha → query any two lat/lon points
- **Internal API**: `GET /api/route?from=lat,lon&to=lat,lon&token=ROUTECHECK_TOKEN` — bypasses captcha, used by `CommuteTool`
Requires `.env`: `YANDEX_ROUTING_KEY` (free tier from `developer.tech.yandex.ru`) and `ROUTECHECK_TOKEN`. The container routes Yandex API calls through the host HTTPS proxy (`host.docker.internal:56928`).
### Medium vs Complex agent ### Medium vs Complex agent
| Agent | Builder | Speed | Use case | | Agent | Builder | Speed | Use case |
@@ -143,7 +165,9 @@ MCP tools from openmemory (`add_memory`, `search_memory`, `get_all_memories`) ar
### Key files ### Key files
- `agent.py` — FastAPI app, lifespan wiring, `run_agent_task()`, Crawl4AI pre-fetch, memory pipeline, all endpoints - `agent.py` — FastAPI app, lifespan wiring, `run_agent_task()`, Crawl4AI pre-fetch, fast tools, memory pipeline, all endpoints
- `fast_tools.py``FastTool` base class, `FastToolRunner`, `WeatherTool`, `CommuteTool`
- `routecheck/app.py` — captcha UI + `/api/route` Yandex proxy
- `bifrost-config.json` — Bifrost provider config (Ollama GPU, retries, timeouts) - `bifrost-config.json` — Bifrost provider config (Ollama GPU, retries, timeouts)
- `channels.py` — channel registry and `deliver()` dispatcher - `channels.py` — channel registry and `deliver()` dispatcher
- `router.py``Router` class: regex + LLM classification, light-tier reply generation - `router.py``Router` class: regex + LLM classification, light-tier reply generation

View File

@@ -5,6 +5,6 @@ WORKDIR /app
RUN pip install --no-cache-dir deepagents langchain-openai langgraph \ RUN pip install --no-cache-dir deepagents langchain-openai langgraph \
fastapi uvicorn langchain-mcp-adapters langchain-community httpx fastapi uvicorn langchain-mcp-adapters langchain-community httpx
COPY agent.py channels.py vram_manager.py router.py agent_factory.py hello_world.py . COPY agent.py channels.py vram_manager.py router.py agent_factory.py fast_tools.py hello_world.py ./
CMD ["uvicorn", "agent:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "agent:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -24,6 +24,7 @@ from langchain_core.tools import Tool
from vram_manager import VRAMManager from vram_manager import VRAMManager
from router import Router from router import Router
from agent_factory import build_medium_agent, build_complex_agent from agent_factory import build_medium_agent, build_complex_agent
from fast_tools import FastToolRunner, WeatherTool, CommuteTool
import channels import channels
# Bifrost gateway — all LLM inference goes through here # Bifrost gateway — all LLM inference goes through here
@@ -37,6 +38,8 @@ COMPLEX_MODEL = os.getenv("DEEPAGENTS_COMPLEX_MODEL", "qwen3:8b")
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://host.docker.internal:11437") SEARXNG_URL = os.getenv("SEARXNG_URL", "http://host.docker.internal:11437")
OPENMEMORY_URL = os.getenv("OPENMEMORY_URL", "http://openmemory:8765") OPENMEMORY_URL = os.getenv("OPENMEMORY_URL", "http://openmemory:8765")
CRAWL4AI_URL = os.getenv("CRAWL4AI_URL", "http://crawl4ai:11235") CRAWL4AI_URL = os.getenv("CRAWL4AI_URL", "http://crawl4ai:11235")
ROUTECHECK_URL = os.getenv("ROUTECHECK_URL", "http://routecheck:8090")
ROUTECHECK_TOKEN = os.getenv("ROUTECHECK_TOKEN", "")
MAX_HISTORY_TURNS = 5 MAX_HISTORY_TURNS = 5
_conversation_buffers: dict[str, list] = {} _conversation_buffers: dict[str, list] = {}
@@ -88,6 +91,7 @@ async def _fetch_urls_from_message(message: str) -> str:
return "User's message contains URLs. Fetched content:\n\n" + "\n\n".join(parts) return "User's message contains URLs. Fetched content:\n\n" + "\n\n".join(parts)
# /no_think at the start of the system prompt disables qwen3 chain-of-thought. # /no_think at the start of the system prompt disables qwen3 chain-of-thought.
# create_deep_agent prepends our system_prompt before BASE_AGENT_PROMPT, so # create_deep_agent prepends our system_prompt before BASE_AGENT_PROMPT, so
# /no_think lands at position 0 and is respected by qwen3 models via Ollama. # /no_think lands at position 0 and is respected by qwen3 models via Ollama.
@@ -117,6 +121,12 @@ mcp_client = None
_memory_add_tool = None _memory_add_tool = None
_memory_search_tool = None _memory_search_tool = None
# Fast tools run before the LLM — classifier + context enricher
_fast_tool_runner = FastToolRunner([
WeatherTool(searxng_url=SEARXNG_URL),
CommuteTool(routecheck_url=ROUTECHECK_URL, internal_token=ROUTECHECK_TOKEN),
])
# GPU mutex: one LLM inference at a time # GPU mutex: one LLM inference at a time
_reply_semaphore = asyncio.Semaphore(1) _reply_semaphore = asyncio.Semaphore(1)
@@ -154,7 +164,7 @@ async def lifespan(app: FastAPI):
) )
vram_manager = VRAMManager(base_url=OLLAMA_BASE_URL) vram_manager = VRAMManager(base_url=OLLAMA_BASE_URL)
router = Router(model=router_model) router = Router(model=router_model, fast_tool_runner=_fast_tool_runner)
mcp_connections = { mcp_connections = {
"openmemory": {"transport": "sse", "url": f"{OPENMEMORY_URL}/sse"}, "openmemory": {"transport": "sse", "url": f"{OPENMEMORY_URL}/sse"},
@@ -379,18 +389,24 @@ async def run_agent_task(message: str, session_id: str, channel: str = "telegram
history = _conversation_buffers.get(session_id, []) history = _conversation_buffers.get(session_id, [])
print(f"[agent] running: {clean_message[:80]!r}", flush=True) print(f"[agent] running: {clean_message[:80]!r}", flush=True)
# Fetch URL content and memories concurrently — both are IO-bound, neither needs GPU # Fetch URL content, memories, and fast-tool context concurrently — all IO-bound
url_context, memories = await asyncio.gather( url_context, memories, fast_context = await asyncio.gather(
_fetch_urls_from_message(clean_message), _fetch_urls_from_message(clean_message),
_retrieve_memories(clean_message, session_id), _retrieve_memories(clean_message, session_id),
_fast_tool_runner.run_matching(clean_message),
) )
if url_context: if url_context:
print(f"[agent] crawl4ai: {len(url_context)} chars fetched from message URLs", flush=True) print(f"[agent] crawl4ai: {len(url_context)} chars fetched from message URLs", flush=True)
if fast_context:
names = _fast_tool_runner.matching_names(clean_message)
print(f"[agent] fast_tools={names}: {len(fast_context)} chars injected", flush=True)
# Build enriched history: memories + url_context as system context for ALL tiers # Build enriched history: memories + url_context + fast_context for ALL tiers
enriched_history = list(history) enriched_history = list(history)
if url_context: if url_context:
enriched_history = [{"role": "system", "content": url_context}] + enriched_history enriched_history = [{"role": "system", "content": url_context}] + enriched_history
if fast_context:
enriched_history = [{"role": "system", "content": fast_context}] + enriched_history
if memories: if memories:
enriched_history = [{"role": "system", "content": memories}] + enriched_history enriched_history = [{"role": "system", "content": memories}] + enriched_history
@@ -418,6 +434,8 @@ async def run_agent_task(message: str, session_id: str, channel: str = "telegram
system_prompt = system_prompt + "\n\n" + memories system_prompt = system_prompt + "\n\n" + memories
if url_context: if url_context:
system_prompt = system_prompt + "\n\n" + url_context system_prompt = system_prompt + "\n\n" + url_context
if fast_context:
system_prompt = system_prompt + "\n\nLive web search results (use these to answer):\n\n" + fast_context
# Stream tokens directly — filter out qwen3 <think> blocks # Stream tokens directly — filter out qwen3 <think> blocks
in_think = False in_think = False

View File

@@ -31,6 +31,8 @@ services:
- SEARXNG_URL=http://host.docker.internal:11437 - SEARXNG_URL=http://host.docker.internal:11437
- GRAMMY_URL=http://grammy:3001 - GRAMMY_URL=http://grammy:3001
- CRAWL4AI_URL=http://crawl4ai:11235 - CRAWL4AI_URL=http://crawl4ai:11235
- ROUTECHECK_URL=http://routecheck:8090
- ROUTECHECK_TOKEN=${ROUTECHECK_TOKEN}
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
depends_on: depends_on:
@@ -38,6 +40,7 @@ services:
- grammy - grammy
- crawl4ai - crawl4ai
- bifrost - bifrost
- routecheck
restart: unless-stopped restart: unless-stopped
openmemory: openmemory:
@@ -79,6 +82,19 @@ services:
profiles: profiles:
- tools - tools
routecheck:
build: ./routecheck
container_name: routecheck
ports:
- "8090:8090"
environment:
- YANDEX_ROUTING_KEY=${YANDEX_ROUTING_KEY}
- INTERNAL_TOKEN=${ROUTECHECK_TOKEN}
- HTTPS_PROXY=http://host.docker.internal:56928
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
crawl4ai: crawl4ai:
image: unclecode/crawl4ai:latest image: unclecode/crawl4ai:latest
container_name: crawl4ai container_name: crawl4ai

184
fast_tools.py Normal file
View File

@@ -0,0 +1,184 @@
"""
Fast Tools — pre-flight tools invoked by a classifier before the main LLM call.
Each FastTool has:
- matches(message) → bool : regex classifier that determines if this tool applies
- run(message) → str : async fetch that returns enrichment context
FastToolRunner holds a list of FastTools. The Router uses any_matches() to force
the tier to medium before LLM classification. run_agent_task() calls run_matching()
to build extra context that is injected into the system prompt.
To add a new fast tool:
1. Subclass FastTool, implement name/matches/run
2. Add an instance to the list passed to FastToolRunner in agent.py
"""
import asyncio
import re
from abc import ABC, abstractmethod
import httpx
class FastTool(ABC):
"""Base class for all pre-flight fast tools."""
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def matches(self, message: str) -> bool: ...
@abstractmethod
async def run(self, message: str) -> str: ...
class WeatherTool(FastTool):
"""
Fetches current weather for the user's location (Balashikha, Moscow region)
by querying SearXNG, which has external internet access.
Triggered by any weather-related query. The Router also forces medium tier
when this tool matches so the richer model handles the injected data.
"""
_PATTERN = re.compile(
r"\b(weather|forecast|temperature|rain(ing)?|snow(ing)?|humidity|wind\s*speed"
r"|холодно|тепло|погода|прогноз погоды"
r"|how (hot|cold|warm) is it|what.?s the (weather|temp)|dress for the weather)\b",
re.IGNORECASE,
)
# Fixed query — always fetch home location weather
_SEARCH_QUERY = "погода Балашиха сейчас" # Russian query → Celsius sources
def __init__(self, searxng_url: str):
self._searxng_url = searxng_url
@property
def name(self) -> str:
return "weather"
def matches(self, message: str) -> bool:
return bool(self._PATTERN.search(message))
async def run(self, message: str) -> str:
"""Query SearXNG for Balashikha weather and return current conditions snippet."""
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(
f"{self._searxng_url}/search",
params={"q": self._SEARCH_QUERY, "format": "json"},
)
r.raise_for_status()
items = r.json().get("results", [])[:5]
except Exception as e:
return f"[weather error: {e}]"
if not items:
return ""
# Prefer results whose snippets contain actual current conditions
lines = ["Current weather data for Balashikha, Moscow region (temperatures in °C):\n"]
for item in items:
snippet = item.get("content", "")
title = item.get("title", "")
url = item.get("url", "")
if snippet:
lines.append(f"[{title}]\n{snippet}\nSource: {url}\n")
return "\n".join(lines) if len(lines) > 1 else ""
class CommuteTool(FastTool):
"""
Returns real-time driving time from home (Balashikha) to a destination
using Yandex traffic data via the local routecheck service.
Triggered by queries about commute time, arrival, or road traffic.
The routecheck service handles Yandex API auth and the HTTPS proxy.
"""
_PATTERN = re.compile(
r"\b(commute|arrival time|how long.{0,20}(drive|get|travel|reach)"
r"|сколько.{0,20}(ехать|добираться|минут)"
r"|пробки|traffic|road.{0,10}now|drive to (work|office|center|москва|moscow)"
r"|when (will i|do i) (arrive|get there|reach))\b",
re.IGNORECASE,
)
# Home: Balashikha. Default destination: Moscow city center.
_HOME = "55.7963,37.9382"
_DEST = "55.7558,37.6173"
def __init__(self, routecheck_url: str, internal_token: str):
self._url = routecheck_url.rstrip("/")
self._token = internal_token
@property
def name(self) -> str:
return "commute"
def matches(self, message: str) -> bool:
return bool(self._PATTERN.search(message))
async def run(self, message: str) -> str:
if not self._token:
return "[commute: ROUTECHECK_TOKEN not configured]"
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(
f"{self._url}/api/route",
params={"from": self._HOME, "to": self._DEST, "token": self._token},
)
r.raise_for_status()
d = r.json()
except Exception as e:
return f"[commute error: {e}]"
traffic = d["duration_traffic_min"]
normal = d["duration_min"]
dist = d["distance_km"]
delay = traffic - normal
lines = [
f"Current drive time from Balashikha to Moscow center:",
f" With traffic: {traffic} min",
f" Without traffic: {normal} min",
f" Distance: {dist} km",
]
if delay > 5:
lines.append(f" Traffic delay: +{delay} min")
return "\n".join(lines)
class FastToolRunner:
"""
Classifier + executor for fast tools.
Used in two places:
- Router.route(): any_matches() forces medium tier before LLM classification
- run_agent_task(): run_matching() builds enrichment context in the pre-flight gather
"""
def __init__(self, tools: list[FastTool]):
self._tools = tools
def any_matches(self, message: str) -> bool:
"""True if any fast tool applies to this message."""
return any(t.matches(message) for t in self._tools)
def matching_names(self, message: str) -> list[str]:
"""Names of tools that match this message (for logging)."""
return [t.name for t in self._tools if t.matches(message)]
async def run_matching(self, message: str) -> str:
"""Run all matching tools concurrently and return combined context."""
matching = [t for t in self._tools if t.matches(message)]
if not matching:
return ""
results = await asyncio.gather(*[t.run(message) for t in matching])
parts = [r for r in results if r and not r.startswith("[")]
return "\n\n".join(parts)

6
routecheck/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir fastapi uvicorn pillow httpx
COPY app.py .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]

377
routecheck/app.py Normal file
View File

@@ -0,0 +1,377 @@
"""
RouteCheck — local routing web service with image captcha.
Endpoints:
GET / — web UI
GET /captcha/image/{id} — PNG captcha image
POST /api/captcha/new — create captcha, return {id}
POST /api/captcha/solve — {id, answer} → {token} or 400
GET /api/route — ?from=lat,lon&to=lat,lon&token=...
token = solved captcha token OR INTERNAL_TOKEN env var
"""
import io
import math
import os
import random
import string
import time
import uuid
from typing import Optional
import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from pydantic import BaseModel
app = FastAPI(title="RouteCheck")
# ── Config ─────────────────────────────────────────────────────────────────────
YANDEX_KEY = os.getenv("YANDEX_ROUTING_KEY", "")
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "")
HTTPS_PROXY = os.getenv("HTTPS_PROXY", "")
CAPTCHA_TTL = 300 # seconds a captcha is valid
TOKEN_TTL = 3600 # seconds a solved token is valid
# ── In-memory captcha store ────────────────────────────────────────────────────
_captchas: dict[str, dict] = {} # id → {answer, token, expires}
_tokens: dict[str, float] = {} # token → expires
def _purge():
now = time.time()
for k in list(_captchas.keys()):
if _captchas[k]["expires"] < now:
del _captchas[k]
for k in list(_tokens.keys()):
if _tokens[k] < now:
del _tokens[k]
# ── Captcha image generation ───────────────────────────────────────────────────
def _rand_color(dark=False):
if dark:
return tuple(random.randint(0, 100) for _ in range(3))
return tuple(random.randint(140, 255) for _ in range(3))
def _make_captcha_image(text: str) -> bytes:
W, H = 220, 80
img = Image.new("RGB", (W, H), color=_rand_color())
draw = ImageDraw.Draw(img)
# Background noise: random lines
for _ in range(8):
x1, y1 = random.randint(0, W), random.randint(0, H)
x2, y2 = random.randint(0, W), random.randint(0, H)
draw.line([(x1, y1), (x2, y2)], fill=_rand_color(dark=True), width=2)
# Background noise: random dots
for _ in range(300):
x, y = random.randint(0, W), random.randint(0, H)
draw.point((x, y), fill=_rand_color(dark=True))
# Draw each character with slight random offset and rotation
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36)
except Exception:
font = ImageFont.load_default()
char_w = W // (len(text) + 2)
for i, ch in enumerate(text):
x = char_w + i * char_w + random.randint(-4, 4)
y = (H - 40) // 2 + random.randint(-6, 6)
# Draw shadow
draw.text((x + 2, y + 2), ch, font=font, fill=_rand_color(dark=True))
draw.text((x, y), ch, font=font, fill=_rand_color(dark=True))
# Wavy distortion via pixel manipulation
pixels = img.load()
for x in range(W):
shift = int(4 * math.sin(x / 15.0))
col = [pixels[x, y] for y in range(H)]
for y in range(H):
pixels[x, y] = col[(y - shift) % H]
img = img.filter(ImageFilter.SMOOTH)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def _generate_problem() -> tuple[str, int]:
"""Return (display_text, answer)."""
ops = [
lambda a, b: (f"{a} + {b} = ?", a + b),
lambda a, b: (f"{a} × {b} = ?", a * b),
lambda a, b: (f"{max(a,b)} {min(a,b)} = ?", max(a, b) - min(a, b)),
]
op = random.choice(ops)
a, b = random.randint(2, 9), random.randint(2, 9)
text, answer = op(a, b)
return text, answer
# ── Routes ─────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index():
return HTML_PAGE
@app.get("/captcha/image/{captcha_id}")
async def captcha_image(captcha_id: str):
_purge()
entry = _captchas.get(captcha_id)
if not entry:
raise HTTPException(404, "Captcha not found or expired")
png = _make_captcha_image(entry["problem"])
return StreamingResponse(io.BytesIO(png), media_type="image/png",
headers={"Cache-Control": "no-store"})
class CaptchaNewResponse(BaseModel):
id: str
@app.post("/api/captcha/new")
async def captcha_new():
_purge()
problem_text, answer = _generate_problem()
cid = str(uuid.uuid4())
_captchas[cid] = {
"problem": problem_text,
"answer": answer,
"expires": time.time() + CAPTCHA_TTL,
}
return {"id": cid}
class SolveRequest(BaseModel):
id: str
answer: int
@app.post("/api/captcha/solve")
async def captcha_solve(req: SolveRequest):
_purge()
entry = _captchas.get(req.id)
if not entry:
raise HTTPException(400, "Captcha expired or not found")
if entry["answer"] != req.answer:
raise HTTPException(400, "Wrong answer")
token = str(uuid.uuid4())
_tokens[token] = time.time() + TOKEN_TTL
del _captchas[req.id]
return {"token": token}
@app.get("/api/route")
async def route(
from_coords: str = Query(..., alias="from", description="lat,lon"),
to_coords: str = Query(..., alias="to", description="lat,lon"),
token: str = Query(...),
):
_purge()
# Auth: internal service token or valid captcha token
if token != INTERNAL_TOKEN:
if token not in _tokens:
raise HTTPException(401, "Invalid or expired token — solve captcha first")
if not YANDEX_KEY:
raise HTTPException(503, "YANDEX_ROUTING_KEY not configured")
# Parse coords
try:
from_lat, from_lon = map(float, from_coords.split(","))
to_lat, to_lon = map(float, to_coords.split(","))
except ValueError:
raise HTTPException(400, "coords must be lat,lon")
# Yandex Routing API expects lon,lat order
waypoints = f"{from_lon},{from_lat}|{to_lon},{to_lat}"
transport = httpx.AsyncHTTPTransport(proxy=HTTPS_PROXY) if HTTPS_PROXY else None
async with httpx.AsyncClient(timeout=15, transport=transport) as client:
try:
r = await client.get(
"https://api.routing.yandex.net/v2/route",
params={"apikey": YANDEX_KEY, "waypoints": waypoints, "mode": "driving"},
)
except Exception as e:
raise HTTPException(502, f"Yandex API unreachable: {e}")
if r.status_code != 200:
raise HTTPException(502, f"Yandex API error {r.status_code}: {r.text[:200]}")
data = r.json()
try:
leg = data["route"]["legs"][0]
duration_s = leg["duration"]
duration_traffic_s = leg.get("duration_in_traffic", duration_s)
distance_m = leg["distance"]
except (KeyError, IndexError) as e:
raise HTTPException(502, f"Unexpected Yandex response: {e}{str(data)[:200]}")
return {
"duration_min": round(duration_s / 60),
"duration_traffic_min": round(duration_traffic_s / 60),
"distance_km": round(distance_m / 1000, 1),
}
# ── HTML ───────────────────────────────────────────────────────────────────────
HTML_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RouteCheck</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh;
display: flex; align-items: center; justify-content: center; }
.card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 420px;
box-shadow: 0 20px 60px rgba(0,0,0,.5); }
h1 { font-size: 1.4rem; font-weight: 700; color: #38bdf8; margin-bottom: .3rem; }
.sub { color: #94a3b8; font-size: .85rem; margin-bottom: 1.5rem; }
label { display: block; font-size: .8rem; color: #94a3b8; margin-bottom: .3rem; margin-top: 1rem; }
input { width: 100%; background: #0f172a; border: 1px solid #334155; border-radius: 6px;
color: #e2e8f0; padding: .55rem .75rem; font-size: .95rem; outline: none; }
input:focus { border-color: #38bdf8; }
button { width: 100%; margin-top: 1.2rem; padding: .7rem; background: #0ea5e9;
border: none; border-radius: 6px; color: #fff; font-size: 1rem;
font-weight: 600; cursor: pointer; transition: background .2s; }
button:hover { background: #0284c7; }
button:disabled { background: #334155; cursor: default; }
.captcha-row { display: flex; gap: .75rem; align-items: center; margin-top: 1rem; }
.captcha-row img { border-radius: 6px; border: 1px solid #334155; cursor: pointer; }
.captcha-row input { flex: 1; }
.result { margin-top: 1.2rem; background: #0f172a; border-radius: 8px; padding: 1rem;
border-left: 3px solid #38bdf8; display: none; }
.result .big { font-size: 1.6rem; font-weight: 700; color: #38bdf8; }
.result .label { font-size: .8rem; color: #64748b; margin-top: .2rem; }
.result .row { display: flex; gap: 1.5rem; margin-top: .8rem; }
.result .metric { flex: 1; }
.result .metric .val { font-size: 1.1rem; font-weight: 600; }
.error { color: #f87171; margin-top: .8rem; font-size: .85rem; display: none; }
.step { display: none; }
.step.active { display: block; }
a.refresh { font-size: .75rem; color: #38bdf8; text-decoration: none; display: block;
margin-top: .4rem; }
a.refresh:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="card">
<h1>RouteCheck</h1>
<p class="sub">Real-time driving time with Yandex traffic data</p>
<!-- Step 1: captcha -->
<div class="step active" id="step-captcha">
<label>Prove you are human</label>
<div class="captcha-row">
<img id="captcha-img" src="" alt="captcha" width="160" height="60"
title="Click to refresh" onclick="loadCaptcha()">
<input id="captcha-ans" type="number" placeholder="Answer" min="0" max="999">
</div>
<a class="refresh" href="#" onclick="loadCaptcha();return false;">↻ New challenge</a>
<div class="error" id="captcha-err">Wrong answer, try again.</div>
<button id="captcha-btn" onclick="solveCaptcha()">Verify →</button>
</div>
<!-- Step 2: route query -->
<div class="step" id="step-route">
<label>From (lat, lon)</label>
<input id="from" type="text" placeholder="55.7963, 37.9382" value="55.7963, 37.9382">
<label>To (lat, lon)</label>
<input id="to" type="text" placeholder="55.7558, 37.6173" value="55.7558, 37.6173">
<button id="route-btn" onclick="queryRoute()">Get travel time</button>
<div class="error" id="route-err"></div>
<div class="result" id="result">
<div class="big" id="res-traffic"></div>
<div class="label">with current traffic</div>
<div class="row">
<div class="metric"><div class="val" id="res-normal"></div>
<div class="label">without traffic</div></div>
<div class="metric"><div class="val" id="res-dist"></div>
<div class="label">distance</div></div>
</div>
</div>
</div>
</div>
<script>
let captchaId = null;
let routeToken = null;
async function loadCaptcha() {
const r = await fetch('/api/captcha/new', {method: 'POST'});
const d = await r.json();
captchaId = d.id;
document.getElementById('captcha-img').src = '/captcha/image/' + captchaId + '?t=' + Date.now();
document.getElementById('captcha-ans').value = '';
document.getElementById('captcha-err').style.display = 'none';
}
async function solveCaptcha() {
const ans = parseInt(document.getElementById('captcha-ans').value);
if (isNaN(ans)) return;
const btn = document.getElementById('captcha-btn');
btn.disabled = true;
const r = await fetch('/api/captcha/solve', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: captchaId, answer: ans})
});
if (r.ok) {
const d = await r.json();
routeToken = d.token;
document.getElementById('step-captcha').classList.remove('active');
document.getElementById('step-route').classList.add('active');
} else {
document.getElementById('captcha-err').style.display = 'block';
loadCaptcha();
}
btn.disabled = false;
}
async function queryRoute() {
const from = document.getElementById('from').value.trim();
const to = document.getElementById('to').value.trim();
const btn = document.getElementById('route-btn');
const err = document.getElementById('route-err');
err.style.display = 'none';
document.getElementById('result').style.display = 'none';
btn.disabled = true;
btn.textContent = 'Fetching…';
const r = await fetch(`/api/route?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&token=${routeToken}`);
btn.disabled = false;
btn.textContent = 'Get travel time';
if (!r.ok) {
const d = await r.json();
err.textContent = d.detail || 'Error';
err.style.display = 'block';
return;
}
const d = await r.json();
document.getElementById('res-traffic').textContent = d.duration_traffic_min + ' min';
document.getElementById('res-normal').textContent = d.duration_min + ' min';
document.getElementById('res-dist').textContent = d.distance_km + ' km';
document.getElementById('result').style.display = 'block';
}
loadCaptcha();
document.getElementById('captcha-ans').addEventListener('keydown', e => {
if (e.key === 'Enter') solveCaptcha();
});
</script>
</body>
</html>
"""

View File

@@ -1,6 +1,7 @@
import re import re
from typing import Optional from typing import Optional
from langchain_core.messages import SystemMessage, HumanMessage from langchain_core.messages import SystemMessage, HumanMessage
from fast_tools import FastToolRunner
# ── Regex pre-classifier ────────────────────────────────────────────────────── # ── Regex pre-classifier ──────────────────────────────────────────────────────
# Catches obvious light-tier patterns before calling the LLM. # Catches obvious light-tier patterns before calling the LLM.
@@ -73,8 +74,9 @@ def _parse_tier(text: str) -> str:
class Router: class Router:
def __init__(self, model): def __init__(self, model, fast_tool_runner: FastToolRunner | None = None):
self.model = model self.model = model
self._fast_tool_runner = fast_tool_runner
async def route( async def route(
self, self,
@@ -90,7 +92,13 @@ class Router:
if force_complex: if force_complex:
return "complex", None return "complex", None
# Step 0: regex pre-classification for obvious light patterns # Step 0a: force medium if any fast tool matches (live-data queries)
if self._fast_tool_runner and self._fast_tool_runner.any_matches(message.strip()):
names = self._fast_tool_runner.matching_names(message.strip())
print(f"[router] fast_tool_match={names} → medium", flush=True)
return "medium", None
# Step 0b: regex pre-classification for obvious light patterns
if _LIGHT_PATTERNS.match(message.strip()): if _LIGHT_PATTERNS.match(message.strip()):
print(f"[router] regex→light", flush=True) print(f"[router] regex→light", flush=True)
return await self._generate_light_reply(message, history) return await self._generate_light_reply(message, history)

View File

@@ -0,0 +1,40 @@
# Use Case: Current Weather Query
Verify how Adolf handles a real-time information request ("what's the weather now?").
This question requires live data that an LLM cannot answer from training alone.
## Steps
**1. Send the weather query:**
```bash
curl -s -X POST http://localhost:8000/message \
-H "Content-Type: application/json" \
-d '{"text": "whats the weather right now?", "session_id": "use-case-weather", "channel": "cli", "user_id": "claude"}'
```
**2. Stream the reply** (medium tier should respond within 30s):
```bash
curl -s -N --max-time 60 "http://localhost:8000/stream/use-case-weather"
```
**3. Check routing tier and any tool usage in logs:**
```bash
docker compose -f /home/alvis/adolf/docker-compose.yml logs deepagents \
--since=120s | grep -E "tier=|web_search|fetch_url|crawl4ai"
```
## Evaluate (use your judgment)
Check each of the following:
- **Routing**: which tier was selected? Was it appropriate for a real-time query?
- **Tool use**: did the agent use web_search or any external data source?
- **Accuracy**: does the response contain actual current weather data (temperature, conditions) or is it a guess/refusal?
- **Honesty**: if the agent cannot fetch weather, does it say so — or does it hallucinate fake data?
- **Helpfulness**: does the response suggest how the user could get weather info (e.g. check a website, use /think)?
Report PASS only if the response is both honest and helpful. A hallucinated weather
report is a FAIL. A honest "I can't check weather" with guidance is a PASS.