feat: upgrade to qwen3.5, self-hosted Gitea CI, optimize Docker image
All checks were successful
Tests / lint (pull_request) Successful in 2s
Tests / test (pull_request) Successful in 32s

Model upgrade:
- qwen2.5:14b → qwen3.5:latest across config, tools, and docs
- Added qwen3.5 to multimodal model registry

Self-hosted Gitea CI:
- .gitea/workflows/tests.yml: lint + test jobs via act_runner
- Unified Dockerfile: pre-baked deps from poetry.lock for fast CI
- sitepackages=true in tox for ~2s dep resolution (was ~40s)
- OLLAMA_URL set to dead port in CI to prevent real LLM calls

Test isolation fixes:
- Smoke test fixture mocks create_timmy (was hitting real Ollama)
- WebSocket sends initial_state before joining broadcast pool (race fix)
- Tests use settings.ollama_model/url instead of hardcoded values
- skip_ci marker for Ollama-dependent tests, excluded in CI tox envs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trip T
2026-03-11 18:36:42 -04:00
parent 36fc10097f
commit f6a6c0f62e
24 changed files with 236 additions and 292 deletions

View File

@@ -14,8 +14,8 @@
# In production (docker-compose.prod.yml), this is set to http://ollama:11434 automatically.
# OLLAMA_URL=http://localhost:11434
# LLM model to use via Ollama (default: qwen2.5:14b)
# OLLAMA_MODEL=qwen2.5:14b
# LLM model to use via Ollama (default: qwen3.5:latest)
# OLLAMA_MODEL=qwen3.5:latest
# Enable FastAPI interactive docs at /docs and /redoc (default: false)
# DEBUG=true

View File

@@ -0,0 +1,40 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
# Runs in timmy-time:latest — tox, ruff, and all deps are pre-installed.
# Rebuild the image only when poetry.lock changes:
# docker build -t timmy-time:latest .
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint (ruff via tox)
run: tox -e lint
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- name: Run tests (via tox)
env:
OLLAMA_URL: "http://127.0.0.1:1"
run: tox -e ci
- name: Test summary
if: always()
run: |
if [ -f reports/junit.xml ]; then
python3 -c "
import xml.etree.ElementTree as ET
root = ET.parse('reports/junit.xml').getroot()
t,f,e,s = (root.attrib.get(k,'0') for k in ('tests','failures','errors','skipped'))
print(f'Tests: {t} | Failures: {f} | Errors: {e} | Skipped: {s}')
"
fi

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Pre-push hook: runs the full CI-mirror suite before allowing a push.
# Prevents broken builds from reaching GitHub.
# Prevents broken builds from reaching Gitea.
#
# Auto-activated by `make install` via git core.hooksPath.

3
.gitignore vendored
View File

@@ -72,6 +72,9 @@ scripts/migrate_to_zeroclaw.py
src/infrastructure/db_pool.py
workspace/
# Gitea Actions runner state
.runner
# macOS
.DS_Store
.AppleDouble

View File

@@ -1,73 +1,34 @@
# ── Timmy Time — agent image ────────────────────────────────────────────────
# Timmy Time — unified image (CI · dev · production)
#
# Serves two purposes:
# 1. `make docker-up` → runs the FastAPI dashboard (default CMD)
# 2. `make docker-agent` → runs a swarm agent worker (override CMD)
# All deps pre-installed from poetry.lock; project mounted at runtime.
#
# Build: docker build -t timmy-time:latest .
# Dash: docker run -p 8000:8000 -v $(pwd)/data:/app/data timmy-time:latest
# Agent: docker run -e COORDINATOR_URL=http://dashboard:8000 \
# -e AGENT_NAME=Worker-1 \
# timmy-time:latest \
# python -m swarm.agent_runner --agent-id w1 --name Worker-1
# Build: docker build -t timmy-time .
# CI: act_runner mounts checkout automatically
# Dev: docker run -v .:/app -p 8000:8000 timmy-time tox -e dev
# Run: docker run -v .:/app -p 8000:8000 timmy-time
# ── Stage 1: Builder — install deps via Poetry ──────────────────────────────
FROM python:3.12-slim AS builder
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc curl \
# ── System + build prereqs ────────────────────────────────────────────────────
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gcc git bash curl fonts-dejavu-core nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Install Poetry (only needed to resolve deps, not in runtime)
RUN pip install --no-cache-dir poetry
# Copy dependency files only (layer caching)
COPY pyproject.toml poetry.lock ./
# Install deps directly from lock file (no virtualenv, no export plugin needed)
RUN poetry config virtualenvs.create false && \
poetry install --only main --extras telegram --extras discord --no-root --no-interaction
# ── Stage 2: Runtime ───────────────────────────────────────────────────────
FROM python:3.12-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
curl fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# ── Python tooling ────────────────────────────────────────────────────────────
RUN pip install --no-cache-dir poetry tox
# ── Pre-install all project deps (source mounted at runtime) ──────────────────
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false \
&& poetry install --with dev --no-root --no-interaction \
&& rm pyproject.toml poetry.lock
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# ── Application source ───────────────────────────────────────────────────────
COPY src/ ./src/
COPY static/ ./static/
# Create data directory (mounted as a volume in production)
RUN mkdir -p /app/data
# ── Non-root user for production ─────────────────────────────────────────────
RUN groupadd -r timmy && useradd -r -g timmy -d /app -s /sbin/nologin timmy \
&& chown -R timmy:timmy /app
# Ensure static/ and data/ are world-readable so bind-mounted files
# from the macOS host remain accessible when running as the timmy user.
RUN chmod -R o+rX /app/static /app/data
USER timmy
# ── Environment ──────────────────────────────────────────────────────────────
ENV PYTHONPATH=/app/src
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# ── Environment ───────────────────────────────────────────────────────────────
ENV PYTHONPATH=/app/src \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
# ── Healthcheck ──────────────────────────────────────────────────────────────
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# ── Default: run the dashboard ───────────────────────────────────────────────
CMD ["uvicorn", "dashboard.app:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,6 +1,5 @@
# Timmy Time — Mission Control
[![Tests](https://github.com/AlexanderWhitestone/Timmy-time-dashboard/actions/workflows/tests.yml/badge.svg)](https://github.com/AlexanderWhitestone/Timmy-time-dashboard/actions/workflows/tests.yml)
![Python](https://img.shields.io/badge/python-3.11+-blue)
![Coverage](https://img.shields.io/badge/coverage-73%25-brightgreen)
![License](https://img.shields.io/badge/license-MIT-green)
@@ -8,28 +7,26 @@
A local-first, sovereign AI agent system. Talk to Timmy, watch his swarm, gate
API access with Bitcoin Lightning — all from a browser, no cloud AI required.
**[Live Docs →](https://alexanderwhitestone.github.io/Timmy-time-dashboard/)**
---
## Quick Start
```bash
git clone https://github.com/AlexanderWhitestone/Timmy-time-dashboard.git
git clone http://localhost:3000/rockachopa/Timmy-time-dashboard.git
cd Timmy-time-dashboard
make install # create venv + install deps
cp .env.example .env # configure environment
ollama serve # separate terminal
ollama pull qwen2.5:14b # Required for reliable tool calling
ollama pull qwen3.5:latest # Required for reliable tool calling
make dev # http://localhost:8000
make test # no Ollama needed
```
**Note:** qwen2.5:14b is the primary model — better reasoning and tool calling
**Note:** qwen3.5:latest is the primary model — better reasoning and tool calling
than llama3.1:8b-instruct while still running locally on modest hardware.
Fallback: llama3.1:8b-instruct if qwen2.5:14b is not available.
Fallback: llama3.1:8b-instruct if qwen3.5:latest is not available.
llama3.2 (3B) was found to hallucinate tool output consistently in testing.
---
@@ -82,7 +79,7 @@ cp .env.example .env
| Variable | Default | Purpose |
|----------|---------|---------|
| `OLLAMA_URL` | `http://localhost:11434` | Ollama host |
| `OLLAMA_MODEL` | `qwen2.5:14b` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
| `OLLAMA_MODEL` | `qwen3.5:latest` | Primary model for reasoning and tool calling. Fallback: `llama3.1:8b-instruct` |
| `DEBUG` | `false` | Enable `/docs` and `/redoc` |
| `TIMMY_MODEL_BACKEND` | `ollama` | `ollama` \| `airllm` \| `auto` |
| `AIRLLM_MODEL_SIZE` | `70b` | `8b` \| `70b` \| `405b` |

View File

@@ -113,6 +113,7 @@ fallback_chains:
# Tool-calling models (for function calling)
tools:
- llama3.1:8b-instruct # Best tool use
- qwen3.5:latest # Qwen 3.5 — strong tool use
- qwen2.5:7b # Reliable tools
- llama3.2:3b # Small but capable

View File

@@ -172,7 +172,7 @@ support:
```python
class LLMConfig(BaseModel):
ollama_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:14b"
ollama_model: str = "qwen3.5:latest"
# ... all LLM settings
class MemoryConfig(BaseModel):

197
poetry.lock generated
View File

@@ -537,57 +537,6 @@ files = [
{file = "billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f"},
]
[[package]]
name = "black"
version = "26.3.0"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "black-26.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:135bf8a352e35b3bfba4999c256063d8d86514654599eca7635e914a55d60ec3"},
{file = "black-26.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6024a2959b6c62c311c564ce23ce0eaa977a50ed52a53f7abc83d2c9eb62b8d8"},
{file = "black-26.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:264144203ea3374542a1591b6fb317561662d074bce5d91ad6afa8d8d3e4ec3d"},
{file = "black-26.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:1a15d1386dce3af3993bf9baeb68d3e492cbb003dae05c3ecf8530a9b75edf85"},
{file = "black-26.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:d86a70bf048235aff62a79e229fe5d9e7809c7a05a3dd12982e7ccdc2678e096"},
{file = "black-26.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3da07abe65732483e915ab7f9c7c50332c293056436e9519373775d62539607c"},
{file = "black-26.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fc9fd683ccabc3dc9791b93db494d93b5c6c03b105453b76d71e5474e9dfa6e7"},
{file = "black-26.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2c7e2c5ee09ff575869258b2c07064c952637918fc5e15f6ebd45e45eae0aa"},
{file = "black-26.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:a849286bfc3054eaeb233b6df9056fcf969ee18bf7ecb71b0257e838a0f05e6d"},
{file = "black-26.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:c93c83af43cda73ed8265d001214779ab245fa7a861a75b3e43828f4fb1f5657"},
{file = "black-26.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2b1e5eec220b419e3591a0aaa6351bd3a9c01fe6291fbaf76d84308eb7a2ede"},
{file = "black-26.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bab64de70bccc992432bee56cdffbe004ceeaa07352127c386faa87e81f9261"},
{file = "black-26.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b6c5f734290803b7b26493ffd734b02b72e6c90d82d45ac4d5b862b9bdf7720"},
{file = "black-26.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7c767396af15b54e1a6aae99ddf241ae97e589f666b1d22c4b6618282a04e4ca"},
{file = "black-26.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:765fd6ddd00f35c55250fdc6b790c272d54ac3f44da719cc42df428269b45980"},
{file = "black-26.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:59754fd8f43ef457be190594c07a52c999e22cb1534dc5344bff1d46fdf1027d"},
{file = "black-26.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1fd94cfee67b8d336761a0b08629a25938e4a491c440951ce517a7209c99b5ff"},
{file = "black-26.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b3e653a90ca1ef4e821c20f8edaee80b649c38d2532ed2e9073a9534b14a7"},
{file = "black-26.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:f8fb9d7c2496adc83614856e1f6e55a9ce4b7ae7fc7f45b46af9189ddb493464"},
{file = "black-26.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:e8618c1d06838f56afbcb3ffa1aa16436cec62b86b38c7b32ca86f53948ffb91"},
{file = "black-26.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d0c6f64ead44f4369c66f1339ecf68e99b40f2e44253c257f7807c5a3ef0ca32"},
{file = "black-26.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ed6f0809134e51ec4a7509e069cdfa42bf996bd0fd1df6d3146b907f36e28893"},
{file = "black-26.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc6ac0ea5dd5fa6311ca82edfa3620cba0ed0426022d10d2d5d39aedbf3e1958"},
{file = "black-26.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:884bc0aefa96adabcba0b77b10e9775fd52d4b766e88c44dc6f41f7c82787fc8"},
{file = "black-26.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:be3bd02aab5c4ab03703172f5530ddc8fc8b5b7bb8786230e84c9e011cee9ca1"},
{file = "black-26.3.0-py3-none-any.whl", hash = "sha256:e825d6b121910dff6f04d7691f826d2449327e8e71c26254c030c4f3d2311985"},
{file = "black-26.3.0.tar.gz", hash = "sha256:4d438dfdba1c807c6c7c63c4f15794dda0820d2222e7c4105042ac9ddfc5dd0b"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=1.0.0"
platformdirs = ">=2"
pytokens = ">=0.4.0,<0.5.0"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.10)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""]
[[package]]
name = "celery"
version = "5.3.1"
@@ -885,7 +834,7 @@ version = "8.3.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
groups = ["main"]
files = [
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
@@ -956,11 +905,11 @@ description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""}
[[package]]
name = "comtypes"
@@ -1741,21 +1690,6 @@ files = [
]
markers = {main = "extra == \"dev\""}
[[package]]
name = "isort"
version = "8.0.1"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=3.10.0"
groups = ["dev"]
files = [
{file = "isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"},
{file = "isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d"},
]
[package.extras]
colors = ["colorama"]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -2247,18 +2181,6 @@ files = [
{file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"},
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "networkx"
version = "3.6"
@@ -2700,36 +2622,6 @@ files = [
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
]
[[package]]
name = "pathspec"
version = "1.0.4"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723"},
{file = "pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645"},
]
[package.extras]
hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"]
tests = ["pytest (>=9)", "typing-extensions (>=4.15)"]
[[package]]
name = "platformdirs"
version = "4.9.4"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"},
{file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -6911,61 +6803,6 @@ rate-limiter = ["aiolimiter (>=1.1,<1.3)"]
socks = ["httpx[socks]"]
webhooks = ["tornado (>=6.5,<7.0)"]
[[package]]
name = "pytokens"
version = "0.4.1"
description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"},
{file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"},
{file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"},
{file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"},
{file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"},
{file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"},
{file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"},
{file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"},
{file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"},
{file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"},
{file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"},
{file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"},
{file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"},
{file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"},
{file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"},
{file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"},
{file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"},
{file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"},
{file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"},
{file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"},
{file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"},
{file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"},
{file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"},
{file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"},
{file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"},
{file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"},
{file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"},
{file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"},
{file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"},
{file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"},
{file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"},
{file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"},
{file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"},
{file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"},
{file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"},
{file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"},
{file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"},
{file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"},
{file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"},
{file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"},
{file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"},
{file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"},
]
[package.extras]
dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"]
[[package]]
name = "pyttsx3"
version = "2.99"
@@ -7288,6 +7125,34 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
version = "0.15.5"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c"},
{file = "ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080"},
{file = "ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010"},
{file = "ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65"},
{file = "ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440"},
{file = "ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204"},
{file = "ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8"},
{file = "ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681"},
{file = "ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a"},
{file = "ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca"},
{file = "ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd"},
{file = "ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d"},
{file = "ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752"},
{file = "ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2"},
{file = "ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74"},
{file = "ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe"},
{file = "ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b"},
{file = "ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2"},
]
[[package]]
name = "safetensors"
version = "0.7.0"
@@ -8613,4 +8478,4 @@ voice = ["pyttsx3"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<4"
content-hash = "fd2362ee582a0dcffca740b971ef13f1ddefd7dc6323d4c6a066b7ab16b7acff"
content-hash = "bb8088a38625a65b8f7d296f912b0c1437b12c53f6b26698a91f030e82b1bf57"

View File

@@ -9,8 +9,8 @@ description = "Mission Control for sovereign AI agents"
readme = "README.md"
license = "MIT"
authors = ["Alexander Whitestone"]
homepage = "https://alexanderwhitestone.github.io/Timmy-time-dashboard/"
repository = "https://github.com/AlexanderWhitestone/Timmy-time-dashboard"
homepage = "http://localhost:3000/rockachopa/Timmy-time-dashboard"
repository = "http://localhost:3000/rockachopa/Timmy-time-dashboard"
packages = [
{ include = "config.py", from = "src" },
{ include = "brain", from = "src" },

View File

@@ -14,11 +14,11 @@ class Settings(BaseSettings):
ollama_url: str = "http://localhost:11434"
# LLM model passed to Agno/Ollama — override with OLLAMA_MODEL
# qwen2.5:14b is the primary model — better reasoning and tool calling
# qwen3.5:latest is the primary model — better reasoning and tool calling
# than llama3.1:8b-instruct while still running locally on modest hardware.
# Fallback: llama3.1:8b-instruct if qwen2.5:14b not available.
# Fallback: llama3.1:8b-instruct if qwen3.5:latest not available.
# llama3.2 (3B) hallucinated tool output consistently in testing.
ollama_model: str = "qwen2.5:14b"
ollama_model: str = "qwen3.5:latest"
# Set DEBUG=true to enable /docs and /redoc (disabled by default)
debug: bool = False
@@ -108,7 +108,7 @@ class Settings(BaseSettings):
# Default is False (telemetry disabled) to align with sovereign AI vision.
telemetry_enabled: bool = False
# CORS allowed origins for the web chat interface (GitHub Pages, etc.)
# CORS allowed origins for the web chat interface (Gitea Pages, etc.)
# Set CORS_ORIGINS as a comma-separated list, e.g. "http://localhost:3000,https://example.com"
cors_origins: list[str] = ["*"]
@@ -302,8 +302,8 @@ if not settings.repo_root:
# ── Model fallback configuration ────────────────────────────────────────────
# Primary model for reliable tool calling (llama3.1:8b-instruct)
# Fallback if primary not available: qwen2.5:14b
OLLAMA_MODEL_PRIMARY: str = "qwen2.5:14b"
# Fallback if primary not available: qwen3.5:latest
OLLAMA_MODEL_PRIMARY: str = "qwen3.5:latest"
OLLAMA_MODEL_FALLBACK: str = "llama3.1:8b-instruct"

View File

@@ -73,9 +73,8 @@ async def swarm_live(request: Request):
@router.websocket("/live")
async def swarm_ws(websocket: WebSocket):
"""WebSocket endpoint for live swarm updates."""
await ws_manager.connect(websocket)
try:
# Send initial state so frontend can clear loading placeholders
await websocket.accept()
# Send initial state before joining broadcast pool to avoid race conditions
await websocket.send_json(
{
"type": "initial_state",
@@ -86,6 +85,8 @@ async def swarm_ws(websocket: WebSocket):
},
}
)
await ws_manager.connect(websocket, accept=False)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:

View File

@@ -93,6 +93,18 @@ KNOWN_MODEL_CAPABILITIES: dict[str, set[ModelCapability]] = {
ModelCapability.VISION,
},
# Qwen series
"qwen3.5": {
ModelCapability.TEXT,
ModelCapability.TOOLS,
ModelCapability.JSON,
ModelCapability.STREAMING,
},
"qwen3.5:latest": {
ModelCapability.TEXT,
ModelCapability.TOOLS,
ModelCapability.JSON,
ModelCapability.STREAMING,
},
"qwen2.5": {
ModelCapability.TEXT,
ModelCapability.TOOLS,
@@ -259,6 +271,7 @@ DEFAULT_FALLBACK_CHAINS: dict[ModelCapability, list[str]] = {
],
ModelCapability.TOOLS: [
"llama3.1:8b-instruct", # Best tool use
"qwen3.5:latest", # Qwen 3.5 — strong tool use
"llama3.2:3b", # Smaller but capable
"qwen2.5:7b", # Reliable fallback
],

View File

@@ -36,8 +36,14 @@ class WebSocketManager:
self._connections: list[WebSocket] = []
self._event_history: collections.deque[WSEvent] = collections.deque(maxlen=100)
async def connect(self, websocket: WebSocket) -> None:
"""Accept a new WebSocket connection."""
async def connect(self, websocket: WebSocket, *, accept: bool = True) -> None:
"""Accept a new WebSocket connection and add it to the broadcast pool.
Args:
websocket: The WebSocket to register.
accept: If False, skip the accept() call (caller already accepted).
"""
if accept:
await websocket.accept()
self._connections.append(websocket)
logger.info(

View File

@@ -33,6 +33,7 @@ logger = logging.getLogger(__name__)
DEFAULT_MODEL_FALLBACKS = [
"llama3.1:8b-instruct",
"llama3.1",
"qwen3.5:latest",
"qwen2.5:14b",
"qwen2.5:7b",
"llama3.2:3b",

View File

@@ -142,6 +142,35 @@ def calculator(expression: str) -> str:
return f"Error evaluating '{expression}': {e}"
def _make_smart_read_file(file_tools: FileTools) -> Callable:
"""Wrap FileTools.read_file so directories auto-list their contents.
When the user (or the LLM) passes a directory path to read_file,
the raw Agno implementation throws an IsADirectoryError. This
wrapper detects that case, lists the directory entries, and returns
a helpful message so the model can pick the right file on its own.
"""
original_read = file_tools.read_file
def smart_read_file(file_name: str, encoding: str = "utf-8") -> str:
"""Reads the contents of the file `file_name` and returns the contents if successful."""
# Resolve the path the same way FileTools does
_safe, resolved = file_tools.check_escape(file_name)
if _safe and resolved.is_dir():
entries = sorted(p.name for p in resolved.iterdir() if not p.name.startswith("."))
listing = "\n".join(f" - {e}" for e in entries) if entries else " (empty directory)"
return (
f"'{file_name}' is a directory, not a file. "
f"Files inside:\n{listing}\n\n"
"Please call read_file with one of the files listed above."
)
return original_read(file_name, encoding=encoding)
# Preserve the original docstring for Agno tool schema generation
smart_read_file.__doc__ = original_read.__doc__
return smart_read_file
def create_research_tools(base_dir: str | Path | None = None):
"""Create tools for the research agent (Echo).
@@ -161,7 +190,7 @@ def create_research_tools(base_dir: str | Path | None = None):
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.list_files, name="list_files")
return toolkit
@@ -189,7 +218,7 @@ def create_code_tools(base_dir: str | Path | None = None):
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.save_file, name="write_file")
toolkit.register(file_tools.list_files, name="list_files")
@@ -210,12 +239,12 @@ def create_aider_tool(base_path: Path):
def __init__(self, base_dir: Path):
self.base_dir = base_dir
def run_aider(self, prompt: str, model: str = "qwen2.5:14b") -> str:
def run_aider(self, prompt: str, model: str = "qwen3.5:latest") -> str:
"""Run Aider to generate code changes.
Args:
prompt: What you want Aider to do (e.g., "add a fibonacci function")
model: Ollama model to use (default: qwen2.5:14b)
model: Ollama model to use (default: qwen3.5:latest)
Returns:
Aider's response with the code changes made
@@ -269,7 +298,7 @@ def create_data_tools(base_dir: str | Path | None = None):
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.list_files, name="list_files")
# Web search for finding datasets
@@ -292,7 +321,7 @@ def create_writing_tools(base_dir: str | Path | None = None):
# File operations
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.save_file, name="write_file")
toolkit.register(file_tools.list_files, name="list_files")
@@ -320,7 +349,7 @@ def create_security_tools(base_dir: str | Path | None = None):
# File reading for logs/configs
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.list_files, name="list_files")
return toolkit
@@ -342,7 +371,7 @@ def create_devops_tools(base_dir: str | Path | None = None):
# File operations for config management
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.save_file, name="write_file")
toolkit.register(file_tools.list_files, name="list_files")
@@ -444,7 +473,7 @@ def create_full_toolkit(base_dir: str | Path | None = None):
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.save_file, name="write_file")
toolkit.register(file_tools.list_files, name="list_files")
@@ -586,7 +615,7 @@ def create_experiment_tools(base_dir: str | Path | None = None):
base_path = Path(base_dir) if base_dir else Path(settings.repo_root)
file_tools = FileTools(base_dir=base_path)
toolkit.register(file_tools.read_file, name="read_file")
toolkit.register(_make_smart_read_file(file_tools), name="read_file")
toolkit.register(file_tools.save_file, name="write_file")
toolkit.register(file_tools.list_files, name="list_files")
@@ -706,7 +735,7 @@ def get_all_available_tools() -> dict[str, dict]:
},
"aider": {
"name": "Aider AI Assistant",
"description": "Local AI coding assistant using Ollama (qwen2.5:14b or deepseek-coder)",
"description": "Local AI coding assistant using Ollama (qwen3.5:latest or deepseek-coder)",
"available_in": ["forge", "orchestrator"],
},
"prepare_experiment": {

View File

@@ -82,10 +82,12 @@ def test_agents_list(client):
def test_agents_list_metadata(client):
from config import settings
response = client.get("/agents")
agent = next(a for a in response.json()["agents"] if a["id"] == "default")
assert agent["name"] == "Agent"
assert agent["model"] == "qwen2.5:14b"
assert agent["model"] == settings.ollama_model
assert agent["type"] == "local"

View File

@@ -283,15 +283,18 @@ def test_M604_airllm_print_response_delegates_to_run():
def test_M605_health_status_passes_model_to_template(client):
"""Health status partial must receive the configured model name, not a hardcoded string."""
from config import settings
with patch(
"dashboard.routes.health.check_ollama",
new_callable=AsyncMock,
return_value=True,
):
response = client.get("/health/status")
# The default model is qwen2.5:14b — it should appear from settings
# Model name should come from settings, not be hardcoded
assert response.status_code == 200
assert "qwen2.5" in response.text # rendered via template variable, not hardcoded literal
model_short = settings.ollama_model.split(":")[0]
assert model_short in response.text
# ── M7xx — XSS prevention ─────────────────────────────────────────────────────

View File

@@ -7,6 +7,8 @@ and Ollama timeout parameter.
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fix 1: /calm no longer returns 500
# ---------------------------------------------------------------------------
@@ -103,11 +105,12 @@ def test_swarm_live_websocket_sends_initial_state(client):
with client.websocket_connect("/swarm/live") as ws:
data = ws.receive_json()
assert data["type"] == "initial_state"
assert "agents" in data["data"]
assert "tasks" in data["data"]
assert "auctions" in data["data"]
assert data["data"]["agents"]["total"] >= 0
# First message should be initial_state with swarm data
assert data.get("type") == "initial_state", f"Unexpected WS message: {data}"
payload = data.get("data", {})
assert "agents" in payload
assert "tasks" in payload
assert "auctions" in payload
# ---------------------------------------------------------------------------
@@ -251,8 +254,9 @@ def test_task_full_lifecycle(client):
# ---------------------------------------------------------------------------
@pytest.mark.skip_ci
def test_all_dashboard_pages_return_200(client):
"""Smoke test: all main dashboard routes return 200."""
"""Smoke test: all main dashboard routes return 200 (needs Ollama for /thinking)."""
pages = [
"/",
"/tasks",

View File

@@ -66,6 +66,7 @@ async def test_timmy_agent_with_available_model():
pytest.skip(f"Timmy agent creation failed: {e}")
@pytest.mark.ollama
@pytest.mark.asyncio
async def test_timmy_chat_with_simple_query():
"""Test that Timmy can respond to a simple chat query."""

View File

@@ -240,10 +240,14 @@ class TestInviteParser:
@pytest.mark.asyncio
async def test_parse_image_no_deps(self):
"""parse_image returns None when pyzbar/Pillow are not installed."""
from unittest.mock import AsyncMock, patch
from integrations.chat_bridge.invite_parser import InviteParser
parser = InviteParser()
# With mocked pyzbar, this should gracefully return None
# Mock out the Ollama vision call so we don't make a real HTTP request
with patch.object(parser, "_try_ollama_vision", new_callable=AsyncMock, return_value=None):
# With mocked pyzbar + mocked vision, this should gracefully return None
result = await parser.parse_image(b"fake-image-bytes")
assert result is None

View File

@@ -6,6 +6,8 @@ crashes. They run fast (no Ollama needed) and should stay green on
every commit.
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
@@ -14,6 +16,12 @@ from fastapi.testclient import TestClient
def client():
from dashboard.app import app
# Block all LLM calls so smoke tests never hit a real Ollama
mock_run = MagicMock()
mock_run.content = "Smoke test — no LLM."
mock_agent = MagicMock()
mock_agent.run.return_value = mock_run
with patch("timmy.agent.create_timmy", return_value=mock_agent):
with TestClient(app, raise_server_exceptions=False) as c:
yield c

View File

@@ -51,7 +51,9 @@ def test_create_timmy_passes_ollama_url_to_model():
kwargs = MockOllama.call_args.kwargs
assert "host" in kwargs, "Ollama() must receive host= parameter"
assert kwargs["host"] == "http://localhost:11434" # default from config
from config import settings
assert kwargs["host"] == settings.ollama_url
def test_create_timmy_respects_custom_ollama_url():

13
tox.ini
View File

@@ -4,9 +4,12 @@ no_package = true
# ── Base ─────────────────────────────────────────────────────────────────────
[testenv]
allowlist_externals = timeout, perl, docker, mkdir, bash, grep
allowlist_externals = timeout, perl, docker, mkdir, bash, grep, ruff, pytest, mypy, uvicorn
sitepackages = true
commands_pre = pip install -e ".[dev]" --quiet
passenv = OLLAMA_URL
setenv =
TIMMY_TEST_MODE = 1
TIMMY_DISABLE_CSRF = 1
@@ -84,7 +87,7 @@ commands =
# ── CI / Coverage ────────────────────────────────────────────────────────────
[testenv:ci]
description = CI test suite with coverage + JUnit XML (mirrors GitHub Actions)
description = CI test suite with coverage + JUnit XML (mirrors Gitea Actions)
commands =
mkdir -p reports
pytest tests/ \
@@ -94,7 +97,7 @@ commands =
--cov-fail-under=73 \
--junitxml=reports/junit.xml \
-p no:xdist \
-m "not ollama and not docker and not selenium and not external_api"
-m "not ollama and not docker and not selenium and not external_api and not skip_ci"
[testenv:coverage]
description = Full coverage report (terminal + XML)
@@ -121,7 +124,7 @@ commands =
# ── Pre-push (mirrors CI exactly) ────────────────────────────────────────────
[testenv:pre-push]
description = Local gate — lint + full CI suite (same as GitHub Actions)
description = Local gate — lint + full CI suite (same as Gitea Actions)
deps =
ruff>=0.8.0
commands =
@@ -136,7 +139,7 @@ commands =
--cov-fail-under=73 \
--junitxml=reports/junit.xml \
-p no:xdist \
-m "not ollama and not docker and not selenium and not external_api"
-m "not ollama and not docker and not selenium and not external_api and not skip_ci"
# ── Pre-commit (fast local gate) ────────────────────────────────────────────