Compare commits
13 Commits
claude/iss
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8934b63f6 | ||
| a7ccfbddc9 | |||
| f1f67e62a7 | |||
| 00ef4fbd22 | |||
| fc0a94202f | |||
| bd3e207c0d | |||
| cc8ed5b57d | |||
| 823216db60 | |||
| 75ecfaba64 | |||
| 55beaf241f | |||
| 69498c9add | |||
| 6c76bf2f66 | |||
| 0436dfd4c4 |
@@ -27,8 +27,12 @@
|
||||
|
||||
# ── AirLLM / big-brain backend ───────────────────────────────────────────────
|
||||
# Inference backend: "ollama" (default) | "airllm" | "auto"
|
||||
# "auto" → uses AirLLM on Apple Silicon if installed, otherwise Ollama.
|
||||
# Requires: pip install ".[bigbrain]"
|
||||
# "ollama" → always use Ollama (safe everywhere, any OS)
|
||||
# "airllm" → AirLLM layer-by-layer loading (Apple Silicon M1/M2/M3/M4 only)
|
||||
# Requires 16 GB RAM minimum (32 GB recommended).
|
||||
# Automatically falls back to Ollama on Intel Mac or Linux.
|
||||
# Install extra: pip install "airllm[mlx]"
|
||||
# "auto" → use AirLLM on Apple Silicon if installed, otherwise Ollama
|
||||
# TIMMY_MODEL_BACKEND=ollama
|
||||
|
||||
# AirLLM model size (default: 70b).
|
||||
|
||||
@@ -62,6 +62,9 @@ Per AGENTS.md roster:
|
||||
- Run `tox -e pre-push` (lint + full CI suite)
|
||||
- Ensure tests stay green
|
||||
- Update TODO.md
|
||||
- **CRITICAL: Stage files before committing** — always run `git add .` or `git add <files>` first
|
||||
- Verify staged changes are non-empty: `git diff --cached --stat` must show files
|
||||
- **NEVER run `git commit` without staging files first** — empty commits waste review cycles
|
||||
|
||||
---
|
||||
|
||||
|
||||
42
AGENTS.md
42
AGENTS.md
@@ -247,6 +247,48 @@ make docker-agent # add a worker
|
||||
|
||||
---
|
||||
|
||||
## Search Capability (SearXNG + Crawl4AI)
|
||||
|
||||
Timmy has a self-hosted search backend requiring **no paid API key**.
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Module | Description |
|
||||
|------|--------|-------------|
|
||||
| `web_search(query)` | `timmy/tools/search.py` | Meta-search via SearXNG — returns ranked results |
|
||||
| `scrape_url(url)` | `timmy/tools/search.py` | Full-page scrape via Crawl4AI → clean markdown |
|
||||
|
||||
Both tools are registered in the **orchestrator** (full) and **echo** (research) toolkits.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `TIMMY_SEARCH_BACKEND` | `searxng` | `searxng` or `none` (disable) |
|
||||
| `TIMMY_SEARCH_URL` | `http://localhost:8888` | SearXNG base URL |
|
||||
| `TIMMY_CRAWL_URL` | `http://localhost:11235` | Crawl4AI base URL |
|
||||
|
||||
Inside Docker Compose (when `--profile search` is active), the dashboard
|
||||
uses `http://searxng:8080` and `http://crawl4ai:11235` by default.
|
||||
|
||||
### Starting the services
|
||||
|
||||
```bash
|
||||
# Start SearXNG + Crawl4AI alongside the dashboard:
|
||||
docker compose --profile search up
|
||||
|
||||
# Or start only the search services:
|
||||
docker compose --profile search up searxng crawl4ai
|
||||
```
|
||||
|
||||
### Graceful degradation
|
||||
|
||||
- If `TIMMY_SEARCH_BACKEND=none`: tools return a "disabled" message.
|
||||
- If SearXNG or Crawl4AI is unreachable: tools log a WARNING and return an
|
||||
error string — the app never crashes.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
**v2.0 Exodus (in progress):** Voice + Marketplace + Integrations
|
||||
|
||||
15
README.md
15
README.md
@@ -9,6 +9,21 @@ API access with Bitcoin Lightning — all from a browser, no cloud AI required.
|
||||
|
||||
---
|
||||
|
||||
## System Requirements
|
||||
|
||||
| Path | Hardware | RAM | Disk |
|
||||
|------|----------|-----|------|
|
||||
| **Ollama** (default) | Any OS — x86-64 or ARM | 8 GB min | 5–10 GB (model files) |
|
||||
| **AirLLM** (Apple Silicon) | M1, M2, M3, or M4 Mac | 16 GB min (32 GB recommended) | ~15 GB free |
|
||||
|
||||
**Ollama path** runs on any modern machine — macOS, Linux, or Windows. No GPU required.
|
||||
|
||||
**AirLLM path** uses layer-by-layer loading for 70B+ models without a GPU. Requires Apple
|
||||
Silicon and the `bigbrain` extras (`pip install ".[bigbrain]"`). On Intel Mac or Linux the
|
||||
app automatically falls back to Ollama — no crash, no config change needed.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -42,6 +42,10 @@ services:
|
||||
GROK_ENABLED: "${GROK_ENABLED:-false}"
|
||||
XAI_API_KEY: "${XAI_API_KEY:-}"
|
||||
GROK_DEFAULT_MODEL: "${GROK_DEFAULT_MODEL:-grok-3-fast}"
|
||||
# Search backend (SearXNG + Crawl4AI) — set TIMMY_SEARCH_BACKEND=none to disable
|
||||
TIMMY_SEARCH_BACKEND: "${TIMMY_SEARCH_BACKEND:-searxng}"
|
||||
TIMMY_SEARCH_URL: "${TIMMY_SEARCH_URL:-http://searxng:8080}"
|
||||
TIMMY_CRAWL_URL: "${TIMMY_CRAWL_URL:-http://crawl4ai:11235}"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway" # Linux: maps to host IP
|
||||
networks:
|
||||
@@ -74,6 +78,50 @@ services:
|
||||
profiles:
|
||||
- celery
|
||||
|
||||
# ── SearXNG — self-hosted meta-search engine ─────────────────────────
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: timmy-searxng
|
||||
profiles:
|
||||
- search
|
||||
ports:
|
||||
- "${SEARXNG_PORT:-8888}:8080"
|
||||
environment:
|
||||
SEARXNG_BASE_URL: "${SEARXNG_BASE_URL:-http://localhost:8888}"
|
||||
volumes:
|
||||
- ./docker/searxng:/etc/searxng:rw
|
||||
networks:
|
||||
- timmy-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
# ── Crawl4AI — self-hosted web scraper ────────────────────────────────
|
||||
crawl4ai:
|
||||
image: unclecode/crawl4ai:latest
|
||||
container_name: timmy-crawl4ai
|
||||
profiles:
|
||||
- search
|
||||
ports:
|
||||
- "${CRAWL4AI_PORT:-11235}:11235"
|
||||
environment:
|
||||
CRAWL4AI_API_TOKEN: "${CRAWL4AI_API_TOKEN:-}"
|
||||
volumes:
|
||||
- timmy-data:/app/data
|
||||
networks:
|
||||
- timmy-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:11235/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── OpenFang — vendored agent runtime sidecar ────────────────────────────
|
||||
openfang:
|
||||
build:
|
||||
|
||||
67
docker/searxng/settings.yml
Normal file
67
docker/searxng/settings.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
# SearXNG configuration for Timmy Time self-hosted search
|
||||
# https://docs.searxng.org/admin/settings/settings.html
|
||||
|
||||
general:
|
||||
debug: false
|
||||
instance_name: "Timmy Search"
|
||||
privacypolicy_url: false
|
||||
donation_url: false
|
||||
contact_url: false
|
||||
enable_metrics: false
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
bind_address: "0.0.0.0"
|
||||
secret_key: "timmy-searxng-key-change-in-production"
|
||||
base_url: false
|
||||
image_proxy: false
|
||||
|
||||
ui:
|
||||
static_use_hash: false
|
||||
default_locale: ""
|
||||
query_in_title: false
|
||||
infinite_scroll: false
|
||||
default_theme: simple
|
||||
center_alignment: false
|
||||
|
||||
search:
|
||||
safe_search: 0
|
||||
autocomplete: ""
|
||||
default_lang: "en"
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
|
||||
outgoing:
|
||||
request_timeout: 6.0
|
||||
max_request_timeout: 10.0
|
||||
useragent_suffix: "TimmyResearchBot"
|
||||
pool_connections: 100
|
||||
pool_maxsize: 20
|
||||
|
||||
enabled_plugins:
|
||||
- Hash_plugin
|
||||
- Search_on_category_select
|
||||
- Tracker_url_remover
|
||||
|
||||
engines:
|
||||
- name: google
|
||||
engine: google
|
||||
shortcut: g
|
||||
categories: general
|
||||
|
||||
- name: bing
|
||||
engine: bing
|
||||
shortcut: b
|
||||
categories: general
|
||||
|
||||
- name: duckduckgo
|
||||
engine: duckduckgo
|
||||
shortcut: d
|
||||
categories: general
|
||||
|
||||
- name: wikipedia
|
||||
engine: wikipedia
|
||||
shortcut: wp
|
||||
categories: general
|
||||
timeout: 3.0
|
||||
89
docs/SCREENSHOT_TRIAGE_2026-03-24.md
Normal file
89
docs/SCREENSHOT_TRIAGE_2026-03-24.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Screenshot Dump Triage — Visual Inspiration & Research Leads
|
||||
|
||||
**Date:** March 24, 2026
|
||||
**Source:** Issue #1275 — "Screenshot dump for triage #1"
|
||||
**Analyst:** Claude (Sonnet 4.6)
|
||||
|
||||
---
|
||||
|
||||
## Screenshots Ingested
|
||||
|
||||
| File | Subject | Action |
|
||||
|------|---------|--------|
|
||||
| IMG_6187.jpeg | AirLLM / Apple Silicon local LLM requirements | → Issue #1284 |
|
||||
| IMG_6125.jpeg | vLLM backend for agentic workloads | → Issue #1281 |
|
||||
| IMG_6124.jpeg | DeerFlow autonomous research pipeline | → Issue #1283 |
|
||||
| IMG_6123.jpeg | "Vibe Coder vs Normal Developer" meme | → Issue #1285 |
|
||||
| IMG_6410.jpeg | SearXNG + Crawl4AI self-hosted search MCP | → Issue #1282 |
|
||||
|
||||
---
|
||||
|
||||
## Tickets Created
|
||||
|
||||
### #1281 — feat: add vLLM as alternative inference backend
|
||||
**Source:** IMG_6125 (vLLM for agentic workloads)
|
||||
|
||||
vLLM's continuous batching makes it 3–10x more throughput-efficient than Ollama for multi-agent
|
||||
request patterns. Implement `VllmBackend` in `infrastructure/llm_router/` as a selectable
|
||||
backend (`TIMMY_LLM_BACKEND=vllm`) with graceful fallback to Ollama.
|
||||
|
||||
**Priority:** Medium — impactful for research pipeline performance once #972 is in use
|
||||
|
||||
---
|
||||
|
||||
### #1282 — feat: integrate SearXNG + Crawl4AI as self-hosted search backend
|
||||
**Source:** IMG_6410 (luxiaolei/searxng-crawl4ai-mcp)
|
||||
|
||||
Self-hosted search via SearXNG + Crawl4AI removes the hard dependency on paid search APIs
|
||||
(Brave, Tavily). Add both as Docker Compose services, implement `web_search()` and
|
||||
`scrape_url()` tools in `timmy/tools/`, and register them with the research agent.
|
||||
|
||||
**Priority:** High — unblocks fully local/private operation of research agents
|
||||
|
||||
---
|
||||
|
||||
### #1283 — research: evaluate DeerFlow as autonomous research orchestration layer
|
||||
**Source:** IMG_6124 (deer-flow Docker setup)
|
||||
|
||||
DeerFlow is ByteDance's open-source autonomous research pipeline framework. Before investing
|
||||
further in Timmy's custom orchestrator (#972), evaluate whether DeerFlow's architecture offers
|
||||
integration value or design patterns worth borrowing.
|
||||
|
||||
**Priority:** Medium — research first, implementation follows if go/no-go is positive
|
||||
|
||||
---
|
||||
|
||||
### #1284 — chore: document and validate AirLLM Apple Silicon requirements
|
||||
**Source:** IMG_6187 (Mac-compatible LLM setup)
|
||||
|
||||
AirLLM graceful degradation is already implemented but undocumented. Add System Requirements
|
||||
to README (M1/M2/M3/M4, 16 GB RAM min, 15 GB disk) and document `TIMMY_LLM_BACKEND` in
|
||||
`.env.example`.
|
||||
|
||||
**Priority:** Low — documentation only, no code risk
|
||||
|
||||
---
|
||||
|
||||
### #1285 — chore: enforce "Normal Developer" discipline — tighten quality gates
|
||||
**Source:** IMG_6123 (Vibe Coder vs Normal Developer meme)
|
||||
|
||||
Tighten the existing mypy/bandit/coverage gates: fix all mypy errors, raise coverage from 73%
|
||||
to 80%, add a documented pre-push hook, and run `vulture` for dead code. The infrastructure
|
||||
exists — it just needs enforcing.
|
||||
|
||||
**Priority:** Medium — technical debt prevention, pairs well with any green-field feature work
|
||||
|
||||
---
|
||||
|
||||
## Patterns Observed Across Screenshots
|
||||
|
||||
1. **Local-first is the north star.** All five images reinforce the same theme: private,
|
||||
self-hosted, runs on your hardware. vLLM, SearXNG, AirLLM, DeerFlow — none require cloud.
|
||||
Timmy is already aligned with this direction; these are tactical additions.
|
||||
|
||||
2. **Agentic performance bottlenecks are real.** Two of five images (vLLM, DeerFlow) focus
|
||||
specifically on throughput and reliability for multi-agent loops. As the research pipeline
|
||||
matures, inference speed and search reliability will become the main constraints.
|
||||
|
||||
3. **Discipline compounds.** The meme is a reminder that the quality gates we have (tox,
|
||||
mypy, bandit, coverage) only pay off if they are enforced without exceptions.
|
||||
290
docs/research/kimi-creative-blueprint-891.md
Normal file
290
docs/research/kimi-creative-blueprint-891.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Building Timmy: Technical Blueprint for Sovereign Creative AI
|
||||
|
||||
> **Source:** PDF attached to issue #891, "Building Timmy: a technical blueprint for sovereign
|
||||
> creative AI" — generated by Kimi.ai, 16 pages, filed by Perplexity for Timmy's review.
|
||||
> **Filed:** 2026-03-22 · **Reviewed:** 2026-03-23
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The blueprint establishes that a sovereign creative AI capable of coding, composing music,
|
||||
generating art, building worlds, publishing narratives, and managing its own economy is
|
||||
**technically feasible today** — but only through orchestration of dozens of tools operating
|
||||
at different maturity levels. The core insight: *the integration is the invention*. No single
|
||||
component is new; the missing piece is a coherent identity operating across all domains
|
||||
simultaneously with persistent memory, autonomous economics, and cross-domain creative
|
||||
reactions.
|
||||
|
||||
Three non-negotiable architectural decisions:
|
||||
1. **Human oversight for all public-facing content** — every successful creative AI has this;
|
||||
every one that removed it failed.
|
||||
2. **Legal entity before economic activity** — AI agents are not legal persons; establish
|
||||
structure before wealth accumulates (Truth Terminal cautionary tale: $20M acquired before
|
||||
a foundation was retroactively created).
|
||||
3. **Hybrid memory: vector search + knowledge graph** — neither alone is sufficient for
|
||||
multi-domain context breadth.
|
||||
|
||||
---
|
||||
|
||||
## Domain-by-Domain Assessment
|
||||
|
||||
### Software Development (immediately deployable)
|
||||
|
||||
| Component | Recommendation | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| Primary agent | Claude Code (Opus 4.6, 77.2% SWE-bench) | Already in use |
|
||||
| Self-hosted forge | Forgejo (MIT, 170–200MB RAM) | Project uses Gitea/Forgejo now |
|
||||
| CI/CD | GitHub Actions-compatible via `act_runner` | — |
|
||||
| Tool-making | LATM pattern: frontier model creates tools, cheaper model applies them | New — see ADR opportunity |
|
||||
| Open-source fallback | OpenHands (~65% SWE-bench, Docker sandboxed) | Backup to Claude Code |
|
||||
| Self-improvement | Darwin Gödel Machine / SICA patterns | 3–6 month investment |
|
||||
|
||||
**Development estimate:** 2–3 weeks for Forgejo + Claude Code integration with automated
|
||||
PR workflows; 1–2 months for self-improving tool-making pipeline.
|
||||
|
||||
**Cross-reference:** This project already runs Claude Code agents on Forgejo. The LATM
|
||||
pattern (tool registry) and self-improvement loop are the actionable gaps.
|
||||
|
||||
---
|
||||
|
||||
### Music (1–4 weeks)
|
||||
|
||||
| Component | Recommendation | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| Commercial vocals | Suno v5 API (~$0.03/song, $30/month Premier) | No official API; third-party: sunoapi.org, AIMLAPI, EvoLink |
|
||||
| Local instrumental | MusicGen 1.5B (CC-BY-NC — monetization blocker) | On M2 Max: ~60s for 5s clip |
|
||||
| Voice cloning | GPT-SoVITS v4 (MIT) | Works on Apple Silicon CPU, RTF 0.526 on M4 |
|
||||
| Voice conversion | RVC (MIT, 5–10 min training audio) | — |
|
||||
| Apple Silicon TTS | MLX-Audio: Kokoro 82M + Qwen3-TTS 0.6B | 4–5x faster via Metal |
|
||||
| Publishing | Wavlake (90/10 split, Lightning micropayments) | Auto-syndicates to Fountain.fm |
|
||||
| Nostr | NIP-94 (kind:1063) audio events → NIP-96 servers | — |
|
||||
|
||||
**Copyright reality:** US Copyright Office (Jan 2025) and US Court of Appeals (Mar 2025):
|
||||
purely AI-generated music cannot be copyrighted and enters public domain. Wavlake's
|
||||
Value4Value model works around this — fans pay for relationship, not exclusive rights.
|
||||
|
||||
**Avoid:** Udio (download disabled since Oct 2025, 2.4/5 Trustpilot).
|
||||
|
||||
---
|
||||
|
||||
### Visual Art (1–3 weeks)
|
||||
|
||||
| Component | Recommendation | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| Local generation | ComfyUI API at `127.0.0.1:8188` (programmatic control via WebSocket) | MLX extension: 50–70% faster |
|
||||
| Speed | Draw Things (free, Mac App Store) | 3× faster than ComfyUI via Metal shaders |
|
||||
| Quality frontier | Flux 2 (Nov 2025, 4MP, multi-reference) | SDXL needs 16GB+, Flux Dev 32GB+ |
|
||||
| Character consistency | LoRA training (30 min, 15–30 references) + Flux.1 Kontext | Solved problem |
|
||||
| Face consistency | IP-Adapter + FaceID (ComfyUI-IP-Adapter-Plus) | Training-free |
|
||||
| Comics | Jenova AI ($20/month, 200+ page consistency) or LlamaGen AI (free) | — |
|
||||
| Publishing | Blossom protocol (SHA-256 addressed, kind:10063) + Nostr NIP-94 | — |
|
||||
| Physical | Printful REST API (200+ products, automated fulfillment) | — |
|
||||
|
||||
---
|
||||
|
||||
### Writing / Narrative (1–4 weeks for pipeline; ongoing for quality)
|
||||
|
||||
| Component | Recommendation | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| LLM | Claude Opus 4.5/4.6 (leads Mazur Writing Benchmark at 8.561) | Already in use |
|
||||
| Context | 500K tokens (1M in beta) — entire novels fit | — |
|
||||
| Architecture | Outline-first → RAG lore bible → chapter-by-chapter generation | Without outline: novels meander |
|
||||
| Lore management | WorldAnvil Pro or custom LoreScribe (local RAG) | No tool achieves 100% consistency |
|
||||
| Publishing (ebooks) | Pandoc → EPUB / KDP PDF | pandoc-novel template on GitHub |
|
||||
| Publishing (print) | Lulu Press REST API (80% profit, global print network) | KDP: no official API, 3-book/day limit |
|
||||
| Publishing (Nostr) | NIP-23 kind:30023 long-form events | Habla.news, YakiHonne, Stacker News |
|
||||
| Podcasts | LLM script → TTS (ElevenLabs or local Kokoro/MLX-Audio) → feedgen RSS → Fountain.fm | Value4Value sats-per-minute |
|
||||
|
||||
**Key constraint:** AI-assisted (human directs, AI drafts) = 40% faster. Fully autonomous
|
||||
without editing = "generic, soulless prose" and character drift by chapter 3 without explicit
|
||||
memory.
|
||||
|
||||
---
|
||||
|
||||
### World Building / Games (2 weeks–3 months depending on target)
|
||||
|
||||
| Component | Recommendation | Notes |
|
||||
|-----------|----------------|-------|
|
||||
| Algorithms | Wave Function Collapse, Perlin noise (FastNoiseLite in Godot 4), L-systems | All mature |
|
||||
| Platform | Godot Engine + gd-agentic-skills (82+ skills, 26 genre blueprints) | Strong LLM/GDScript knowledge |
|
||||
| Narrative design | Knowledge graph (world state) + LLM + quest template grammar | CHI 2023 validated |
|
||||
| Quick win | Luanti/Minetest (Lua API, 2,800+ open mods for reference) | Immediately feasible |
|
||||
| Medium effort | OpenMW content creation (omwaddon format engineering required) | 2–3 months |
|
||||
| Future | Unity MCP (AI direct Unity Editor interaction) | Early-stage |
|
||||
|
||||
---
|
||||
|
||||
### Identity Architecture (2 months)
|
||||
|
||||
The blueprint formalizes the **SOUL.md standard** (GitHub: aaronjmars/soul.md):
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SOUL.md` | Who you are — identity, worldview, opinions |
|
||||
| `STYLE.md` | How you write — voice, syntax, patterns |
|
||||
| `SKILL.md` | Operating modes |
|
||||
| `MEMORY.md` | Session continuity |
|
||||
|
||||
**Critical decision — static vs self-modifying identity:**
|
||||
- Static Core Truths (version-controlled, human-approved changes only) ✓
|
||||
- Self-modifying Learned Preferences (logged with rollback, monitored by guardian) ✓
|
||||
- **Warning:** OpenClaw's "Soul Evolution" creates a security attack surface — Zenity Labs
|
||||
demonstrated a complete zero-click attack chain targeting SOUL.md files.
|
||||
|
||||
**Relevance to this repo:** Claude Code agents already use a `MEMORY.md` pattern in
|
||||
this project. The SOUL.md stack is a natural extension.
|
||||
|
||||
---
|
||||
|
||||
### Memory Architecture (2 months)
|
||||
|
||||
Hybrid vector + knowledge graph is the recommendation:
|
||||
|
||||
| Component | Tool | Notes |
|
||||
|-----------|------|-------|
|
||||
| Vector + KG combined | Mem0 (mem0.ai) | 26% accuracy improvement over OpenAI memory, 91% lower p95 latency, 90% token savings |
|
||||
| Vector store | Qdrant (Rust, open-source) | High-throughput with metadata filtering |
|
||||
| Temporal KG | Neo4j + Graphiti (Zep AI) | P95 retrieval: 300ms, hybrid semantic + BM25 + graph |
|
||||
| Backup/migration | AgentKeeper (95% critical fact recovery across model migrations) | — |
|
||||
|
||||
**Journal pattern (Stanford Generative Agents):** Agent writes about experiences, generates
|
||||
high-level reflections 2–3x/day when importance scores exceed threshold. Ablation studies:
|
||||
removing any component (observation, planning, reflection) significantly reduces behavioral
|
||||
believability.
|
||||
|
||||
**Cross-reference:** The existing `brain/` package is the memory system. Qdrant and
|
||||
Mem0 are the recommended upgrade targets.
|
||||
|
||||
---
|
||||
|
||||
### Multi-Agent Sub-System (3–6 months)
|
||||
|
||||
The blueprint describes a named sub-agent hierarchy:
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| Oracle | Top-level planner / supervisor |
|
||||
| Sentinel | Safety / moderation |
|
||||
| Scout | Research / information gathering |
|
||||
| Scribe | Writing / narrative |
|
||||
| Ledger | Economic management |
|
||||
| Weaver | Visual art generation |
|
||||
| Composer | Music generation |
|
||||
| Social | Platform publishing |
|
||||
|
||||
**Orchestration options:**
|
||||
- **Agno** (already in use) — microsecond instantiation, 50× less memory than LangGraph
|
||||
- **CrewAI Flows** — event-driven with fine-grained control
|
||||
- **LangGraph** — DAG-based with stateful workflows and time-travel debugging
|
||||
|
||||
**Scheduling pattern (Stanford Generative Agents):** Top-down recursive daily → hourly →
|
||||
5-minute planning. Event interrupts for reactive tasks. Re-planning triggers when accumulated
|
||||
importance scores exceed threshold.
|
||||
|
||||
**Cross-reference:** The existing `spark/` package (event capture, advisory engine) aligns
|
||||
with this architecture. `infrastructure/event_bus` is the choreography backbone.
|
||||
|
||||
---
|
||||
|
||||
### Economic Engine (1–4 weeks)
|
||||
|
||||
Lightning Labs released `lightning-agent-tools` (open-source) in February 2026:
|
||||
- `lnget` — CLI HTTP client for L402 payments
|
||||
- Remote signer architecture (private keys on separate machine from agent)
|
||||
- Scoped macaroon credentials (pay-only, invoice-only, read-only roles)
|
||||
- **Aperture** — converts any API to pay-per-use via L402 (HTTP 402)
|
||||
|
||||
| Option | Effort | Notes |
|
||||
|--------|--------|-------|
|
||||
| ln.bot | 1 week | "Bitcoin for AI Agents" — 3 commands create a wallet; CLI + MCP + REST |
|
||||
| LND via gRPC | 2–3 weeks | Full programmatic node management for production |
|
||||
| Coinbase Agentic Wallets | — | Fiat-adjacent; less aligned with sovereignty ethos |
|
||||
|
||||
**Revenue channels:** Wavlake (music, 90/10 Lightning), Nostr zaps (articles), Stacker News
|
||||
(earn sats from engagement), Printful (physical goods), L402-gated API access (pay-per-use
|
||||
services), Geyser.fund (Lightning crowdfunding, better initial runway than micropayments).
|
||||
|
||||
**Cross-reference:** The existing `lightning/` package in this repo is the foundation.
|
||||
L402 paywall endpoints for Timmy's own services is the actionable gap.
|
||||
|
||||
---
|
||||
|
||||
## Pioneer Case Studies
|
||||
|
||||
| Agent | Active | Revenue | Key Lesson |
|
||||
|-------|--------|---------|-----------|
|
||||
| Botto | Since Oct 2021 | $5M+ (art auctions) | Community governance via DAO sustains engagement; "taste model" (humans guide, not direct) preserves autonomous authorship |
|
||||
| Neuro-sama | Since Dec 2022 | $400K+/month (subscriptions) | 3+ years of iteration; errors became entertainment features; 24/7 capability is an insurmountable advantage |
|
||||
| Truth Terminal | Since Jun 2024 | $20M accumulated | Memetic fitness > planned monetization; human gatekeeper approved tweets while selecting AI-intent responses; **establish legal entity first** |
|
||||
| Holly+ | Since 2021 | Conceptual | DAO of stewards for voice governance; "identity play" as alternative to defensive IP |
|
||||
| AI Sponge | 2023 | Banned | Unmoderated content → TOS violations + copyright |
|
||||
| Nothing Forever | 2022–present | 8 viewers | Unmoderated content → ban → audience collapse; novelty-only propositions fail |
|
||||
|
||||
**Universal pattern:** Human oversight + economic incentive alignment + multi-year personality
|
||||
development + platform-native economics = success.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Sequence
|
||||
|
||||
From the blueprint, mapped against Timmy's existing architecture:
|
||||
|
||||
### Phase 1: Immediate (weeks)
|
||||
1. **Code sovereignty** — Forgejo + Claude Code automated PR workflows (already substantially done)
|
||||
2. **Music pipeline** — Suno API → Wavlake/Nostr NIP-94 publishing
|
||||
3. **Visual art pipeline** — ComfyUI API → Blossom/Nostr with LoRA character consistency
|
||||
4. **Basic Lightning wallet** — ln.bot integration for receiving micropayments
|
||||
5. **Long-form publishing** — Nostr NIP-23 + RSS feed generation
|
||||
|
||||
### Phase 2: Moderate effort (1–3 months)
|
||||
6. **LATM tool registry** — frontier model creates Python utilities, caches them, lighter model applies
|
||||
7. **Event-driven cross-domain reactions** — game event → blog + artwork + music (CrewAI/LangGraph)
|
||||
8. **Podcast generation** — TTS + feedgen → Fountain.fm
|
||||
9. **Self-improving pipeline** — agent creates, tests, caches own Python utilities
|
||||
10. **Comic generation** — character-consistent panels with Jenova AI or local LoRA
|
||||
|
||||
### Phase 3: Significant investment (3–6 months)
|
||||
11. **Full sub-agent hierarchy** — Oracle/Sentinel/Scout/Scribe/Ledger/Weaver with Agno
|
||||
12. **SOUL.md identity system** — bounded evolution + guardian monitoring
|
||||
13. **Hybrid memory upgrade** — Qdrant + Mem0/Graphiti replacing or extending `brain/`
|
||||
14. **Procedural world generation** — Godot + AI-driven narrative (quests, NPCs, lore)
|
||||
15. **Self-sustaining economic loop** — earned revenue covers compute costs
|
||||
|
||||
### Remains aspirational (12+ months)
|
||||
- Fully autonomous novel-length fiction without editorial intervention
|
||||
- YouTube monetization for AI-generated content (tightening platform policies)
|
||||
- Copyright protection for AI-generated works (current US law denies this)
|
||||
- True artistic identity evolution (genuine creative voice vs pattern remixing)
|
||||
- Self-modifying architecture without regression or identity drift
|
||||
|
||||
---
|
||||
|
||||
## Gap Analysis: Blueprint vs Current Codebase
|
||||
|
||||
| Blueprint Capability | Current Status | Gap |
|
||||
|---------------------|----------------|-----|
|
||||
| Code sovereignty | Done (Claude Code + Forgejo) | LATM tool registry |
|
||||
| Music generation | Not started | Suno API integration + Wavlake publishing |
|
||||
| Visual art | Not started | ComfyUI API client + Blossom publishing |
|
||||
| Writing/publishing | Not started | Nostr NIP-23 + Pandoc pipeline |
|
||||
| World building | Bannerlord work (different scope) | Luanti mods as quick win |
|
||||
| Identity (SOUL.md) | Partial (CLAUDE.md + MEMORY.md) | Full SOUL.md stack |
|
||||
| Memory (hybrid) | `brain/` package (SQLite-based) | Qdrant + knowledge graph |
|
||||
| Multi-agent | Agno in use | Named hierarchy + event choreography |
|
||||
| Lightning payments | `lightning/` package | ln.bot wallet + L402 endpoints |
|
||||
| Nostr identity | Referenced in roadmap, not built | NIP-05, NIP-89 capability cards |
|
||||
| Legal entity | Unknown | **Must be resolved before economic activity** |
|
||||
|
||||
---
|
||||
|
||||
## ADR Candidates
|
||||
|
||||
Issues that warrant Architecture Decision Records based on this review:
|
||||
|
||||
1. **LATM tool registry pattern** — How Timmy creates, tests, and caches self-made tools
|
||||
2. **Music generation strategy** — Suno (cloud, commercial quality) vs MusicGen (local, CC-BY-NC)
|
||||
3. **Memory upgrade path** — When/how to migrate `brain/` from SQLite to Qdrant + KG
|
||||
4. **SOUL.md adoption** — Extending existing CLAUDE.md/MEMORY.md to full SOUL.md stack
|
||||
5. **Lightning L402 strategy** — Which services Timmy gates behind micropayments
|
||||
6. **Sub-agent naming and contracts** — Formalizing Oracle/Sentinel/Scout/Scribe/Ledger/Weaver
|
||||
@@ -15,6 +15,7 @@ packages = [
|
||||
{ include = "config.py", from = "src" },
|
||||
|
||||
{ include = "bannerlord", from = "src" },
|
||||
{ include = "brain", from = "src" },
|
||||
{ include = "dashboard", from = "src" },
|
||||
{ include = "infrastructure", from = "src" },
|
||||
{ include = "integrations", from = "src" },
|
||||
|
||||
1
src/brain/__init__.py
Normal file
1
src/brain/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Brain — identity system and task coordination."""
|
||||
314
src/brain/worker.py
Normal file
314
src/brain/worker.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""DistributedWorker — task lifecycle management and backend routing.
|
||||
|
||||
Routes delegated tasks to appropriate execution backends:
|
||||
|
||||
- agentic_loop: local multi-step execution via Timmy's agentic loop
|
||||
- kimi: heavy research tasks dispatched via Gitea kimi-ready issues
|
||||
- paperclip: task submission to the Paperclip API
|
||||
|
||||
Task lifecycle: queued → running → completed | failed
|
||||
|
||||
Failure handling: auto-retry up to MAX_RETRIES, then mark failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, ClassVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_RETRIES = 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task record
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DelegatedTask:
|
||||
"""Record of one delegated task and its execution state."""
|
||||
|
||||
task_id: str
|
||||
agent_name: str
|
||||
agent_role: str
|
||||
task_description: str
|
||||
priority: str
|
||||
backend: str # "agentic_loop" | "kimi" | "paperclip"
|
||||
status: str = "queued" # queued | running | completed | failed
|
||||
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
retries: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DistributedWorker:
|
||||
"""Routes and tracks delegated task execution across multiple backends.
|
||||
|
||||
All methods are class-methods; DistributedWorker is a singleton-style
|
||||
service — no instantiation needed.
|
||||
|
||||
Usage::
|
||||
|
||||
from brain.worker import DistributedWorker
|
||||
|
||||
task_id = DistributedWorker.submit("researcher", "research", "summarise X")
|
||||
status = DistributedWorker.get_status(task_id)
|
||||
"""
|
||||
|
||||
_tasks: ClassVar[dict[str, DelegatedTask]] = {}
|
||||
_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def submit(
|
||||
cls,
|
||||
agent_name: str,
|
||||
agent_role: str,
|
||||
task_description: str,
|
||||
priority: str = "normal",
|
||||
) -> str:
|
||||
"""Submit a task for execution. Returns task_id immediately.
|
||||
|
||||
The task is registered as 'queued' and a daemon thread begins
|
||||
execution in the background. Use get_status(task_id) to poll.
|
||||
"""
|
||||
task_id = uuid.uuid4().hex[:8]
|
||||
backend = cls._select_backend(agent_role, task_description)
|
||||
|
||||
record = DelegatedTask(
|
||||
task_id=task_id,
|
||||
agent_name=agent_name,
|
||||
agent_role=agent_role,
|
||||
task_description=task_description,
|
||||
priority=priority,
|
||||
backend=backend,
|
||||
)
|
||||
|
||||
with cls._lock:
|
||||
cls._tasks[task_id] = record
|
||||
|
||||
thread = threading.Thread(
|
||||
target=cls._run_task,
|
||||
args=(record,),
|
||||
daemon=True,
|
||||
name=f"worker-{task_id}",
|
||||
)
|
||||
thread.start()
|
||||
|
||||
logger.info(
|
||||
"Task %s queued: %s → %.60s (backend=%s, priority=%s)",
|
||||
task_id,
|
||||
agent_name,
|
||||
task_description,
|
||||
backend,
|
||||
priority,
|
||||
)
|
||||
return task_id
|
||||
|
||||
@classmethod
|
||||
def get_status(cls, task_id: str) -> dict[str, Any]:
|
||||
"""Return current status of a task by ID."""
|
||||
record = cls._tasks.get(task_id)
|
||||
if record is None:
|
||||
return {"found": False, "task_id": task_id}
|
||||
return {
|
||||
"found": True,
|
||||
"task_id": record.task_id,
|
||||
"agent": record.agent_name,
|
||||
"role": record.agent_role,
|
||||
"status": record.status,
|
||||
"backend": record.backend,
|
||||
"priority": record.priority,
|
||||
"created_at": record.created_at,
|
||||
"retries": record.retries,
|
||||
"result": record.result,
|
||||
"error": record.error,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def list_tasks(cls) -> list[dict[str, Any]]:
|
||||
"""Return a summary list of all tracked tasks."""
|
||||
with cls._lock:
|
||||
return [
|
||||
{
|
||||
"task_id": t.task_id,
|
||||
"agent": t.agent_name,
|
||||
"status": t.status,
|
||||
"backend": t.backend,
|
||||
"created_at": t.created_at,
|
||||
}
|
||||
for t in cls._tasks.values()
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def clear(cls) -> None:
|
||||
"""Clear the task registry (for tests)."""
|
||||
with cls._lock:
|
||||
cls._tasks.clear()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backend selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _select_backend(cls, agent_role: str, task_description: str) -> str:
|
||||
"""Choose the execution backend for a given agent role and task.
|
||||
|
||||
Priority:
|
||||
1. kimi — research role + Gitea enabled + task exceeds local capacity
|
||||
2. paperclip — paperclip API key is configured
|
||||
3. agentic_loop — local fallback (always available)
|
||||
"""
|
||||
try:
|
||||
from config import settings
|
||||
from timmy.kimi_delegation import exceeds_local_capacity
|
||||
|
||||
if (
|
||||
agent_role == "research"
|
||||
and getattr(settings, "gitea_enabled", False)
|
||||
and getattr(settings, "gitea_token", "")
|
||||
and exceeds_local_capacity(task_description)
|
||||
):
|
||||
return "kimi"
|
||||
|
||||
if getattr(settings, "paperclip_api_key", ""):
|
||||
return "paperclip"
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("Backend selection error — defaulting to agentic_loop: %s", exc)
|
||||
|
||||
return "agentic_loop"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _run_task(cls, record: DelegatedTask) -> None:
|
||||
"""Execute a task with retry logic. Runs inside a daemon thread."""
|
||||
record.status = "running"
|
||||
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
"Retrying task %s (attempt %d/%d)",
|
||||
record.task_id,
|
||||
attempt + 1,
|
||||
MAX_RETRIES + 1,
|
||||
)
|
||||
record.retries = attempt
|
||||
|
||||
result = cls._dispatch(record)
|
||||
record.status = "completed"
|
||||
record.result = result
|
||||
logger.info(
|
||||
"Task %s completed via %s",
|
||||
record.task_id,
|
||||
record.backend,
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Task %s attempt %d failed: %s",
|
||||
record.task_id,
|
||||
attempt + 1,
|
||||
exc,
|
||||
)
|
||||
if attempt == MAX_RETRIES:
|
||||
record.status = "failed"
|
||||
record.error = str(exc)
|
||||
logger.error(
|
||||
"Task %s exhausted %d retries. Final error: %s",
|
||||
record.task_id,
|
||||
MAX_RETRIES,
|
||||
exc,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _dispatch(cls, record: DelegatedTask) -> dict[str, Any]:
|
||||
"""Route to the selected backend. Raises on failure."""
|
||||
if record.backend == "kimi":
|
||||
return asyncio.run(cls._execute_kimi(record))
|
||||
if record.backend == "paperclip":
|
||||
return asyncio.run(cls._execute_paperclip(record))
|
||||
return asyncio.run(cls._execute_agentic_loop(record))
|
||||
|
||||
@classmethod
|
||||
async def _execute_kimi(cls, record: DelegatedTask) -> dict[str, Any]:
|
||||
"""Create a kimi-ready Gitea issue for the task.
|
||||
|
||||
Kimi picks up the issue via the kimi-ready label and executes it.
|
||||
"""
|
||||
from timmy.kimi_delegation import create_kimi_research_issue
|
||||
|
||||
result = await create_kimi_research_issue(
|
||||
task=record.task_description[:120],
|
||||
context=f"Delegated by agent '{record.agent_name}' via delegate_task.",
|
||||
question=record.task_description,
|
||||
priority=record.priority,
|
||||
)
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"Kimi issue creation failed: {result.get('error')}")
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def _execute_paperclip(cls, record: DelegatedTask) -> dict[str, Any]:
|
||||
"""Submit the task to the Paperclip API."""
|
||||
import httpx
|
||||
|
||||
from timmy.paperclip import PaperclipClient
|
||||
|
||||
client = PaperclipClient()
|
||||
async with httpx.AsyncClient(timeout=client.timeout) as http:
|
||||
resp = await http.post(
|
||||
f"{client.base_url}/api/tasks",
|
||||
headers={"Authorization": f"Bearer {client.api_key}"},
|
||||
json={
|
||||
"kind": record.agent_role,
|
||||
"agent_id": client.agent_id,
|
||||
"company_id": client.company_id,
|
||||
"priority": record.priority,
|
||||
"context": {"task": record.task_description},
|
||||
},
|
||||
)
|
||||
|
||||
if resp.status_code in (200, 201):
|
||||
data = resp.json()
|
||||
logger.info(
|
||||
"Task %s submitted to Paperclip (paperclip_id=%s)",
|
||||
record.task_id,
|
||||
data.get("id"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"paperclip_task_id": data.get("id"),
|
||||
"backend": "paperclip",
|
||||
}
|
||||
raise RuntimeError(f"Paperclip API error {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
@classmethod
|
||||
async def _execute_agentic_loop(cls, record: DelegatedTask) -> dict[str, Any]:
|
||||
"""Execute the task via Timmy's local agentic loop."""
|
||||
from timmy.agentic_loop import run_agentic_loop
|
||||
|
||||
result = await run_agentic_loop(record.task_description)
|
||||
return {
|
||||
"success": result.status != "failed",
|
||||
"agentic_task_id": result.task_id,
|
||||
"summary": result.summary,
|
||||
"status": result.status,
|
||||
"backend": "agentic_loop",
|
||||
}
|
||||
@@ -94,8 +94,9 @@ class Settings(BaseSettings):
|
||||
|
||||
# ── Backend selection ────────────────────────────────────────────────────
|
||||
# "ollama" — always use Ollama (default, safe everywhere)
|
||||
# "airllm" — AirLLM layer-by-layer loading (Apple Silicon only; degrades to Ollama)
|
||||
# "auto" — pick best available local backend, fall back to Ollama
|
||||
timmy_model_backend: Literal["ollama", "grok", "claude", "auto"] = "ollama"
|
||||
timmy_model_backend: Literal["ollama", "airllm", "grok", "claude", "auto"] = "ollama"
|
||||
|
||||
# ── Grok (xAI) — opt-in premium cloud backend ────────────────────────
|
||||
# Grok is a premium augmentation layer — local-first ethos preserved.
|
||||
@@ -108,6 +109,16 @@ class Settings(BaseSettings):
|
||||
grok_sats_hard_cap: int = 100 # Absolute ceiling on sats per Grok query
|
||||
grok_free: bool = False # Skip Lightning invoice when user has own API key
|
||||
|
||||
# ── Search Backend (SearXNG + Crawl4AI) ──────────────────────────────
|
||||
# "searxng" — self-hosted SearXNG meta-search engine (default, no API key)
|
||||
# "none" — disable web search (private/offline deployments)
|
||||
# Override with TIMMY_SEARCH_BACKEND env var.
|
||||
timmy_search_backend: Literal["searxng", "none"] = "searxng"
|
||||
# SearXNG base URL — override with TIMMY_SEARCH_URL env var
|
||||
search_url: str = "http://localhost:8888"
|
||||
# Crawl4AI base URL — override with TIMMY_CRAWL_URL env var
|
||||
crawl_url: str = "http://localhost:11235"
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────
|
||||
db_busy_timeout_ms: int = 5000 # SQLite PRAGMA busy_timeout (ms)
|
||||
|
||||
|
||||
@@ -186,6 +186,24 @@
|
||||
<p class="chat-history-placeholder">Loading sovereignty metrics...</p>
|
||||
{% endcall %}
|
||||
|
||||
<!-- Agent Scorecards -->
|
||||
<div class="card mc-card-spaced" id="mc-scorecards-card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Agent Scorecards</h2>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="mc-scorecard-period" class="form-select form-select-sm" style="width: auto;"
|
||||
onchange="loadMcScorecards()">
|
||||
<option value="daily" selected>Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
<a href="/scorecards" class="btn btn-sm btn-outline-secondary">Full View</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mc-scorecards-content" class="p-2">
|
||||
<p class="chat-history-placeholder">Loading scorecards...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat History -->
|
||||
<div class="card mc-card-spaced">
|
||||
<div class="card-header">
|
||||
@@ -502,6 +520,20 @@ async function loadSparkStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load agent scorecards
|
||||
async function loadMcScorecards() {
|
||||
var period = document.getElementById('mc-scorecard-period').value;
|
||||
var container = document.getElementById('mc-scorecards-content');
|
||||
container.innerHTML = '<p class="chat-history-placeholder">Loading scorecards...</p>';
|
||||
try {
|
||||
var response = await fetch('/scorecards/all/panels?period=' + period);
|
||||
var html = await response.text();
|
||||
container.innerHTML = html;
|
||||
} catch (error) {
|
||||
container.innerHTML = '<p class="chat-history-placeholder">Scorecards unavailable</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadSparkStatus();
|
||||
loadSovereignty();
|
||||
@@ -510,6 +542,7 @@ loadSwarmStats();
|
||||
loadLightningStats();
|
||||
loadGrokStats();
|
||||
loadChatHistory();
|
||||
loadMcScorecards();
|
||||
|
||||
// Periodic updates
|
||||
setInterval(loadSovereignty, 30000);
|
||||
@@ -518,5 +551,6 @@ setInterval(loadSwarmStats, 5000);
|
||||
setInterval(updateHeartbeat, 5000);
|
||||
setInterval(loadGrokStats, 10000);
|
||||
setInterval(loadSparkStatus, 15000);
|
||||
setInterval(loadMcScorecards, 300000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -301,6 +301,26 @@ def create_timmy(
|
||||
|
||||
return GrokBackend()
|
||||
|
||||
if resolved == "airllm":
|
||||
# AirLLM requires Apple Silicon. On any other platform (Intel Mac, Linux,
|
||||
# Windows) or when the package is not installed, degrade silently to Ollama.
|
||||
from timmy.backends import is_apple_silicon
|
||||
|
||||
if not is_apple_silicon():
|
||||
logger.warning(
|
||||
"TIMMY_MODEL_BACKEND=airllm requested but not running on Apple Silicon "
|
||||
"— falling back to Ollama"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
import airllm # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"AirLLM not installed — falling back to Ollama. "
|
||||
"Install with: pip install 'airllm[mlx]'"
|
||||
)
|
||||
# Fall through to Ollama in all cases (AirLLM integration is scaffolded)
|
||||
|
||||
# Default: Ollama via Agno.
|
||||
model_name, is_fallback = _resolve_model_with_fallback(
|
||||
requested_model=None,
|
||||
|
||||
@@ -46,6 +46,7 @@ from timmy.tools.file_tools import (
|
||||
create_research_tools,
|
||||
create_writing_tools,
|
||||
)
|
||||
from timmy.tools.search import scrape_url, web_search
|
||||
from timmy.tools.system_tools import (
|
||||
_safe_eval,
|
||||
calculator,
|
||||
@@ -72,6 +73,9 @@ __all__ = [
|
||||
"create_data_tools",
|
||||
"create_research_tools",
|
||||
"create_writing_tools",
|
||||
# search
|
||||
"scrape_url",
|
||||
"web_search",
|
||||
# system_tools
|
||||
"_safe_eval",
|
||||
"calculator",
|
||||
|
||||
@@ -28,6 +28,7 @@ from timmy.tools.file_tools import (
|
||||
create_research_tools,
|
||||
create_writing_tools,
|
||||
)
|
||||
from timmy.tools.search import scrape_url, web_search
|
||||
from timmy.tools.system_tools import (
|
||||
calculator,
|
||||
consult_grok,
|
||||
@@ -54,6 +55,16 @@ def _register_web_fetch_tool(toolkit: Toolkit) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _register_search_tools(toolkit: Toolkit) -> None:
|
||||
"""Register SearXNG web_search and Crawl4AI scrape_url tools."""
|
||||
try:
|
||||
toolkit.register(web_search, name="web_search")
|
||||
toolkit.register(scrape_url, name="scrape_url")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to register search tools: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
def _register_core_tools(toolkit: Toolkit, base_path: Path) -> None:
|
||||
"""Register core execution and file tools."""
|
||||
# Python execution
|
||||
@@ -261,6 +272,7 @@ def create_full_toolkit(base_dir: str | Path | None = None):
|
||||
|
||||
_register_core_tools(toolkit, base_path)
|
||||
_register_web_fetch_tool(toolkit)
|
||||
_register_search_tools(toolkit)
|
||||
_register_grok_tool(toolkit)
|
||||
_register_memory_tools(toolkit)
|
||||
_register_agentic_loop_tool(toolkit)
|
||||
@@ -433,6 +445,16 @@ def _analysis_tool_catalog() -> dict:
|
||||
"description": "Fetch a web page and extract clean readable text (trafilatura)",
|
||||
"available_in": ["orchestrator"],
|
||||
},
|
||||
"web_search": {
|
||||
"name": "Web Search",
|
||||
"description": "Search the web via self-hosted SearXNG (no API key required)",
|
||||
"available_in": ["echo", "orchestrator"],
|
||||
},
|
||||
"scrape_url": {
|
||||
"name": "Scrape URL",
|
||||
"description": "Scrape a URL with Crawl4AI and return clean markdown content",
|
||||
"available_in": ["echo", "orchestrator"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def _make_smart_read_file(file_tools: FileTools) -> Callable:
|
||||
def create_research_tools(base_dir: str | Path | None = None):
|
||||
"""Create tools for the research agent (Echo).
|
||||
|
||||
Includes: file reading
|
||||
Includes: file reading, web search (SearXNG), URL scraping (Crawl4AI)
|
||||
"""
|
||||
if not _AGNO_TOOLS_AVAILABLE:
|
||||
raise ImportError(f"Agno tools not available: {_ImportError}")
|
||||
@@ -73,6 +73,12 @@ def create_research_tools(base_dir: str | Path | None = None):
|
||||
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
|
||||
toolkit.register(file_tools.list_files, name="list_files")
|
||||
|
||||
# Web search + scraping (gracefully no-ops when backend=none or service down)
|
||||
from timmy.tools.search import scrape_url, web_search
|
||||
|
||||
toolkit.register(web_search, name="web_search")
|
||||
toolkit.register(scrape_url, name="scrape_url")
|
||||
|
||||
return toolkit
|
||||
|
||||
|
||||
|
||||
186
src/timmy/tools/search.py
Normal file
186
src/timmy/tools/search.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Self-hosted web search and scraping tools using SearXNG + Crawl4AI.
|
||||
|
||||
Provides:
|
||||
- web_search(query) — SearXNG meta-search (no API key required)
|
||||
- scrape_url(url) — Crawl4AI full-page scrape to clean markdown
|
||||
|
||||
Both tools degrade gracefully when the backing service is unavailable
|
||||
(logs WARNING, returns descriptive error string — never crashes).
|
||||
|
||||
Services are started via `docker compose --profile search up` or configured
|
||||
with TIMMY_SEARCH_URL / TIMMY_CRAWL_URL environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Crawl4AI polling: up to _CRAWL_MAX_POLLS × _CRAWL_POLL_INTERVAL seconds
|
||||
_CRAWL_MAX_POLLS = 6
|
||||
_CRAWL_POLL_INTERVAL = 5 # seconds
|
||||
_CRAWL_CHAR_BUDGET = 4000 * 4 # ~4000 tokens
|
||||
|
||||
|
||||
def web_search(query: str, num_results: int = 5) -> str:
|
||||
"""Search the web using the self-hosted SearXNG meta-search engine.
|
||||
|
||||
Returns ranked results (title + URL + snippet) without requiring any
|
||||
paid API key. Requires SearXNG running locally (docker compose
|
||||
--profile search up) or TIMMY_SEARCH_URL pointing to a reachable instance.
|
||||
|
||||
Args:
|
||||
query: The search query.
|
||||
num_results: Maximum number of results to return (default 5).
|
||||
|
||||
Returns:
|
||||
Formatted search results string, or an error/status message on failure.
|
||||
"""
|
||||
if settings.timmy_search_backend == "none":
|
||||
return "Web search is disabled (TIMMY_SEARCH_BACKEND=none)."
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
return "Error: 'requests' package is not installed."
|
||||
|
||||
base_url = settings.search_url.rstrip("/")
|
||||
params: dict = {
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"categories": "general",
|
||||
}
|
||||
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{base_url}/search",
|
||||
params=params,
|
||||
timeout=10,
|
||||
headers={"User-Agent": "TimmyResearchBot/1.0"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc:
|
||||
logger.warning("SearXNG unavailable at %s: %s", base_url, exc)
|
||||
return f"Search unavailable — SearXNG not reachable ({base_url}): {exc}"
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("SearXNG response parse error: %s", exc)
|
||||
return "Search error: could not parse SearXNG response."
|
||||
|
||||
results = data.get("results", [])[:num_results]
|
||||
if not results:
|
||||
return f"No results found for: {query!r}"
|
||||
|
||||
lines = [f"Web search results for: {query!r}\n"]
|
||||
for i, r in enumerate(results, 1):
|
||||
title = r.get("title", "Untitled")
|
||||
url = r.get("url", "")
|
||||
snippet = r.get("content", "").strip()
|
||||
lines.append(f"{i}. {title}\n URL: {url}\n {snippet}\n")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def scrape_url(url: str) -> str:
|
||||
"""Scrape a URL with Crawl4AI and return the main content as clean markdown.
|
||||
|
||||
Crawl4AI extracts well-structured markdown from any public page —
|
||||
articles, docs, product pages — suitable for LLM consumption.
|
||||
Requires Crawl4AI running locally (docker compose --profile search up)
|
||||
or TIMMY_CRAWL_URL pointing to a reachable instance.
|
||||
|
||||
Args:
|
||||
url: The URL to scrape (must start with http:// or https://).
|
||||
|
||||
Returns:
|
||||
Extracted markdown text (up to ~4000 tokens), or an error message.
|
||||
"""
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
return f"Error: invalid URL — must start with http:// or https://: {url!r}"
|
||||
|
||||
if settings.timmy_search_backend == "none":
|
||||
return "Web scraping is disabled (TIMMY_SEARCH_BACKEND=none)."
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
return "Error: 'requests' package is not installed."
|
||||
|
||||
base = settings.crawl_url.rstrip("/")
|
||||
|
||||
# Submit crawl task
|
||||
try:
|
||||
resp = _requests.post(
|
||||
f"{base}/crawl",
|
||||
json={"urls": [url], "priority": 10},
|
||||
timeout=15,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc:
|
||||
logger.warning("Crawl4AI unavailable at %s: %s", base, exc)
|
||||
return f"Scrape unavailable — Crawl4AI not reachable ({base}): {exc}"
|
||||
|
||||
try:
|
||||
submit_data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Crawl4AI submit parse error: %s", exc)
|
||||
return "Scrape error: could not parse Crawl4AI response."
|
||||
|
||||
# Check if result came back synchronously
|
||||
if "results" in submit_data:
|
||||
return _extract_crawl_content(submit_data["results"], url)
|
||||
|
||||
task_id = submit_data.get("task_id")
|
||||
if not task_id:
|
||||
return f"Scrape error: Crawl4AI returned no task_id for {url}"
|
||||
|
||||
# Poll for async result
|
||||
for _ in range(_CRAWL_MAX_POLLS):
|
||||
time.sleep(_CRAWL_POLL_INTERVAL)
|
||||
try:
|
||||
poll = _requests.get(f"{base}/task/{task_id}", timeout=10)
|
||||
poll.raise_for_status()
|
||||
task_data = poll.json()
|
||||
except Exception as exc:
|
||||
logger.warning("Crawl4AI poll error (task=%s): %s", task_id, exc)
|
||||
continue
|
||||
|
||||
status = task_data.get("status", "")
|
||||
if status == "completed":
|
||||
results = task_data.get("results") or task_data.get("result")
|
||||
if isinstance(results, dict):
|
||||
results = [results]
|
||||
return _extract_crawl_content(results or [], url)
|
||||
if status == "failed":
|
||||
return f"Scrape failed for {url}: {task_data.get('error', 'unknown error')}"
|
||||
|
||||
return f"Scrape timed out after {_CRAWL_MAX_POLLS * _CRAWL_POLL_INTERVAL}s for {url}"
|
||||
|
||||
|
||||
def _extract_crawl_content(results: list, url: str) -> str:
|
||||
"""Extract and truncate markdown content from Crawl4AI results list."""
|
||||
if not results:
|
||||
return f"No content returned by Crawl4AI for: {url}"
|
||||
|
||||
result = results[0]
|
||||
content = (
|
||||
result.get("markdown")
|
||||
or result.get("markdown_v2", {}).get("raw_markdown")
|
||||
or result.get("extracted_content")
|
||||
or result.get("content")
|
||||
or ""
|
||||
)
|
||||
if not content:
|
||||
return f"No readable content extracted from: {url}"
|
||||
|
||||
if len(content) > _CRAWL_CHAR_BUDGET:
|
||||
content = content[:_CRAWL_CHAR_BUDGET] + "\n\n[…truncated to ~4000 tokens]"
|
||||
|
||||
return content
|
||||
@@ -41,17 +41,38 @@ def delegate_task(
|
||||
if priority not in valid_priorities:
|
||||
priority = "normal"
|
||||
|
||||
agent_role = available[agent_name]
|
||||
|
||||
# Wire to DistributedWorker for actual execution
|
||||
task_id: str | None = None
|
||||
status = "queued"
|
||||
try:
|
||||
from brain.worker import DistributedWorker
|
||||
|
||||
task_id = DistributedWorker.submit(agent_name, agent_role, task_description, priority)
|
||||
except Exception as exc:
|
||||
logger.warning("DistributedWorker unavailable — task noted only: %s", exc)
|
||||
status = "noted"
|
||||
|
||||
logger.info(
|
||||
"Delegation intent: %s → %s (priority=%s)", agent_name, task_description[:80], priority
|
||||
"Delegated task %s: %s → %s (priority=%s, status=%s)",
|
||||
task_id or "?",
|
||||
agent_name,
|
||||
task_description[:80],
|
||||
priority,
|
||||
status,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": None,
|
||||
"task_id": task_id,
|
||||
"agent": agent_name,
|
||||
"role": available[agent_name],
|
||||
"status": "noted",
|
||||
"message": f"Delegation to {agent_name} ({available[agent_name]}): {task_description[:100]}",
|
||||
"role": agent_role,
|
||||
"status": status,
|
||||
"message": (
|
||||
f"Task {task_id or 'noted'}: delegated to {agent_name} ({agent_role}): "
|
||||
f"{task_description[:100]}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class VoiceTTS:
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether the TTS engine initialized successfully and can produce audio."""
|
||||
return self._available
|
||||
|
||||
def speak(self, text: str) -> None:
|
||||
@@ -68,11 +69,13 @@ class VoiceTTS:
|
||||
logger.error("VoiceTTS: speech failed — %s", exc)
|
||||
|
||||
def set_rate(self, rate: int) -> None:
|
||||
"""Set speech rate in words per minute (typical range: 100–300, default 175)."""
|
||||
self._rate = rate
|
||||
if self._engine:
|
||||
self._engine.setProperty("rate", rate)
|
||||
|
||||
def set_volume(self, volume: float) -> None:
|
||||
"""Set speech volume. Value is clamped to the 0.0–1.0 range."""
|
||||
self._volume = max(0.0, min(1.0, volume))
|
||||
if self._engine:
|
||||
self._engine.setProperty("volume", self._volume)
|
||||
@@ -92,6 +95,7 @@ class VoiceTTS:
|
||||
return []
|
||||
|
||||
def set_voice(self, voice_id: str) -> None:
|
||||
"""Set the active TTS voice by system voice ID (see ``get_voices()``)."""
|
||||
if self._engine:
|
||||
self._engine.setProperty("voice", voice_id)
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
import infrastructure.events.bus as bus_module
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
from infrastructure.events.bus import (
|
||||
Event,
|
||||
EventBus,
|
||||
@@ -352,6 +354,14 @@ class TestEventBusPersistence:
|
||||
events = bus.replay()
|
||||
assert events == []
|
||||
|
||||
def test_init_persistence_db_noop_when_path_is_none(self):
|
||||
"""_init_persistence_db() is a no-op when _persistence_db_path is None."""
|
||||
bus = EventBus()
|
||||
# _persistence_db_path is None by default; calling _init_persistence_db
|
||||
# should silently return without touching the filesystem.
|
||||
bus._init_persistence_db() # must not raise
|
||||
assert bus._persistence_db_path is None
|
||||
|
||||
async def test_wal_mode_on_persistence_db(self, persistent_bus):
|
||||
"""Persistence database should use WAL mode."""
|
||||
conn = sqlite3.connect(str(persistent_bus._persistence_db_path))
|
||||
|
||||
589
tests/infrastructure/test_graceful_degradation.py
Normal file
589
tests/infrastructure/test_graceful_degradation.py
Normal file
@@ -0,0 +1,589 @@
|
||||
"""Graceful degradation test scenarios — Issue #919.
|
||||
|
||||
Tests specifically for service failure paths and fallback logic:
|
||||
|
||||
* Ollama health-check failures (connection refused, timeout, HTTP errors)
|
||||
* Cascade router: Ollama down → falls back to Anthropic/cloud provider
|
||||
* Circuit-breaker lifecycle: CLOSED → OPEN (repeated failures) → HALF_OPEN (recovery window)
|
||||
* All providers fail → descriptive RuntimeError
|
||||
* Disabled provider skipped without touching circuit breaker
|
||||
* ``requests`` library unavailable → optimistic availability assumption
|
||||
* ClaudeBackend / GrokBackend no-key graceful messages
|
||||
* Chat store: SQLite directory auto-creation and concurrent access safety
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from infrastructure.router.cascade import (
|
||||
CascadeRouter,
|
||||
CircuitState,
|
||||
Provider,
|
||||
ProviderStatus,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ollama_provider(name: str = "local-ollama", priority: int = 1) -> Provider:
|
||||
return Provider(
|
||||
name=name,
|
||||
type="ollama",
|
||||
enabled=True,
|
||||
priority=priority,
|
||||
url="http://localhost:11434",
|
||||
models=[{"name": "llama3", "default": True}],
|
||||
)
|
||||
|
||||
|
||||
def _make_anthropic_provider(name: str = "cloud-fallback", priority: int = 2) -> Provider:
|
||||
return Provider(
|
||||
name=name,
|
||||
type="anthropic",
|
||||
enabled=True,
|
||||
priority=priority,
|
||||
api_key="sk-ant-test",
|
||||
models=[{"name": "claude-haiku-4-5-20251001", "default": True}],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama health-check failure scenarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOllamaHealthCheckFailures:
|
||||
"""_check_provider_available returns False for all Ollama failure modes."""
|
||||
|
||||
def _router(self) -> CascadeRouter:
|
||||
return CascadeRouter(config_path=Path("/nonexistent"))
|
||||
|
||||
def test_connection_refused_returns_false(self):
|
||||
"""Connection refused during Ollama health check → provider excluded."""
|
||||
router = self._router()
|
||||
provider = _make_ollama_provider()
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_req:
|
||||
mock_req.get.side_effect = ConnectionError("Connection refused")
|
||||
assert router._check_provider_available(provider) is False
|
||||
|
||||
def test_timeout_returns_false(self):
|
||||
"""Request timeout during Ollama health check → provider excluded."""
|
||||
router = self._router()
|
||||
provider = _make_ollama_provider()
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_req:
|
||||
# Simulate a timeout using a generic OSError (matches real-world timeout behaviour)
|
||||
mock_req.get.side_effect = OSError("timed out")
|
||||
assert router._check_provider_available(provider) is False
|
||||
|
||||
def test_http_503_returns_false(self):
|
||||
"""HTTP 503 from Ollama health endpoint → provider excluded."""
|
||||
router = self._router()
|
||||
provider = _make_ollama_provider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 503
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_req:
|
||||
mock_req.get.return_value = mock_response
|
||||
assert router._check_provider_available(provider) is False
|
||||
|
||||
def test_http_500_returns_false(self):
|
||||
"""HTTP 500 from Ollama health endpoint → provider excluded."""
|
||||
router = self._router()
|
||||
provider = _make_ollama_provider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_req:
|
||||
mock_req.get.return_value = mock_response
|
||||
assert router._check_provider_available(provider) is False
|
||||
|
||||
def test_generic_exception_returns_false(self):
|
||||
"""Unexpected exception during Ollama check → provider excluded (no crash)."""
|
||||
router = self._router()
|
||||
provider = _make_ollama_provider()
|
||||
|
||||
with patch("infrastructure.router.cascade.requests") as mock_req:
|
||||
mock_req.get.side_effect = RuntimeError("unexpected error")
|
||||
assert router._check_provider_available(provider) is False
|
||||
|
||||
def test_requests_unavailable_assumes_available(self):
|
||||
"""When ``requests`` lib is None, Ollama availability is assumed True."""
|
||||
import infrastructure.router.cascade as cascade_module
|
||||
|
||||
router = self._router()
|
||||
provider = _make_ollama_provider()
|
||||
|
||||
old_requests = cascade_module.requests
|
||||
cascade_module.requests = None
|
||||
try:
|
||||
assert router._check_provider_available(provider) is True
|
||||
finally:
|
||||
cascade_module.requests = old_requests
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade: Ollama fails → Anthropic fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOllamaToAnthropicFallback:
|
||||
"""Cascade router falls back to Anthropic when Ollama is unavailable or failing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_connection_refused_falls_back_to_anthropic(self):
|
||||
"""When Ollama raises a connection error, cascade uses Anthropic provider."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
ollama_provider = _make_ollama_provider(priority=1)
|
||||
anthropic_provider = _make_anthropic_provider(priority=2)
|
||||
router.providers = [ollama_provider, anthropic_provider]
|
||||
|
||||
with (
|
||||
patch.object(router, "_call_ollama", side_effect=ConnectionError("refused")),
|
||||
patch.object(
|
||||
router,
|
||||
"_call_anthropic",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"content": "fallback response", "model": "claude-haiku-4-5-20251001"},
|
||||
),
|
||||
# Allow cloud bypass of the metabolic quota gate in test
|
||||
patch.object(router, "_quota_allows_cloud", return_value=True),
|
||||
):
|
||||
result = await router.complete(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
model="llama3",
|
||||
)
|
||||
|
||||
assert result["provider"] == "cloud-fallback"
|
||||
assert "fallback response" in result["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_circuit_open_skips_to_anthropic(self):
|
||||
"""When Ollama circuit is OPEN, cascade skips directly to Anthropic."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
ollama_provider = _make_ollama_provider(priority=1)
|
||||
anthropic_provider = _make_anthropic_provider(priority=2)
|
||||
router.providers = [ollama_provider, anthropic_provider]
|
||||
|
||||
# Force the circuit open on Ollama
|
||||
ollama_provider.circuit_state = CircuitState.OPEN
|
||||
ollama_provider.status = ProviderStatus.UNHEALTHY
|
||||
import time
|
||||
|
||||
ollama_provider.circuit_opened_at = time.time() # just opened — not yet recoverable
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
router,
|
||||
"_call_anthropic",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"content": "cloud answer", "model": "claude-haiku-4-5-20251001"},
|
||||
) as mock_anthropic,
|
||||
# Allow cloud bypass of the metabolic quota gate in test
|
||||
patch.object(router, "_quota_allows_cloud", return_value=True),
|
||||
):
|
||||
result = await router.complete(
|
||||
messages=[{"role": "user", "content": "ping"}],
|
||||
)
|
||||
|
||||
mock_anthropic.assert_called_once()
|
||||
assert result["provider"] == "cloud-fallback"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_providers_fail_raises_runtime_error(self):
|
||||
"""When every provider fails, RuntimeError is raised with combined error info."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
ollama_provider = _make_ollama_provider(priority=1)
|
||||
anthropic_provider = _make_anthropic_provider(priority=2)
|
||||
router.providers = [ollama_provider, anthropic_provider]
|
||||
|
||||
with (
|
||||
patch.object(router, "_call_ollama", side_effect=RuntimeError("Ollama down")),
|
||||
patch.object(router, "_call_anthropic", side_effect=RuntimeError("API quota exceeded")),
|
||||
patch.object(router, "_quota_allows_cloud", return_value=True),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="All providers failed"):
|
||||
await router.complete(messages=[{"role": "user", "content": "test"}])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_message_includes_individual_provider_errors(self):
|
||||
"""RuntimeError from all-fail scenario lists each provider's error."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
ollama_provider = _make_ollama_provider(priority=1)
|
||||
anthropic_provider = _make_anthropic_provider(priority=2)
|
||||
router.providers = [ollama_provider, anthropic_provider]
|
||||
router.config.max_retries_per_provider = 1
|
||||
|
||||
with (
|
||||
patch.object(router, "_call_ollama", side_effect=RuntimeError("connection refused")),
|
||||
patch.object(router, "_call_anthropic", side_effect=RuntimeError("rate limit")),
|
||||
patch.object(router, "_quota_allows_cloud", return_value=True),
|
||||
):
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
await router.complete(messages=[{"role": "user", "content": "test"}])
|
||||
|
||||
error_msg = str(exc_info.value)
|
||||
assert "connection refused" in error_msg
|
||||
assert "rate limit" in error_msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Circuit-breaker lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCircuitBreakerLifecycle:
|
||||
"""Full CLOSED → OPEN → HALF_OPEN → CLOSED lifecycle."""
|
||||
|
||||
def test_closed_initially(self):
|
||||
"""New provider starts with circuit CLOSED and HEALTHY status."""
|
||||
provider = _make_ollama_provider()
|
||||
assert provider.circuit_state == CircuitState.CLOSED
|
||||
assert provider.status == ProviderStatus.HEALTHY
|
||||
|
||||
def test_open_after_threshold_failures(self):
|
||||
"""Circuit opens once consecutive failures reach the threshold."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.config.circuit_breaker_failure_threshold = 3
|
||||
provider = _make_ollama_provider()
|
||||
|
||||
for _ in range(3):
|
||||
router._record_failure(provider)
|
||||
|
||||
assert provider.circuit_state == CircuitState.OPEN
|
||||
assert provider.status == ProviderStatus.UNHEALTHY
|
||||
assert provider.circuit_opened_at is not None
|
||||
|
||||
def test_open_circuit_skips_provider(self):
|
||||
"""_is_provider_available returns False when circuit is OPEN (and timeout not elapsed)."""
|
||||
import time
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.config.circuit_breaker_recovery_timeout = 9999 # won't elapse during test
|
||||
provider = _make_ollama_provider()
|
||||
provider.circuit_state = CircuitState.OPEN
|
||||
provider.status = ProviderStatus.UNHEALTHY
|
||||
provider.circuit_opened_at = time.time()
|
||||
|
||||
assert router._is_provider_available(provider) is False
|
||||
|
||||
def test_half_open_after_recovery_timeout(self):
|
||||
"""After the recovery timeout elapses, _is_provider_available transitions to HALF_OPEN."""
|
||||
import time
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.config.circuit_breaker_recovery_timeout = 0.01 # 10 ms
|
||||
|
||||
provider = _make_ollama_provider()
|
||||
provider.circuit_state = CircuitState.OPEN
|
||||
provider.status = ProviderStatus.UNHEALTHY
|
||||
provider.circuit_opened_at = time.time() - 1.0 # clearly elapsed
|
||||
|
||||
result = router._is_provider_available(provider)
|
||||
|
||||
assert result is True
|
||||
assert provider.circuit_state == CircuitState.HALF_OPEN
|
||||
|
||||
def test_closed_after_half_open_successes(self):
|
||||
"""Circuit closes after enough successful half-open test calls."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.config.circuit_breaker_half_open_max_calls = 2
|
||||
|
||||
provider = _make_ollama_provider()
|
||||
provider.circuit_state = CircuitState.HALF_OPEN
|
||||
provider.half_open_calls = 0
|
||||
|
||||
router._record_success(provider, 50.0)
|
||||
assert provider.circuit_state == CircuitState.HALF_OPEN # not yet
|
||||
|
||||
router._record_success(provider, 50.0)
|
||||
assert provider.circuit_state == CircuitState.CLOSED
|
||||
assert provider.status == ProviderStatus.HEALTHY
|
||||
assert provider.metrics.consecutive_failures == 0
|
||||
|
||||
def test_failure_in_half_open_reopens_circuit(self):
|
||||
"""A failure during HALF_OPEN increments consecutive failures, reopening if threshold met."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
router.config.circuit_breaker_failure_threshold = 1 # reopen on first failure
|
||||
|
||||
provider = _make_ollama_provider()
|
||||
provider.circuit_state = CircuitState.HALF_OPEN
|
||||
|
||||
router._record_failure(provider)
|
||||
|
||||
assert provider.circuit_state == CircuitState.OPEN
|
||||
|
||||
def test_disabled_provider_skipped_without_circuit_change(self):
|
||||
"""A disabled provider is immediately rejected; its circuit state is not touched."""
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
provider = _make_ollama_provider()
|
||||
provider.enabled = False
|
||||
|
||||
available = router._is_provider_available(provider)
|
||||
|
||||
assert available is False
|
||||
assert provider.circuit_state == CircuitState.CLOSED # unchanged
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClaudeBackend graceful degradation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestClaudeBackendGracefulDegradation:
|
||||
"""ClaudeBackend degrades gracefully when the API is unavailable."""
|
||||
|
||||
def test_run_no_key_returns_unconfigured_message(self):
|
||||
"""run() returns a graceful message when no API key is set."""
|
||||
from timmy.backends import ClaudeBackend
|
||||
|
||||
backend = ClaudeBackend(api_key="", model="haiku")
|
||||
result = backend.run("hello")
|
||||
|
||||
assert "not configured" in result.content.lower()
|
||||
assert "ANTHROPIC_API_KEY" in result.content
|
||||
|
||||
def test_run_api_error_returns_unavailable_message(self):
|
||||
"""run() returns a graceful error when the Anthropic API raises."""
|
||||
from timmy.backends import ClaudeBackend
|
||||
|
||||
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.messages.create.side_effect = ConnectionError("API unreachable")
|
||||
|
||||
with patch.object(backend, "_get_client", return_value=mock_client):
|
||||
result = backend.run("ping")
|
||||
|
||||
assert "unavailable" in result.content.lower()
|
||||
|
||||
def test_health_check_no_key_reports_error(self):
|
||||
"""health_check() reports not-ok when API key is missing."""
|
||||
from timmy.backends import ClaudeBackend
|
||||
|
||||
backend = ClaudeBackend(api_key="", model="haiku")
|
||||
status = backend.health_check()
|
||||
|
||||
assert status["ok"] is False
|
||||
assert "ANTHROPIC_API_KEY" in status["error"]
|
||||
|
||||
def test_health_check_api_error_reports_error(self):
|
||||
"""health_check() returns ok=False and captures the error on API failure."""
|
||||
from timmy.backends import ClaudeBackend
|
||||
|
||||
backend = ClaudeBackend(api_key="sk-ant-test", model="haiku")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.messages.create.side_effect = RuntimeError("connection timed out")
|
||||
|
||||
with patch.object(backend, "_get_client", return_value=mock_client):
|
||||
status = backend.health_check()
|
||||
|
||||
assert status["ok"] is False
|
||||
assert "connection timed out" in status["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GrokBackend graceful degradation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGrokBackendGracefulDegradation:
|
||||
"""GrokBackend degrades gracefully when xAI API is unavailable."""
|
||||
|
||||
def test_run_no_key_returns_unconfigured_message(self):
|
||||
"""run() returns a graceful message when no XAI_API_KEY is set."""
|
||||
from timmy.backends import GrokBackend
|
||||
|
||||
backend = GrokBackend(api_key="", model="grok-3-mini")
|
||||
result = backend.run("hello")
|
||||
|
||||
assert "not configured" in result.content.lower()
|
||||
|
||||
def test_run_api_error_returns_unavailable_message(self):
|
||||
"""run() returns graceful error when xAI API raises."""
|
||||
from timmy.backends import GrokBackend
|
||||
|
||||
backend = GrokBackend(api_key="xai-test-key", model="grok-3-mini")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = RuntimeError("network error")
|
||||
|
||||
with patch.object(backend, "_get_client", return_value=mock_client):
|
||||
result = backend.run("ping")
|
||||
|
||||
assert "unavailable" in result.content.lower()
|
||||
|
||||
def test_health_check_no_key_reports_error(self):
|
||||
"""health_check() reports not-ok when XAI_API_KEY is missing."""
|
||||
from timmy.backends import GrokBackend
|
||||
|
||||
backend = GrokBackend(api_key="", model="grok-3-mini")
|
||||
status = backend.health_check()
|
||||
|
||||
assert status["ok"] is False
|
||||
assert "XAI_API_KEY" in status["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chat store: SQLite resilience
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestChatStoreSQLiteResilience:
|
||||
"""MessageLog handles edge cases without crashing."""
|
||||
|
||||
def test_auto_creates_missing_parent_directory(self, tmp_path):
|
||||
"""MessageLog creates the data directory automatically on first use."""
|
||||
from infrastructure.chat_store import MessageLog
|
||||
|
||||
db_path = tmp_path / "deep" / "nested" / "chat.db"
|
||||
assert not db_path.parent.exists()
|
||||
|
||||
log = MessageLog(db_path=db_path)
|
||||
log.append("user", "hello", "2026-01-01T00:00:00")
|
||||
|
||||
assert db_path.exists()
|
||||
assert len(log) == 1
|
||||
log.close()
|
||||
|
||||
def test_concurrent_appends_are_safe(self, tmp_path):
|
||||
"""Multiple threads appending simultaneously do not corrupt the DB."""
|
||||
from infrastructure.chat_store import MessageLog
|
||||
|
||||
db_path = tmp_path / "chat.db"
|
||||
log = MessageLog(db_path=db_path)
|
||||
|
||||
errors: list[Exception] = []
|
||||
|
||||
def write_messages(thread_id: int) -> None:
|
||||
try:
|
||||
for i in range(10):
|
||||
log.append("user", f"thread {thread_id} msg {i}", "2026-01-01T00:00:00")
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=write_messages, args=(t,)) for t in range(5)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert errors == [], f"Concurrent writes produced errors: {errors}"
|
||||
# 5 threads × 10 messages each
|
||||
assert len(log) == 50
|
||||
log.close()
|
||||
|
||||
def test_all_returns_messages_in_insertion_order(self, tmp_path):
|
||||
"""all() returns messages ordered oldest-first."""
|
||||
from infrastructure.chat_store import MessageLog
|
||||
|
||||
db_path = tmp_path / "chat.db"
|
||||
log = MessageLog(db_path=db_path)
|
||||
log.append("user", "first", "2026-01-01T00:00:00")
|
||||
log.append("agent", "second", "2026-01-01T00:00:01")
|
||||
log.append("user", "third", "2026-01-01T00:00:02")
|
||||
|
||||
messages = log.all()
|
||||
assert [m.content for m in messages] == ["first", "second", "third"]
|
||||
log.close()
|
||||
|
||||
def test_recent_returns_latest_n_messages(self, tmp_path):
|
||||
"""recent(n) returns the n most recent messages, oldest-first within the slice."""
|
||||
from infrastructure.chat_store import MessageLog
|
||||
|
||||
db_path = tmp_path / "chat.db"
|
||||
log = MessageLog(db_path=db_path)
|
||||
for i in range(20):
|
||||
log.append("user", f"msg {i}", f"2026-01-01T00:{i:02d}:00")
|
||||
|
||||
recent = log.recent(5)
|
||||
assert len(recent) == 5
|
||||
assert recent[0].content == "msg 15"
|
||||
assert recent[-1].content == "msg 19"
|
||||
log.close()
|
||||
|
||||
def test_prune_keeps_max_messages(self, tmp_path):
|
||||
"""append() prunes oldest messages when count exceeds MAX_MESSAGES."""
|
||||
import infrastructure.chat_store as store_mod
|
||||
from infrastructure.chat_store import MessageLog
|
||||
|
||||
original_max = store_mod.MAX_MESSAGES
|
||||
store_mod.MAX_MESSAGES = 5
|
||||
try:
|
||||
db_path = tmp_path / "chat.db"
|
||||
log = MessageLog(db_path=db_path)
|
||||
for i in range(8):
|
||||
log.append("user", f"msg {i}", "2026-01-01T00:00:00")
|
||||
|
||||
assert len(log) == 5
|
||||
messages = log.all()
|
||||
# Oldest 3 should be pruned
|
||||
assert messages[0].content == "msg 3"
|
||||
log.close()
|
||||
finally:
|
||||
store_mod.MAX_MESSAGES = original_max
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider availability: requests lib missing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRequestsLibraryMissing:
|
||||
"""When ``requests`` is not installed, providers assume they are available."""
|
||||
|
||||
def _swap_requests(self, value):
|
||||
import infrastructure.router.cascade as cascade_module
|
||||
|
||||
old = cascade_module.requests
|
||||
cascade_module.requests = value
|
||||
return old
|
||||
|
||||
def test_ollama_assumes_available_without_requests(self):
|
||||
"""Ollama provider returns True when requests is None."""
|
||||
import infrastructure.router.cascade as cascade_module
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
provider = _make_ollama_provider()
|
||||
old = self._swap_requests(None)
|
||||
try:
|
||||
assert router._check_provider_available(provider) is True
|
||||
finally:
|
||||
cascade_module.requests = old
|
||||
|
||||
def test_vllm_mlx_assumes_available_without_requests(self):
|
||||
"""vllm-mlx provider returns True when requests is None."""
|
||||
import infrastructure.router.cascade as cascade_module
|
||||
|
||||
router = CascadeRouter(config_path=Path("/nonexistent"))
|
||||
provider = Provider(
|
||||
name="vllm-local",
|
||||
type="vllm_mlx",
|
||||
enabled=True,
|
||||
priority=1,
|
||||
base_url="http://localhost:8000/v1",
|
||||
)
|
||||
old = self._swap_requests(None)
|
||||
try:
|
||||
assert router._check_provider_available(provider) is True
|
||||
finally:
|
||||
cascade_module.requests = old
|
||||
839
tests/timmy/test_quest_system.py
Normal file
839
tests/timmy/test_quest_system.py
Normal file
@@ -0,0 +1,839 @@
|
||||
"""Unit tests for timmy.quest_system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import timmy.quest_system as qs
|
||||
from timmy.quest_system import (
|
||||
QuestDefinition,
|
||||
QuestProgress,
|
||||
QuestStatus,
|
||||
QuestType,
|
||||
_get_progress_key,
|
||||
_get_target_value,
|
||||
_is_on_cooldown,
|
||||
check_daily_run_quest,
|
||||
check_issue_count_quest,
|
||||
check_issue_reduce_quest,
|
||||
claim_quest_reward,
|
||||
evaluate_quest_progress,
|
||||
get_active_quests,
|
||||
get_agent_quests_status,
|
||||
get_or_create_progress,
|
||||
get_quest_definition,
|
||||
get_quest_definitions,
|
||||
get_quest_leaderboard,
|
||||
get_quest_progress,
|
||||
load_quest_config,
|
||||
reset_quest_progress,
|
||||
update_quest_progress,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_quest(
|
||||
quest_id: str = "test_quest",
|
||||
quest_type: QuestType = QuestType.ISSUE_COUNT,
|
||||
reward_tokens: int = 10,
|
||||
enabled: bool = True,
|
||||
repeatable: bool = False,
|
||||
cooldown_hours: int = 0,
|
||||
criteria: dict[str, Any] | None = None,
|
||||
) -> QuestDefinition:
|
||||
return QuestDefinition(
|
||||
id=quest_id,
|
||||
name=f"Quest {quest_id}",
|
||||
description="Test quest",
|
||||
reward_tokens=reward_tokens,
|
||||
quest_type=quest_type,
|
||||
enabled=enabled,
|
||||
repeatable=repeatable,
|
||||
cooldown_hours=cooldown_hours,
|
||||
criteria=criteria or {"target_count": 3},
|
||||
notification_message="Quest Complete! You earned {tokens} tokens.",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state():
|
||||
"""Reset module-level state before and after each test."""
|
||||
reset_quest_progress()
|
||||
qs._quest_definitions.clear()
|
||||
qs._quest_settings.clear()
|
||||
yield
|
||||
reset_quest_progress()
|
||||
qs._quest_definitions.clear()
|
||||
qs._quest_settings.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QuestDefinition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQuestDefinition:
|
||||
def test_from_dict_minimal(self):
|
||||
data = {"id": "q1"}
|
||||
defn = QuestDefinition.from_dict(data)
|
||||
assert defn.id == "q1"
|
||||
assert defn.name == "Unnamed Quest"
|
||||
assert defn.reward_tokens == 0
|
||||
assert defn.quest_type == QuestType.CUSTOM
|
||||
assert defn.enabled is True
|
||||
assert defn.repeatable is False
|
||||
assert defn.cooldown_hours == 0
|
||||
|
||||
def test_from_dict_full(self):
|
||||
data = {
|
||||
"id": "q2",
|
||||
"name": "Full Quest",
|
||||
"description": "A full quest",
|
||||
"reward_tokens": 50,
|
||||
"type": "issue_count",
|
||||
"enabled": False,
|
||||
"repeatable": True,
|
||||
"cooldown_hours": 24,
|
||||
"criteria": {"target_count": 5},
|
||||
"notification_message": "You earned {tokens}!",
|
||||
}
|
||||
defn = QuestDefinition.from_dict(data)
|
||||
assert defn.id == "q2"
|
||||
assert defn.name == "Full Quest"
|
||||
assert defn.reward_tokens == 50
|
||||
assert defn.quest_type == QuestType.ISSUE_COUNT
|
||||
assert defn.enabled is False
|
||||
assert defn.repeatable is True
|
||||
assert defn.cooldown_hours == 24
|
||||
assert defn.criteria == {"target_count": 5}
|
||||
assert defn.notification_message == "You earned {tokens}!"
|
||||
|
||||
def test_from_dict_invalid_type_raises(self):
|
||||
data = {"id": "q3", "type": "not_a_real_type"}
|
||||
with pytest.raises(ValueError):
|
||||
QuestDefinition.from_dict(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QuestProgress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQuestProgress:
|
||||
def test_to_dict_roundtrip(self):
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.IN_PROGRESS,
|
||||
current_value=2,
|
||||
target_value=5,
|
||||
started_at="2026-01-01T00:00:00",
|
||||
metadata={"key": "val"},
|
||||
)
|
||||
d = progress.to_dict()
|
||||
assert d["quest_id"] == "q1"
|
||||
assert d["agent_id"] == "agent_a"
|
||||
assert d["status"] == "in_progress"
|
||||
assert d["current_value"] == 2
|
||||
assert d["target_value"] == 5
|
||||
assert d["metadata"] == {"key": "val"}
|
||||
|
||||
def test_to_dict_defaults(self):
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.NOT_STARTED,
|
||||
)
|
||||
d = progress.to_dict()
|
||||
assert d["completion_count"] == 0
|
||||
assert d["started_at"] == ""
|
||||
assert d["completed_at"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_progress_key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_get_progress_key():
|
||||
assert _get_progress_key("q1", "agent_a") == "agent_a:q1"
|
||||
|
||||
|
||||
def test_get_progress_key_different_agents():
|
||||
key_a = _get_progress_key("q1", "agent_a")
|
||||
key_b = _get_progress_key("q1", "agent_b")
|
||||
assert key_a != key_b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_quest_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLoadQuestConfig:
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
missing = tmp_path / "nonexistent.yaml"
|
||||
with patch.object(qs, "QUEST_CONFIG_PATH", missing):
|
||||
defs, settings = load_quest_config()
|
||||
assert defs == {}
|
||||
assert settings == {}
|
||||
|
||||
def test_valid_yaml_loads_quests(self, tmp_path):
|
||||
config_path = tmp_path / "quests.yaml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
quests:
|
||||
first_quest:
|
||||
name: First Quest
|
||||
description: Do stuff
|
||||
reward_tokens: 25
|
||||
type: issue_count
|
||||
enabled: true
|
||||
repeatable: false
|
||||
cooldown_hours: 0
|
||||
criteria:
|
||||
target_count: 3
|
||||
notification_message: "Done! {tokens} tokens"
|
||||
settings:
|
||||
some_setting: true
|
||||
"""
|
||||
)
|
||||
with patch.object(qs, "QUEST_CONFIG_PATH", config_path):
|
||||
defs, settings = load_quest_config()
|
||||
|
||||
assert "first_quest" in defs
|
||||
assert defs["first_quest"].name == "First Quest"
|
||||
assert defs["first_quest"].reward_tokens == 25
|
||||
assert settings == {"some_setting": True}
|
||||
|
||||
def test_invalid_yaml_returns_empty(self, tmp_path):
|
||||
config_path = tmp_path / "quests.yaml"
|
||||
config_path.write_text(":: not valid yaml ::")
|
||||
with patch.object(qs, "QUEST_CONFIG_PATH", config_path):
|
||||
defs, settings = load_quest_config()
|
||||
assert defs == {}
|
||||
assert settings == {}
|
||||
|
||||
def test_non_dict_yaml_returns_empty(self, tmp_path):
|
||||
config_path = tmp_path / "quests.yaml"
|
||||
config_path.write_text("- item1\n- item2\n")
|
||||
with patch.object(qs, "QUEST_CONFIG_PATH", config_path):
|
||||
defs, settings = load_quest_config()
|
||||
assert defs == {}
|
||||
assert settings == {}
|
||||
|
||||
def test_bad_quest_entry_is_skipped(self, tmp_path):
|
||||
config_path = tmp_path / "quests.yaml"
|
||||
config_path.write_text(
|
||||
"""
|
||||
quests:
|
||||
good_quest:
|
||||
name: Good
|
||||
type: issue_count
|
||||
reward_tokens: 10
|
||||
enabled: true
|
||||
repeatable: false
|
||||
cooldown_hours: 0
|
||||
criteria: {}
|
||||
notification_message: "{tokens}"
|
||||
bad_quest:
|
||||
type: invalid_type_that_does_not_exist
|
||||
"""
|
||||
)
|
||||
with patch.object(qs, "QUEST_CONFIG_PATH", config_path):
|
||||
defs, _ = load_quest_config()
|
||||
assert "good_quest" in defs
|
||||
assert "bad_quest" not in defs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_quest_definitions / get_quest_definition / get_active_quests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQuestLookup:
|
||||
def setup_method(self):
|
||||
q1 = _make_quest("q1", enabled=True)
|
||||
q2 = _make_quest("q2", enabled=False)
|
||||
qs._quest_definitions.update({"q1": q1, "q2": q2})
|
||||
|
||||
def test_get_quest_definitions_returns_all(self):
|
||||
defs = get_quest_definitions()
|
||||
assert "q1" in defs
|
||||
assert "q2" in defs
|
||||
|
||||
def test_get_quest_definition_found(self):
|
||||
defn = get_quest_definition("q1")
|
||||
assert defn is not None
|
||||
assert defn.id == "q1"
|
||||
|
||||
def test_get_quest_definition_not_found(self):
|
||||
assert get_quest_definition("missing") is None
|
||||
|
||||
def test_get_active_quests_only_enabled(self):
|
||||
active = get_active_quests()
|
||||
ids = [q.id for q in active]
|
||||
assert "q1" in ids
|
||||
assert "q2" not in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_target_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetTargetValue:
|
||||
def test_issue_count(self):
|
||||
q = _make_quest(quest_type=QuestType.ISSUE_COUNT, criteria={"target_count": 7})
|
||||
assert _get_target_value(q) == 7
|
||||
|
||||
def test_issue_reduce(self):
|
||||
q = _make_quest(quest_type=QuestType.ISSUE_REDUCE, criteria={"target_reduction": 5})
|
||||
assert _get_target_value(q) == 5
|
||||
|
||||
def test_daily_run(self):
|
||||
q = _make_quest(quest_type=QuestType.DAILY_RUN, criteria={"min_sessions": 3})
|
||||
assert _get_target_value(q) == 3
|
||||
|
||||
def test_docs_update(self):
|
||||
q = _make_quest(quest_type=QuestType.DOCS_UPDATE, criteria={"min_files_changed": 2})
|
||||
assert _get_target_value(q) == 2
|
||||
|
||||
def test_test_improve(self):
|
||||
q = _make_quest(quest_type=QuestType.TEST_IMPROVE, criteria={"min_new_tests": 4})
|
||||
assert _get_target_value(q) == 4
|
||||
|
||||
def test_custom_defaults_to_one(self):
|
||||
q = _make_quest(quest_type=QuestType.CUSTOM, criteria={})
|
||||
assert _get_target_value(q) == 1
|
||||
|
||||
def test_missing_criteria_key_defaults_to_one(self):
|
||||
q = _make_quest(quest_type=QuestType.ISSUE_COUNT, criteria={})
|
||||
assert _get_target_value(q) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_or_create_progress / get_quest_progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProgressCreation:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["q1"] = _make_quest("q1", criteria={"target_count": 5})
|
||||
|
||||
def test_creates_new_progress(self):
|
||||
progress = get_or_create_progress("q1", "agent_a")
|
||||
assert progress.quest_id == "q1"
|
||||
assert progress.agent_id == "agent_a"
|
||||
assert progress.status == QuestStatus.NOT_STARTED
|
||||
assert progress.target_value == 5
|
||||
assert progress.current_value == 0
|
||||
|
||||
def test_returns_existing_progress(self):
|
||||
p1 = get_or_create_progress("q1", "agent_a")
|
||||
p1.current_value = 3
|
||||
p2 = get_or_create_progress("q1", "agent_a")
|
||||
assert p2.current_value == 3
|
||||
assert p1 is p2
|
||||
|
||||
def test_raises_for_unknown_quest(self):
|
||||
with pytest.raises(ValueError, match="Quest unknown not found"):
|
||||
get_or_create_progress("unknown", "agent_a")
|
||||
|
||||
def test_get_quest_progress_none_before_creation(self):
|
||||
assert get_quest_progress("q1", "agent_a") is None
|
||||
|
||||
def test_get_quest_progress_after_creation(self):
|
||||
get_or_create_progress("q1", "agent_a")
|
||||
progress = get_quest_progress("q1", "agent_a")
|
||||
assert progress is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update_quest_progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateQuestProgress:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["q1"] = _make_quest("q1", criteria={"target_count": 3})
|
||||
|
||||
def test_updates_current_value(self):
|
||||
progress = update_quest_progress("q1", "agent_a", 2)
|
||||
assert progress.current_value == 2
|
||||
assert progress.status == QuestStatus.NOT_STARTED
|
||||
|
||||
def test_marks_completed_when_target_reached(self):
|
||||
progress = update_quest_progress("q1", "agent_a", 3)
|
||||
assert progress.status == QuestStatus.COMPLETED
|
||||
assert progress.completed_at != ""
|
||||
|
||||
def test_marks_completed_when_value_exceeds_target(self):
|
||||
progress = update_quest_progress("q1", "agent_a", 10)
|
||||
assert progress.status == QuestStatus.COMPLETED
|
||||
|
||||
def test_does_not_re_complete_already_completed(self):
|
||||
p = update_quest_progress("q1", "agent_a", 3)
|
||||
first_completed_at = p.completed_at
|
||||
p2 = update_quest_progress("q1", "agent_a", 5)
|
||||
# should not change completed_at again
|
||||
assert p2.completed_at == first_completed_at
|
||||
|
||||
def test_does_not_re_complete_claimed_quest(self):
|
||||
p = update_quest_progress("q1", "agent_a", 3)
|
||||
p.status = QuestStatus.CLAIMED
|
||||
p2 = update_quest_progress("q1", "agent_a", 5)
|
||||
assert p2.status == QuestStatus.CLAIMED
|
||||
|
||||
def test_updates_metadata(self):
|
||||
progress = update_quest_progress("q1", "agent_a", 1, metadata={"info": "value"})
|
||||
assert progress.metadata["info"] == "value"
|
||||
|
||||
def test_merges_metadata(self):
|
||||
update_quest_progress("q1", "agent_a", 1, metadata={"a": 1})
|
||||
progress = update_quest_progress("q1", "agent_a", 2, metadata={"b": 2})
|
||||
assert progress.metadata["a"] == 1
|
||||
assert progress.metadata["b"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_on_cooldown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsOnCooldown:
|
||||
def test_non_repeatable_never_on_cooldown(self):
|
||||
quest = _make_quest(repeatable=False, cooldown_hours=24)
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.CLAIMED,
|
||||
last_completed_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
assert _is_on_cooldown(progress, quest) is False
|
||||
|
||||
def test_no_last_completed_not_on_cooldown(self):
|
||||
quest = _make_quest(repeatable=True, cooldown_hours=24)
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.NOT_STARTED,
|
||||
last_completed_at="",
|
||||
)
|
||||
assert _is_on_cooldown(progress, quest) is False
|
||||
|
||||
def test_zero_cooldown_not_on_cooldown(self):
|
||||
quest = _make_quest(repeatable=True, cooldown_hours=0)
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.CLAIMED,
|
||||
last_completed_at=datetime.now(UTC).isoformat(),
|
||||
)
|
||||
assert _is_on_cooldown(progress, quest) is False
|
||||
|
||||
def test_recent_completion_is_on_cooldown(self):
|
||||
quest = _make_quest(repeatable=True, cooldown_hours=24)
|
||||
recent = datetime.now(UTC) - timedelta(hours=1)
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.NOT_STARTED,
|
||||
last_completed_at=recent.isoformat(),
|
||||
)
|
||||
assert _is_on_cooldown(progress, quest) is True
|
||||
|
||||
def test_expired_cooldown_not_on_cooldown(self):
|
||||
quest = _make_quest(repeatable=True, cooldown_hours=24)
|
||||
old = datetime.now(UTC) - timedelta(hours=25)
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.NOT_STARTED,
|
||||
last_completed_at=old.isoformat(),
|
||||
)
|
||||
assert _is_on_cooldown(progress, quest) is False
|
||||
|
||||
def test_invalid_last_completed_returns_false(self):
|
||||
quest = _make_quest(repeatable=True, cooldown_hours=24)
|
||||
progress = QuestProgress(
|
||||
quest_id="q1",
|
||||
agent_id="agent_a",
|
||||
status=QuestStatus.NOT_STARTED,
|
||||
last_completed_at="not-a-date",
|
||||
)
|
||||
assert _is_on_cooldown(progress, quest) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# claim_quest_reward
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestClaimQuestReward:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["q1"] = _make_quest("q1", reward_tokens=25)
|
||||
|
||||
def test_returns_none_if_no_progress(self):
|
||||
assert claim_quest_reward("q1", "agent_a") is None
|
||||
|
||||
def test_returns_none_if_not_completed(self):
|
||||
get_or_create_progress("q1", "agent_a")
|
||||
assert claim_quest_reward("q1", "agent_a") is None
|
||||
|
||||
def test_returns_none_if_quest_not_found(self):
|
||||
assert claim_quest_reward("nonexistent", "agent_a") is None
|
||||
|
||||
def test_successful_claim(self):
|
||||
progress = get_or_create_progress("q1", "agent_a")
|
||||
progress.status = QuestStatus.COMPLETED
|
||||
progress.completed_at = datetime.now(UTC).isoformat()
|
||||
|
||||
mock_invoice = MagicMock()
|
||||
mock_invoice.payment_hash = "quest_q1_agent_a_123"
|
||||
|
||||
with (
|
||||
patch("timmy.quest_system.create_invoice_entry", return_value=mock_invoice),
|
||||
patch("timmy.quest_system.mark_settled"),
|
||||
):
|
||||
result = claim_quest_reward("q1", "agent_a")
|
||||
|
||||
assert result is not None
|
||||
assert result["tokens_awarded"] == 25
|
||||
assert result["quest_id"] == "q1"
|
||||
assert result["agent_id"] == "agent_a"
|
||||
assert result["completion_count"] == 1
|
||||
|
||||
def test_successful_claim_marks_claimed(self):
|
||||
progress = get_or_create_progress("q1", "agent_a")
|
||||
progress.status = QuestStatus.COMPLETED
|
||||
progress.completed_at = datetime.now(UTC).isoformat()
|
||||
|
||||
mock_invoice = MagicMock()
|
||||
mock_invoice.payment_hash = "phash"
|
||||
|
||||
with (
|
||||
patch("timmy.quest_system.create_invoice_entry", return_value=mock_invoice),
|
||||
patch("timmy.quest_system.mark_settled"),
|
||||
):
|
||||
claim_quest_reward("q1", "agent_a")
|
||||
|
||||
assert progress.status == QuestStatus.CLAIMED
|
||||
|
||||
def test_repeatable_quest_resets_after_claim(self):
|
||||
qs._quest_definitions["rep"] = _make_quest(
|
||||
"rep", repeatable=True, cooldown_hours=0, reward_tokens=10
|
||||
)
|
||||
progress = get_or_create_progress("rep", "agent_a")
|
||||
progress.status = QuestStatus.COMPLETED
|
||||
progress.completed_at = datetime.now(UTC).isoformat()
|
||||
progress.current_value = 5
|
||||
|
||||
mock_invoice = MagicMock()
|
||||
mock_invoice.payment_hash = "phash"
|
||||
|
||||
with (
|
||||
patch("timmy.quest_system.create_invoice_entry", return_value=mock_invoice),
|
||||
patch("timmy.quest_system.mark_settled"),
|
||||
):
|
||||
result = claim_quest_reward("rep", "agent_a")
|
||||
|
||||
assert result is not None
|
||||
assert progress.status == QuestStatus.NOT_STARTED
|
||||
assert progress.current_value == 0
|
||||
assert progress.completed_at == ""
|
||||
|
||||
def test_on_cooldown_returns_none(self):
|
||||
qs._quest_definitions["rep"] = _make_quest("rep", repeatable=True, cooldown_hours=24)
|
||||
progress = get_or_create_progress("rep", "agent_a")
|
||||
progress.status = QuestStatus.COMPLETED
|
||||
recent = datetime.now(UTC) - timedelta(hours=1)
|
||||
progress.last_completed_at = recent.isoformat()
|
||||
|
||||
assert claim_quest_reward("rep", "agent_a") is None
|
||||
|
||||
def test_ledger_error_returns_none(self):
|
||||
progress = get_or_create_progress("q1", "agent_a")
|
||||
progress.status = QuestStatus.COMPLETED
|
||||
progress.completed_at = datetime.now(UTC).isoformat()
|
||||
|
||||
with patch("timmy.quest_system.create_invoice_entry", side_effect=Exception("ledger error")):
|
||||
result = claim_quest_reward("q1", "agent_a")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_issue_count_quest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckIssueCountQuest:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["iq"] = _make_quest(
|
||||
"iq", quest_type=QuestType.ISSUE_COUNT, criteria={"target_count": 2, "issue_labels": ["bug"]}
|
||||
)
|
||||
|
||||
def test_counts_matching_issues(self):
|
||||
issues = [
|
||||
{"labels": [{"name": "bug"}]},
|
||||
{"labels": [{"name": "bug"}, {"name": "priority"}]},
|
||||
{"labels": [{"name": "feature"}]}, # doesn't match
|
||||
]
|
||||
progress = check_issue_count_quest(
|
||||
qs._quest_definitions["iq"], "agent_a", issues
|
||||
)
|
||||
assert progress.current_value == 2
|
||||
assert progress.status == QuestStatus.COMPLETED
|
||||
|
||||
def test_empty_issues_returns_zero(self):
|
||||
progress = check_issue_count_quest(qs._quest_definitions["iq"], "agent_a", [])
|
||||
assert progress.current_value == 0
|
||||
|
||||
def test_no_labels_filter_counts_all_labeled(self):
|
||||
q = _make_quest(
|
||||
"nolabel",
|
||||
quest_type=QuestType.ISSUE_COUNT,
|
||||
criteria={"target_count": 1, "issue_labels": []},
|
||||
)
|
||||
qs._quest_definitions["nolabel"] = q
|
||||
issues = [
|
||||
{"labels": [{"name": "bug"}]},
|
||||
{"labels": [{"name": "feature"}]},
|
||||
]
|
||||
progress = check_issue_count_quest(q, "agent_a", issues)
|
||||
assert progress.current_value == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_issue_reduce_quest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckIssueReduceQuest:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["ir"] = _make_quest(
|
||||
"ir", quest_type=QuestType.ISSUE_REDUCE, criteria={"target_reduction": 5}
|
||||
)
|
||||
|
||||
def test_computes_reduction(self):
|
||||
progress = check_issue_reduce_quest(qs._quest_definitions["ir"], "agent_a", 20, 15)
|
||||
assert progress.current_value == 5
|
||||
assert progress.status == QuestStatus.COMPLETED
|
||||
|
||||
def test_negative_reduction_treated_as_zero(self):
|
||||
progress = check_issue_reduce_quest(qs._quest_definitions["ir"], "agent_a", 10, 15)
|
||||
assert progress.current_value == 0
|
||||
|
||||
def test_no_change_yields_zero(self):
|
||||
progress = check_issue_reduce_quest(qs._quest_definitions["ir"], "agent_a", 10, 10)
|
||||
assert progress.current_value == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# check_daily_run_quest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCheckDailyRunQuest:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["dr"] = _make_quest(
|
||||
"dr", quest_type=QuestType.DAILY_RUN, criteria={"min_sessions": 2}
|
||||
)
|
||||
|
||||
def test_tracks_sessions(self):
|
||||
progress = check_daily_run_quest(qs._quest_definitions["dr"], "agent_a", 2)
|
||||
assert progress.current_value == 2
|
||||
assert progress.status == QuestStatus.COMPLETED
|
||||
|
||||
def test_incomplete_sessions(self):
|
||||
progress = check_daily_run_quest(qs._quest_definitions["dr"], "agent_a", 1)
|
||||
assert progress.current_value == 1
|
||||
assert progress.status != QuestStatus.COMPLETED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# evaluate_quest_progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEvaluateQuestProgress:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["iq"] = _make_quest(
|
||||
"iq", quest_type=QuestType.ISSUE_COUNT, criteria={"target_count": 1}
|
||||
)
|
||||
qs._quest_definitions["dis"] = _make_quest("dis", enabled=False)
|
||||
|
||||
def test_disabled_quest_returns_none(self):
|
||||
result = evaluate_quest_progress("dis", "agent_a", {})
|
||||
assert result is None
|
||||
|
||||
def test_missing_quest_returns_none(self):
|
||||
result = evaluate_quest_progress("nonexistent", "agent_a", {})
|
||||
assert result is None
|
||||
|
||||
def test_issue_count_quest_evaluated(self):
|
||||
context = {"closed_issues": [{"labels": [{"name": "bug"}]}]}
|
||||
result = evaluate_quest_progress("iq", "agent_a", context)
|
||||
assert result is not None
|
||||
assert result.current_value == 1
|
||||
|
||||
def test_issue_reduce_quest_evaluated(self):
|
||||
qs._quest_definitions["ir"] = _make_quest(
|
||||
"ir", quest_type=QuestType.ISSUE_REDUCE, criteria={"target_reduction": 3}
|
||||
)
|
||||
context = {"previous_issue_count": 10, "current_issue_count": 7}
|
||||
result = evaluate_quest_progress("ir", "agent_a", context)
|
||||
assert result is not None
|
||||
assert result.current_value == 3
|
||||
|
||||
def test_daily_run_quest_evaluated(self):
|
||||
qs._quest_definitions["dr"] = _make_quest(
|
||||
"dr", quest_type=QuestType.DAILY_RUN, criteria={"min_sessions": 1}
|
||||
)
|
||||
context = {"sessions_completed": 2}
|
||||
result = evaluate_quest_progress("dr", "agent_a", context)
|
||||
assert result is not None
|
||||
assert result.current_value == 2
|
||||
|
||||
def test_custom_quest_returns_existing_progress(self):
|
||||
qs._quest_definitions["cust"] = _make_quest("cust", quest_type=QuestType.CUSTOM)
|
||||
# No progress yet => None (custom quests don't auto-create progress here)
|
||||
result = evaluate_quest_progress("cust", "agent_a", {})
|
||||
assert result is None
|
||||
|
||||
def test_cooldown_prevents_evaluation(self):
|
||||
q = _make_quest("rep_iq", quest_type=QuestType.ISSUE_COUNT, repeatable=True, cooldown_hours=24, criteria={"target_count": 1})
|
||||
qs._quest_definitions["rep_iq"] = q
|
||||
progress = get_or_create_progress("rep_iq", "agent_a")
|
||||
recent = datetime.now(UTC) - timedelta(hours=1)
|
||||
progress.last_completed_at = recent.isoformat()
|
||||
|
||||
context = {"closed_issues": [{"labels": [{"name": "bug"}]}]}
|
||||
result = evaluate_quest_progress("rep_iq", "agent_a", context)
|
||||
# Should return existing progress without updating
|
||||
assert result is progress
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset_quest_progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResetQuestProgress:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["q1"] = _make_quest("q1")
|
||||
qs._quest_definitions["q2"] = _make_quest("q2")
|
||||
|
||||
def test_reset_all(self):
|
||||
get_or_create_progress("q1", "agent_a")
|
||||
get_or_create_progress("q2", "agent_a")
|
||||
count = reset_quest_progress()
|
||||
assert count == 2
|
||||
assert get_quest_progress("q1", "agent_a") is None
|
||||
assert get_quest_progress("q2", "agent_a") is None
|
||||
|
||||
def test_reset_specific_quest(self):
|
||||
get_or_create_progress("q1", "agent_a")
|
||||
get_or_create_progress("q2", "agent_a")
|
||||
count = reset_quest_progress(quest_id="q1")
|
||||
assert count == 1
|
||||
assert get_quest_progress("q1", "agent_a") is None
|
||||
assert get_quest_progress("q2", "agent_a") is not None
|
||||
|
||||
def test_reset_specific_agent(self):
|
||||
get_or_create_progress("q1", "agent_a")
|
||||
get_or_create_progress("q1", "agent_b")
|
||||
count = reset_quest_progress(agent_id="agent_a")
|
||||
assert count == 1
|
||||
assert get_quest_progress("q1", "agent_a") is None
|
||||
assert get_quest_progress("q1", "agent_b") is not None
|
||||
|
||||
def test_reset_specific_quest_and_agent(self):
|
||||
get_or_create_progress("q1", "agent_a")
|
||||
get_or_create_progress("q1", "agent_b")
|
||||
count = reset_quest_progress(quest_id="q1", agent_id="agent_a")
|
||||
assert count == 1
|
||||
|
||||
def test_reset_empty_returns_zero(self):
|
||||
count = reset_quest_progress()
|
||||
assert count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_quest_leaderboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetQuestLeaderboard:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["q1"] = _make_quest("q1", reward_tokens=10)
|
||||
qs._quest_definitions["q2"] = _make_quest("q2", reward_tokens=20)
|
||||
|
||||
def test_empty_progress_returns_empty(self):
|
||||
assert get_quest_leaderboard() == []
|
||||
|
||||
def test_leaderboard_sorted_by_tokens(self):
|
||||
p_a = get_or_create_progress("q1", "agent_a")
|
||||
p_a.completion_count = 1
|
||||
p_b = get_or_create_progress("q2", "agent_b")
|
||||
p_b.completion_count = 2
|
||||
|
||||
board = get_quest_leaderboard()
|
||||
assert board[0]["agent_id"] == "agent_b" # 40 tokens
|
||||
assert board[1]["agent_id"] == "agent_a" # 10 tokens
|
||||
|
||||
def test_leaderboard_aggregates_multiple_quests(self):
|
||||
p1 = get_or_create_progress("q1", "agent_a")
|
||||
p1.completion_count = 2 # 20 tokens
|
||||
p2 = get_or_create_progress("q2", "agent_a")
|
||||
p2.completion_count = 1 # 20 tokens
|
||||
|
||||
board = get_quest_leaderboard()
|
||||
assert len(board) == 1
|
||||
assert board[0]["total_tokens"] == 40
|
||||
assert board[0]["total_completions"] == 3
|
||||
|
||||
def test_leaderboard_counts_unique_quests(self):
|
||||
p1 = get_or_create_progress("q1", "agent_a")
|
||||
p1.completion_count = 2
|
||||
p2 = get_or_create_progress("q2", "agent_a")
|
||||
p2.completion_count = 1
|
||||
|
||||
board = get_quest_leaderboard()
|
||||
assert board[0]["unique_quests_completed"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_agent_quests_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetAgentQuestsStatus:
|
||||
def setup_method(self):
|
||||
qs._quest_definitions["q1"] = _make_quest("q1", reward_tokens=10)
|
||||
|
||||
def test_returns_status_structure(self):
|
||||
result = get_agent_quests_status("agent_a")
|
||||
assert result["agent_id"] == "agent_a"
|
||||
assert isinstance(result["quests"], list)
|
||||
assert "total_tokens_earned" in result
|
||||
assert "total_quests_completed" in result
|
||||
assert "active_quests_count" in result
|
||||
|
||||
def test_includes_quest_info(self):
|
||||
result = get_agent_quests_status("agent_a")
|
||||
quest_info = result["quests"][0]
|
||||
assert quest_info["quest_id"] == "q1"
|
||||
assert quest_info["reward_tokens"] == 10
|
||||
assert quest_info["status"] == QuestStatus.NOT_STARTED.value
|
||||
|
||||
def test_accumulates_tokens_from_completions(self):
|
||||
p = get_or_create_progress("q1", "agent_a")
|
||||
p.completion_count = 3
|
||||
result = get_agent_quests_status("agent_a")
|
||||
assert result["total_tokens_earned"] == 30
|
||||
assert result["total_quests_completed"] == 3
|
||||
|
||||
def test_cooldown_hours_remaining_calculated(self):
|
||||
q = _make_quest("qcool", repeatable=True, cooldown_hours=24, reward_tokens=5)
|
||||
qs._quest_definitions["qcool"] = q
|
||||
p = get_or_create_progress("qcool", "agent_a")
|
||||
recent = datetime.now(UTC) - timedelta(hours=2)
|
||||
p.last_completed_at = recent.isoformat()
|
||||
p.completion_count = 1
|
||||
|
||||
result = get_agent_quests_status("agent_a")
|
||||
qcool_info = next(qi for qi in result["quests"] if qi["quest_id"] == "qcool")
|
||||
assert qcool_info["on_cooldown"] is True
|
||||
assert qcool_info["cooldown_hours_remaining"] > 0
|
||||
308
tests/timmy/test_tools_search.py
Normal file
308
tests/timmy/test_tools_search.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""Unit tests for web_search and scrape_url tools (SearXNG + Crawl4AI).
|
||||
|
||||
All tests use mocked HTTP — no live services required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timmy.tools.search import _extract_crawl_content, scrape_url, web_search
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_requests(json_response=None, status_code=200, raise_exc=None):
|
||||
"""Build a mock requests module whose .get/.post return controlled responses."""
|
||||
mock_req = MagicMock()
|
||||
|
||||
# Exception hierarchy
|
||||
class Timeout(Exception):
|
||||
pass
|
||||
|
||||
class HTTPError(Exception):
|
||||
def __init__(self, *a, response=None, **kw):
|
||||
super().__init__(*a, **kw)
|
||||
self.response = response
|
||||
|
||||
class RequestException(Exception):
|
||||
pass
|
||||
|
||||
exc_mod = MagicMock()
|
||||
exc_mod.Timeout = Timeout
|
||||
exc_mod.HTTPError = HTTPError
|
||||
exc_mod.RequestException = RequestException
|
||||
mock_req.exceptions = exc_mod
|
||||
|
||||
if raise_exc is not None:
|
||||
mock_req.get.side_effect = raise_exc
|
||||
mock_req.post.side_effect = raise_exc
|
||||
else:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.json.return_value = json_response or {}
|
||||
if status_code >= 400:
|
||||
mock_resp.raise_for_status.side_effect = HTTPError(
|
||||
response=MagicMock(status_code=status_code)
|
||||
)
|
||||
mock_req.get.return_value = mock_resp
|
||||
mock_req.post.return_value = mock_resp
|
||||
|
||||
return mock_req
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# web_search tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWebSearch:
|
||||
def test_backend_none_short_circuits(self):
|
||||
"""TIMMY_SEARCH_BACKEND=none returns disabled message immediately."""
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "none"
|
||||
result = web_search("anything")
|
||||
assert "disabled" in result
|
||||
|
||||
def test_missing_requests_package(self):
|
||||
"""Graceful error when requests is not installed."""
|
||||
with patch.dict("sys.modules", {"requests": None}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.search_url = "http://localhost:8888"
|
||||
result = web_search("test query")
|
||||
assert "requests" in result and "not installed" in result
|
||||
|
||||
def test_successful_search(self):
|
||||
"""Happy path: returns formatted result list."""
|
||||
mock_data = {
|
||||
"results": [
|
||||
{"title": "Foo Bar", "url": "https://example.com/foo", "content": "Foo is great"},
|
||||
{"title": "Baz", "url": "https://example.com/baz", "content": "Baz rules"},
|
||||
]
|
||||
}
|
||||
mock_req = _mock_requests(json_response=mock_data)
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.search_url = "http://localhost:8888"
|
||||
result = web_search("foo bar")
|
||||
|
||||
assert "Foo Bar" in result
|
||||
assert "https://example.com/foo" in result
|
||||
assert "Baz" in result
|
||||
assert "foo bar" in result
|
||||
|
||||
def test_no_results(self):
|
||||
"""Empty results list returns a helpful no-results message."""
|
||||
mock_req = _mock_requests(json_response={"results": []})
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.search_url = "http://localhost:8888"
|
||||
result = web_search("xyzzy")
|
||||
assert "No results" in result
|
||||
|
||||
def test_num_results_respected(self):
|
||||
"""Only up to num_results entries are returned."""
|
||||
mock_data = {
|
||||
"results": [
|
||||
{"title": f"Result {i}", "url": f"https://example.com/{i}", "content": "x"}
|
||||
for i in range(10)
|
||||
]
|
||||
}
|
||||
mock_req = _mock_requests(json_response=mock_data)
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.search_url = "http://localhost:8888"
|
||||
result = web_search("test", num_results=3)
|
||||
|
||||
# Only 3 numbered entries should appear
|
||||
assert "1." in result
|
||||
assert "3." in result
|
||||
assert "4." not in result
|
||||
|
||||
def test_service_unavailable(self):
|
||||
"""Connection error degrades gracefully."""
|
||||
mock_req = MagicMock()
|
||||
mock_req.get.side_effect = OSError("connection refused")
|
||||
mock_req.exceptions = MagicMock()
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.search_url = "http://localhost:8888"
|
||||
result = web_search("test")
|
||||
assert "not reachable" in result or "unavailable" in result
|
||||
|
||||
def test_catalog_entry_exists(self):
|
||||
"""web_search must appear in the tool catalog."""
|
||||
from timmy.tools import get_all_available_tools
|
||||
|
||||
catalog = get_all_available_tools()
|
||||
assert "web_search" in catalog
|
||||
assert "orchestrator" in catalog["web_search"]["available_in"]
|
||||
assert "echo" in catalog["web_search"]["available_in"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scrape_url tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScrapeUrl:
|
||||
def test_invalid_url_no_scheme(self):
|
||||
"""URLs without http(s) scheme are rejected before any HTTP call."""
|
||||
result = scrape_url("example.com/page")
|
||||
assert "Error: invalid URL" in result
|
||||
|
||||
def test_invalid_url_empty(self):
|
||||
result = scrape_url("")
|
||||
assert "Error: invalid URL" in result
|
||||
|
||||
def test_backend_none_short_circuits(self):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "none"
|
||||
result = scrape_url("https://example.com")
|
||||
assert "disabled" in result
|
||||
|
||||
def test_missing_requests_package(self):
|
||||
with patch.dict("sys.modules", {"requests": None}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.crawl_url = "http://localhost:11235"
|
||||
result = scrape_url("https://example.com")
|
||||
assert "requests" in result and "not installed" in result
|
||||
|
||||
def test_sync_result_returned_immediately(self):
|
||||
"""If Crawl4AI returns results in the POST response, use them directly."""
|
||||
mock_data = {
|
||||
"results": [{"markdown": "# Hello\n\nThis is the page content."}]
|
||||
}
|
||||
mock_req = _mock_requests(json_response=mock_data)
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.crawl_url = "http://localhost:11235"
|
||||
result = scrape_url("https://example.com")
|
||||
|
||||
assert "Hello" in result
|
||||
assert "page content" in result
|
||||
|
||||
def test_async_poll_completed(self):
|
||||
"""Async task_id flow: polls until completed and returns content."""
|
||||
submit_response = MagicMock()
|
||||
submit_response.json.return_value = {"task_id": "abc123"}
|
||||
submit_response.raise_for_status.return_value = None
|
||||
|
||||
poll_response = MagicMock()
|
||||
poll_response.json.return_value = {
|
||||
"status": "completed",
|
||||
"results": [{"markdown": "# Async content"}],
|
||||
}
|
||||
poll_response.raise_for_status.return_value = None
|
||||
|
||||
mock_req = MagicMock()
|
||||
mock_req.post.return_value = submit_response
|
||||
mock_req.get.return_value = poll_response
|
||||
mock_req.exceptions = MagicMock()
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.crawl_url = "http://localhost:11235"
|
||||
with patch("timmy.tools.search.time") as mock_time:
|
||||
mock_time.sleep = MagicMock()
|
||||
result = scrape_url("https://example.com")
|
||||
|
||||
assert "Async content" in result
|
||||
|
||||
def test_async_poll_failed_task(self):
|
||||
"""Crawl4AI task failure is reported clearly."""
|
||||
submit_response = MagicMock()
|
||||
submit_response.json.return_value = {"task_id": "abc123"}
|
||||
submit_response.raise_for_status.return_value = None
|
||||
|
||||
poll_response = MagicMock()
|
||||
poll_response.json.return_value = {"status": "failed", "error": "site blocked"}
|
||||
poll_response.raise_for_status.return_value = None
|
||||
|
||||
mock_req = MagicMock()
|
||||
mock_req.post.return_value = submit_response
|
||||
mock_req.get.return_value = poll_response
|
||||
mock_req.exceptions = MagicMock()
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.crawl_url = "http://localhost:11235"
|
||||
with patch("timmy.tools.search.time") as mock_time:
|
||||
mock_time.sleep = MagicMock()
|
||||
result = scrape_url("https://example.com")
|
||||
|
||||
assert "failed" in result and "site blocked" in result
|
||||
|
||||
def test_service_unavailable(self):
|
||||
"""Connection error degrades gracefully."""
|
||||
mock_req = MagicMock()
|
||||
mock_req.post.side_effect = OSError("connection refused")
|
||||
mock_req.exceptions = MagicMock()
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.crawl_url = "http://localhost:11235"
|
||||
result = scrape_url("https://example.com")
|
||||
assert "not reachable" in result or "unavailable" in result
|
||||
|
||||
def test_content_truncation(self):
|
||||
"""Content longer than ~4000 tokens is truncated."""
|
||||
long_content = "x" * 20000
|
||||
mock_data = {"results": [{"markdown": long_content}]}
|
||||
mock_req = _mock_requests(json_response=mock_data)
|
||||
with patch.dict("sys.modules", {"requests": mock_req}):
|
||||
with patch("timmy.tools.search.settings") as mock_settings:
|
||||
mock_settings.timmy_search_backend = "searxng"
|
||||
mock_settings.crawl_url = "http://localhost:11235"
|
||||
result = scrape_url("https://example.com")
|
||||
|
||||
assert "[…truncated" in result
|
||||
assert len(result) < 17000
|
||||
|
||||
def test_catalog_entry_exists(self):
|
||||
"""scrape_url must appear in the tool catalog."""
|
||||
from timmy.tools import get_all_available_tools
|
||||
|
||||
catalog = get_all_available_tools()
|
||||
assert "scrape_url" in catalog
|
||||
assert "orchestrator" in catalog["scrape_url"]["available_in"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_crawl_content helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractCrawlContent:
|
||||
def test_empty_results(self):
|
||||
result = _extract_crawl_content([], "https://example.com")
|
||||
assert "No content" in result
|
||||
|
||||
def test_markdown_field_preferred(self):
|
||||
results = [{"markdown": "# Title", "content": "fallback"}]
|
||||
result = _extract_crawl_content(results, "https://example.com")
|
||||
assert "Title" in result
|
||||
|
||||
def test_fallback_to_content_field(self):
|
||||
results = [{"content": "plain text content"}]
|
||||
result = _extract_crawl_content(results, "https://example.com")
|
||||
assert "plain text content" in result
|
||||
|
||||
def test_no_content_fields(self):
|
||||
results = [{"url": "https://example.com"}]
|
||||
result = _extract_crawl_content(results, "https://example.com")
|
||||
assert "No readable content" in result
|
||||
270
tests/timmy_automations/test_orchestrator.py
Normal file
270
tests/timmy_automations/test_orchestrator.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Tests for Daily Run orchestrator — health snapshot integration.
|
||||
|
||||
Verifies that the orchestrator runs a pre-flight health snapshot before
|
||||
any coding work begins, and aborts on red status unless --force is passed.
|
||||
|
||||
Refs: #923
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add timmy_automations to path for imports
|
||||
_TA_PATH = Path(__file__).resolve().parent.parent.parent / "timmy_automations" / "daily_run"
|
||||
if str(_TA_PATH) not in sys.path:
|
||||
sys.path.insert(0, str(_TA_PATH))
|
||||
# Also add utils path
|
||||
_TA_UTILS = Path(__file__).resolve().parent.parent.parent / "timmy_automations"
|
||||
if str(_TA_UTILS) not in sys.path:
|
||||
sys.path.insert(0, str(_TA_UTILS))
|
||||
|
||||
import health_snapshot as hs
|
||||
import orchestrator as orch
|
||||
|
||||
|
||||
def _make_snapshot(overall_status: str) -> hs.HealthSnapshot:
|
||||
"""Build a minimal HealthSnapshot for testing."""
|
||||
return hs.HealthSnapshot(
|
||||
timestamp="2026-01-01T00:00:00+00:00",
|
||||
overall_status=overall_status,
|
||||
ci=hs.CISignal(status="pass", message="CI passing"),
|
||||
issues=hs.IssueSignal(count=0, p0_count=0, p1_count=0),
|
||||
flakiness=hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
),
|
||||
tokens=hs.TokenEconomySignal(status="balanced", message="Balanced"),
|
||||
)
|
||||
|
||||
|
||||
def _make_red_snapshot() -> hs.HealthSnapshot:
|
||||
return hs.HealthSnapshot(
|
||||
timestamp="2026-01-01T00:00:00+00:00",
|
||||
overall_status="red",
|
||||
ci=hs.CISignal(status="fail", message="CI failed"),
|
||||
issues=hs.IssueSignal(count=1, p0_count=1, p1_count=0),
|
||||
flakiness=hs.FlakinessSignal(
|
||||
status="critical",
|
||||
recent_failures=8,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.8,
|
||||
message="High flakiness",
|
||||
),
|
||||
tokens=hs.TokenEconomySignal(status="unknown", message="No data"),
|
||||
)
|
||||
|
||||
|
||||
def _default_args(**overrides) -> argparse.Namespace:
|
||||
"""Build an argparse Namespace with defaults matching the orchestrator flags."""
|
||||
defaults = {
|
||||
"review": False,
|
||||
"json": False,
|
||||
"max_items": None,
|
||||
"skip_health_check": False,
|
||||
"force": False,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
class TestRunHealthSnapshot:
|
||||
"""Test run_health_snapshot() — the pre-flight check called by main()."""
|
||||
|
||||
def test_green_returns_zero(self, capsys):
|
||||
"""Green snapshot returns 0 (proceed)."""
|
||||
args = _default_args()
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", return_value=_make_snapshot("green")):
|
||||
rc = orch.run_health_snapshot(args)
|
||||
|
||||
assert rc == 0
|
||||
|
||||
def test_yellow_returns_zero(self, capsys):
|
||||
"""Yellow snapshot returns 0 (proceed with caution)."""
|
||||
args = _default_args()
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", return_value=_make_snapshot("yellow")):
|
||||
rc = orch.run_health_snapshot(args)
|
||||
|
||||
assert rc == 0
|
||||
|
||||
def test_red_returns_one(self, capsys):
|
||||
"""Red snapshot returns 1 (abort)."""
|
||||
args = _default_args()
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", return_value=_make_red_snapshot()):
|
||||
rc = orch.run_health_snapshot(args)
|
||||
|
||||
assert rc == 1
|
||||
|
||||
def test_red_with_force_returns_zero(self, capsys):
|
||||
"""Red snapshot with --force returns 0 (proceed anyway)."""
|
||||
args = _default_args(force=True)
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", return_value=_make_red_snapshot()):
|
||||
rc = orch.run_health_snapshot(args)
|
||||
|
||||
assert rc == 0
|
||||
|
||||
def test_snapshot_exception_is_skipped(self, capsys):
|
||||
"""If health snapshot raises, it degrades gracefully and returns 0."""
|
||||
args = _default_args()
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", side_effect=RuntimeError("boom")):
|
||||
rc = orch.run_health_snapshot(args)
|
||||
|
||||
assert rc == 0
|
||||
captured = capsys.readouterr()
|
||||
assert "warning" in captured.err.lower() or "skipping" in captured.err.lower()
|
||||
|
||||
def test_snapshot_prints_summary(self, capsys):
|
||||
"""Health snapshot prints a pre-flight summary block."""
|
||||
args = _default_args()
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", return_value=_make_snapshot("green")):
|
||||
orch.run_health_snapshot(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "PRE-FLIGHT HEALTH CHECK" in captured.out
|
||||
assert "CI" in captured.out
|
||||
|
||||
def test_red_prints_abort_message(self, capsys):
|
||||
"""Red snapshot prints an abort message to stderr."""
|
||||
args = _default_args()
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", return_value=_make_red_snapshot()):
|
||||
orch.run_health_snapshot(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "RED" in captured.err or "aborting" in captured.err.lower()
|
||||
|
||||
def test_p0_issues_shown_in_output(self, capsys):
|
||||
"""P0 issue count is shown in the pre-flight output."""
|
||||
args = _default_args()
|
||||
snapshot = hs.HealthSnapshot(
|
||||
timestamp="2026-01-01T00:00:00+00:00",
|
||||
overall_status="red",
|
||||
ci=hs.CISignal(status="pass", message="CI passing"),
|
||||
issues=hs.IssueSignal(count=2, p0_count=2, p1_count=0),
|
||||
flakiness=hs.FlakinessSignal(
|
||||
status="healthy",
|
||||
recent_failures=0,
|
||||
recent_cycles=10,
|
||||
failure_rate=0.0,
|
||||
message="All good",
|
||||
),
|
||||
tokens=hs.TokenEconomySignal(status="balanced", message="Balanced"),
|
||||
)
|
||||
|
||||
with patch.object(orch, "_generate_health_snapshot", return_value=snapshot):
|
||||
orch.run_health_snapshot(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "P0" in captured.out
|
||||
|
||||
|
||||
class TestMainHealthCheckIntegration:
|
||||
"""Test that main() runs health snapshot before any coding work."""
|
||||
|
||||
def _patch_gitea_unavailable(self):
|
||||
return patch.object(orch.GiteaClient, "is_available", return_value=False)
|
||||
|
||||
def test_main_runs_health_check_before_gitea(self):
|
||||
"""Health snapshot is called before Gitea client work."""
|
||||
call_order = []
|
||||
|
||||
def fake_snapshot(*_a, **_kw):
|
||||
call_order.append("health")
|
||||
return _make_snapshot("green")
|
||||
|
||||
def fake_gitea_available(self):
|
||||
call_order.append("gitea")
|
||||
return False
|
||||
|
||||
args = _default_args()
|
||||
|
||||
with (
|
||||
patch.object(orch, "_generate_health_snapshot", side_effect=fake_snapshot),
|
||||
patch.object(orch.GiteaClient, "is_available", fake_gitea_available),
|
||||
patch("sys.argv", ["orchestrator"]),
|
||||
):
|
||||
orch.main()
|
||||
|
||||
assert call_order.index("health") < call_order.index("gitea")
|
||||
|
||||
def test_main_aborts_on_red_before_gitea(self):
|
||||
"""main() aborts with non-zero exit code when health is red."""
|
||||
gitea_called = []
|
||||
|
||||
def fake_gitea_available(self):
|
||||
gitea_called.append(True)
|
||||
return True
|
||||
|
||||
with (
|
||||
patch.object(orch, "_generate_health_snapshot", return_value=_make_red_snapshot()),
|
||||
patch.object(orch.GiteaClient, "is_available", fake_gitea_available),
|
||||
patch("sys.argv", ["orchestrator"]),
|
||||
):
|
||||
rc = orch.main()
|
||||
|
||||
assert rc != 0
|
||||
assert not gitea_called, "Gitea should NOT be called when health is red"
|
||||
|
||||
def test_main_skips_health_check_with_flag(self):
|
||||
"""--skip-health-check bypasses the pre-flight snapshot."""
|
||||
health_called = []
|
||||
|
||||
def fake_snapshot(*_a, **_kw):
|
||||
health_called.append(True)
|
||||
return _make_snapshot("green")
|
||||
|
||||
with (
|
||||
patch.object(orch, "_generate_health_snapshot", side_effect=fake_snapshot),
|
||||
patch.object(orch.GiteaClient, "is_available", return_value=False),
|
||||
patch("sys.argv", ["orchestrator", "--skip-health-check"]),
|
||||
):
|
||||
orch.main()
|
||||
|
||||
assert not health_called, "Health snapshot should be skipped"
|
||||
|
||||
def test_main_force_flag_continues_despite_red(self):
|
||||
"""--force allows Daily Run to continue even when health is red."""
|
||||
gitea_called = []
|
||||
|
||||
def fake_gitea_available(self):
|
||||
gitea_called.append(True)
|
||||
return False # Gitea unavailable → exits early but after health check
|
||||
|
||||
with (
|
||||
patch.object(orch, "_generate_health_snapshot", return_value=_make_red_snapshot()),
|
||||
patch.object(orch.GiteaClient, "is_available", fake_gitea_available),
|
||||
patch("sys.argv", ["orchestrator", "--force"]),
|
||||
):
|
||||
orch.main()
|
||||
|
||||
# Gitea was reached despite red status because --force was passed
|
||||
assert gitea_called
|
||||
|
||||
def test_main_json_output_on_red_includes_error(self, capsys):
|
||||
"""JSON output includes error key when health is red."""
|
||||
with (
|
||||
patch.object(orch, "_generate_health_snapshot", return_value=_make_red_snapshot()),
|
||||
patch.object(orch.GiteaClient, "is_available", return_value=True),
|
||||
patch("sys.argv", ["orchestrator", "--json"]),
|
||||
):
|
||||
rc = orch.main()
|
||||
|
||||
assert rc != 0
|
||||
captured = capsys.readouterr()
|
||||
data = json.loads(captured.out)
|
||||
assert "error" in data
|
||||
135
tests/unit/test_airllm_backend.py
Normal file
135
tests/unit/test_airllm_backend.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Unit tests for AirLLM backend graceful degradation.
|
||||
|
||||
Verifies that setting TIMMY_MODEL_BACKEND=airllm on non-Apple-Silicon hardware
|
||||
(Intel Mac, Linux, Windows) or when the airllm package is not installed
|
||||
falls back to the Ollama backend without crashing.
|
||||
|
||||
Refs #1284
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class TestIsAppleSilicon:
|
||||
"""is_apple_silicon() correctly identifies the host platform."""
|
||||
|
||||
def test_returns_true_on_arm64_darwin(self):
|
||||
from timmy.backends import is_apple_silicon
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), patch(
|
||||
"platform.machine", return_value="arm64"
|
||||
):
|
||||
assert is_apple_silicon() is True
|
||||
|
||||
def test_returns_false_on_intel_mac(self):
|
||||
from timmy.backends import is_apple_silicon
|
||||
|
||||
with patch("platform.system", return_value="Darwin"), patch(
|
||||
"platform.machine", return_value="x86_64"
|
||||
):
|
||||
assert is_apple_silicon() is False
|
||||
|
||||
def test_returns_false_on_linux(self):
|
||||
from timmy.backends import is_apple_silicon
|
||||
|
||||
with patch("platform.system", return_value="Linux"), patch(
|
||||
"platform.machine", return_value="x86_64"
|
||||
):
|
||||
assert is_apple_silicon() is False
|
||||
|
||||
def test_returns_false_on_windows(self):
|
||||
from timmy.backends import is_apple_silicon
|
||||
|
||||
with patch("platform.system", return_value="Windows"), patch(
|
||||
"platform.machine", return_value="AMD64"
|
||||
):
|
||||
assert is_apple_silicon() is False
|
||||
|
||||
|
||||
class TestAirLLMGracefulDegradation:
|
||||
"""create_timmy(backend='airllm') falls back to Ollama on unsupported platforms."""
|
||||
|
||||
def _make_fake_ollama_agent(self):
|
||||
"""Return a lightweight stub that satisfies the Agno Agent interface."""
|
||||
agent = MagicMock()
|
||||
agent.run = MagicMock(return_value=MagicMock(content="ok"))
|
||||
return agent
|
||||
|
||||
def test_falls_back_to_ollama_on_non_apple_silicon(self, caplog):
|
||||
"""On Intel/Linux, airllm backend logs a warning and creates an Ollama agent."""
|
||||
import logging
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
fake_agent = self._make_fake_ollama_agent()
|
||||
|
||||
with (
|
||||
patch("timmy.backends.is_apple_silicon", return_value=False),
|
||||
patch("timmy.agent._create_ollama_agent", return_value=fake_agent) as mock_create,
|
||||
patch("timmy.agent._resolve_model_with_fallback", return_value=("qwen3:8b", False)),
|
||||
patch("timmy.agent._check_model_available", return_value=True),
|
||||
patch("timmy.agent._build_tools_list", return_value=[]),
|
||||
patch("timmy.agent._build_prompt", return_value="test prompt"),
|
||||
caplog.at_level(logging.WARNING, logger="timmy.agent"),
|
||||
):
|
||||
result = create_timmy(backend="airllm")
|
||||
|
||||
assert result is fake_agent
|
||||
mock_create.assert_called_once()
|
||||
assert "Apple Silicon" in caplog.text
|
||||
|
||||
def test_falls_back_to_ollama_when_airllm_not_installed(self, caplog):
|
||||
"""When the airllm package is missing, log a warning and use Ollama."""
|
||||
import logging
|
||||
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
fake_agent = self._make_fake_ollama_agent()
|
||||
|
||||
# Simulate Apple Silicon + missing airllm package
|
||||
def _import_side_effect(name, *args, **kwargs):
|
||||
if name == "airllm":
|
||||
raise ImportError("No module named 'airllm'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__
|
||||
|
||||
with (
|
||||
patch("timmy.backends.is_apple_silicon", return_value=True),
|
||||
patch("builtins.__import__", side_effect=_import_side_effect),
|
||||
patch("timmy.agent._create_ollama_agent", return_value=fake_agent) as mock_create,
|
||||
patch("timmy.agent._resolve_model_with_fallback", return_value=("qwen3:8b", False)),
|
||||
patch("timmy.agent._check_model_available", return_value=True),
|
||||
patch("timmy.agent._build_tools_list", return_value=[]),
|
||||
patch("timmy.agent._build_prompt", return_value="test prompt"),
|
||||
caplog.at_level(logging.WARNING, logger="timmy.agent"),
|
||||
):
|
||||
result = create_timmy(backend="airllm")
|
||||
|
||||
assert result is fake_agent
|
||||
mock_create.assert_called_once()
|
||||
assert "airllm" in caplog.text.lower() or "AirLLM" in caplog.text
|
||||
|
||||
def test_airllm_backend_does_not_raise(self):
|
||||
"""create_timmy(backend='airllm') never raises — it degrades gracefully."""
|
||||
from timmy.agent import create_timmy
|
||||
|
||||
fake_agent = self._make_fake_ollama_agent()
|
||||
|
||||
with (
|
||||
patch("timmy.backends.is_apple_silicon", return_value=False),
|
||||
patch("timmy.agent._create_ollama_agent", return_value=fake_agent),
|
||||
patch("timmy.agent._resolve_model_with_fallback", return_value=("qwen3:8b", False)),
|
||||
patch("timmy.agent._check_model_available", return_value=True),
|
||||
patch("timmy.agent._build_tools_list", return_value=[]),
|
||||
patch("timmy.agent._build_prompt", return_value="test prompt"),
|
||||
):
|
||||
# Should not raise under any circumstances
|
||||
result = create_timmy(backend="airllm")
|
||||
|
||||
assert result is not None
|
||||
235
tests/unit/test_brain_worker.py
Normal file
235
tests/unit/test_brain_worker.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Unit tests for brain.worker.DistributedWorker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from brain.worker import MAX_RETRIES, DelegatedTask, DistributedWorker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_task_registry():
|
||||
"""Reset the worker registry before each test."""
|
||||
DistributedWorker.clear()
|
||||
yield
|
||||
DistributedWorker.clear()
|
||||
|
||||
|
||||
class TestSubmit:
|
||||
def test_returns_task_id(self):
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
task_id = DistributedWorker.submit("researcher", "research", "find something")
|
||||
assert isinstance(task_id, str)
|
||||
assert len(task_id) == 8
|
||||
|
||||
def test_task_registered_as_queued(self):
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
task_id = DistributedWorker.submit("coder", "code", "fix the bug")
|
||||
status = DistributedWorker.get_status(task_id)
|
||||
assert status["found"] is True
|
||||
assert status["task_id"] == task_id
|
||||
assert status["agent"] == "coder"
|
||||
|
||||
def test_unique_task_ids(self):
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
ids = [DistributedWorker.submit("coder", "code", "task") for _ in range(10)]
|
||||
assert len(set(ids)) == 10
|
||||
|
||||
def test_starts_daemon_thread(self):
|
||||
event = threading.Event()
|
||||
|
||||
def fake_run_task(record):
|
||||
event.set()
|
||||
|
||||
with patch.object(DistributedWorker, "_run_task", side_effect=fake_run_task):
|
||||
DistributedWorker.submit("coder", "code", "something")
|
||||
|
||||
assert event.wait(timeout=2), "Background thread did not start"
|
||||
|
||||
def test_priority_stored(self):
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
task_id = DistributedWorker.submit("coder", "code", "task", priority="high")
|
||||
status = DistributedWorker.get_status(task_id)
|
||||
assert status["priority"] == "high"
|
||||
|
||||
|
||||
class TestGetStatus:
|
||||
def test_unknown_task_id(self):
|
||||
result = DistributedWorker.get_status("deadbeef")
|
||||
assert result["found"] is False
|
||||
assert result["task_id"] == "deadbeef"
|
||||
|
||||
def test_known_task_has_all_fields(self):
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
task_id = DistributedWorker.submit("writer", "writing", "write a blog post")
|
||||
status = DistributedWorker.get_status(task_id)
|
||||
for key in ("found", "task_id", "agent", "role", "status", "backend", "created_at"):
|
||||
assert key in status, f"Missing key: {key}"
|
||||
|
||||
|
||||
class TestListTasks:
|
||||
def test_empty_initially(self):
|
||||
assert DistributedWorker.list_tasks() == []
|
||||
|
||||
def test_returns_registered_tasks(self):
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
DistributedWorker.submit("coder", "code", "task A")
|
||||
DistributedWorker.submit("writer", "writing", "task B")
|
||||
tasks = DistributedWorker.list_tasks()
|
||||
assert len(tasks) == 2
|
||||
agents = {t["agent"] for t in tasks}
|
||||
assert agents == {"coder", "writer"}
|
||||
|
||||
|
||||
class TestSelectBackend:
|
||||
def test_defaults_to_agentic_loop(self):
|
||||
with patch("brain.worker.logger"):
|
||||
backend = DistributedWorker._select_backend("code", "fix the bug")
|
||||
assert backend == "agentic_loop"
|
||||
|
||||
def test_kimi_for_heavy_research_with_gitea(self):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = True
|
||||
mock_settings.gitea_token = "tok"
|
||||
mock_settings.paperclip_api_key = ""
|
||||
|
||||
with (
|
||||
patch("timmy.kimi_delegation.exceeds_local_capacity", return_value=True),
|
||||
patch("config.settings", mock_settings),
|
||||
):
|
||||
backend = DistributedWorker._select_backend("research", "comprehensive survey " * 10)
|
||||
assert backend == "kimi"
|
||||
|
||||
def test_agentic_loop_when_no_gitea(self):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = False
|
||||
mock_settings.gitea_token = ""
|
||||
mock_settings.paperclip_api_key = ""
|
||||
|
||||
with patch("config.settings", mock_settings):
|
||||
backend = DistributedWorker._select_backend("research", "comprehensive survey " * 10)
|
||||
assert backend == "agentic_loop"
|
||||
|
||||
def test_paperclip_when_api_key_configured(self):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.gitea_enabled = False
|
||||
mock_settings.gitea_token = ""
|
||||
mock_settings.paperclip_api_key = "pk_test_123"
|
||||
|
||||
with patch("config.settings", mock_settings):
|
||||
backend = DistributedWorker._select_backend("code", "build a widget")
|
||||
assert backend == "paperclip"
|
||||
|
||||
|
||||
class TestRunTask:
|
||||
def test_marks_completed_on_success(self):
|
||||
record = DelegatedTask(
|
||||
task_id="abc12345",
|
||||
agent_name="coder",
|
||||
agent_role="code",
|
||||
task_description="fix bug",
|
||||
priority="normal",
|
||||
backend="agentic_loop",
|
||||
)
|
||||
|
||||
with patch.object(DistributedWorker, "_dispatch", return_value={"success": True}):
|
||||
DistributedWorker._run_task(record)
|
||||
|
||||
assert record.status == "completed"
|
||||
assert record.result == {"success": True}
|
||||
assert record.error is None
|
||||
|
||||
def test_marks_failed_after_exhausting_retries(self):
|
||||
record = DelegatedTask(
|
||||
task_id="fail1234",
|
||||
agent_name="coder",
|
||||
agent_role="code",
|
||||
task_description="broken task",
|
||||
priority="normal",
|
||||
backend="agentic_loop",
|
||||
)
|
||||
|
||||
with patch.object(DistributedWorker, "_dispatch", side_effect=RuntimeError("boom")):
|
||||
DistributedWorker._run_task(record)
|
||||
|
||||
assert record.status == "failed"
|
||||
assert "boom" in record.error
|
||||
assert record.retries == MAX_RETRIES
|
||||
|
||||
def test_retries_before_failing(self):
|
||||
record = DelegatedTask(
|
||||
task_id="retry001",
|
||||
agent_name="coder",
|
||||
agent_role="code",
|
||||
task_description="flaky task",
|
||||
priority="normal",
|
||||
backend="agentic_loop",
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def flaky_dispatch(r):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < MAX_RETRIES + 1:
|
||||
raise RuntimeError("transient failure")
|
||||
return {"success": True}
|
||||
|
||||
with patch.object(DistributedWorker, "_dispatch", side_effect=flaky_dispatch):
|
||||
DistributedWorker._run_task(record)
|
||||
|
||||
assert record.status == "completed"
|
||||
assert call_count == MAX_RETRIES + 1
|
||||
|
||||
def test_succeeds_on_first_attempt(self):
|
||||
record = DelegatedTask(
|
||||
task_id="ok000001",
|
||||
agent_name="writer",
|
||||
agent_role="writing",
|
||||
task_description="write summary",
|
||||
priority="low",
|
||||
backend="agentic_loop",
|
||||
)
|
||||
|
||||
with patch.object(DistributedWorker, "_dispatch", return_value={"summary": "done"}):
|
||||
DistributedWorker._run_task(record)
|
||||
|
||||
assert record.status == "completed"
|
||||
assert record.retries == 0
|
||||
|
||||
|
||||
class TestDelegatetaskIntegration:
|
||||
"""Integration: delegate_task should wire to DistributedWorker."""
|
||||
|
||||
def test_delegate_task_returns_task_id(self):
|
||||
from timmy.tools_delegation import delegate_task
|
||||
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
result = delegate_task("researcher", "research something for me")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["task_id"] is not None
|
||||
assert result["status"] == "queued"
|
||||
|
||||
def test_delegate_task_status_queued_for_valid_agent(self):
|
||||
from timmy.tools_delegation import delegate_task
|
||||
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
result = delegate_task("coder", "implement feature X")
|
||||
|
||||
assert result["status"] == "queued"
|
||||
assert len(result["task_id"]) == 8
|
||||
|
||||
def test_task_in_registry_after_delegation(self):
|
||||
from timmy.tools_delegation import delegate_task
|
||||
|
||||
with patch.object(DistributedWorker, "_run_task"):
|
||||
result = delegate_task("writer", "write documentation")
|
||||
|
||||
task_id = result["task_id"]
|
||||
status = DistributedWorker.get_status(task_id)
|
||||
assert status["found"] is True
|
||||
assert status["agent"] == "writer"
|
||||
@@ -4,10 +4,13 @@
|
||||
Connects to local Gitea, fetches candidate issues, and produces a concise agenda
|
||||
plus a day summary (review mode).
|
||||
|
||||
The Daily Run begins with a Quick Health Snapshot (#710) to ensure mandatory
|
||||
systems are green before burning cycles on work that cannot land.
|
||||
|
||||
Run: python3 timmy_automations/daily_run/orchestrator.py [--review]
|
||||
Env: See timmy_automations/config/daily_run.json for configuration
|
||||
|
||||
Refs: #703
|
||||
Refs: #703, #923
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,6 +33,11 @@ sys.path.insert(
|
||||
)
|
||||
from utils.token_rules import TokenRules, compute_token_reward
|
||||
|
||||
# Health snapshot lives in the same package
|
||||
from health_snapshot import generate_snapshot as _generate_health_snapshot
|
||||
from health_snapshot import get_token as _hs_get_token
|
||||
from health_snapshot import load_config as _hs_load_config
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -495,6 +503,16 @@ def parse_args() -> argparse.Namespace:
|
||||
default=None,
|
||||
help="Override max agenda items",
|
||||
)
|
||||
p.add_argument(
|
||||
"--skip-health-check",
|
||||
action="store_true",
|
||||
help="Skip the pre-flight health snapshot (not recommended)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Continue even if health snapshot is red (overrides abort-on-red)",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
@@ -535,6 +553,76 @@ def compute_daily_run_tokens(success: bool = True) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def run_health_snapshot(args: argparse.Namespace) -> int:
|
||||
"""Run pre-flight health snapshot and return 0 (ok) or 1 (abort).
|
||||
|
||||
Prints a concise summary of CI, issues, flakiness, and token economy.
|
||||
Returns 1 if the overall status is red AND --force was not passed.
|
||||
Returns 0 for green/yellow or when --force is active.
|
||||
On any import/runtime error the check is skipped with a warning.
|
||||
"""
|
||||
try:
|
||||
hs_config = _hs_load_config()
|
||||
hs_token = _hs_get_token(hs_config)
|
||||
snapshot = _generate_health_snapshot(hs_config, hs_token)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[health] Warning: health snapshot failed ({exc}) — skipping", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Print concise pre-flight header
|
||||
status_emoji = {"green": "🟢", "yellow": "🟡", "red": "🔴"}.get(
|
||||
snapshot.overall_status, "⚪"
|
||||
)
|
||||
print("─" * 60)
|
||||
print(f"PRE-FLIGHT HEALTH CHECK {status_emoji} {snapshot.overall_status.upper()}")
|
||||
print("─" * 60)
|
||||
|
||||
ci_emoji = {"pass": "✅", "fail": "❌", "unknown": "⚠️", "unavailable": "⚪"}.get(
|
||||
snapshot.ci.status, "⚪"
|
||||
)
|
||||
print(f" {ci_emoji} CI: {snapshot.ci.message}")
|
||||
|
||||
if snapshot.issues.p0_count > 0:
|
||||
issue_emoji = "🔴"
|
||||
elif snapshot.issues.p1_count > 0:
|
||||
issue_emoji = "🟡"
|
||||
else:
|
||||
issue_emoji = "✅"
|
||||
critical_str = f"{snapshot.issues.count} critical"
|
||||
if snapshot.issues.p0_count:
|
||||
critical_str += f" (P0: {snapshot.issues.p0_count})"
|
||||
if snapshot.issues.p1_count:
|
||||
critical_str += f" (P1: {snapshot.issues.p1_count})"
|
||||
print(f" {issue_emoji} Issues: {critical_str}")
|
||||
|
||||
flak_emoji = {"healthy": "✅", "degraded": "🟡", "critical": "🔴", "unknown": "⚪"}.get(
|
||||
snapshot.flakiness.status, "⚪"
|
||||
)
|
||||
print(f" {flak_emoji} Flakiness: {snapshot.flakiness.message}")
|
||||
|
||||
token_emoji = {"balanced": "✅", "inflationary": "🟡", "deflationary": "🔵", "unknown": "⚪"}.get(
|
||||
snapshot.tokens.status, "⚪"
|
||||
)
|
||||
print(f" {token_emoji} Tokens: {snapshot.tokens.message}")
|
||||
print()
|
||||
|
||||
if snapshot.overall_status == "red" and not args.force:
|
||||
print(
|
||||
"🛑 Health status is RED — aborting Daily Run to avoid burning cycles.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" Fix the issues above or re-run with --force to override.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if snapshot.overall_status == "red":
|
||||
print("⚠️ Health is RED but --force passed — proceeding anyway.", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
config = load_config()
|
||||
@@ -542,6 +630,15 @@ def main() -> int:
|
||||
if args.max_items:
|
||||
config["max_agenda_items"] = args.max_items
|
||||
|
||||
# ── Step 0: Pre-flight health snapshot ──────────────────────────────────
|
||||
if not args.skip_health_check:
|
||||
health_rc = run_health_snapshot(args)
|
||||
if health_rc != 0:
|
||||
tokens = compute_daily_run_tokens(success=False)
|
||||
if args.json:
|
||||
print(json.dumps({"error": "health_check_failed", "tokens": tokens}))
|
||||
return health_rc
|
||||
|
||||
token = get_token(config)
|
||||
client = GiteaClient(config, token)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user