Compare commits
6 Commits
8cd41940f0
...
eba805f787
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba805f787 | ||
|
|
32089ed596 | ||
|
|
d2ca1926f8 | ||
|
|
af181ba7ec | ||
|
|
f5fc2e9bfb | ||
|
|
436299f7e2 |
@@ -56,7 +56,9 @@ Autonomous personal assistant with a multi-channel gateway. Three-tier model rou
|
||||
5. Parallel IO (asyncio.gather):
|
||||
a. _fetch_urls_from_message() — Crawl4AI fetches any URLs in message
|
||||
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
|
||||
7. Invoke agent for tier with url_context + memories in system prompt
|
||||
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.
|
||||
|
||||
## 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 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/
|
||||
├── 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.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
|
||||
├── 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
|
||||
├── agent_factory.py _DirectModel (medium) / create_deep_agent (complex)
|
||||
├── cli.py Interactive CLI REPL — Rich Live streaming + Markdown render
|
||||
├── 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/
|
||||
│ ├── integration/ Standalone integration test scripts (common.py + test_*.py)
|
||||
│ └── use_cases/ Claude Code skill markdown files — Claude acts as user + evaluator
|
||||
├── .env TELEGRAM_BOT_TOKEN (not committed)
|
||||
├── openmemory/
|
||||
│ ├── server.py FastMCP + mem0: add_memory, search_memory, get_all_memories
|
||||
│ └── Dockerfile
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@@ -48,8 +48,10 @@ Channel adapter → POST /message {text, session_id, channel, user_id}
|
||||
→ asyncio.gather(
|
||||
_fetch_urls_from_message() ← Crawl4AI, concurrent
|
||||
_retrieve_memories() ← openmemory search, concurrent
|
||||
_fast_tool_runner.run_matching() ← FastTools (weather, commute), concurrent
|
||||
)
|
||||
→ router.route() → tier decision (light/medium/complex)
|
||||
fast tool match → force medium
|
||||
if URL content fetched → upgrade light→medium
|
||||
→ invoke agent for tier via Bifrost (url_context + memories in system prompt)
|
||||
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) |
|
||||
| `grammy` | 3001 | grammY Telegram bot + `/send` HTTP endpoint |
|
||||
| `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 |
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
| Agent | Builder | Speed | Use case |
|
||||
@@ -143,7 +165,9 @@ MCP tools from openmemory (`add_memory`, `search_memory`, `get_all_memories`) ar
|
||||
|
||||
### 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)
|
||||
- `channels.py` — channel registry and `deliver()` dispatcher
|
||||
- `router.py` — `Router` class: regex + LLM classification, light-tier reply generation
|
||||
|
||||
@@ -5,6 +5,6 @@ WORKDIR /app
|
||||
RUN pip install --no-cache-dir deepagents langchain-openai langgraph \
|
||||
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"]
|
||||
|
||||
26
agent.py
26
agent.py
@@ -24,6 +24,7 @@ from langchain_core.tools import Tool
|
||||
from vram_manager import VRAMManager
|
||||
from router import Router
|
||||
from agent_factory import build_medium_agent, build_complex_agent
|
||||
from fast_tools import FastToolRunner, WeatherTool, CommuteTool
|
||||
import channels
|
||||
|
||||
# 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")
|
||||
OPENMEMORY_URL = os.getenv("OPENMEMORY_URL", "http://openmemory:8765")
|
||||
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
|
||||
_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)
|
||||
|
||||
|
||||
|
||||
# /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
|
||||
# /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_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
|
||||
_reply_semaphore = asyncio.Semaphore(1)
|
||||
|
||||
@@ -154,7 +164,7 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
|
||||
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 = {
|
||||
"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, [])
|
||||
print(f"[agent] running: {clean_message[:80]!r}", flush=True)
|
||||
|
||||
# Fetch URL content and memories concurrently — both are IO-bound, neither needs GPU
|
||||
url_context, memories = await asyncio.gather(
|
||||
# Fetch URL content, memories, and fast-tool context concurrently — all IO-bound
|
||||
url_context, memories, fast_context = await asyncio.gather(
|
||||
_fetch_urls_from_message(clean_message),
|
||||
_retrieve_memories(clean_message, session_id),
|
||||
_fast_tool_runner.run_matching(clean_message),
|
||||
)
|
||||
if url_context:
|
||||
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)
|
||||
if url_context:
|
||||
enriched_history = [{"role": "system", "content": url_context}] + enriched_history
|
||||
if fast_context:
|
||||
enriched_history = [{"role": "system", "content": fast_context}] + enriched_history
|
||||
if memories:
|
||||
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
|
||||
if 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
|
||||
in_think = False
|
||||
|
||||
@@ -31,6 +31,8 @@ services:
|
||||
- SEARXNG_URL=http://host.docker.internal:11437
|
||||
- GRAMMY_URL=http://grammy:3001
|
||||
- CRAWL4AI_URL=http://crawl4ai:11235
|
||||
- ROUTECHECK_URL=http://routecheck:8090
|
||||
- ROUTECHECK_TOKEN=${ROUTECHECK_TOKEN}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
@@ -38,6 +40,7 @@ services:
|
||||
- grammy
|
||||
- crawl4ai
|
||||
- bifrost
|
||||
- routecheck
|
||||
restart: unless-stopped
|
||||
|
||||
openmemory:
|
||||
@@ -79,6 +82,19 @@ services:
|
||||
profiles:
|
||||
- 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:
|
||||
image: unclecode/crawl4ai:latest
|
||||
container_name: crawl4ai
|
||||
|
||||
184
fast_tools.py
Normal file
184
fast_tools.py
Normal 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
6
routecheck/Dockerfile
Normal 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
377
routecheck/app.py
Normal 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>
|
||||
"""
|
||||
12
router.py
12
router.py
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
from fast_tools import FastToolRunner
|
||||
|
||||
# ── Regex pre-classifier ──────────────────────────────────────────────────────
|
||||
# Catches obvious light-tier patterns before calling the LLM.
|
||||
@@ -73,8 +74,9 @@ def _parse_tier(text: str) -> str:
|
||||
|
||||
|
||||
class Router:
|
||||
def __init__(self, model):
|
||||
def __init__(self, model, fast_tool_runner: FastToolRunner | None = None):
|
||||
self.model = model
|
||||
self._fast_tool_runner = fast_tool_runner
|
||||
|
||||
async def route(
|
||||
self,
|
||||
@@ -90,7 +92,13 @@ class Router:
|
||||
if force_complex:
|
||||
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()):
|
||||
print(f"[router] regex→light", flush=True)
|
||||
return await self._generate_light_reply(message, history)
|
||||
|
||||
40
tests/use_cases/weather_now.md
Normal file
40
tests/use_cases/weather_now.md
Normal 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.
|
||||
Reference in New Issue
Block a user