mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-16 13:08:02 +00:00
Compare commits
16 Commits
f70db19cc6
...
a226c94df7
| Author | SHA1 | Date | |
|---|---|---|---|
| a226c94df7 | |||
| 057ec0552c | |||
| cdae9879f2 | |||
| 39a802bea2 | |||
| 1cc8a373b0 | |||
| a52ac6822b | |||
| 7475779b7c | |||
| e7ffc69729 | |||
| 396e26b4bf | |||
| 0bfc7750a2 | |||
| 790ef81b06 | |||
| 804691501f | |||
| 8e6a2e89f8 | |||
| dbcc7874bf | |||
| 16e660ad09 | |||
| b51d83b16d |
@@ -19,7 +19,7 @@ jobs:
|
||||
name: Python syntax (compileall)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
name: JS syntax (node --check)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
security-events: write # upload SARIF to the Security tab
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Buildx
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Read APP_VERSION + short sha
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
# Skip bots (Dependabot, release-drafter, etc.)
|
||||
if: ${{ github.event.issue.user.type != 'Bot' }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: .github/scripts
|
||||
persist-credentials: false
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
# Skip bots: they open PRs programmatically and have their own process.
|
||||
if: github.event.pull_request.user.type != 'Bot'
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Full history so a secret committed in an earlier commit (and later
|
||||
# deleted) is still caught -- deletion does not remove it from Git.
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ load_dotenv(encoding="utf-8-sig")
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -863,7 +863,7 @@ async def get_version():
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check() -> Dict[str, str]:
|
||||
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
@app.get("/api/ready")
|
||||
async def readiness_check() -> JSONResponse:
|
||||
|
||||
Generated
+4
-78
@@ -4,93 +4,19 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.104.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.104.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
|
||||
"integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
"@antithesishq/bombadil": "^0.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@antithesishq/bombadil": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.5.0.tgz",
|
||||
"integrity": "sha512-s0zImmr0iyvSP6QcVLvf40CUiZYIdWBAxiq20uhzujwvfitYa3PGJN652k/pLtVccHM/JrGQxZdvLnihZpltHA==",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.6.1.tgz",
|
||||
"integrity": "sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"bombadil": "bin/bombadil.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -4,9 +4,6 @@
|
||||
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.104.1"
|
||||
"@antithesishq/bombadil": "^0.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||
from src.rate_limiter import RateLimiter
|
||||
from src.settings_scrub import scrub_settings
|
||||
@@ -161,7 +161,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
path="/",
|
||||
)
|
||||
if body.remember:
|
||||
cookie_kwargs["max_age"] = 60 * 60 * 24 * 7 # 7 days
|
||||
cookie_kwargs["max_age"] = TOKEN_TTL
|
||||
response.set_cookie(**cookie_kwargs)
|
||||
return {"ok": True, "username": username}
|
||||
|
||||
|
||||
+18
-2
@@ -23,6 +23,22 @@ from fastapi import HTTPException
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Strong references to in-flight fire-and-forget tasks scheduled from this
|
||||
# module. asyncio only keeps weak references to tasks created via
|
||||
# create_task, so without this the GC can collect a task mid-execution and
|
||||
# the background work (extraction, auto-naming) silently never runs.
|
||||
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
|
||||
_BG_TASKS: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _spawn_bg(coro) -> asyncio.Task:
|
||||
"""Schedule a background task and hold a strong reference until it finishes."""
|
||||
task = asyncio.create_task(coro)
|
||||
_BG_TASKS.add(task)
|
||||
task.add_done_callback(_BG_TASKS.discard)
|
||||
return task
|
||||
|
||||
|
||||
# ── Data containers ────────────────────────────────────────────────────── #
|
||||
|
||||
@dataclass
|
||||
@@ -1105,7 +1121,7 @@ def run_post_response_tasks(
|
||||
)))
|
||||
|
||||
if _extraction_jobs:
|
||||
asyncio.create_task(_run_extraction_jobs_sequentially(session_id, _extraction_jobs))
|
||||
_spawn_bg(_run_extraction_jobs_sequentially(session_id, _extraction_jobs))
|
||||
|
||||
# Token accumulation
|
||||
if last_metrics:
|
||||
@@ -1120,4 +1136,4 @@ def run_post_response_tasks(
|
||||
|
||||
# Auto-name
|
||||
if needs_auto_name(sess.name):
|
||||
asyncio.create_task(auto_name_session(session_manager, sess))
|
||||
_spawn_bg(auto_name_session(session_manager, sess))
|
||||
|
||||
@@ -146,17 +146,18 @@ def _canonical_cpu_backend(system):
|
||||
if backend in ("cpu_x86", "cpu_arm"):
|
||||
return backend
|
||||
|
||||
# Raw CPU-architecture aliases
|
||||
# Raw CPU-architecture aliases. Treat plain "arm" as 32-bit ARM, not the
|
||||
# ARM64-class CPU fallback used for Apple Silicon/aarch64 machines.
|
||||
if backend in ("x86_64", "amd64", "i386", "i686"):
|
||||
return "cpu_x86"
|
||||
if backend in ("arm64", "aarch64", "arm"):
|
||||
if backend in ("arm64", "aarch64"):
|
||||
return "cpu_arm"
|
||||
|
||||
# Prefer an explicit CPU architecture field when present
|
||||
if cpu_arch:
|
||||
if cpu_arch in ("x86_64", "amd64", "x86", "i386", "i686"):
|
||||
return "cpu_x86"
|
||||
if cpu_arch in ("arm64", "aarch64", "arm"):
|
||||
if cpu_arch in ("arm64", "aarch64"):
|
||||
return "cpu_arm"
|
||||
|
||||
# Apple Silicon enters ranking as backend="metal"; its CPU path is ARM.
|
||||
|
||||
@@ -320,7 +320,7 @@ def _detect_apple_silicon():
|
||||
|
||||
# Only Apple Silicon (arm64) has a Metal GPU worth serving LLMs on; Intel
|
||||
# Macs fall through to the CPU path.
|
||||
if "arm" not in arch and "aarch64" not in arch:
|
||||
if _canonical_cpu_arch(arch) != "arm64":
|
||||
return None
|
||||
|
||||
# Chip name, e.g. "Apple M4 Max" — carries the Pro/Max/Ultra variant that
|
||||
@@ -503,6 +503,25 @@ def _get_cpu_count():
|
||||
return os.cpu_count() or 1
|
||||
|
||||
|
||||
def _canonical_cpu_arch(value):
|
||||
arch = str(value or "").lower().strip().replace("-", "_")
|
||||
if arch in ("x86_64", "amd64", "x64"):
|
||||
return "x86_64"
|
||||
if arch in ("i386", "i686", "x86"):
|
||||
return "x86"
|
||||
if arch in ("arm64", "aarch64"):
|
||||
return "arm64"
|
||||
if arch == "arm" or arch.startswith("armv"):
|
||||
return "arm"
|
||||
return arch
|
||||
|
||||
|
||||
def _get_cpu_arch():
|
||||
if _remote_host:
|
||||
return _canonical_cpu_arch(_run(["uname", "-m"]) or "")
|
||||
return _canonical_cpu_arch(platform.machine())
|
||||
|
||||
|
||||
def _powershell_exe():
|
||||
"""Pick the best PowerShell executable for LOCAL execution: prefer pwsh
|
||||
(PowerShell 7+), fall back to Windows PowerShell 5.1. Returns an absolute
|
||||
@@ -528,6 +547,7 @@ def _detect_windows():
|
||||
$r.cpu_name = $cpu.Name
|
||||
$r.cpu_cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||
$r.arch = $cpu.AddressWidth
|
||||
$r.cpu_arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
|
||||
# GPU detection via nvidia-smi (fastest) or WMI fallback
|
||||
try {
|
||||
$nv = nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits 2>$null
|
||||
@@ -599,6 +619,7 @@ def _detect_windows():
|
||||
"available_ram_gb": d.get("avail_gb", 0),
|
||||
"cpu_cores": _as_int(d.get("cpu_cores"), 1),
|
||||
"cpu_name": _cpu_name,
|
||||
"cpu_arch": _canonical_cpu_arch(d.get("cpu_arch")),
|
||||
"has_gpu": bool(d.get("gpu_name")),
|
||||
"gpu_name": d.get("gpu_name"),
|
||||
"gpu_vram_gb": d.get("gpu_vram_gb"),
|
||||
@@ -794,6 +815,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
available_ram = round(_get_available_ram_gb(), 1)
|
||||
cpu_cores = _get_cpu_count()
|
||||
cpu_name = _get_cpu_name()
|
||||
cpu_arch = _get_cpu_arch()
|
||||
|
||||
gpu_info = _detect_apple_silicon() or _detect_nvidia() or _detect_amd()
|
||||
|
||||
@@ -803,6 +825,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"available_ram_gb": available_ram,
|
||||
"cpu_cores": cpu_cores,
|
||||
"cpu_name": cpu_name,
|
||||
"cpu_arch": cpu_arch,
|
||||
"has_gpu": True,
|
||||
"gpu_name": gpu_info["gpu_name"],
|
||||
"gpu_vram_gb": gpu_info["gpu_vram_gb"],
|
||||
@@ -817,17 +840,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"unified_memory": gpu_info.get("unified_memory", False),
|
||||
}
|
||||
else:
|
||||
if _remote_host:
|
||||
arch_out = _run(["uname", "-m"]) or ""
|
||||
else:
|
||||
import platform as _platform
|
||||
arch_out = _platform.machine().lower()
|
||||
backend = "cpu_arm" if "aarch64" in arch_out or "arm" in arch_out else "cpu_x86"
|
||||
backend = "cpu_arm" if cpu_arch == "arm64" else "cpu_x86"
|
||||
result = {
|
||||
"total_ram_gb": total_ram,
|
||||
"available_ram_gb": available_ram,
|
||||
"cpu_cores": cpu_cores,
|
||||
"cpu_name": cpu_name,
|
||||
"cpu_arch": cpu_arch,
|
||||
"has_gpu": False,
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
|
||||
+21
-1
@@ -267,6 +267,10 @@ _DOMAIN_RULES = {
|
||||
- Use `resolve_contact` to look up a contact's email or phone number by name. Searches the CardDAV address book and sent email history.
|
||||
- Use `manage_contact` to list, add, update, or delete contacts in the address book.
|
||||
- Do NOT use `manage_memory` for contact lookups — contact details live in the address book, not memory.""",
|
||||
"integrations": """\
|
||||
## Integration/API rules
|
||||
- To query or control a configured service integration (Home Assistant, Miniflux, Gitea, Linkding, Jellyfin, or any other registered service), use `api_call` with the integration name, HTTP method, path, and optional JSON body.
|
||||
- Do not use shell, curl, or `app_api` to reach a user's connected integration when `api_call` is available.""",
|
||||
}
|
||||
|
||||
_DOMAIN_TOOL_MAP = {
|
||||
@@ -277,9 +281,10 @@ _DOMAIN_TOOL_MAP = {
|
||||
"notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
"ui": {"ui_control"},
|
||||
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace", "manage_bg_jobs"},
|
||||
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
|
||||
"contacts": {"resolve_contact", "manage_contact"},
|
||||
"integrations": {"api_call"},
|
||||
}
|
||||
|
||||
def _domain_rules_for_tools(tool_names: set) -> list[str]:
|
||||
@@ -811,10 +816,25 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
||||
domains.add("sessions")
|
||||
if has(r"\b(file|folder|directory|repo|git|grep|find in files|read file|edit file|shell|terminal|bash|python)\b"):
|
||||
domains.add("files")
|
||||
# Managing detached bash jobs: "kill the background job", "stop the job",
|
||||
# "kill that job", "check the job output", "is the bg job done".
|
||||
if (has(r"\b(background|bg)\s+(jobs?|task)\b")
|
||||
or has(r"\b(kill|stop|cancel|terminate|check|tail|show|list)\b.{0,16}\bjobs?\b")
|
||||
or has(r"\bjobs?\b.{0,16}\b(output|status|done|finished|running)\b")):
|
||||
domains.add("files")
|
||||
if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"):
|
||||
domains.add("settings")
|
||||
if has(r"\b(contact|contacts|phone|phone number|address book|vcard)\b"):
|
||||
domains.add("contacts")
|
||||
# API-integration intent — calling a configured service via the api_call
|
||||
# tool. Without this the #3794 repro ("Use the api_call tool to call Home
|
||||
# Assistant GET /api/states") matched no domain, classified as low-signal,
|
||||
# and the tool never reached the schema filter. Detect it explicitly so the
|
||||
# "integrations" domain seeds api_call deterministically (see
|
||||
# _DOMAIN_TOOL_MAP), independent of embedding retrieval.
|
||||
if has(r"\bapi[ _]call\b", r"\bintegrations?\b",
|
||||
r"\b(?:home ?assistant|miniflux|gitea|linkding|jellyfin)\b"):
|
||||
domains.add("integrations")
|
||||
|
||||
low_signal = not continuation and not domains
|
||||
return {
|
||||
|
||||
@@ -23,6 +23,7 @@ from .web_tools import WebSearchTool, WebFetchTool
|
||||
from .filesystem_tools import ReadFileTool, WriteFileTool, EditFileTool, LsTool, GlobTool, GrepTool, GetWorkspaceTool
|
||||
from .document_tools import CreateDocumentTool, UpdateDocumentTool, EditDocumentTool, SuggestDocumentTool, ManageDocumentTool
|
||||
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
|
||||
from .bg_job_tools import ManageBgJobsTool
|
||||
|
||||
TOOL_HANDLERS = {
|
||||
"bash": BashTool().execute,
|
||||
@@ -44,6 +45,7 @@ TOOL_HANDLERS = {
|
||||
"chat_with_model": ChatWithModelTool().execute,
|
||||
"ask_teacher": AskTeacherTool().execute,
|
||||
"list_models": ListModelsTool().execute,
|
||||
"manage_bg_jobs": ManageBgJobsTool().execute,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -56,7 +58,7 @@ PYTHON_TIMEOUT = 30
|
||||
|
||||
# Tool types that trigger execution
|
||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
||||
"grep", "glob", "ls", "get_workspace",
|
||||
"grep", "glob", "ls", "get_workspace", "manage_bg_jobs",
|
||||
"create_document", "update_document", "edit_document",
|
||||
"search_chats",
|
||||
"chat_with_model", "create_session", "list_sessions",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Agent tool to inspect and control detached background `bash` jobs.
|
||||
|
||||
`bash` blocks prefixed with a `#!bg` marker run detached via `src.bg_jobs`; the
|
||||
agent is auto-re-invoked with the output when they finish. This tool covers the
|
||||
gaps in that flow: list the jobs in the current chat, read a still-running job's
|
||||
output on demand, and kill a runaway job instead of waiting out its max-runtime.
|
||||
|
||||
Registry tool (`TOOL_HANDLERS["manage_bg_jobs"]`). Jobs are scoped to the chat
|
||||
that launched them, so every action requires the caller's `session_id` and a job
|
||||
from another session is treated as not found.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
_LIST_ACTIONS = {"list", "ls", "jobs"}
|
||||
_OUTPUT_ACTIONS = {"output", "get", "read", "tail", "status", "show"}
|
||||
_KILL_ACTIONS = {"kill", "stop", "cancel", "terminate"}
|
||||
|
||||
|
||||
def _age(rec: Dict[str, Any]) -> str:
|
||||
start = rec.get("started_at")
|
||||
if not start:
|
||||
return "?"
|
||||
secs = int(time.time() - start)
|
||||
if secs < 60:
|
||||
return f"{secs}s"
|
||||
if secs < 3600:
|
||||
return f"{secs // 60}m"
|
||||
return f"{secs // 3600}h{(secs % 3600) // 60}m"
|
||||
|
||||
|
||||
def _status_label(rec: Dict[str, Any]) -> str:
|
||||
status = rec.get("status", "?")
|
||||
if rec.get("killed"):
|
||||
return "killed"
|
||||
if rec.get("timed_out"):
|
||||
return "timed out"
|
||||
if rec.get("died"):
|
||||
return "died"
|
||||
if status in ("done", "failed"):
|
||||
return f"{status} (exit {rec.get('exit_code')})"
|
||||
return status
|
||||
|
||||
|
||||
def _row(rec: Dict[str, Any]) -> str:
|
||||
cmd = (rec.get("command") or "").strip().splitlines()[0][:80]
|
||||
return f"[{rec.get('id')}] {_status_label(rec)} | {_age(rec)} | {cmd}"
|
||||
|
||||
|
||||
class ManageBgJobsTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src import bg_jobs
|
||||
|
||||
session_id = ctx.get("session_id")
|
||||
raw = (content or "").strip()
|
||||
try:
|
||||
args = json.loads(raw) if raw else {}
|
||||
except (ValueError, TypeError):
|
||||
args = {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
action = str(args.get("action", "list")).strip().lower()
|
||||
job_id = str(args.get("job_id") or args.get("id") or "").strip()
|
||||
|
||||
if not session_id:
|
||||
return {"error": "manage_bg_jobs: no active chat session; background jobs are scoped to a chat.", "exit_code": 1}
|
||||
|
||||
if action in _LIST_ACTIONS:
|
||||
jobs: List[Dict[str, Any]] = bg_jobs.list_for_session(session_id)
|
||||
if not jobs:
|
||||
return {"output": "No background jobs in this chat.", "exit_code": 0}
|
||||
jobs.sort(key=lambda r: r.get("started_at") or 0, reverse=True)
|
||||
lines = "\n".join(_row(r) for r in jobs)
|
||||
return {"output": f"{len(jobs)} background job(s):\n{lines}", "exit_code": 0}
|
||||
|
||||
if action in _OUTPUT_ACTIONS or action in _KILL_ACTIONS:
|
||||
if not job_id:
|
||||
return {"error": f"manage_bg_jobs: action '{action}' requires a job_id (see action='list').", "exit_code": 1}
|
||||
rec = bg_jobs.get(job_id)
|
||||
# Scope: only the chat that launched a job may see or control it.
|
||||
if rec is None or rec.get("session_id") != session_id:
|
||||
return {"error": f"manage_bg_jobs: no background job '{job_id}' in this chat.", "exit_code": 1}
|
||||
|
||||
if action in _KILL_ACTIONS:
|
||||
if rec.get("status") != "running":
|
||||
return {"output": f"Job `{job_id}` already {_status_label(rec)}; nothing to kill.", "exit_code": 0}
|
||||
killed = bg_jobs.kill(job_id)
|
||||
return {"output": f"Killed background job `{job_id}` ({(killed or {}).get('command', '').splitlines()[0][:80]}).", "exit_code": 0}
|
||||
|
||||
out = rec.get("output") or "(no output yet)"
|
||||
return {
|
||||
"output": f"Job `{job_id}` [{_status_label(rec)}, {_age(rec)}]\nCommand: {rec.get('command')}\n\nOutput:\n{out}",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
return {"error": f"manage_bg_jobs: unknown action '{action}'. Use list, output, or kill.", "exit_code": 1}
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import difflib
|
||||
import fnmatch
|
||||
import shutil
|
||||
@@ -16,6 +17,31 @@ _CODENAV_SKIP_DIRS = frozenset({
|
||||
_CODENAV_MAX_HITS = 200
|
||||
_CODENAV_MAX_LINE = 400
|
||||
|
||||
|
||||
def _glob_to_regex(pat: str) -> "re.Pattern":
|
||||
"""Translate a forward-slash glob (**, *, ?) into a compiled regex.
|
||||
`**/` matches zero or more complete directories.
|
||||
`*` matches within a single path segment (does not cross /).
|
||||
"""
|
||||
i, n, out = 0, len(pat), []
|
||||
while i < n:
|
||||
if pat[i : i + 3] == "**/":
|
||||
out.append("(?:[^/]+/)*")
|
||||
i += 3
|
||||
elif pat[i : i + 2] == "**":
|
||||
out.append(".*")
|
||||
i += 2
|
||||
elif pat[i] == "*":
|
||||
out.append("[^/]*")
|
||||
i += 1
|
||||
elif pat[i] == "?":
|
||||
out.append("[^/]")
|
||||
i += 1
|
||||
else:
|
||||
out.append(re.escape(pat[i]))
|
||||
i += 1
|
||||
return re.compile("".join(out))
|
||||
|
||||
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||
if old == new:
|
||||
return None
|
||||
@@ -259,23 +285,38 @@ class GlobTool:
|
||||
return {"error": f"glob: {e}", "exit_code": 1}
|
||||
|
||||
def _glob():
|
||||
from pathlib import Path
|
||||
base = Path(root)
|
||||
if not base.is_dir():
|
||||
base = os.path.abspath(root)
|
||||
if not os.path.isdir(base):
|
||||
return None, f"glob: {root}: not a directory"
|
||||
norm_pat = pattern.replace("\\", "/")
|
||||
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
||||
if not any(c in norm_pat for c in "*?["):
|
||||
cand = os.path.normpath(os.path.join(base, norm_pat))
|
||||
if os.path.exists(cand):
|
||||
return [cand], None
|
||||
# Literal not at exact path — fall through to walk so
|
||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||
# Compile glob to regex: * stays within one segment, **/ spans dirs.
|
||||
regex = _glob_to_regex(norm_pat)
|
||||
matched = []
|
||||
cap = _CODENAV_MAX_HITS * 5
|
||||
try:
|
||||
for p in base.rglob(pattern):
|
||||
if set(p.relative_to(base).parts) & _CODENAV_SKIP_DIRS:
|
||||
continue
|
||||
try:
|
||||
mtime = p.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0
|
||||
matched.append((mtime, str(p)))
|
||||
if len(matched) > _CODENAV_MAX_HITS * 5:
|
||||
for dp, dns, fns in os.walk(base):
|
||||
# Prune skipped dirs before descending (unlike rglob which
|
||||
# descends first then filters — fatal on large node_modules).
|
||||
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
||||
for name in fns + dns:
|
||||
full = os.path.join(dp, name)
|
||||
rel = os.path.relpath(full, base).replace(os.sep, "/")
|
||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
except OSError:
|
||||
mtime = 0
|
||||
matched.append((mtime, full))
|
||||
if len(matched) > cap:
|
||||
break
|
||||
except (OSError, ValueError) as _e:
|
||||
except OSError as _e:
|
||||
return None, f"glob: {_e}"
|
||||
matched.sort(key=lambda t: t[0], reverse=True)
|
||||
return [pth for _, pth in matched[:_CODENAV_MAX_HITS]], None
|
||||
|
||||
+23
-1
@@ -263,10 +263,32 @@ def list_for_session(session_id: str) -> List[Dict[str, Any]]:
|
||||
return [r for r in refresh().values() if r.get("session_id") == session_id]
|
||||
|
||||
|
||||
def kill(job_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Terminate a running job's process tree and mark it killed. Returns the
|
||||
updated record, or None if the id is unknown. Idempotent: a job that already
|
||||
finished is returned unchanged. Sets followed_up so the monitor does not also
|
||||
fire an auto-continue for a job the agent deliberately stopped."""
|
||||
jobs = _load()
|
||||
rec = jobs.get(job_id)
|
||||
if rec is None:
|
||||
return None
|
||||
if rec.get("status") == "running":
|
||||
_kill(rec.get("pid"))
|
||||
rec["status"] = "failed"
|
||||
rec["exit_code"] = -1
|
||||
rec["ended_at"] = time.time()
|
||||
rec["killed"] = True
|
||||
rec["followed_up"] = True
|
||||
_save(jobs)
|
||||
return rec
|
||||
|
||||
|
||||
def result_text(rec: Dict[str, Any]) -> str:
|
||||
"""Human/agent-readable summary of a finished job, for the follow-up."""
|
||||
out = _read_output(rec)
|
||||
if rec.get("timed_out"):
|
||||
if rec.get("killed"):
|
||||
head = "Background job was killed."
|
||||
elif rec.get("timed_out"):
|
||||
head = f"Background job timed out after {rec.get('max_runtime_s')}s."
|
||||
elif rec.get("died"):
|
||||
head = "Background job process died unexpectedly (no exit code)."
|
||||
|
||||
+6
-2
@@ -87,7 +87,7 @@ _host_health_lock = threading.Lock()
|
||||
_model_activity: Dict[str, float] = {}
|
||||
|
||||
_HARMONY_MARKER_RE = re.compile(
|
||||
r"<\|channel\|>(analysis|final)"
|
||||
r"<\|channel\|>(analysis|commentary|final)"
|
||||
r"|<\|start\|>(?:assistant|system|user|tool)?"
|
||||
r"|<\|message\|>"
|
||||
r"|<\|end\|>"
|
||||
@@ -96,6 +96,7 @@ _HARMONY_MARKER_RE = re.compile(
|
||||
)
|
||||
_HARMONY_MARKERS = (
|
||||
"<|channel|>analysis",
|
||||
"<|channel|>commentary",
|
||||
"<|channel|>final",
|
||||
"<|start|>assistant",
|
||||
"<|start|>system",
|
||||
@@ -145,7 +146,10 @@ class _HarmonyStreamRouter:
|
||||
out.append((text, False))
|
||||
return
|
||||
if self._in_message:
|
||||
out.append((text, self._channel == "analysis"))
|
||||
# analysis + commentary (tool-call preambles / function-arg bodies)
|
||||
# are internal, not user-facing — route them to thinking so they
|
||||
# don't leak into the visible answer; only `final` is visible.
|
||||
out.append((text, self._channel in ("analysis", "commentary")))
|
||||
|
||||
def _handle_marker(self, match: re.Match[str]) -> None:
|
||||
marker = match.group(0)
|
||||
|
||||
+14
-2
@@ -471,6 +471,8 @@ async def _direct_fallback(
|
||||
tool: str,
|
||||
content: str,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> Optional[Dict]:
|
||||
_subproc_env = {
|
||||
**os.environ,
|
||||
@@ -484,6 +486,8 @@ async def _direct_fallback(
|
||||
ctx = {
|
||||
"progress_cb": progress_cb,
|
||||
"subproc_env": _subproc_env,
|
||||
"session_id": session_id,
|
||||
"owner": owner,
|
||||
}
|
||||
|
||||
from src.agent_tools import TOOL_HANDLERS
|
||||
@@ -731,10 +735,13 @@ async def _execute_tool_block_impl(
|
||||
desc = f"bash (background): {short}"
|
||||
result = {
|
||||
"output": (
|
||||
f"Started background job `{rec['id']}`. It is running detached — "
|
||||
f"Started background job `{rec['id']}`. It is running detached; "
|
||||
f"do NOT wait for it or poll it. You will be automatically re-invoked "
|
||||
f"with its full output when it finishes. Continue with other work, or "
|
||||
f"end your turn now and resume when the result arrives."
|
||||
f"end your turn now and resume when the result arrives. If the user "
|
||||
f"later asks to check progress or stop it, call the manage_bg_jobs "
|
||||
f"tool yourself (output or kill); do not tell them to run a tool "
|
||||
f"command, and do not surface raw tool syntax in your reply."
|
||||
),
|
||||
"exit_code": 0,
|
||||
"bg_job_id": rec["id"],
|
||||
@@ -755,6 +762,11 @@ async def _execute_tool_block_impl(
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _direct_fallback(tool, content, progress_cb=progress_cb) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool == "manage_bg_jobs":
|
||||
# Inspect/kill detached `bash` jobs; needs session_id to scope to chat.
|
||||
desc = f"manage_bg_jobs: {content.split(chr(10))[0][:80]}"
|
||||
result = await _direct_fallback(tool, content, session_id=session_id, owner=owner) \
|
||||
or {"error": "manage_bg_jobs: execution failed", "exit_code": 1}
|
||||
elif tool in ("create_document", "update_document", "edit_document",
|
||||
"suggest_document", "manage_documents"):
|
||||
desc = f"{tool}: {content.split(chr(10))[0][:80]}"
|
||||
|
||||
@@ -12,12 +12,24 @@ import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
|
||||
from src.tool_utils import get_mcp_manager
|
||||
from core.constants import internal_api_base
|
||||
from routes._validators import validate_remote_host, validate_ssh_port
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _string_arg(value: Any) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple[str, str]:
|
||||
remote = validate_remote_host(_string_arg(remote_host) or None) or ""
|
||||
sport = validate_ssh_port(_string_arg(ssh_port) or None) or ""
|
||||
return remote, sport
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active email state
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3025,6 +3037,10 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
|
||||
break
|
||||
|
||||
if remote:
|
||||
try:
|
||||
remote, sport = _validate_cookbook_ssh_target(remote, sport)
|
||||
except HTTPException as e:
|
||||
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
|
||||
_pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
|
||||
cmd = (
|
||||
f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
|
||||
@@ -3113,8 +3129,8 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
|
||||
tail = 400
|
||||
tail = max(20, min(tail, 4000))
|
||||
headers = _internal_headers()
|
||||
remote = (args.get("remote_host") or args.get("host") or "").strip()
|
||||
sport = (args.get("ssh_port") or "").strip()
|
||||
remote = _string_arg(args.get("remote_host") or args.get("host"))
|
||||
sport = _string_arg(args.get("ssh_port"))
|
||||
# Resolve host from cookbook state if caller didn't pass one — same
|
||||
# lookup _cookbook_kill_session uses.
|
||||
if not remote:
|
||||
@@ -3132,6 +3148,12 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
|
||||
if not sport:
|
||||
sport = t.get("sshPort") or ""
|
||||
break
|
||||
if remote:
|
||||
try:
|
||||
remote, sport = _validate_cookbook_ssh_target(remote, sport)
|
||||
except HTTPException as e:
|
||||
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
|
||||
|
||||
# Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the
|
||||
# live tmux pane. The pane is what the user would see scrolling on
|
||||
# their screen — including the post-crash neofetch banner and the
|
||||
@@ -3309,7 +3331,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
host = (args.get("host") or args.get("remote_host") or "").strip()
|
||||
host = _string_arg(args.get("host") or args.get("remote_host"))
|
||||
sess = (args.get("tmux_session") or args.get("session_id") or "").strip()
|
||||
model = (args.get("model") or args.get("repo_id") or "").strip()
|
||||
port = args.get("port") or 8000
|
||||
@@ -3320,6 +3342,12 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
|
||||
return {"error": "tmux_session and model are required", "exit_code": 1}
|
||||
|
||||
# Verify tmux session exists on the target host
|
||||
if host:
|
||||
try:
|
||||
host, _ = _validate_cookbook_ssh_target(host)
|
||||
except HTTPException as e:
|
||||
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
|
||||
|
||||
headers = _internal_headers()
|
||||
if host:
|
||||
check = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)} 2>&1'"
|
||||
|
||||
@@ -94,6 +94,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"manage_endpoints": "Endpoint management: list, add, delete, enable, or disable model API endpoints.",
|
||||
"manage_mcp": "MCP server management: list, add, delete, reconnect servers, or list available tools.",
|
||||
"manage_webhooks": "Webhook management: list, add, delete, enable, or disable webhooks.",
|
||||
"api_call": "Call a configured API integration by name (Home Assistant, Miniflux, Gitea, Linkding, Jellyfin, RSS reader, git forge, bookmark manager, smart home, or any other registered service). Make a GET/POST/PUT/PATCH/DELETE request to the integration's endpoint path, with an optional JSON body. Use whenever the user asks to query or control one of their connected integrations/services.",
|
||||
"manage_tokens": "API token management: list, create, or delete API access tokens.",
|
||||
"manage_documents": "List, read, delete, or tidy documents in the editor panel. action='list' returns clickable rows (most-recent first) so the user can open any doc by clicking. action='read' (aka view/open/get) with document_id returns the content; supports offset=<N> + limit=<N> to page through large docs (response includes next_offset when more remains, so you can keep calling with offset=next_offset). action='delete' with document_id removes a doc (only way to delete). Use this for ANY 'show/read/list/open my documents/docs/files/notes' request — never shell or curl.",
|
||||
"manage_research": "List, read/open, or delete saved DEEP RESEARCH results from the Library. action='list' returns clickable [query](#research-<id>) rows (most-recent first). action='read' (aka open/view/get) with id returns the report + sources. action='delete' with id removes it. Use this for ANY 'open/read/find/delete my research / that report / the research on X' request. NOTE: this is for EXISTING research; to START new research use trigger_research.",
|
||||
@@ -134,6 +135,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
|
||||
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
|
||||
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
|
||||
"manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.",
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +350,12 @@ class ToolIndex:
|
||||
{"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
|
||||
frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
|
||||
{"manage_calendar"},
|
||||
# Detached background `bash` jobs (#!bg): check on / read output / kill.
|
||||
frozenset({"background job", "background jobs", "bg job", "bg jobs",
|
||||
"background task", "is the job done", "check the job",
|
||||
"check on that job", "job output", "kill the job",
|
||||
"kill the background", "stop the background", "running job"}):
|
||||
{"manage_bg_jobs"},
|
||||
frozenset({"note", "todo", "reminder", "remind", "checklist", "remember to"}):
|
||||
{"manage_notes"},
|
||||
# Chat/session management. "rename" alone maps to documents below, so a
|
||||
@@ -414,6 +422,14 @@ class ToolIndex:
|
||||
"my settings", "change setting", "change a setting", "set setting",
|
||||
"preference", "preferences", "configure"}):
|
||||
{"manage_settings", "ui_control"},
|
||||
# API-integration intent → the api_call tool. Mirrors the agent-loop
|
||||
# "integrations" domain so api_call still surfaces on the retrieval and
|
||||
# keyword-fallback paths (not just the deterministic domain seed) when a
|
||||
# user names a connected service.
|
||||
frozenset({"api_call", "api call", "integration", "integrations",
|
||||
"home assistant", "homeassistant", "miniflux", "gitea",
|
||||
"linkding", "jellyfin"}):
|
||||
{"api_call"},
|
||||
# Managing EXISTING research in the Library — open/read/find/delete.
|
||||
frozenset({"my research", "the research", "research on", "open research",
|
||||
"read research", "find research", "delete research",
|
||||
|
||||
@@ -175,6 +175,9 @@ _TOOL_NAME_MAP = {
|
||||
"notes": "manage_notes",
|
||||
"todo": "manage_notes",
|
||||
"todos": "manage_notes",
|
||||
"manage_bg_jobs": "manage_bg_jobs",
|
||||
"bg_jobs": "manage_bg_jobs",
|
||||
"background_jobs": "manage_bg_jobs",
|
||||
}
|
||||
|
||||
_MISFENCED_WEB_TOOL_NAMES = {
|
||||
|
||||
@@ -1188,6 +1188,21 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "manage_bg_jobs",
|
||||
"description": "Inspect and control detached background `bash` jobs (started with the `#!bg` marker). action='list' shows this chat's jobs with id/status/age/command; action='output' returns a job's captured output so far (use for a still-running job, or to re-read a finished one); action='kill' terminates a runaway job's process tree instead of waiting out its max-runtime. output and kill need job_id from list.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["list", "output", "kill"], "description": "list | output | kill (default: list)"},
|
||||
"job_id": {"type": "string", "description": "Background job id (required for output/kill; from action='list')"},
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
NON_ADMIN_BLOCKED_TOOLS = {
|
||||
"bash",
|
||||
"python",
|
||||
"manage_bg_jobs",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
@@ -114,6 +115,8 @@ _PLAN_MODE_KNOWN_MUTATORS = {
|
||||
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
||||
# mode even if the schema list fails to load.
|
||||
"bash", "python",
|
||||
# Controls shell processes (kill); plan mode can't run bash anyway.
|
||||
"manage_bg_jobs",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const _RECIPES = [
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggerganov/llama.cpp:server-cuda'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggml-org/llama.cpp:server-cuda'] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -578,24 +578,50 @@ export const ERROR_PATTERNS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// Tail-only + healthy-server suppression. tmux capture-pane returns the
|
||||
// entire scrollback every poll, so a one-shot startup traceback would
|
||||
// otherwise stick on the panel forever even while the server happily
|
||||
// serves /v1/models. Only fire if the traceback is in recent output AND
|
||||
// the server isn't currently logging healthy traffic.
|
||||
// Dependency-install (pip) build failure — a required package failed to
|
||||
// build its wheel (common when an old sdist's setup.py breaks on a newer
|
||||
// Python, e.g. basicsr on 3.13). This is an install problem, NOT a serve
|
||||
// problem, so it must never suggest killing vLLM.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-6000);
|
||||
// A serve script can run a fallback build and then start serving fine —
|
||||
// don't flag a stale build error once the server is up.
|
||||
if (/Application startup complete|"(?:GET|POST)\s+\/v1\/[^"]+ HTTP\/[\d.]+"\s*2\d\d|Uvicorn running on|server is listening on https?:\/\//i.test(TAIL)) return false;
|
||||
return /Failed to build\b|subprocess-exited-with-error|Could not build wheels|metadata-generation-failed/i.test(TAIL);
|
||||
},
|
||||
message: 'A dependency failed to build during install — usually an older package whose build breaks on this Python version, not a server problem. The install did not finish.',
|
||||
suggestion: 'Suggested action: check the captured output for the package that failed to build; it may need a newer release or a patch to install on this Python version.',
|
||||
fixes: [],
|
||||
},
|
||||
{
|
||||
// vLLM-specific traceback: only offer the kill-processes recovery when the
|
||||
// output is actually about vLLM. Tail-only + healthy-server suppression so
|
||||
// a one-shot startup traceback doesn't stick on the panel forever while
|
||||
// the server happily serves /v1/models.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
// Healthy markers in the tail mean whatever blew up has been recovered
|
||||
// from — the server is up and answering requests.
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
return /vllm/i.test(TAIL);
|
||||
},
|
||||
message: 'Python traceback detected — may be a handled error, check logs.',
|
||||
message: 'A vLLM process hit a Python traceback and may be wedged.',
|
||||
fixes: [
|
||||
{ label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Generic traceback (not vLLM, not a pip build): surface it without
|
||||
// suggesting an unrelated vLLM kill. Same tail-only + healthy suppression.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
},
|
||||
message: 'Python traceback detected — check the captured output below for the underlying error.',
|
||||
suggestion: 'Suggested action: read the captured output for the failing step; copy the troubleshooting bundle if you need help.',
|
||||
fixes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function _diagnose(text) {
|
||||
|
||||
@@ -784,40 +784,47 @@ function _winSessionCmd(task, tmuxArgs) {
|
||||
const ps = host
|
||||
? `Get-Content '${sd}\\${sid}.log' -Tail ${lines} -ErrorAction SilentlyContinue`
|
||||
: `Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.log') -Tail ${lines} -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('has-session')) {
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('kill-session')) {
|
||||
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter "ParentProcessId = $Id" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||
const ps = host
|
||||
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
const ps = _winSessionStopTreePs(task);
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _winPowerShellCmd(task, ps) {
|
||||
const command = `powershell -Command "${ps}"`;
|
||||
if (!task.remoteHost) return command;
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(command)}`;
|
||||
}
|
||||
|
||||
function _winSessionStopTreePs(task) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter ('ParentProcessId = ' + $Id) -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||
return host
|
||||
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
}
|
||||
|
||||
export function _tmuxGracefulKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const pf = _sshPrefix(_getPort(task));
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
const ps = _winSessionStopTreePs(task);
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Regression: api_call reaches the model for API-integration intent (#3794).
|
||||
|
||||
The repro prompt — "Use the api_call tool to call Home Assistant GET
|
||||
/api/states" — matched no domain in ``_classify_agent_request``, so it was
|
||||
treated as low-signal. The agent loop then skipped retrieval and the function
|
||||
schema filter sent only the always-available tools (manage_memory / ask_user /
|
||||
update_plan); ``api_call`` was never advertised to the model even though the
|
||||
ToolIndex description existed. Adding the registry description alone did not fix
|
||||
runtime selection.
|
||||
|
||||
These tests drive the real path the agent uses — classifier -> domain tool map
|
||||
(relevant tools) -> FUNCTION_TOOL_SCHEMAS filter — using the actual functions and
|
||||
constants, so they would fail on the pre-fix code (empty domains -> low-signal ->
|
||||
no api_call). They skip locally when the agent's heavy deps (httpx/embeddings)
|
||||
are absent, and run in CI where they are installed.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
agent_loop = pytest.importorskip("src.agent_loop")
|
||||
|
||||
REPRO = "Use the api_call tool to call Home Assistant GET /api/states"
|
||||
|
||||
|
||||
def _selected_tools(domains):
|
||||
"""Mirror agent_loop's deterministic domain seeding (see the loop over
|
||||
`_intent['domains']` that updates `_relevant_tools` from `_DOMAIN_TOOL_MAP`)."""
|
||||
tools = set()
|
||||
for domain in domains:
|
||||
tools |= agent_loop._DOMAIN_TOOL_MAP.get(domain, set())
|
||||
return tools
|
||||
|
||||
|
||||
def _schema_names_sent(tools):
|
||||
"""Mirror the api-model schema filter that keeps only selected tools."""
|
||||
return {
|
||||
s.get("function", {}).get("name")
|
||||
for s in agent_loop.FUNCTION_TOOL_SCHEMAS
|
||||
if s.get("function", {}).get("name") in tools
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prompt",
|
||||
[
|
||||
REPRO,
|
||||
"check my home assistant lights",
|
||||
"fetch the latest unread from miniflux via the api_call tool",
|
||||
"call my gitea integration to list repos",
|
||||
],
|
||||
)
|
||||
def test_integration_prompts_are_not_low_signal(prompt):
|
||||
intent = agent_loop._classify_agent_request([], prompt)
|
||||
assert intent["low_signal"] is False, intent
|
||||
assert "integrations" in intent["domains"], intent
|
||||
|
||||
|
||||
def test_repro_selects_and_sends_api_call_schema():
|
||||
intent = agent_loop._classify_agent_request([], REPRO)
|
||||
selected = _selected_tools(intent["domains"])
|
||||
assert "api_call" in selected, selected
|
||||
# The schema filter must actually advertise api_call to the model.
|
||||
assert "api_call" in _schema_names_sent(selected), "api_call schema must reach the model"
|
||||
|
||||
|
||||
def test_integrations_domain_has_a_rule_pack():
|
||||
# _domain_rules_for_tools indexes _DOMAIN_RULES[domain] directly, so a domain
|
||||
# present in _DOMAIN_TOOL_MAP without a _DOMAIN_RULES entry would KeyError the
|
||||
# moment api_call is selected.
|
||||
rules = agent_loop._domain_rules_for_tools({"api_call"})
|
||||
assert any("api_call" in r for r in rules), rules
|
||||
|
||||
|
||||
def test_plain_greeting_does_not_pull_api_call():
|
||||
# Guard against over-matching: an unrelated message stays low-signal and must
|
||||
# not drag the integration tool into context.
|
||||
intent = agent_loop._classify_agent_request([], "hey there, how are you")
|
||||
assert "integrations" not in intent["domains"], intent
|
||||
assert "api_call" not in _selected_tools(intent["domains"])
|
||||
@@ -215,3 +215,58 @@ def test_setup_rejects_seven_char_password(tmp_path):
|
||||
asyncio.run(endpoint(body=body, request=request))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── Login "remember me" cookie lifetime ────────────────────────────────
|
||||
|
||||
|
||||
class _CapturingResponse:
|
||||
"""Stand-in for fastapi.Response that records set_cookie kwargs."""
|
||||
|
||||
def __init__(self):
|
||||
self.cookie_kwargs = None
|
||||
|
||||
def set_cookie(self, **kwargs):
|
||||
self.cookie_kwargs = kwargs
|
||||
|
||||
|
||||
def _login_endpoint(auth_manager):
|
||||
sys.modules.pop("routes.auth_routes", None)
|
||||
_real_core_package()
|
||||
from routes.auth_routes import LoginRequest, setup_auth_routes
|
||||
|
||||
router = setup_auth_routes(auth_manager)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", None) == "/api/auth/login":
|
||||
return route.endpoint, LoginRequest
|
||||
raise AssertionError("login route not found")
|
||||
|
||||
|
||||
def test_remember_cookie_max_age_matches_token_ttl(tmp_path):
|
||||
auth_mod = _auth_module()
|
||||
mgr = _make_manager(tmp_path)
|
||||
mgr.create_user("alice", "alice-password", is_admin=False)
|
||||
endpoint, LoginRequest = _login_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
response = _CapturingResponse()
|
||||
body = LoginRequest(username="alice", password="alice-password", remember=True)
|
||||
|
||||
result = asyncio.run(endpoint(body=body, request=request, response=response))
|
||||
|
||||
assert result == {"ok": True, "username": "alice"}
|
||||
# The persistent cookie must outlive neither more nor less than the token.
|
||||
assert response.cookie_kwargs["max_age"] == auth_mod.TOKEN_TTL
|
||||
|
||||
|
||||
def test_no_remember_omits_cookie_max_age(tmp_path):
|
||||
mgr = _make_manager(tmp_path)
|
||||
mgr.create_user("bob", "bob-password", is_admin=False)
|
||||
endpoint, LoginRequest = _login_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
response = _CapturingResponse()
|
||||
body = LoginRequest(username="bob", password="bob-password", remember=False)
|
||||
|
||||
asyncio.run(endpoint(body=body, request=request, response=response))
|
||||
|
||||
# Without "remember", the cookie is a session cookie (no max_age).
|
||||
assert "max_age" not in response.cookie_kwargs
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests for bg_jobs.kill and the manage_bg_jobs agent tool.
|
||||
|
||||
Process-free: the store/dir are redirected to tmp, _pid_alive is forced True so
|
||||
seeded "running" jobs stay running through refresh(), and _kill is stubbed so no
|
||||
real signal is sent. Jobs are scoped to a chat (session_id), which is the main
|
||||
invariant under test.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src import bg_jobs
|
||||
from src.agent_tools.bg_job_tools import ManageBgJobsTool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
jobs_dir = tmp_path / "bg_jobs"
|
||||
jobs_dir.mkdir()
|
||||
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "bg_jobs.json")
|
||||
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", jobs_dir)
|
||||
monkeypatch.setattr(bg_jobs, "_pid_alive", lambda pid: True)
|
||||
killed: list = []
|
||||
monkeypatch.setattr(bg_jobs, "_kill", lambda pid: killed.append(pid))
|
||||
return {"dir": jobs_dir, "killed": killed}
|
||||
|
||||
|
||||
def _seed(session_id="sess-a", status="running", job_id="job0001", output="", pid=4321):
|
||||
rec = {
|
||||
"id": job_id, "session_id": session_id, "command": "sleep 60",
|
||||
"status": status, "pid": pid, "started_at": time.time(),
|
||||
"ended_at": None if status == "running" else time.time(),
|
||||
"exit_code": None if status == "running" else 0,
|
||||
"max_runtime_s": 3600, "followed_up": False,
|
||||
"log_path": str(bg_jobs._JOBS_DIR / f"{job_id}.log"),
|
||||
"exit_path": str(bg_jobs._JOBS_DIR / f"{job_id}.exit"),
|
||||
}
|
||||
if output:
|
||||
(bg_jobs._JOBS_DIR / f"{job_id}.log").write_text(output, encoding="utf-8")
|
||||
jobs = bg_jobs._load()
|
||||
jobs[job_id] = rec
|
||||
bg_jobs._save(jobs)
|
||||
return rec
|
||||
|
||||
|
||||
def _run(args, session_id="sess-a"):
|
||||
return asyncio.run(ManageBgJobsTool().execute(json.dumps(args), {"session_id": session_id, "owner": None}))
|
||||
|
||||
|
||||
# ── bg_jobs.kill ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_kill_marks_killed_and_suppresses_followup(store):
|
||||
_seed(job_id="job0001", pid=4321)
|
||||
rec = bg_jobs.kill("job0001")
|
||||
assert rec["status"] == "failed"
|
||||
assert rec["killed"] is True
|
||||
assert rec["exit_code"] == -1
|
||||
# followed_up True so the monitor won't ALSO auto-continue a deliberate kill.
|
||||
assert rec["followed_up"] is True
|
||||
assert store["killed"] == [4321]
|
||||
|
||||
|
||||
def test_kill_unknown_job_returns_none(store):
|
||||
assert bg_jobs.kill("nope") is None
|
||||
|
||||
|
||||
def test_kill_finished_job_is_noop(store):
|
||||
_seed(job_id="done01", status="done")
|
||||
rec = bg_jobs.kill("done01")
|
||||
assert rec["status"] == "done"
|
||||
assert store["killed"] == [] # no signal sent to an already-finished job
|
||||
|
||||
|
||||
def test_result_text_reports_killed(store):
|
||||
rec = _seed(job_id="job0001")
|
||||
bg_jobs.kill("job0001")
|
||||
assert "killed" in bg_jobs.result_text(bg_jobs.get("job0001")).lower()
|
||||
|
||||
|
||||
# ── manage_bg_jobs tool ─────────────────────────────────────────────────────
|
||||
|
||||
def test_no_session_is_rejected(store):
|
||||
out = asyncio.run(ManageBgJobsTool().execute('{"action":"list"}', {"session_id": None}))
|
||||
assert "error" in out
|
||||
|
||||
|
||||
def test_list_empty(store):
|
||||
assert "No background jobs" in _run({"action": "list"})["output"]
|
||||
|
||||
|
||||
def test_list_scoped_to_session(store):
|
||||
_seed(session_id="sess-a", job_id="aaaa")
|
||||
_seed(session_id="sess-b", job_id="bbbb")
|
||||
out = _run({"action": "list"}, session_id="sess-a")["output"]
|
||||
assert "aaaa" in out and "bbbb" not in out
|
||||
|
||||
|
||||
def test_output_returns_captured_log(store):
|
||||
_seed(job_id="job0001", output="hello from the job\n")
|
||||
out = _run({"action": "output", "job_id": "job0001"})["output"]
|
||||
assert "hello from the job" in out
|
||||
|
||||
|
||||
def test_output_cross_session_denied(store):
|
||||
_seed(session_id="sess-a", job_id="job0001", output="secret")
|
||||
out = _run({"action": "output", "job_id": "job0001"}, session_id="sess-b")
|
||||
assert "error" in out and "secret" not in out.get("error", "")
|
||||
|
||||
|
||||
def test_kill_via_tool(store):
|
||||
_seed(job_id="job0001", pid=999)
|
||||
out = _run({"action": "kill", "job_id": "job0001"})
|
||||
assert "Killed" in out["output"]
|
||||
assert store["killed"] == [999]
|
||||
assert bg_jobs.get("job0001")["killed"] is True
|
||||
|
||||
|
||||
def test_kill_cross_session_denied(store):
|
||||
_seed(session_id="sess-a", job_id="job0001")
|
||||
out = _run({"action": "kill", "job_id": "job0001"}, session_id="sess-b")
|
||||
assert "error" in out
|
||||
assert store["killed"] == [] # never touched another chat's job
|
||||
|
||||
|
||||
def test_kill_requires_job_id(store):
|
||||
assert "error" in _run({"action": "kill"})
|
||||
|
||||
|
||||
def test_unknown_action(store):
|
||||
assert "error" in _run({"action": "frobnicate"})
|
||||
|
||||
|
||||
def test_action_aliases(store):
|
||||
_seed(job_id="job0001", output="aliased")
|
||||
# 'read' aliases to output, 'jobs' to list, 'stop' to kill
|
||||
assert "aliased" in _run({"action": "read", "job_id": "job0001"})["output"]
|
||||
assert "job0001" in _run({"action": "jobs"})["output"]
|
||||
assert "Killed" in _run({"action": "stop", "job_id": "job0001"})["output"]
|
||||
|
||||
|
||||
# ── intent classifier: short bg-job commands must not be dropped as low-signal ─
|
||||
# A short imperative ("kill that job") otherwise trips the low-signal gate, which
|
||||
# skips tool retrieval entirely and never surfaces manage_bg_jobs (the live bug
|
||||
# this feature hit). These lock in that bg-job control reaches the files domain.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("msg", [
|
||||
"stop the job",
|
||||
"kill that job",
|
||||
"Now kill that background job.",
|
||||
"is the job done?",
|
||||
"check the job output",
|
||||
"list my jobs",
|
||||
"kill the bg task",
|
||||
])
|
||||
def test_bg_job_commands_are_not_low_signal(msg):
|
||||
from src.agent_loop import _classify_agent_request, _DOMAIN_TOOL_MAP
|
||||
r = _classify_agent_request([{"role": "user", "content": msg}], msg)
|
||||
assert r["low_signal"] is False
|
||||
assert "files" in r["domains"]
|
||||
# files domain seeds manage_bg_jobs, so it gets offered to the model.
|
||||
assert "manage_bg_jobs" in _DOMAIN_TOOL_MAP["files"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("msg", [
|
||||
"run this in the background", # launching, not managing
|
||||
"find me a job listing", # unrelated use of "job"
|
||||
])
|
||||
def test_non_bg_messages_do_not_trip_files_domain(msg):
|
||||
from src.agent_loop import _classify_agent_request
|
||||
r = _classify_agent_request([{"role": "user", "content": msg}], msg)
|
||||
assert "files" not in r["domains"]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Guard: chat hot-path background tasks must go through _spawn_bg.
|
||||
|
||||
asyncio only holds a weak reference to a bare create_task() result, so the
|
||||
GC can collect the outer task before its body runs and the background work
|
||||
(memory/skill extraction, session auto-naming) silently never happens.
|
||||
routes/chat_helpers.py owns these schedules via _spawn_bg(), which adds the
|
||||
task to _BG_TASKS and discards it via a done-callback. This guard catches a
|
||||
regression where a copy-paste re-introduces a bare asyncio.create_task.
|
||||
|
||||
This is the routes/chat_helpers.py-scoped sibling of the webhook-emitter
|
||||
guard added in #4336 (tests/test_webhook_emitters_use_manager.py).
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
CHAT_HELPERS = (
|
||||
Path(__file__).resolve().parent.parent / "routes" / "chat_helpers.py"
|
||||
)
|
||||
|
||||
|
||||
def _untracked_create_task_calls(tree: ast.AST) -> list[tuple[int, str]]:
|
||||
"""(lineno, snippet) for any bare asyncio.create_task(...).
|
||||
|
||||
A call is "bare" when its return value is dropped — i.e. it is the direct
|
||||
expression of an ast.Expr statement. Captured forms (`x = asyncio.create_task(...)`,
|
||||
`[asyncio.create_task(...), ...]`, `await asyncio.create_task(...)`) are fine
|
||||
because something else holds the reference.
|
||||
|
||||
The helper itself (_spawn_bg) is exempt: it calls asyncio.create_task once
|
||||
and registers the task in _BG_TASKS before returning.
|
||||
"""
|
||||
hits: list[tuple[int, str]] = []
|
||||
|
||||
def _is_create_task(call: ast.Call) -> bool:
|
||||
f = call.func
|
||||
return (
|
||||
isinstance(f, ast.Attribute)
|
||||
and f.attr == "create_task"
|
||||
and isinstance(f.value, ast.Name)
|
||||
and f.value.id == "asyncio"
|
||||
)
|
||||
|
||||
spawn_helper_lines: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "_spawn_bg":
|
||||
for n in ast.walk(node):
|
||||
if hasattr(n, "lineno"):
|
||||
spawn_helper_lines.add(n.lineno)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Expr):
|
||||
continue
|
||||
if not isinstance(node.value, ast.Call):
|
||||
continue
|
||||
if not _is_create_task(node.value):
|
||||
continue
|
||||
if node.lineno in spawn_helper_lines:
|
||||
continue
|
||||
hits.append((node.lineno, ast.unparse(node.value)))
|
||||
return hits
|
||||
|
||||
|
||||
def test_no_untracked_create_task_in_chat_helpers():
|
||||
tree = ast.parse(CHAT_HELPERS.read_text(), filename=str(CHAT_HELPERS))
|
||||
offenders = _untracked_create_task_calls(tree)
|
||||
assert not offenders, (
|
||||
"Background tasks scheduled from routes/chat_helpers.py must go through "
|
||||
"_spawn_bg(coro) so the task is registered in _BG_TASKS and survives until "
|
||||
"it finishes. Found bare asyncio.create_task(...) call(s):\n "
|
||||
+ "\n ".join(f"chat_helpers.py:{ln}: {snip}" for ln, snip in offenders)
|
||||
)
|
||||
@@ -24,6 +24,9 @@ def repo():
|
||||
os.mkdir(os.path.join(root, "sub"))
|
||||
with open(os.path.join(root, "sub", "b.txt"), "w") as f:
|
||||
f.write("nothing\nNEEDLE upper\n")
|
||||
os.mkdir(os.path.join(root, "sub", "deep"))
|
||||
with open(os.path.join(root, "sub", "deep", "c.py"), "w") as f:
|
||||
f.write("# deep python\n")
|
||||
os.mkdir(os.path.join(root, "node_modules"))
|
||||
with open(os.path.join(root, "node_modules", "dep.py"), "w") as f:
|
||||
f.write("needle in dep\n")
|
||||
@@ -107,6 +110,37 @@ def test_glob_requires_pattern(repo):
|
||||
assert r["exit_code"] == 1
|
||||
|
||||
|
||||
def test_glob_literal_in_subdir(repo):
|
||||
"""Bare literal should match at any depth (like rglob), not only at root."""
|
||||
r = _run("glob", f'{{"pattern": "b.txt", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "b.txt" in r["output"]
|
||||
|
||||
|
||||
def test_glob_multi_segment_single_star(repo):
|
||||
"""sub/*.txt matches sub/b.txt but NOT sub/deep/c.py (single * stays in one segment)."""
|
||||
r = _run("glob", f'{{"pattern": "sub/*.txt", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "b.txt" in r["output"]
|
||||
assert "c.py" not in r["output"]
|
||||
|
||||
|
||||
def test_glob_star_does_not_cross_slash(repo):
|
||||
"""src/*.py must NOT match src/a/b/x.py — * is single-segment only."""
|
||||
r = _run("glob", f'{{"pattern": "sub/*.py", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
# sub/ has no .py directly, only sub/deep/c.py — should NOT match
|
||||
assert "No files matching" in r["output"]
|
||||
|
||||
|
||||
def test_glob_double_star_matches_deep(repo):
|
||||
"""**/*.py should match files at any depth."""
|
||||
r = _run("glob", f'{{"pattern": "**/*.py", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "a.py" in r["output"]
|
||||
assert "c.py" in r["output"]
|
||||
|
||||
|
||||
# ── ls ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_ls_lists_entries(repo):
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import tool_implementations as tools
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data=None, status_code=200):
|
||||
self._data = data or {}
|
||||
self.status_code = status_code
|
||||
self.text = json.dumps(self._data)
|
||||
self.content = self.text.encode("utf-8")
|
||||
self.headers = {"content-type": "application/json"}
|
||||
|
||||
def json(self):
|
||||
return self._data
|
||||
|
||||
|
||||
def _install_httpx_client(monkeypatch, *, state=None, posts=None):
|
||||
import httpx
|
||||
|
||||
posts = posts if posts is not None else []
|
||||
state = state if state is not None else {"tasks": []}
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
return FakeResponse(state)
|
||||
|
||||
async def post(self, url, json=None, **kwargs):
|
||||
posts.append((url, json, kwargs))
|
||||
return FakeResponse({"stdout": "", "stderr": "", "exit_code": 0})
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps({"session_id": "serve-abc123", "remote_host": "-bad"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_rejects_invalid_state_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(
|
||||
monkeypatch,
|
||||
state={
|
||||
"tasks": [
|
||||
{
|
||||
"sessionId": "serve-abc123",
|
||||
"remoteHost": "-bad",
|
||||
"sshPort": "22",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_rejects_invalid_ssh_port_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": "serve-abc123",
|
||||
"remote_host": "gpu-box",
|
||||
"ssh_port": "not-a-port",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid ssh_port" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_served_model_uses_validated_remote_target(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_stop_served_model(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": "serve-abc123",
|
||||
"remote_host": "user@gpu-box",
|
||||
"ssh_port": 2222,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert len(posts) == 1
|
||||
command = posts[0][1]["command"]
|
||||
assert "ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no" in command
|
||||
assert "-p 2222 user@gpu-box" in command
|
||||
assert "tmux kill-session -t serve-abc123" in command
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_download_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_cancel_download(
|
||||
json.dumps({"session_id": "cookbook-abc123", "remote_host": "-bad"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_download_rejects_invalid_state_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(
|
||||
monkeypatch,
|
||||
state={
|
||||
"tasks": [
|
||||
{
|
||||
"sessionId": "cookbook-abc123",
|
||||
"remoteHost": "-bad",
|
||||
"sshPort": "22",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = await tools.do_cancel_download(
|
||||
json.dumps({"session_id": "cookbook-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_serve_output_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_tail_serve_output(
|
||||
json.dumps({"session_id": "serve-abc123", "remote_host": "-bad"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_serve_output_rejects_invalid_state_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(
|
||||
monkeypatch,
|
||||
state={
|
||||
"tasks": [
|
||||
{
|
||||
"sessionId": "serve-abc123",
|
||||
"remoteHost": "-bad",
|
||||
"sshPort": "22",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
result = await tools.do_tail_serve_output(
|
||||
json.dumps({"session_id": "serve-abc123"})
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopt_served_model_rejects_invalid_remote_host_before_shell(monkeypatch):
|
||||
posts = _install_httpx_client(monkeypatch)
|
||||
|
||||
result = await tools.do_adopt_served_model(
|
||||
json.dumps(
|
||||
{
|
||||
"tmux_session": "serve_abc123",
|
||||
"model": "org/model",
|
||||
"host": "-bad",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Invalid remote_host" in result["error"]
|
||||
assert posts == []
|
||||
@@ -28,13 +28,15 @@ def test_background_status_poll_reconciles_into_local_tasks():
|
||||
assert "completedDeps.forEach(t => _refreshDepsAfterInstall(t));" in source
|
||||
|
||||
|
||||
def test_local_windows_session_commands_use_local_powershell_log_dir():
|
||||
def test_windows_session_commands_use_shared_powershell_wrapper_and_local_log_dir():
|
||||
source = _read("static/js/cookbookRunning.js")
|
||||
|
||||
assert "const host = task.remoteHost;" in source
|
||||
assert "host ? '$env:TEMP\\\\odysseus-sessions' : '$env:TEMP\\\\odysseus-tmux'" in source
|
||||
assert "return host ? `ssh ${pf}${host}" in source
|
||||
assert ": `powershell -Command \"${ps}\"`;" in source
|
||||
assert "function _winPowerShellCmd(task, ps)" in source
|
||||
assert "const command = `powershell -Command \"${ps}\"`;" in source
|
||||
assert "if (!task.remoteHost) return command;" in source
|
||||
assert "return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(command)}`;" in source
|
||||
|
||||
|
||||
def test_dep_install_success_recognized_from_exit_sentinel():
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Guard the llama.cpp Docker pull recipe surfaced in Cookbook → Dependencies.
|
||||
|
||||
The upstream repo moved from github.com/ggerganov/llama.cpp to
|
||||
github.com/ggml-org/llama.cpp. The old GHCR namespace
|
||||
(ghcr.io/ggerganov/llama.cpp) no longer publishes images, so the
|
||||
docker variant in the Dependencies panel returned
|
||||
"failed to resolve reference … not found" when copied verbatim (#4457).
|
||||
The other llama.cpp reference in routes/cookbook_routes.py already uses
|
||||
ggml-org; this guards the JS recipe so the two stay aligned.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
RECIPES_JS = (
|
||||
Path(__file__).resolve().parent.parent / "static" / "js" / "cookbook-deps-recipes.js"
|
||||
)
|
||||
|
||||
|
||||
def test_llama_cpp_docker_recipe_uses_ggml_org_namespace():
|
||||
source = RECIPES_JS.read_text(encoding="utf-8")
|
||||
|
||||
assert "ghcr.io/ggml-org/llama.cpp:server-cuda" in source, (
|
||||
"Expected the llama.cpp docker recipe to pull from the ggml-org namespace."
|
||||
)
|
||||
assert "ghcr.io/ggerganov/llama.cpp" not in source, (
|
||||
"The ggerganov GHCR namespace no longer publishes llama.cpp images. "
|
||||
"Use ghcr.io/ggml-org/llama.cpp:server-cuda."
|
||||
)
|
||||
@@ -718,7 +718,7 @@ def test_local_windows_download_pid_tracks_inner_bash_and_stop_kills_tree():
|
||||
|
||||
assert 'printf \'%s\\\\n\' \\"$$\\" > {pp}' in routes_src
|
||||
assert "function Stop-Tree([int]$Id)" in running_src
|
||||
assert "ParentProcessId = $Id" in running_src
|
||||
assert "('ParentProcessId = ' + $Id)" in running_src
|
||||
assert "Stop-Tree ([int]$p)" in running_src
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNING_JS = ROOT / "static" / "js" / "cookbookRunning.js"
|
||||
|
||||
|
||||
def _between(source, start, end):
|
||||
start_idx = source.index(start)
|
||||
end_idx = source.index(end, start_idx)
|
||||
return source[start_idx:end_idx]
|
||||
|
||||
|
||||
def test_windows_graceful_kill_reuses_recursive_stop_tree_helper():
|
||||
source = RUNNING_JS.read_text(encoding="utf-8")
|
||||
wrapper = _between(source, "function _winPowerShellCmd(task, ps)", "function _winSessionStopTreePs(task)")
|
||||
helper = _between(source, "function _winSessionStopTreePs(task)", "function _tmuxGracefulKill(task)")
|
||||
graceful = _between(source, "function _tmuxGracefulKill(task)", "function _shQuote(value)")
|
||||
win_session = _between(source, "function _winSessionCmd(task, tmuxArgs)", "function _winPowerShellCmd(task, ps)")
|
||||
|
||||
assert "function Stop-Tree([int]$Id)" in helper
|
||||
assert "('ParentProcessId = ' + $Id)" in helper
|
||||
assert "Stop-Tree ([int]$p)" in helper
|
||||
assert "${_shQuote(command)}" in wrapper
|
||||
assert "_winSessionStopTreePs(task)" in win_session
|
||||
assert "_winPowerShellCmd(task, ps)" in win_session
|
||||
assert "_winSessionStopTreePs(task)" in graceful
|
||||
assert "_winPowerShellCmd(task, ps)" in graceful
|
||||
assert "Stop-Process -Id $p -Force" not in graceful
|
||||
assert '-Filter "ParentProcessId = $Id"' not in helper
|
||||
assert 'powershell -Command \\\\"${ps}\\\\"' not in source
|
||||
|
||||
|
||||
def _posix_quote(value):
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def test_remote_windows_stop_tree_payload_survives_shell_parsing():
|
||||
ps = (
|
||||
"function Stop-Tree([int]$Id) { "
|
||||
"Get-CimInstance Win32_Process -Filter ('ParentProcessId = ' + $Id) "
|
||||
"-ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; "
|
||||
"Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }; "
|
||||
"$p = Get-Content '$env:TEMP\\odysseus-sessions\\serve_abc.pid' "
|
||||
"-ErrorAction SilentlyContinue; "
|
||||
"if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }"
|
||||
)
|
||||
remote_command = f'powershell -Command "{ps}"'
|
||||
shell_command = f"ssh -p 2222 winbox {_posix_quote(remote_command)}"
|
||||
|
||||
argv = shlex.split(shell_command)
|
||||
|
||||
assert argv == ["ssh", "-p", "2222", "winbox", remote_command]
|
||||
assert "$Id" in argv[-1]
|
||||
assert "$_.ProcessId" in argv[-1]
|
||||
assert "$env:TEMP" in argv[-1]
|
||||
assert "$p" in argv[-1]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""CPU architecture normalization for HW Fit hardware detection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from services.hwfit import hardware
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_hwfit_cache(monkeypatch):
|
||||
hardware._cache_by_host.clear()
|
||||
monkeypatch.setattr(hardware, "_remote_host", None)
|
||||
monkeypatch.setattr(hardware, "_remote_platform", None)
|
||||
monkeypatch.setattr(hardware, "_is_containerized", lambda: False)
|
||||
yield
|
||||
hardware._cache_by_host.clear()
|
||||
|
||||
|
||||
def _stub_common_probe(monkeypatch, machine):
|
||||
monkeypatch.setattr(hardware.platform, "machine", lambda: machine)
|
||||
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 64.0)
|
||||
monkeypatch.setattr(hardware, "_get_available_ram_gb", lambda: 48.0)
|
||||
monkeypatch.setattr(hardware, "_get_cpu_count", lambda: 16)
|
||||
monkeypatch.setattr(hardware, "_get_cpu_name", lambda: "Test CPU")
|
||||
monkeypatch.setattr(hardware, "_detect_apple_silicon", lambda: None)
|
||||
monkeypatch.setattr(hardware, "_detect_amd", lambda: None)
|
||||
|
||||
|
||||
def test_detect_system_reports_cpu_arch_for_gpu_backends(monkeypatch):
|
||||
"""GPU-backed systems still need CPU architecture for cpu_only estimates."""
|
||||
_stub_common_probe(monkeypatch, "aarch64")
|
||||
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: {
|
||||
"gpu_name": "NVIDIA GB10",
|
||||
"gpu_vram_gb": 64.0,
|
||||
"gpu_count": 1,
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"backend": "cuda",
|
||||
})
|
||||
|
||||
system = hardware.detect_system(fresh=True)
|
||||
|
||||
assert system["backend"] == "cuda"
|
||||
assert system["cpu_arch"] == "arm64"
|
||||
|
||||
|
||||
def test_detect_system_keeps_32_bit_arm_on_conservative_cpu_backend(monkeypatch):
|
||||
"""Plain arm/armv7 is not the same as the ARM64-class cpu_arm fallback."""
|
||||
_stub_common_probe(monkeypatch, "armv7l")
|
||||
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: None)
|
||||
|
||||
system = hardware.detect_system(fresh=True)
|
||||
|
||||
assert system["cpu_arch"] == "arm"
|
||||
assert system["backend"] == "cpu_x86"
|
||||
@@ -47,6 +47,12 @@ ARM64_SYSTEM = {
|
||||
"gpu_vram_gb": 0,
|
||||
}
|
||||
|
||||
ARM32_SYSTEM = {
|
||||
"backend": "arm",
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": 0,
|
||||
}
|
||||
|
||||
AARCH64_SYSTEM = {
|
||||
"backend": "aarch64",
|
||||
"gpu_name": None,
|
||||
@@ -79,6 +85,16 @@ def test_cpu_only_on_metal_apple_silicon_uses_cpu_arm_fallback():
|
||||
assert metal_tps > 0
|
||||
|
||||
|
||||
def test_cpu_only_on_gpu_backend_uses_detected_arm64_cpu_arch():
|
||||
"""A GPU backend on an ARM64 host should use the ARM CPU fallback for cpu_only."""
|
||||
cuda_arm64 = dict(CUDA_SYSTEM, cpu_arch="aarch64", cpu_name="Ampere Altra")
|
||||
cuda_arm64_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", cuda_arm64)
|
||||
arm_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", CPU_ARM_SYSTEM)
|
||||
|
||||
assert cuda_arm64_tps == pytest.approx(arm_tps, rel=1e-9, abs=1e-9)
|
||||
assert cuda_arm64_tps > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arm_alias_system",
|
||||
[ARM64_SYSTEM, AARCH64_SYSTEM, CPU_ARM_SYSTEM],
|
||||
@@ -93,6 +109,15 @@ def test_cpu_only_preserves_arm_backends(arm_alias_system):
|
||||
assert alias_tps > 0
|
||||
|
||||
|
||||
def test_cpu_only_does_not_treat_plain_arm_as_arm64_fallback():
|
||||
"""Docker/OCI plain arm is not the ARM64-class fallback used for Apple Silicon."""
|
||||
arm32_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", ARM32_SYSTEM)
|
||||
x86_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", CPU_X86_SYSTEM)
|
||||
|
||||
assert arm32_tps == pytest.approx(x86_tps, rel=1e-9, abs=1e-9)
|
||||
assert arm32_tps > 0
|
||||
|
||||
|
||||
def test_cpu_only_preserves_known_cpu_backends():
|
||||
"""Known CPU backends should be preserved, not rewritten to cpu_x86."""
|
||||
for system in (CPU_X86_SYSTEM, CPU_ARM_SYSTEM):
|
||||
|
||||
@@ -165,6 +165,15 @@ def test_intel_mac_skipped(monkeypatch):
|
||||
assert hardware._detect_apple_silicon() is None
|
||||
|
||||
|
||||
def test_plain_arm_mac_skipped(monkeypatch):
|
||||
"""Only ARM64-class Macs should enter the Apple Silicon Metal path."""
|
||||
monkeypatch.setattr(hardware, "_remote_host", None)
|
||||
monkeypatch.setattr(hardware.platform, "system", lambda: "Darwin")
|
||||
monkeypatch.setattr(hardware.platform, "machine", lambda: "armv7l")
|
||||
monkeypatch.setattr(hardware, "_run", _fake_sysctl())
|
||||
assert hardware._detect_apple_silicon() is None
|
||||
|
||||
|
||||
def test_detect_system_propagates_unified_memory(monkeypatch):
|
||||
"""The unified_memory flag set by GPU detection must survive into the
|
||||
system dict so the API and UI can report it (it was being dropped)."""
|
||||
|
||||
@@ -206,3 +206,33 @@ def test_harmony_analysis_channel_routes_to_thinking(monkeypatch):
|
||||
assert answer == "Here are the files."
|
||||
assert "<|channel|>" not in thinking + answer
|
||||
assert "<|message|>" not in thinking + answer
|
||||
|
||||
|
||||
def test_harmony_commentary_channel_no_marker_or_toolarg_leak(monkeypatch):
|
||||
# gpt-oss commentary channel (tool-call preambles / function-arg bodies) is
|
||||
# internal — it must not leak the channel marker, the `to=functions.*`
|
||||
# recipient, or its body into the visible answer. The `<|channel|>comm` /
|
||||
# `entary` split also exercises the suffix-hold for the new marker.
|
||||
deltas = _run_stream(
|
||||
"gpt-oss:20b",
|
||||
[
|
||||
'data: {"choices":[{"delta":{"content":"<|channel|>comm"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"entary to=functions.web_search<|message|>Let me search the web."}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"<|end|><|channel|>final<|message|>Here are the "}}]}',
|
||||
'data: {"choices":[{"delta":{"content":"results.<|end|>"}}]}',
|
||||
"data: [DONE]",
|
||||
],
|
||||
monkeypatch,
|
||||
)
|
||||
thinking = "".join(d["delta"] for d in deltas if d.get("thinking"))
|
||||
answer = "".join(d["delta"] for d in deltas if not d.get("thinking"))
|
||||
|
||||
# final channel is the only user-facing text
|
||||
assert answer == "Here are the results."
|
||||
# commentary body routed to thinking, not the visible answer
|
||||
assert thinking == "Let me search the web."
|
||||
# no harmony markers, channel name, or tool recipient leak anywhere
|
||||
assert "<|channel|>" not in thinking + answer
|
||||
assert "<|message|>" not in thinking + answer
|
||||
assert "commentary" not in answer
|
||||
assert "to=functions.web_search" not in thinking + answer
|
||||
|
||||
@@ -105,7 +105,7 @@ def _patch_prefs(monkeypatch, data_dir):
|
||||
"skills_enabled": True,
|
||||
"auto_approve_skills": True,
|
||||
}
|
||||
sys.modules["routes.prefs_routes"] = fake_prefs
|
||||
monkeypatch.setitem(sys.modules, "routes.prefs_routes", fake_prefs)
|
||||
|
||||
# Bust the base-prompt cache so our test re-reads the skill index.
|
||||
from src import agent_loop
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Every FUNCTION_TOOL_SCHEMAS tool must have a ToolIndex description.
|
||||
|
||||
Agent mode selects tools by embedding BUILTIN_TOOL_DESCRIPTIONS and
|
||||
retrieving the top-K per message. A tool that exists in tool_schemas but has
|
||||
no description entry can never be retrieved, so the agent advertises the
|
||||
capability (e.g. API integrations in the system prompt) while the schema is
|
||||
never actually sent to the model. api_call was missing exactly this way.
|
||||
|
||||
Parsed with ast instead of importing, so the test does not pull in the
|
||||
embedding/ChromaDB stack.
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _assigned_value(tree, name):
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
if any(isinstance(t, ast.Name) and t.id == name for t in node.targets):
|
||||
return node.value
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
if isinstance(node.target, ast.Name) and node.target.id == name:
|
||||
return node.value
|
||||
raise AssertionError(f"{name} assignment not found")
|
||||
|
||||
|
||||
def _schema_tool_names():
|
||||
src = open(os.path.join(ROOT, "src", "tool_schemas.py"), encoding="utf-8").read()
|
||||
value = _assigned_value(ast.parse(src), "FUNCTION_TOOL_SCHEMAS")
|
||||
return {item["function"]["name"] for item in ast.literal_eval(value)}
|
||||
|
||||
|
||||
def _indexed_tool_names():
|
||||
src = open(os.path.join(ROOT, "src", "tool_index.py"), encoding="utf-8").read()
|
||||
value = _assigned_value(ast.parse(src), "BUILTIN_TOOL_DESCRIPTIONS")
|
||||
return {ast.literal_eval(key) for key in value.keys}
|
||||
|
||||
|
||||
def test_every_schema_tool_has_an_index_description():
|
||||
missing = _schema_tool_names() - _indexed_tool_names()
|
||||
assert not missing, (
|
||||
"Tools defined in FUNCTION_TOOL_SCHEMAS but absent from "
|
||||
f"BUILTIN_TOOL_DESCRIPTIONS (RAG can never select them): {sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
def test_api_call_is_indexed_with_a_real_description():
|
||||
src = open(os.path.join(ROOT, "src", "tool_index.py"), encoding="utf-8").read()
|
||||
value = _assigned_value(ast.parse(src), "BUILTIN_TOOL_DESCRIPTIONS")
|
||||
descriptions = {
|
||||
ast.literal_eval(k): ast.literal_eval(v) for k, v in zip(value.keys, value.values)
|
||||
}
|
||||
assert "api_call" in descriptions
|
||||
assert len(descriptions["api_call"]) > 50
|
||||
Reference in New Issue
Block a user