13 Commits

Author SHA1 Message Date
RaresKeY 3250a4ce68 fix(ci): clear review label when issues close (#5813)
The issue-close lifecycle change is narrowly scoped and correct. Closed issues remove the stale \`ready for review\` label and return before normal validation can restore it. Focused regressions cover closure and subsequent edits to a closed issue.

The branch was updated onto current \`dev\`. The focused test, merged-result validation, diff checks, and GitHub CI passed. No blocking review threads remain.
2026-07-29 22:04:28 +01:00
Boody cb0f6af002 Merge pull request #5822 from bitboody/tts_cache_fix
feat(tts): implement TTS cache size limit and eviction policy
2026-07-29 16:48:41 +03:00
Boody 9297bed5b9 add ODYSSEUS_TTS_CACHE_MAX_BYTES environment variable to docker-compose 2026-07-29 12:54:55 +03:00
Boody 2e631ad816 improve cache size calculation by filtering file types 2026-07-29 12:47:55 +03:00
Boody d183fe545b add test for cache eviction handling unlink errors gracefully 2026-07-29 12:42:20 +03:00
Boody 9914651cc9 improve cache eviction logic to handle file access errors and ensure stability 2026-07-29 12:41:31 +03:00
Boody 46905ab9b0 added ODYSSEUS_TTS_CACHE_MAX_BYTES env variable to docker compose files 2026-07-29 12:32:43 +03:00
Boody 61c138d9e7 fixed .env.example ODYSSEUS_TTS_CACHE_MAX_BYTES into correct 500 MBs 2026-07-29 12:26:09 +03:00
Tal.Yuan 25a4d134b1 refactor(routes): move search domain into routes/search/ subpackage (#5779)
Slice 2j of the route-domain reorganization (#4082/#4071). Moves
search_routes.py into routes/search/, leaving a backward-compat
sys.modules shim. Pure file reorganization, no behavior change.
2026-07-28 22:26:29 +02:00
Boody 98e4d8451b fix(tests): update environment variable for TTS cache limit to include ODYSSEUS prefix 2026-07-28 22:00:54 +03:00
Boody 5104a9a967 feat(tts): implement TTS cache size limit and eviction policy 2026-07-28 21:34:03 +03:00
RaresKeY 01790c2f08 fix(mcp): keep built-in servers on SDK v1 (#5820) 2026-07-28 18:11:34 +01:00
Dividesbyzer0 d96c7af3df fix(agent): import Any for tool event helper (#5735) 2026-07-27 17:29:29 +02:00
17 changed files with 408 additions and 114 deletions
+1
View File
@@ -189,6 +189,7 @@ SEARXNG_INSTANCE=http://localhost:8080
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB)
# ============================================================
# Host Docker access (explicit opt-in)
+10 -3
View File
@@ -153,6 +153,16 @@ module.exports = async ({ github, context, core }) => {
}
}
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
// Closed issues are no longer awaiting review.
// This also prevents later edits to closed issues from restoring the label.
if (issue.state === 'closed') {
await dropLabel(LABEL_GOOD);
return;
}
// ── Find existing bot comment to update in-place ──────────────────────────
const MARKER = '<!-- issue-description-check -->';
const { data: comments } = await github.rest.issues.listComments({
@@ -160,9 +170,6 @@ module.exports = async ({ github, context, core }) => {
});
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
const LABEL_BAD = 'needs more info';
const LABEL_GOOD = 'ready for review';
if (failures.length === 0) {
if (existing) {
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
@@ -2,7 +2,7 @@ name: ci / issue description check
on:
issues:
types: [opened, edited, reopened]
types: [opened, edited, reopened, closed]
permissions:
issues: write
+1 -1
View File
@@ -692,7 +692,7 @@ from routes.history.history_routes import setup_history_routes
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
# Search
from routes.search_routes import setup_search_routes
from routes.search.search_routes import setup_search_routes
app.include_router(setup_search_routes(config))
# Presets
+1
View File
@@ -67,6 +67,7 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+1
View File
@@ -66,6 +66,7 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+1
View File
@@ -55,6 +55,7 @@ services:
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
+4 -1
View File
@@ -38,7 +38,10 @@ python-dateutil
caldav
cryptography
bcrypt
mcp
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
# servers are migrated together.
mcp<2
pyotp
qrcode[pil]
croniter
+5
View File
@@ -0,0 +1,5 @@
"""Search route domain package (slice 2j, #4082/#4071).
Contains search_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/search_routes.py re-exports from here.
"""
+111
View File
@@ -0,0 +1,111 @@
"""Search routes — /api/search/config GET, /api/search POST."""
import logging
from typing import Dict, Any
from fastapi import APIRouter, Request
import time
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
from services.search.core import _call_provider
from services.search.providers import _get_provider_key, _get_search_instance
logger = logging.getLogger(__name__)
async def _request_values(request: Request) -> Dict[str, Any]:
"""Accept JSON, form data, or query params for search endpoints.
The browser UI posts FormData, while the agent's generic app_api tool
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
runs, which made the model think SearXNG was broken.
"""
values: Dict[str, Any] = dict(request.query_params)
content_type = (request.headers.get("content-type") or "").lower()
try:
if "application/json" in content_type:
body = await request.json()
if isinstance(body, dict):
values.update(body)
else:
form = await request.form()
values.update(dict(form))
except Exception:
pass
return values
def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
return get_search_config()
@router.post("/api/search")
async def do_web_search(request: Request) -> Dict[str, Any]:
"""Standalone web search — returns context string + source list.
Used by Compare mode to pre-search once and share results across panes.
"""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
return {"context": "", "sources": [], "error": "query is required"}
time_filter = values.get("time_filter") or values.get("freshness")
if time_filter is not None:
time_filter = str(time_filter).strip() or None
try:
context, sources = comprehensive_web_search(
query, return_sources=True, time_filter=time_filter,
)
return {"context": context, "sources": sources}
except Exception as e:
logger.error(f"Standalone web search failed: {e}")
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/search/providers")
async def list_search_providers():
"""Return available search providers with config status."""
providers = []
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
if pid == "disabled":
continue
available = True
if needs_key and not _get_provider_key(pid):
available = False
if needs_url and pid == "searxng" and not _get_search_instance():
available = False
providers.append({
"id": pid,
"label": label,
"available": available,
})
return providers
@router.post("/api/search/query")
async def search_with_provider(request: Request) -> Dict[str, Any]:
"""Search using a specific provider. Used by compare search mode."""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
try:
count = int(values.get("count") or values.get("limit") or 10)
except Exception:
count = 10
if not query:
return {"results": [], "provider": provider, "error": "query is required"}
if provider not in PROVIDER_INFO or provider == "disabled":
return {"results": [], "provider": provider, "error": "Unknown provider"}
t0 = time.time()
try:
results = _call_provider(provider, query, min(count, 20))
elapsed = round(time.time() - t0, 2)
return {"results": results, "provider": provider, "time": elapsed}
except Exception as e:
elapsed = round(time.time() - t0, 2)
logger.error(f"Search provider {provider} failed: {e}")
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
return router
+9 -107
View File
@@ -1,111 +1,13 @@
"""Search routes — /api/search/config GET, /api/search POST."""
"""Backward-compat shim — canonical location is routes/search/search_routes.py.
import logging
from typing import Dict, Any
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.search_routes`` and ``from routes.search_routes import X``
keep resolving to the canonical module. Keeps existing import paths working
after slice 2j (#4082/#4071).
"""
from fastapi import APIRouter, Request
import sys as _sys
import time
from routes.search import search_routes as _canonical # noqa: F401
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
from services.search.core import _call_provider
from services.search.providers import _get_provider_key, _get_search_instance
logger = logging.getLogger(__name__)
async def _request_values(request: Request) -> Dict[str, Any]:
"""Accept JSON, form data, or query params for search endpoints.
The browser UI posts FormData, while the agent's generic app_api tool
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
runs, which made the model think SearXNG was broken.
"""
values: Dict[str, Any] = dict(request.query_params)
content_type = (request.headers.get("content-type") or "").lower()
try:
if "application/json" in content_type:
body = await request.json()
if isinstance(body, dict):
values.update(body)
else:
form = await request.form()
values.update(dict(form))
except Exception:
pass
return values
def setup_search_routes(config) -> APIRouter:
router = APIRouter(tags=["search"])
@router.get("/api/search/config")
async def get_search_settings() -> Dict[str, Any]:
return get_search_config()
@router.post("/api/search")
async def do_web_search(request: Request) -> Dict[str, Any]:
"""Standalone web search — returns context string + source list.
Used by Compare mode to pre-search once and share results across panes.
"""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
if not query:
return {"context": "", "sources": [], "error": "query is required"}
time_filter = values.get("time_filter") or values.get("freshness")
if time_filter is not None:
time_filter = str(time_filter).strip() or None
try:
context, sources = comprehensive_web_search(
query, return_sources=True, time_filter=time_filter,
)
return {"context": context, "sources": sources}
except Exception as e:
logger.error(f"Standalone web search failed: {e}")
return {"context": "", "sources": [], "error": str(e)}
@router.get("/api/search/providers")
async def list_search_providers():
"""Return available search providers with config status."""
providers = []
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
if pid == "disabled":
continue
available = True
if needs_key and not _get_provider_key(pid):
available = False
if needs_url and pid == "searxng" and not _get_search_instance():
available = False
providers.append({
"id": pid,
"label": label,
"available": available,
})
return providers
@router.post("/api/search/query")
async def search_with_provider(request: Request) -> Dict[str, Any]:
"""Search using a specific provider. Used by compare search mode."""
values = await _request_values(request)
query = str(values.get("query") or values.get("q") or "").strip()
provider = str(values.get("provider") or "").strip()
try:
count = int(values.get("count") or values.get("limit") or 10)
except Exception:
count = 10
if not query:
return {"results": [], "provider": provider, "error": "query is required"}
if provider not in PROVIDER_INFO or provider == "disabled":
return {"results": [], "provider": provider, "error": "Unknown provider"}
t0 = time.time()
try:
results = _call_provider(provider, query, min(count, 20))
elapsed = round(time.time() - t0, 2)
return {"results": results, "provider": provider, "time": elapsed}
except Exception as e:
elapsed = round(time.time() - t0, 2)
logger.error(f"Search provider {provider} failed: {e}")
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
return router
_sys.modules[__name__] = _canonical
+53
View File
@@ -2,6 +2,7 @@
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
import io
import os
import wave
import logging
import hashlib
@@ -41,6 +42,11 @@ class TTSService:
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(parents=True, exist_ok=True)
self._kokoro = None # lazy-init
try:
self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024))
except ValueError:
self.max_cache_bytes = 500 * 1024 * 1024
# ── Settings ──
@@ -89,6 +95,53 @@ class TTSService:
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
(self.cache_dir / f"{key}{ext}").write_bytes(data)
self._enforce_cache_limit()
def _enforce_cache_limit(self):
"""Evicts oldest files if the cache exceeds the configured byte limit."""
if self.max_cache_bytes <= 0:
return
try:
files = []
total_size = 0
# Safely scan files and sum sizes, ignoring files deleted mid-scan
for f in self.cache_dir.iterdir():
try:
if f.is_file() and f.suffix.lower() in (".mp3", ".wav"):
files.append(f)
total_size += f.stat().st_size
except OSError:
continue
if total_size > self.max_cache_bytes:
logger.info(
f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files."
)
# Sort files by modification time (oldest first)
try:
files.sort(key=lambda f: f.stat().st_mtime)
except OSError as e:
logger.warning(f"Failed to sort cache files by mtime: {e}")
# Trim down to 80% of max capacity
target_size = self.max_cache_bytes * 0.8
while files and total_size > target_size:
f = files.pop(0)
try:
size = f.stat().st_size
f.unlink()
total_size -= size
except OSError as e:
logger.warning(f"Failed to evict cache file {f}: {e}")
continue
except Exception as e:
logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True)
def clear_cache(self):
count = 0
for f in self.cache_dir.glob("*.*"):
+1 -1
View File
@@ -12,7 +12,7 @@ import json
import re
import time
import logging
from typing import AsyncGenerator, List, Dict, Optional, Set
from typing import Any, AsyncGenerator, List, Dict, Optional, Set
from urllib.parse import urlparse
from src.llm_core import (
+86
View File
@@ -0,0 +1,86 @@
"""Regression coverage for issue-description label lifecycle events."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_CHECKER = _REPO / ".github" / "scripts" / "check-issue-description.js"
_WORKFLOW = _REPO / ".github" / "workflows" / "issue-description-check.yml"
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
def _run_closed_issue(action):
harness = r"""
const checkIssueDescription = require(process.argv[1]);
const action = process.argv[2];
const calls = [];
const unexpected = (name) => async () => {
throw new Error(`${name} should not be called for a closed issue`);
};
const github = {
rest: {
issues: {
removeLabel: async (params) => calls.push({ method: 'removeLabel', params }),
getLabel: unexpected('getLabel'),
addLabels: unexpected('addLabels'),
listComments: unexpected('listComments'),
createComment: unexpected('createComment'),
updateComment: unexpected('updateComment'),
deleteComment: unexpected('deleteComment'),
},
},
};
const context = {
payload: {
action,
issue: { number: 42, state: 'closed', body: '', labels: [] },
},
repo: { owner: 'odysseus-dev', repo: 'odysseus' },
};
const core = {
warning: unexpected('core.warning'),
setFailed: unexpected('core.setFailed'),
};
checkIssueDescription({ github, context, core })
.then(() => process.stdout.write(JSON.stringify(calls)))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
"""
proc = subprocess.run(
["node", "-e", harness, str(_CHECKER), action],
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_workflow_handles_issue_closures():
workflow = _WORKFLOW.read_text()
assert "types: [opened, edited, reopened, closed]" in workflow
@pytest.mark.parametrize("action", ["closed", "edited"])
def test_closed_issue_only_drops_ready_for_review(action):
assert _run_closed_issue(action) == [
{
"method": "removeLabel",
"params": {
"owner": "odysseus-dev",
"repo": "odysseus",
"issue_number": 42,
"name": "ready for review",
},
}
]
@@ -0,0 +1,15 @@
"""Regression coverage for the built-in MCP servers' SDK compatibility line."""
from pathlib import Path
REQUIREMENTS = Path(__file__).resolve().parents[1] / "requirements.txt"
def test_mcp_requirement_excludes_breaking_v2_sdk():
requirements = [
line.split("#", 1)[0].strip().replace(" ", "")
for line in REQUIREMENTS.read_text(encoding="utf-8").splitlines()
]
assert "mcp<2" in requirements
+11
View File
@@ -0,0 +1,11 @@
"""Regression test for the search route shim (slice 2j, #4082/#4071)."""
import importlib
import routes.search_routes as _shim_search # noqa: F401
def test_legacy_and_canonical_search_module_are_same_object():
legacy = importlib.import_module("routes.search_routes")
canonical = importlib.import_module("routes.search.search_routes")
assert legacy is canonical
@@ -0,0 +1,97 @@
import os
import time
from pathlib import Path
import pytest
# Adjust the import path if your file is directly in ./services instead of ./services/tts
from services.tts.tts_service import TTSService
def test_cache_under_limit(tmp_path, monkeypatch):
"""Test that writing a file under the size limit does not trigger eviction."""
# Set a tiny limit: 100 bytes
monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100")
# Initialize service with pytest's temporary directory
service = TTSService(cache_dir=str(tmp_path))
# Write a 40-byte file (under the 100-byte limit)
service._put_cache("test_key", b"x" * 40)
# Verify the file was written and nothing was deleted
files = list(tmp_path.glob("*.*"))
assert len(files) == 1
assert sum(f.stat().st_size for f in files) == 40
def test_cache_exceeds_limit_triggers_eviction(tmp_path, monkeypatch):
"""Test that exceeding the limit evicts the oldest files down to 80% capacity."""
# Set limit to 100 bytes. 80% target capacity will be 80 bytes.
monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100")
service = TTSService(cache_dir=str(tmp_path))
# 1. Setup: Manually create two older files (40 bytes each)
file1 = tmp_path / "oldest.wav"
file2 = tmp_path / "middle.wav"
file1.write_bytes(b"a" * 40)
file2.write_bytes(b"b" * 40)
# Spoof timestamps so file1 is explicitly older than file2
now = time.time()
os.utime(file1, (now - 100, now - 100)) # 100 seconds ago
os.utime(file2, (now - 50, now - 50)) # 50 seconds ago
# 2. Action: Write a 3rd file using the service method (40 bytes)
# Total cache is now 120 bytes, which exceeds 100.
# It should delete oldest (file1) to drop to 80 bytes (which matches the 80% target).
service._put_cache("newest", b"c" * 40)
# 3. Assertions
# The newest file should exist (saved as .wav because it lacks MP3 magic bytes)
newest_file = tmp_path / "newest.wav"
assert not file1.exists(), "The oldest file should have been evicted."
assert file2.exists(), "The middle file should still exist."
assert newest_file.exists(), "The newest file should have been saved."
# Verify the final directory size is <= 80 bytes
total_size = sum(f.stat().st_size for f in tmp_path.glob("*.*"))
assert total_size <= 80
def test_cache_limit_disabled(tmp_path, monkeypatch):
"""Test that setting max bytes to 0 disables eviction."""
monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "0")
service = TTSService(cache_dir=str(tmp_path))
# Write 3 large files that would normally trigger eviction
service._put_cache("file1", b"x" * 1000)
service._put_cache("file2", b"x" * 1000)
service._put_cache("file3", b"x" * 1000)
# Ensure nothing was deleted
files = list(tmp_path.glob("*.*"))
assert len(files) == 3
assert sum(f.stat().st_size for f in files) == 3000
def test_cache_eviction_handles_unlink_error_gracefully(tmp_path, monkeypatch):
"""Test that if unlinking a file fails, _put_cache still succeeds without raising."""
service = TTSService(cache_dir=str(tmp_path))
service.max_cache_bytes = 50
# Create a file to evict
old_file = tmp_path / "old.wav"
old_file.write_bytes(b"x" * 40)
# Monkeypatch unlink on Path objects to simulate a PermissionError / file-lock failure
def mock_unlink(self_path):
raise OSError("Permission denied / file locked")
monkeypatch.setattr(Path, "unlink", mock_unlink)
# Writing a new file triggers eviction which encounters the mocked unlink error
try:
service._put_cache("new_key", b"y" * 40)
except Exception as e:
pytest.fail(f"_put_cache raised an exception during failed eviction: {e}")
# The new file should still be written successfully
assert (tmp_path / "new_key.wav").exists()