mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc8d3a2f6 | ||
|
|
433d10db5e | ||
|
|
9b7b3681f6 | ||
|
|
6dbe5461bb | ||
|
|
a65592fecb | ||
|
|
0513fbdb84 | ||
|
|
d4eb6308b1 | ||
|
|
2853a0001d | ||
|
|
3c99481975 | ||
|
|
4bf39af9bd | ||
|
|
0a3e812751 | ||
|
|
eb46febad5 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "118,590",
|
||||
"message": "128,077",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 118590,
|
||||
"last_updated": "2026-06-16T08:00:49Z",
|
||||
"total_clones": 128077,
|
||||
"last_updated": "2026-06-22T08:05:29Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -81,6 +81,13 @@
|
||||
"2026-06-11": 2564,
|
||||
"2026-06-12": 1313,
|
||||
"2026-06-13": 2804,
|
||||
"2026-06-14": 1543
|
||||
"2026-06-14": 1543,
|
||||
"2026-06-15": 1379,
|
||||
"2026-06-16": 1317,
|
||||
"2026-06-17": 1170,
|
||||
"2026-06-18": 1408,
|
||||
"2026-06-19": 1350,
|
||||
"2026-06-20": 1437,
|
||||
"2026-06-21": 1426
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra framework-comparison --extra server
|
||||
@@ -55,6 +57,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra framework-comparison --extra server
|
||||
@@ -63,8 +67,13 @@ jobs:
|
||||
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
- name: Run tests
|
||||
# COVERAGE_CORE=sysmon uses CPython 3.12's sys.monitoring backend,
|
||||
# which is dramatically cheaper than the default C trace function.
|
||||
# -n auto fans the suite out across all runner cores via pytest-xdist.
|
||||
env:
|
||||
COVERAGE_CORE: sysmon
|
||||
run: |
|
||||
uv run pytest tests/ -v --tb=short -m "not live and not cloud and not hub" \
|
||||
uv run pytest tests/ -n auto -q --tb=short -m "not live and not cloud and not hub" \
|
||||
--cov=openjarvis \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml \
|
||||
@@ -107,6 +116,8 @@ jobs:
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra server
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
@@ -8,10 +12,22 @@ COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Python package
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
|
||||
# reads uv.lock as-is (no re-resolution) and emits a fully pinned, hash-verified
|
||||
# requirements set; `--no-deps` then installs exactly that set. This is a
|
||||
# separate layer from the source copy so dependency installs stay cached when
|
||||
# only application code changes.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt
|
||||
|
||||
# Copy the source and the non-src force-include paths (see pyproject
|
||||
# [tool.hatch.build.targets.wheel.force-include]) before building the project.
|
||||
COPY src/ src/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
@@ -19,16 +35,25 @@ COPY deploy/windows deploy/windows
|
||||
# Copy built frontend into the server static directory
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps .
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM python:3.12-slim-bookworm
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user — the server needs no root privileges, so dropping
|
||||
# them limits the blast radius of a compromise (#565). The app writes only to
|
||||
# $HOME (config/cache/state), which is owned by this user.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
@@ -8,25 +12,31 @@ COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
# for the rationale behind the frozen export + --no-deps install.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt
|
||||
|
||||
COPY src/ src/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps .
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
@@ -36,6 +46,14 @@ COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user (#565). NVIDIA device nodes (/dev/nvidia*) are
|
||||
# world-accessible, so GPU workloads do not require root.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers — reproducible builds and
|
||||
# safe rollbacks (#563).
|
||||
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
@@ -8,25 +12,31 @@ COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Python package (AMD ROCm 7.2)
|
||||
FROM rocm/dev-ubuntu-22.04:7.2 AS builder
|
||||
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
|
||||
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
|
||||
# for the rationale behind the frozen export + --no-deps install.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt
|
||||
|
||||
COPY src/ src/
|
||||
COPY scripts/install scripts/install
|
||||
COPY deploy/windows deploy/windows
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
RUN uv pip install --system --no-deps .
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM rocm/dev-ubuntu-22.04:7.2
|
||||
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
@@ -36,6 +46,18 @@ COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user (#565). ROCm GPU access is gated by the `video` and
|
||||
# `render` groups (see group_add in docker-compose.gpu.rocm.yml), so the user is
|
||||
# added to both; root is not required.
|
||||
RUN groupadd --system --gid 10001 openjarvis && \
|
||||
useradd --system --uid 10001 --gid openjarvis \
|
||||
--create-home --home-dir /home/openjarvis openjarvis && \
|
||||
(getent group video >/dev/null || groupadd --system video) && \
|
||||
(getent group render >/dev/null || groupadd --system render) && \
|
||||
usermod -aG video,render openjarvis
|
||||
ENV HOME=/home/openjarvis
|
||||
USER openjarvis
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
FROM python:3.12-slim
|
||||
# Base images are pinned to an immutable digest (in addition to a human-readable
|
||||
# tag) so every build resolves the exact same layers (#563).
|
||||
|
||||
# Install Node.js 22
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
# Node.js is sourced from the official, digest-pinned image rather than piping a
|
||||
# remote setup script into bash (`curl ... | bash -`), which performed no
|
||||
# checksum or signature verification of the downloaded installer (#566). The
|
||||
# image digest is the integrity check, and the copy is architecture-agnostic.
|
||||
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS node
|
||||
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
|
||||
|
||||
# libstdc++6 + ca-certificates are the only runtime requirements of the Node
|
||||
# binary copied below (the python slim image already provides libc/libgcc).
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates libstdc++6 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Transplant the Node.js runtime from the official image. Both images are Debian
|
||||
# bookworm, so the glibc/libstdc++ ABI matches.
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies from the committed lockfile (#567): `uv export --frozen`
|
||||
# reads uv.lock as-is and emits a pinned, hash-verified set installed with
|
||||
# --no-deps (no re-resolution). Copied first so this layer caches independently
|
||||
# of application source.
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv export --frozen --no-dev --extra server --no-emit-project > requirements.txt && \
|
||||
uv pip install --system --no-deps -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir ".[server]"
|
||||
|
||||
# Install the project itself without re-resolving dependencies.
|
||||
RUN uv pip install --system --no-deps .
|
||||
|
||||
LABEL openjarvis-sandbox=true
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
capabilities: [gpu]
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
# Pinned to a fixed version + digest for reproducible deployments (#563);
|
||||
# must match the tag in docker-compose.yml.
|
||||
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
|
||||
environment:
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -18,7 +18,9 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
# Pinned to a fixed version + digest for reproducible deployments and
|
||||
# predictable rollbacks (#563). Bump deliberately, not implicitly via :latest.
|
||||
image: ollama/ollama:0.30.10@sha256:bfc9c6d53cc6989aa5131a6fde6b162b2802d4d337657f3253b5f69579bddeee
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
|
||||
@@ -14,7 +14,27 @@ Environment=HOME=/opt/openjarvis
|
||||
# OPENJARVIS_API_KEY=<key> (generate one: `jarvis auth generate-key`)
|
||||
# It is not prefixed with "-", so the unit fails to start if the file is
|
||||
# missing — preventing an accidentally unauthenticated public server.
|
||||
# Keep secrets here (mode 0600, owned by root) rather than inline Environment=
|
||||
# lines, which leak into `systemctl show` and the journal.
|
||||
EnvironmentFile=/etc/openjarvis/env
|
||||
|
||||
# --- Sandboxing / hardening (#564) ---
|
||||
# Conservative set: tightens the unit without blocking the server's normal I/O
|
||||
# or local GPU inference. ProtectSystem=strict makes the whole filesystem
|
||||
# read-only except ReadWritePaths, so $HOME (config/cache/state under
|
||||
# /opt/openjarvis) stays writable.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/openjarvis
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectControlGroups=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -20,7 +20,7 @@ What it does:
|
||||
4. Installs `uv` (https://astral.sh/uv) if absent.
|
||||
5. Clones the OpenJarvis repository to `%LOCALAPPDATA%\OpenJarvis`
|
||||
(override with `$env:OPENJARVIS_HOME`).
|
||||
6. Runs `uv sync --extra server` so the FastAPI server entry point is
|
||||
6. Runs `uv sync --extra desktop` so the FastAPI server and speech backend are
|
||||
importable.
|
||||
7. Optionally prompts to register a scheduled task that auto-starts the
|
||||
server at logon.
|
||||
@@ -105,7 +105,7 @@ To pull the latest:
|
||||
```powershell
|
||||
cd "$env:LOCALAPPDATA\OpenJarvis\src"
|
||||
git pull --ff-only
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
```
|
||||
|
||||
Or re-run the installer with `-Force`:
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
4. Install uv (https://astral.sh/uv) if absent.
|
||||
5. Clone the OpenJarvis repository to $env:LOCALAPPDATA\OpenJarvis
|
||||
(override with $env:OPENJARVIS_HOME).
|
||||
6. Run `uv sync --extra server` so the FastAPI server entry point
|
||||
is importable.
|
||||
6. Run `uv sync --extra desktop` so the FastAPI server and speech
|
||||
backend are importable.
|
||||
7. Optionally register the scheduled-task service (see
|
||||
deploy/windows/jarvis-service.ps1).
|
||||
|
||||
@@ -279,13 +279,13 @@ if (Test-Path (Join-Path $srcDir '.git')) {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. uv sync --extra server
|
||||
# 6. uv sync --extra desktop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Info "Running 'uv sync --extra server' in $srcDir (this can take a few minutes)..."
|
||||
Write-Info "Running 'uv sync --extra desktop' in $srcDir (this can take a few minutes)..."
|
||||
Push-Location $srcDir
|
||||
try {
|
||||
& $uvExe sync --extra server
|
||||
& $uvExe sync --extra desktop
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail "uv sync failed with exit code $LASTEXITCODE. Check the output above."
|
||||
}
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ If you prefer to run each step yourself:
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ If you prefer to run each step yourself:
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
uv sync --extra desktop
|
||||
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
@@ -278,6 +278,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
|
||||
|
||||
| Extra | Install Command | Description |
|
||||
|-------|----------------|-------------|
|
||||
| `desktop` | `uv sync --extra desktop` | Desktop/API server plus local speech input |
|
||||
| `server` | `uv sync --extra server` | OpenAI-compatible API server (`jarvis serve`) |
|
||||
| `dev` | `uv sync --extra dev` | Development and testing tools |
|
||||
| `docs` | `uv sync --extra docs` | Documentation build tools |
|
||||
@@ -285,7 +286,7 @@ OpenJarvis uses optional extras to keep the base installation lightweight.
|
||||
Combine extras:
|
||||
|
||||
```bash
|
||||
uv sync --extra server --extra memory-faiss --extra inference-cloud
|
||||
uv sync --extra desktop --extra memory-faiss --extra inference-cloud
|
||||
```
|
||||
|
||||
## Setting Up an Inference Backend
|
||||
|
||||
@@ -8,7 +8,7 @@ avoid a Linux VM; WSL2 remains the smoother experience for most users.
|
||||
## What you get
|
||||
|
||||
- A PowerShell installer that probes prerequisites, installs `uv`,
|
||||
clones the repo, and runs `uv sync --extra server`.
|
||||
clones the repo, and runs `uv sync --extra desktop`.
|
||||
- An optional Windows scheduled-task service equivalent to the systemd
|
||||
unit and launchd plist.
|
||||
- Loopback default — the service binds `127.0.0.1` so no API key is
|
||||
@@ -38,7 +38,7 @@ The installer will:
|
||||
4. Install `uv` if absent (via the official `astral.sh/uv` PowerShell
|
||||
installer).
|
||||
5. Clone the repo to `%LOCALAPPDATA%\OpenJarvis\src`.
|
||||
6. Run `uv sync --extra server`.
|
||||
6. Run `uv sync --extra desktop`.
|
||||
7. Prompt to register the scheduled-task service (skip with
|
||||
`-SkipService`).
|
||||
|
||||
|
||||
@@ -682,7 +682,7 @@ fn format_uv_sync_failure(
|
||||
format!(
|
||||
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
|
||||
Try opening a terminal in that directory and running \
|
||||
`uv sync --extra server` manually for the full output.",
|
||||
`uv sync --extra desktop` manually for the full output.",
|
||||
root.display(),
|
||||
code,
|
||||
uv_sync_stderr_tail(stderr, 800),
|
||||
@@ -1143,7 +1143,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
sync_cmd
|
||||
.args([
|
||||
"sync",
|
||||
"--extra", "server",
|
||||
"--extra", "desktop",
|
||||
"--extra", "inference-cloud",
|
||||
"--extra", "inference-google",
|
||||
])
|
||||
@@ -1664,11 +1664,29 @@ async fn transcribe_audio(
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
if !status.is_success() {
|
||||
let detail = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("detail")
|
||||
.and_then(|detail| detail.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.filter(|detail| !detail.is_empty())
|
||||
.unwrap_or(body);
|
||||
return Err(format!(
|
||||
"Transcription failed ({}): {}",
|
||||
status.as_u16(),
|
||||
detail
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&body).map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
/// Submit savings to Supabase leaderboard.
|
||||
@@ -2556,7 +2574,7 @@ mod tests {
|
||||
assert!(msg.contains("exit 2"));
|
||||
assert!(msg.contains("/home/u/.openjarvis/src"));
|
||||
assert!(msg.contains("failed to resolve numpy==2.1.3"));
|
||||
assert!(msg.contains("uv sync --extra server")); // actionable next step
|
||||
assert!(msg.contains("uv sync --extra desktop")); // actionable next step
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -97,7 +97,13 @@ export function InputArea() {
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
|
||||
const { state: speechState, available: speechAvailable, startRecording, stopRecording } = useSpeech();
|
||||
const {
|
||||
state: speechState,
|
||||
error: speechError,
|
||||
available: speechAvailable,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} = useSpeech();
|
||||
|
||||
// Abort in-flight stream when the user switches models mid-generation.
|
||||
// This prevents errors from trying to continue a stream with a stale model.
|
||||
@@ -122,6 +128,12 @@ export function InputArea() {
|
||||
: streamState.isStreaming ? 'streaming'
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (speechError) {
|
||||
toast.error(speechError, { duration: 8000 });
|
||||
}
|
||||
}, [speechError]);
|
||||
|
||||
const handleMicClick = useCallback(async () => {
|
||||
if (speechState === 'recording') {
|
||||
try {
|
||||
|
||||
+13
-3
@@ -317,8 +317,9 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
|
||||
audioData: Array.from(new Uint8Array(buffer)),
|
||||
filename,
|
||||
});
|
||||
} catch {
|
||||
// Fall through to fetch
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(msg || 'Transcription failed');
|
||||
}
|
||||
}
|
||||
const formData = new FormData();
|
||||
@@ -327,7 +328,16 @@ export async function transcribeAudio(audioBlob: Blob, filename = 'recording.web
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Transcription failed: ${res.status}`);
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = typeof body.detail === 'string' ? body.detail : "";
|
||||
} catch {
|
||||
// Keep the status-only message below when the body is not JSON.
|
||||
}
|
||||
throw new Error(detail || `Transcription failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ function SelfHostedView() {
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Launch the API server to get the full UI in your browser:
|
||||
</p>
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra server\njarvis serve --port 8000"} />
|
||||
<CodeBlock code={"git clone https://github.com/open-jarvis/OpenJarvis.git\ncd OpenJarvis\nuv sync --extra desktop\njarvis serve --port 8000"} />
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
The chat, dashboard, energy profiling, and cost comparison all run
|
||||
locally on your machine.
|
||||
|
||||
@@ -48,6 +48,7 @@ dev = [
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=5",
|
||||
"pytest-xdist>=3",
|
||||
"respx>=0.22",
|
||||
"ruff>=0.4",
|
||||
"pre-commit>=3.0",
|
||||
@@ -84,6 +85,13 @@ server = [
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
]
|
||||
desktop = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.30",
|
||||
"pydantic>=2.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"faster-whisper>=1.0",
|
||||
]
|
||||
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
|
||||
gpu-metrics = ["pynvml>=12.0"]
|
||||
energy-amd = ["amdsmi>=6.1"]
|
||||
|
||||
@@ -148,7 +148,7 @@ fi
|
||||
|
||||
# ── 7. Install Python dependencies ──────────────────────────────────
|
||||
info "Installing Python dependencies..."
|
||||
uv sync --extra server --quiet 2>/dev/null || uv sync --extra server
|
||||
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
|
||||
ok "Python dependencies installed"
|
||||
|
||||
# ── 7b. Build Rust extension ──────────────────────────────────────
|
||||
|
||||
@@ -73,12 +73,19 @@ WEB_SEARCH_COST_PER_CALL = 0.01
|
||||
# $0.01/call number — kept as a separate constant so it can drift.
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL = 0.01
|
||||
|
||||
# Gemini Google-Search grounding: billed at $35 per 1000 grounded
|
||||
# *requests* (2025-12 public list price for the Grounding-with-Google-Search
|
||||
# tool, charged once per request that uses the tool regardless of how many
|
||||
# internal queries it issues). We charge per grounded request, not per
|
||||
# `web_search_queries` entry.
|
||||
GEMINI_SEARCH_COST_PER_CALL = 0.035
|
||||
# Gemini 3 Google-Search grounding: billed at $14 per 1000 search queries.
|
||||
# `_call_gemini_agent` reports the model's `web_search_queries`, so this is
|
||||
# charged per query, not per outer generate_content request.
|
||||
GEMINI_SEARCH_COST_PER_CALL = 0.014
|
||||
|
||||
# Tavily Search, advanced depth: 2 API credits per search request at $0.008
|
||||
# per credit on the public pay-as-you-go plan. WebSearchTool captures actual
|
||||
# credits when Tavily returns usage metadata; this is the fallback estimate.
|
||||
TAVILY_SEARCH_COST_PER_CREDIT = 0.008
|
||||
TAVILY_ADVANCED_SEARCH_CREDITS = 2
|
||||
TAVILY_SEARCH_COST_PER_CALL = (
|
||||
TAVILY_SEARCH_COST_PER_CREDIT * TAVILY_ADVANCED_SEARCH_CREDITS
|
||||
)
|
||||
|
||||
ANTHROPIC_WEB_SEARCH_TOOL = {
|
||||
"type": "web_search_20250305",
|
||||
@@ -101,6 +108,40 @@ def build_web_search_tool(max_uses: int = 8) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def tavily_search_context(
|
||||
query: str,
|
||||
*,
|
||||
max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run OpenJarvis WebSearchTool and return accounting-friendly metadata."""
|
||||
from openjarvis.tools.web_search import WebSearchTool
|
||||
|
||||
tool = WebSearchTool(max_results=max_results)
|
||||
res = tool.execute(query=query, max_results=max_results)
|
||||
meta = dict(res.metadata or {})
|
||||
engine = str(meta.get("engine") or "unknown")
|
||||
credits = 0
|
||||
cost_usd = 0.0
|
||||
if engine == "tavily":
|
||||
try:
|
||||
credits = int(meta.get("credits") or TAVILY_ADVANCED_SEARCH_CREDITS)
|
||||
except (TypeError, ValueError):
|
||||
credits = TAVILY_ADVANCED_SEARCH_CREDITS
|
||||
cost_usd = credits * TAVILY_SEARCH_COST_PER_CREDIT
|
||||
text = res.content or ""
|
||||
if not res.success and not text:
|
||||
text = "(no search results)"
|
||||
return {
|
||||
"text": text,
|
||||
"success": bool(res.success),
|
||||
"engine": engine,
|
||||
"credits": credits,
|
||||
"cost_usd": cost_usd,
|
||||
"n_searches": 1 if (query or "").strip() else 0,
|
||||
"error": None if res.success else text,
|
||||
}
|
||||
|
||||
|
||||
def web_search_cfg(method_cfg: Optional[Dict[str, Any]]) -> Tuple[bool, int]:
|
||||
"""Parse ``method_cfg.web_search = { enabled, max_uses }``.
|
||||
|
||||
@@ -1322,6 +1363,9 @@ __all__ = [
|
||||
"LocalCloudAgent",
|
||||
"NO_TEMP_PREFIXES",
|
||||
"OPENAI_WEB_SEARCH_COST_PER_CALL",
|
||||
"TAVILY_ADVANCED_SEARCH_CREDITS",
|
||||
"TAVILY_SEARCH_COST_PER_CALL",
|
||||
"TAVILY_SEARCH_COST_PER_CREDIT",
|
||||
"WEB_SEARCH_COST_PER_CALL",
|
||||
"_bump_cloud_calls",
|
||||
"_bump_local_calls",
|
||||
@@ -1329,5 +1373,6 @@ __all__ = [
|
||||
"estimate_cost",
|
||||
"is_gpt5_family",
|
||||
"supports_temperature",
|
||||
"tavily_search_context",
|
||||
"web_search_cfg",
|
||||
]
|
||||
|
||||
@@ -15,13 +15,16 @@ PRICES: dict[str, tuple[float, float]] = {
|
||||
"claude-sonnet-4-6": (3.00, 15.0),
|
||||
"claude-haiku-4-5": (1.00, 5.00),
|
||||
"claude-haiku-4-5-20251001": (1.00, 5.00),
|
||||
"gpt-5.5": (5.00, 30.0),
|
||||
"gpt-5": (1.25, 10.0),
|
||||
"gpt-5-mini": (0.25, 2.00),
|
||||
"gpt-5-mini-2025-08-07": (0.25, 2.00),
|
||||
"gpt-4o": (0.15, 0.60),
|
||||
# Gemini Developer API prices (USD per 1M tokens), 2025-12 list price.
|
||||
# 2.5 Pro uses tiered pricing (>200K context = $2.50/$15); we charge the
|
||||
# low-context tier since GAIA / SWE-bench prompts stay well under 200K.
|
||||
# Gemini Developer API prices (USD per 1M tokens). Pro models use tiered
|
||||
# pricing above 200K prompt tokens; GAIA prompts stay under that tier, so
|
||||
# charge the low-context standard rate.
|
||||
"gemini-3.1-pro-preview": (2.00, 12.0),
|
||||
"gemini-3.1-pro-preview-customtools": (2.00, 12.0),
|
||||
"gemini-2.5-pro": (1.25, 10.0),
|
||||
"gemini-2.5-flash": (0.30, 2.50),
|
||||
"gemini-2.5-flash-lite": (0.10, 0.40),
|
||||
@@ -61,7 +64,11 @@ def is_reasoning_model(model: str) -> bool:
|
||||
before emitting visible answer text. At max_tokens=4096 these silently
|
||||
truncate with empty answers on GAIA (26/100 GPT-5, 18/100 Gemini Pro)."""
|
||||
m = (model or "").lower()
|
||||
return is_gpt5_family(model) or "gemini-2.5-pro" in m
|
||||
return (
|
||||
is_gpt5_family(model)
|
||||
or "gemini-2.5-pro" in m
|
||||
or "gemini-3.1-pro" in m
|
||||
)
|
||||
|
||||
|
||||
def default_max_output_tokens(model: str) -> int:
|
||||
|
||||
@@ -34,6 +34,7 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid.mini_swe_agent import (
|
||||
@@ -135,7 +136,12 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
advisor_temperature = float(cfg.get("advisor_temperature", 0.2))
|
||||
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
if ws_enabled and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS:
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
if (
|
||||
ws_enabled
|
||||
and search_backend != "tavily"
|
||||
and self._cloud_endpoint not in _SEARCH_CAPABLE_ENDPOINTS
|
||||
):
|
||||
raise ValueError(
|
||||
f"web_search.enabled=true but cloud_endpoint={self._cloud_endpoint!r}; "
|
||||
"server-side web_search is wired for anthropic / openai / gemini "
|
||||
@@ -146,19 +152,23 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
use_ws = ws_enabled
|
||||
gaia_max_turns = int(cfg.get("gaia_max_turns", 8))
|
||||
n_searches_total = 0
|
||||
search_cost_total = 0.0
|
||||
|
||||
# 1. Initial executor pass — advisor (Qwen) doesn't get tools;
|
||||
# only the cloud executor passes do. With web_search on, dispatch
|
||||
# to the search-capable agent loop for the configured provider.
|
||||
if use_ws:
|
||||
initial_resp, e1_in, e1_out, n_s1, e1_turns = self._executor_search(
|
||||
(initial_resp, e1_in, e1_out, n_s1, e1_turns,
|
||||
e1_search_cost) = self._executor_search(
|
||||
user=f"Question:\n{question}",
|
||||
system=EXECUTOR_INITIAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
n_searches_total += n_s1
|
||||
search_cost_total += e1_search_cost
|
||||
else:
|
||||
initial_resp, e1_in, e1_out = self._call_cloud(
|
||||
user=f"Question:\n{question}",
|
||||
@@ -196,14 +206,17 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
f"answer-format rules."
|
||||
)
|
||||
if use_ws:
|
||||
final_answer, e2_in, e2_out, n_s2, e2_turns = self._executor_search(
|
||||
(final_answer, e2_in, e2_out, n_s2, e2_turns,
|
||||
e2_search_cost) = self._executor_search(
|
||||
user=final_user,
|
||||
system=EXECUTOR_FINAL_SYS,
|
||||
max_tokens=executor_max_tokens,
|
||||
ws_max_uses=ws_max_uses,
|
||||
max_turns=gaia_max_turns,
|
||||
query=question,
|
||||
)
|
||||
n_searches_total += n_s2
|
||||
search_cost_total += e2_search_cost
|
||||
else:
|
||||
final_answer, e2_in, e2_out = self._call_cloud(
|
||||
user=final_user,
|
||||
@@ -216,7 +229,10 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
tokens_local = adv_in + adv_out
|
||||
tokens_cloud = e1_in + e1_out + e2_in + e2_out
|
||||
cost = self.cost_usd(self._cloud_model, e1_in + e2_in, e1_out + e2_out)
|
||||
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
|
||||
if search_backend == "tavily":
|
||||
cost += search_cost_total
|
||||
else:
|
||||
cost += n_searches_total * _search_cost_per_call(self._cloud_endpoint)
|
||||
|
||||
meta: Dict[str, Any] = {
|
||||
"tokens_local": tokens_local,
|
||||
@@ -233,7 +249,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
"initial_response": initial_resp,
|
||||
"advisor_feedback": advisor_text,
|
||||
"web_search_enabled": use_ws,
|
||||
"search_backend": search_backend,
|
||||
"n_web_searches": n_searches_total,
|
||||
"search_cost_usd": search_cost_total,
|
||||
"note": "inference-only advisor (untrained); lower bound on the technique.",
|
||||
},
|
||||
}
|
||||
@@ -251,16 +269,40 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
max_tokens: int,
|
||||
ws_max_uses: int,
|
||||
max_turns: int,
|
||||
) -> Tuple[str, int, int, int, int]:
|
||||
query: Optional[str] = None,
|
||||
) -> Tuple[str, int, int, int, int, float]:
|
||||
"""Run a search-capable executor pass for the configured cloud.
|
||||
|
||||
Dispatches by ``self._cloud_endpoint`` to the matching ``_base``
|
||||
agent loop. Returns the shared 5-tuple ``(text, p_tok, c_tok,
|
||||
n_searches, turns)``. The endpoint is assumed already validated
|
||||
against ``_SEARCH_CAPABLE_ENDPOINTS`` by the caller.
|
||||
agent loop, or through Tavily when ``method_cfg.search_backend`` is
|
||||
``"tavily"``. Returns ``(text, p_tok, c_tok, n_searches, turns,
|
||||
search_cost_usd)``.
|
||||
"""
|
||||
if str(self._cfg.get("search_backend", "provider")).lower() == "tavily":
|
||||
res = tavily_search_context(
|
||||
query or user,
|
||||
max_results=int(self._cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
grounded_user = (
|
||||
f"Web search results:\n{res['text']}\n\n"
|
||||
f"Using the search results above, answer this request:\n{user}"
|
||||
)
|
||||
text, p, c = self._call_cloud(
|
||||
user=grounded_user,
|
||||
system=system,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
return (
|
||||
text,
|
||||
p,
|
||||
c,
|
||||
int(res["n_searches"]),
|
||||
1,
|
||||
float(res["cost_usd"]),
|
||||
)
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
return self._call_anthropic_agent(
|
||||
text, p, c, n_searches, turns = self._call_anthropic_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -269,8 +311,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
tools=[build_web_search_tool(ws_max_uses)],
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
if self._cloud_endpoint == "openai":
|
||||
return self._call_openai_agent(
|
||||
text, p, c, n_searches, turns = self._call_openai_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -278,8 +321,9 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
temperature=0.0,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
if self._cloud_endpoint == "gemini":
|
||||
return self._call_gemini_agent(
|
||||
text, p, c, n_searches, turns = self._call_gemini_agent(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=system,
|
||||
@@ -287,6 +331,7 @@ class AdvisorsAgent(LocalCloudAgent):
|
||||
temperature=0.0,
|
||||
max_turns=max_turns,
|
||||
)
|
||||
return text, p, c, n_searches, turns, 0.0
|
||||
# Genuinely unsupported (openrouter / vllm / unknown). The caller
|
||||
# guard should have caught this; raise defensively.
|
||||
raise ValueError(
|
||||
|
||||
@@ -46,6 +46,7 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import (
|
||||
@@ -456,8 +457,14 @@ def _format_worker_pool(workers: List[Dict[str, Any]]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _search_capable_indices(workers: List[Dict[str, Any]]) -> List[int]:
|
||||
def _search_capable_indices(
|
||||
workers: List[Dict[str, Any]],
|
||||
*,
|
||||
search_backend: str = "provider",
|
||||
) -> List[int]:
|
||||
"""Indices of workers whose endpoint can run server-side web search."""
|
||||
if search_backend == "tavily":
|
||||
return [w["id"] for w in workers]
|
||||
return [
|
||||
w["id"] for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
@@ -470,6 +477,7 @@ def _build_conductor_prompt(
|
||||
workers: List[Dict[str, Any]],
|
||||
*,
|
||||
web_search_enabled: bool = False,
|
||||
search_backend: str = "provider",
|
||||
) -> str:
|
||||
"""Build the planner prompt.
|
||||
|
||||
@@ -485,12 +493,16 @@ def _build_conductor_prompt(
|
||||
)
|
||||
if not web_search_enabled:
|
||||
return base
|
||||
capable = _search_capable_indices(workers)
|
||||
capable = _search_capable_indices(workers, search_backend=search_backend)
|
||||
if capable:
|
||||
cap_str = ", ".join(str(i) for i in capable)
|
||||
if search_backend == "tavily":
|
||||
capability = "External Tavily search results will be prepended to worker prompts"
|
||||
else:
|
||||
capability = "Only these model indices can perform live web search"
|
||||
constraint = (
|
||||
"\n\nWEB SEARCH CONSTRAINT:\n"
|
||||
f"Only these model indices can perform live web search: [{cap_str}]. "
|
||||
f"{capability}: [{cap_str}]. "
|
||||
"Any step that needs to look up facts, current events, or other "
|
||||
"information not reliably known from memory MUST be routed to one "
|
||||
"of those indices. Steps routed to any other model can only use "
|
||||
@@ -550,8 +562,8 @@ def _call_worker(
|
||||
*,
|
||||
web_search_tool: Optional[Dict[str, Any]] = None,
|
||||
web_search_max_uses: int = 8,
|
||||
) -> Tuple[str, int, int, bool, int]:
|
||||
"""Returns (text, p_tok, c_tok, is_local, n_web_searches).
|
||||
) -> Tuple[str, int, int, bool, int, float]:
|
||||
"""Returns (text, p_tok, c_tok, is_local, n_web_searches, extra_cost).
|
||||
|
||||
``web_search_tool``: a truthy marker that web_search is enabled for
|
||||
this run. When set AND the worker endpoint is search-capable
|
||||
@@ -565,6 +577,22 @@ def _call_worker(
|
||||
max_tok = int(cfg.get("worker_max_tokens", 4096))
|
||||
temp = float(cfg.get("worker_temperature", 0.2))
|
||||
use_ws = web_search_tool is not None
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
extra_cost = 0.0
|
||||
if use_ws and search_backend == "tavily":
|
||||
res = tavily_search_context(
|
||||
prompt,
|
||||
max_results=int(cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
prompt = (
|
||||
f"Web search results:\n{res['text']}\n\n"
|
||||
f"Using the search results above, answer this request:\n{prompt}"
|
||||
)
|
||||
extra_cost = float(res["cost_usd"])
|
||||
use_ws = False
|
||||
tavily_searches = int(res["n_searches"])
|
||||
else:
|
||||
tavily_searches = 0
|
||||
|
||||
if ep == "vllm":
|
||||
text, p, c = LocalCloudAgent._call_vllm(
|
||||
@@ -575,7 +603,7 @@ def _call_worker(
|
||||
temperature=temp,
|
||||
enable_thinking=False,
|
||||
)
|
||||
return text, p, c, True, 0
|
||||
return text, p, c, True, tavily_searches, extra_cost
|
||||
if ep == "openai":
|
||||
if use_ws:
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
@@ -584,14 +612,14 @@ def _call_worker(
|
||||
max_tokens=max_tok,
|
||||
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches, 0.0
|
||||
text, p, c = LocalCloudAgent._call_openai(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=(1.0 if is_gpt5_family(worker["model"]) else temp),
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
if ep == "openrouter":
|
||||
# OpenRouter is OpenAI-compatible; the helper handles the
|
||||
# base_url + OPENROUTER_API_KEY plumbing. No server-side web
|
||||
@@ -607,7 +635,7 @@ def _call_worker(
|
||||
temperature=temp,
|
||||
extra_body=extra_body if isinstance(extra_body, dict) else None,
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
if ep == "anthropic":
|
||||
eff_temp = temp if supports_temperature(worker["model"]) else 0.0
|
||||
anthropic_kwargs: Dict[str, Any] = dict(
|
||||
@@ -620,7 +648,7 @@ def _call_worker(
|
||||
text, p, c, n_searches = LocalCloudAgent._call_anthropic(
|
||||
worker["model"], **anthropic_kwargs
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches or tavily_searches, extra_cost
|
||||
if ep == "gemini":
|
||||
# Gemini Developer API via google-genai. With web_search on, route
|
||||
# through the Google-Search-grounded agent loop; otherwise plain
|
||||
@@ -632,14 +660,14 @@ def _call_worker(
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
return text, p, c, False, n_searches
|
||||
return text, p, c, False, n_searches, 0.0
|
||||
text, p, c = LocalCloudAgent._call_gemini(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
return text, p, c, False, 0
|
||||
return text, p, c, False, tavily_searches, extra_cost
|
||||
raise ValueError(f"unsupported worker endpoint: {ep!r}")
|
||||
|
||||
|
||||
@@ -672,7 +700,7 @@ def _swe_worker_step(
|
||||
# backbones today (the loop's tool-call format is Anthropic- or
|
||||
# OpenAI-via-vllm-shaped only). Fall back to one-shot for those —
|
||||
# SWE-bench-wise they were already weak; this preserves behavior.
|
||||
text, p, c, is_local, n_searches = _call_worker(worker, prompt, cfg)
|
||||
text, p, c, is_local, n_searches, _extra = _call_worker(worker, prompt, cfg)
|
||||
return text, p, c, is_local, n_searches, 0
|
||||
out = run_swe_agent_loop(
|
||||
task,
|
||||
@@ -753,13 +781,17 @@ class ConductorAgent(LocalCloudAgent):
|
||||
and bool(task_meta_early.get("base_commit"))
|
||||
)
|
||||
ws_enabled, ws_max_uses = web_search_cfg(cfg)
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
planner_ws = ws_enabled and not swe_mode_early
|
||||
|
||||
# 1. Plan — when web_search is on (GAIA), the prompt names which
|
||||
# worker indices can actually search, so the planner routes
|
||||
# research steps to a search-capable worker.
|
||||
user = _build_conductor_prompt(
|
||||
question, workers, web_search_enabled=planner_ws,
|
||||
question,
|
||||
workers,
|
||||
web_search_enabled=planner_ws,
|
||||
search_backend=search_backend,
|
||||
)
|
||||
plan_text, p_in, p_out = self._call_cloud(
|
||||
user=user,
|
||||
@@ -833,7 +865,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# memory. Fail loud instead of degrading silently.
|
||||
# ``ws_enabled`` / ``ws_max_uses`` computed up front for the planner
|
||||
# constraint — reuse them here.
|
||||
if ws_enabled and not swe_mode:
|
||||
if ws_enabled and search_backend != "tavily" and not swe_mode:
|
||||
search_workers = [
|
||||
w for w in workers
|
||||
if (w.get("endpoint") or "openai").lower()
|
||||
@@ -894,7 +926,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
# may legitimately not need search; see Task-3 planner
|
||||
# constraint that tries to prevent this upfront).
|
||||
if (
|
||||
ws_enabled and not swe_mode
|
||||
ws_enabled and search_backend != "tavily" and not swe_mode
|
||||
and worker_ep not in _SEARCH_CAPABLE_WORKER_ENDPOINTS
|
||||
):
|
||||
self.record_trace_event({
|
||||
@@ -911,6 +943,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
),
|
||||
})
|
||||
|
||||
extra_cost = 0.0
|
||||
if swe_mode:
|
||||
text, w_in, w_out, is_local, n_searches, bash_turns = (
|
||||
_swe_worker_step(
|
||||
@@ -919,7 +952,9 @@ class ConductorAgent(LocalCloudAgent):
|
||||
)
|
||||
tool_calls += bash_turns
|
||||
else:
|
||||
text, w_in, w_out, is_local, n_searches = _call_worker(
|
||||
(
|
||||
text, w_in, w_out, is_local, n_searches, extra_cost
|
||||
) = _call_worker(
|
||||
worker, prompt, cfg,
|
||||
web_search_tool=ws_tool,
|
||||
web_search_max_uses=ws_max_uses,
|
||||
@@ -930,7 +965,10 @@ class ConductorAgent(LocalCloudAgent):
|
||||
else:
|
||||
tokens_cloud += w_in + w_out
|
||||
cost += self.cost_usd(worker["model"], w_in, w_out)
|
||||
cost += n_searches * _worker_search_cost_per_call(worker_ep)
|
||||
if search_backend != "tavily":
|
||||
cost += n_searches * _worker_search_cost_per_call(worker_ep)
|
||||
if search_backend == "tavily":
|
||||
cost += extra_cost
|
||||
n_web_searches_total += n_searches
|
||||
tool_calls += n_searches
|
||||
steps.append({
|
||||
@@ -981,6 +1019,7 @@ class ConductorAgent(LocalCloudAgent):
|
||||
"plan": plan,
|
||||
"fallback_used": fallback_used,
|
||||
"web_search_enabled": ws_enabled,
|
||||
"search_backend": search_backend,
|
||||
"n_web_searches": n_web_searches_total,
|
||||
"parse_attempts": parse_attempts,
|
||||
"workers": [
|
||||
|
||||
@@ -48,12 +48,13 @@ from openjarvis.agents.hybrid._base import (
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
web_search_cfg,
|
||||
)
|
||||
from openjarvis.agents.hybrid._openai_retry import (
|
||||
patch_openai_globally as _patch_openai_globally,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES
|
||||
from openjarvis.agents.hybrid._prices import NO_TEMP_PREFIXES, default_max_output_tokens
|
||||
from openjarvis.agents.hybrid.mini_swe_agent import run_swe_agent_loop
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
@@ -362,6 +363,8 @@ def _prefetch_context(
|
||||
cloud_endpoint: str,
|
||||
cloud_model: str,
|
||||
max_uses: int = 8,
|
||||
search_backend: str = "provider",
|
||||
tavily_max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Use Anthropic web_search to fetch real source material the worker can read.
|
||||
|
||||
@@ -375,6 +378,22 @@ def _prefetch_context(
|
||||
out: Dict[str, Any] = {
|
||||
"text": "", "tokens": 0, "cost_usd": 0.0, "n_searches": 0,
|
||||
}
|
||||
if search_backend == "tavily":
|
||||
try:
|
||||
res = tavily_search_context(question, max_results=tavily_max_results)
|
||||
out.update(
|
||||
text=res["text"],
|
||||
cost_usd=float(res["cost_usd"]),
|
||||
n_searches=int(res["n_searches"]),
|
||||
tokens=0,
|
||||
engine=res.get("engine"),
|
||||
credits=res.get("credits"),
|
||||
)
|
||||
if res.get("error"):
|
||||
out["error"] = res["error"]
|
||||
except Exception as e:
|
||||
out["error"] = f"{type(e).__name__}: {e}"
|
||||
return out
|
||||
if cloud_endpoint != "anthropic" or not (question or "").strip():
|
||||
return out
|
||||
try:
|
||||
@@ -498,18 +517,22 @@ class MinionsAgent(LocalCloudAgent):
|
||||
max_tokens=cfg.get("worker_max_tokens", 4096),
|
||||
local=True,
|
||||
)
|
||||
cloud_max_tokens = int(
|
||||
cfg.get("cloud_max_tokens")
|
||||
or default_max_output_tokens(self._cloud_model)
|
||||
)
|
||||
if self._cloud_endpoint == "openai":
|
||||
cloud_client = OpenAIClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
elif self._cloud_endpoint == "anthropic":
|
||||
# Temperature stripping is handled by the global patch above for Opus 4.7+.
|
||||
cloud_client = AnthropicClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
elif self._cloud_endpoint == "gemini":
|
||||
# The vendored Minion library already special-cases GeminiClient
|
||||
@@ -520,7 +543,7 @@ class MinionsAgent(LocalCloudAgent):
|
||||
cloud_client = GeminiClient(
|
||||
model_name=self._cloud_model,
|
||||
temperature=0.0,
|
||||
max_tokens=4096,
|
||||
max_tokens=cloud_max_tokens,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported cloud endpoint: {self._cloud_endpoint!r}")
|
||||
@@ -560,6 +583,8 @@ class MinionsAgent(LocalCloudAgent):
|
||||
self._cloud_endpoint,
|
||||
self._cloud_model,
|
||||
max_uses=ws_max_uses,
|
||||
search_backend=str(cfg.get("search_backend", "provider")).lower(),
|
||||
tavily_max_results=int(cfg.get("tavily_max_results", 5)),
|
||||
)
|
||||
|
||||
if prefetch.get("text"):
|
||||
|
||||
@@ -149,6 +149,17 @@ def _build_router_schema(agent_ids: List[str]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _openai_response_format(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "skillorchestra_route",
|
||||
"schema": schema["format"]["schema"],
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_router_json(text: str) -> Dict[str, Any]:
|
||||
s = (text or "").strip()
|
||||
try:
|
||||
@@ -196,6 +207,70 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
|
||||
agent_id = "skillorchestra"
|
||||
|
||||
def _route_call(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
router_sys: str,
|
||||
router_schema: Dict[str, Any],
|
||||
router_max: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
user = f"Question:\n{question}"
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
kwargs: Dict[str, Any] = {
|
||||
"user": user,
|
||||
"system": router_sys,
|
||||
"max_tokens": router_max,
|
||||
"output_config": router_schema,
|
||||
}
|
||||
if supports_temperature(self._cloud_model):
|
||||
kwargs["temperature"] = 0.0
|
||||
text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
**kwargs,
|
||||
)
|
||||
return text, r_in, r_out
|
||||
if self._cloud_endpoint == "openai":
|
||||
return self._call_openai(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
response_format=_openai_response_format(router_schema),
|
||||
)
|
||||
if self._cloud_endpoint == "gemini":
|
||||
return self._call_gemini(
|
||||
self._cloud_model,
|
||||
user=user,
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
)
|
||||
raise ValueError(
|
||||
f"SkillOrchestra router unsupported cloud_endpoint={self._cloud_endpoint!r}"
|
||||
)
|
||||
|
||||
def _executor_call(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
max_tokens: int,
|
||||
) -> Tuple[str, int, int]:
|
||||
if self._cloud_endpoint == "anthropic":
|
||||
text, w_in, w_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=question,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
return text, w_in, w_out
|
||||
return self._call_cloud(
|
||||
user=question,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
def _is_soft_failure(self, exc: BaseException) -> Optional[str]:
|
||||
# Empty/unbalanced router JSON — treat as soft failure to match the
|
||||
# hybrid adapter's behavior (matches `err=1` rows in the n=30 cell).
|
||||
@@ -220,33 +295,13 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
router_sys = _build_router_sys(competence, cost)
|
||||
router_schema = _build_router_schema(agent_ids)
|
||||
|
||||
# 1. Route — Anthropic only (output_config schema is Anthropic-specific
|
||||
# in the hybrid adapter). If you need OpenAI routing, swap the prompt
|
||||
# to JSON-mode and bypass output_config.
|
||||
if self._cloud_endpoint != "anthropic":
|
||||
raise ValueError(
|
||||
"SkillOrchestra router requires cloud_endpoint='anthropic'; "
|
||||
f"got {self._cloud_endpoint!r}"
|
||||
)
|
||||
router_max = int(cfg.get("router_max_tokens", 1024))
|
||||
# Strip temperature for Opus 4.7+; Anthropic's output_config does the schema.
|
||||
if supports_temperature(self._cloud_model):
|
||||
router_text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=f"Question:\n{question}",
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
temperature=0.0,
|
||||
output_config=router_schema,
|
||||
)
|
||||
else:
|
||||
router_text, r_in, r_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=f"Question:\n{question}",
|
||||
system=router_sys,
|
||||
max_tokens=router_max,
|
||||
output_config=router_schema,
|
||||
)
|
||||
router_text, r_in, r_out = self._route_call(
|
||||
question=question,
|
||||
router_sys=router_sys,
|
||||
router_schema=router_schema,
|
||||
router_max=router_max,
|
||||
)
|
||||
|
||||
decision = _parse_router_json(router_text)
|
||||
skill_weights: Dict[str, float] = decision.get("skill_weights") or {}
|
||||
@@ -329,11 +384,9 @@ class SkillOrchestraAgent(LocalCloudAgent):
|
||||
tokens_cloud += out["tokens_in"] + out["tokens_out"]
|
||||
run_cost += out["cost_usd"]
|
||||
else:
|
||||
ans, w_in, w_out, _ = self._call_anthropic(
|
||||
self._cloud_model,
|
||||
user=question,
|
||||
ans, w_in, w_out = self._executor_call(
|
||||
question=question,
|
||||
max_tokens=int(cfg.get("cloud_max_tokens", 4096)),
|
||||
temperature=0.0,
|
||||
)
|
||||
tokens_cloud += w_in + w_out
|
||||
run_cost += self.cost_usd(self._cloud_model, w_in, w_out)
|
||||
|
||||
@@ -31,7 +31,14 @@ from .stage_router import (
|
||||
get_routing_strategy,
|
||||
parse_skill_analysis,
|
||||
)
|
||||
from .tools import anthropic_tools, openai_tools, run_answer, run_code, run_search
|
||||
from .tools import (
|
||||
anthropic_tools,
|
||||
gemini_tools,
|
||||
openai_tools,
|
||||
run_answer,
|
||||
run_code,
|
||||
run_search,
|
||||
)
|
||||
|
||||
# tool name -> routing stage (stage_router uses "reasoning" for code).
|
||||
_TOOL_STAGE = {
|
||||
@@ -115,10 +122,49 @@ def _orchestrate_step(
|
||||
u = resp.usage
|
||||
p = getattr(u, "prompt_tokens", 0) if u else 0
|
||||
c = getattr(u, "completion_tokens", 0) if u else 0
|
||||
elif endpoint == "gemini":
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
client = genai.Client(
|
||||
http_options=types.HttpOptions(timeout=600_000)
|
||||
)
|
||||
cfg = types.GenerateContentConfig(
|
||||
temperature=1.0,
|
||||
max_output_tokens=max_tokens,
|
||||
tools=[types.Tool(function_declarations=gemini_tools())],
|
||||
)
|
||||
resp = client.models.generate_content(
|
||||
model=model,
|
||||
contents=user,
|
||||
config=cfg,
|
||||
)
|
||||
text = (resp.text or "") if hasattr(resp, "text") else ""
|
||||
tool_calls = []
|
||||
try:
|
||||
parts = resp.candidates[0].content.parts or []
|
||||
except Exception: # noqa: BLE001
|
||||
parts = []
|
||||
for part in parts:
|
||||
fc = getattr(part, "function_call", None)
|
||||
if fc is None:
|
||||
continue
|
||||
name = getattr(fc, "name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
args = getattr(fc, "args", None) or {}
|
||||
try:
|
||||
args = dict(args)
|
||||
except Exception: # noqa: BLE001
|
||||
args = {}
|
||||
tool_calls.append({"name": name, "input": args})
|
||||
um = getattr(resp, "usage_metadata", None)
|
||||
p = int(getattr(um, "prompt_token_count", 0) or 0) if um else 0
|
||||
c = int(getattr(um, "candidates_token_count", 0) or 0) if um else 0
|
||||
else:
|
||||
raise ValueError(
|
||||
f"orchestrator endpoint {endpoint!r} unsupported — route the "
|
||||
"orchestrator through anthropic/openai (set method_cfg."
|
||||
"orchestrator through anthropic/openai/gemini (set method_cfg."
|
||||
"orchestrator_endpoint)."
|
||||
)
|
||||
|
||||
@@ -192,6 +238,8 @@ def run_orchestrator(
|
||||
code_timeout = int(cfg.get("code_timeout_s", 60))
|
||||
answer_max_tokens = int(cfg.get("answer_max_tokens", 40000))
|
||||
ws_max_uses = int(cfg.get("web_search_max_uses", 5))
|
||||
search_backend = str(cfg.get("search_backend", "provider")).lower()
|
||||
tavily_max_results = int(cfg.get("tavily_max_results", 5))
|
||||
|
||||
# The orchestrator model: a fixed model per run (the original's
|
||||
# MODEL_NAME). Defaults to the cell's cloud model when that endpoint
|
||||
@@ -203,7 +251,7 @@ def run_orchestrator(
|
||||
orch_model = (cfg.get("orchestrator_model")
|
||||
or cfg.get("router_model")
|
||||
or agent._cloud_model)
|
||||
if orch_endpoint not in ("anthropic", "openai"):
|
||||
if orch_endpoint not in ("anthropic", "openai", "gemini"):
|
||||
orch_endpoint, orch_model = "anthropic", "claude-opus-4-7"
|
||||
orch_max_tokens = int(cfg.get("orchestrator_max_tokens", 4096))
|
||||
|
||||
@@ -306,6 +354,8 @@ def run_orchestrator(
|
||||
res = run_search(
|
||||
agent, spec, context_str=context_str, problem=problem,
|
||||
retriever_url=retriever_url, web_search_max_uses=ws_max_uses,
|
||||
search_backend=search_backend,
|
||||
tavily_max_results=tavily_max_results,
|
||||
)
|
||||
docs = res["search_results_data"]
|
||||
joined = "\n---\n".join(d for d in docs if d)[:char_cap]
|
||||
|
||||
@@ -27,6 +27,7 @@ from .._base import (
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL,
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
build_web_search_tool,
|
||||
tavily_search_context,
|
||||
)
|
||||
from .pool import ModelSpec, call_alias
|
||||
|
||||
@@ -110,6 +111,26 @@ def openai_tools() -> List[Dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def gemini_tools() -> List[Dict[str, Any]]:
|
||||
"""The 3 orchestrator tools in Gemini function-declaration shape."""
|
||||
out = []
|
||||
for name, desc in (
|
||||
("search", _SEARCH_DESC),
|
||||
("enhance_reasoning", _CODE_DESC),
|
||||
("answer", _ANSWER_DESC),
|
||||
):
|
||||
out.append({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"model": _model_prop(name)},
|
||||
"required": ["model"],
|
||||
},
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enhance_reasoning / code — eval_frames.py:659-812
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -256,6 +277,8 @@ def run_search(
|
||||
retriever_url: Optional[str] = None,
|
||||
topk: int = 150,
|
||||
web_search_max_uses: int = 5,
|
||||
search_backend: str = "provider",
|
||||
tavily_max_results: int = 5,
|
||||
) -> Dict[str, Any]:
|
||||
"""Write a search query with ``spec``, then retrieve documents.
|
||||
|
||||
@@ -283,7 +306,12 @@ def run_search(
|
||||
contents: List[str] = []
|
||||
search_uses = 0
|
||||
|
||||
if retriever_url:
|
||||
if search_backend == "tavily":
|
||||
res = tavily_search_context(query, max_results=tavily_max_results)
|
||||
contents.append(res["text"])
|
||||
search_uses = int(res["n_searches"])
|
||||
cost += float(res["cost_usd"])
|
||||
elif retriever_url:
|
||||
# Faithful path — the original FAISS retriever service.
|
||||
import requests
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ Two modes, gated by ``method_cfg.orchestrator_mode``:
|
||||
(``answer-1``, ``reasoner-2``, ``search-3``, …) is mapped to a real
|
||||
backend through ``EXPERT_MODEL_MAPPING`` — by default the frontier
|
||||
Anthropic worker for `*-1` slots, gpt-5-mini for `*-2`, local Qwen
|
||||
for `*-3`. Search routes to the Anthropic server-side web_search.
|
||||
for `*-3`. Search routes to the configured provider's server-side
|
||||
web-search helper when available.
|
||||
|
||||
We do NOT reproduce the upstream Tavily / FAISS-wiki retriever, the
|
||||
code-interpreter sandbox, or the multi-vLLM mix (Llama-3.3-70B,
|
||||
@@ -43,8 +44,8 @@ Prompted-mode pipeline:
|
||||
prompt; fallback to strongest worker on parse failure.
|
||||
|
||||
Workers come from ``cfg["workers"]`` or a sensible default pool (local
|
||||
Qwen if vLLM up, plus a web-search tool via Anthropic, Opus 4.7,
|
||||
gpt-5-mini).
|
||||
Qwen if vLLM up, plus provider-native web search, the configured frontier
|
||||
cloud model, and gpt-5-mini).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -59,8 +60,11 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
from openjarvis.agents.hybrid._base import (
|
||||
ANTHROPIC_WEB_SEARCH_TOOL,
|
||||
GEMINI_SEARCH_COST_PER_CALL,
|
||||
OPENAI_WEB_SEARCH_COST_PER_CALL,
|
||||
WEB_SEARCH_COST_PER_CALL,
|
||||
LocalCloudAgent,
|
||||
tavily_search_context,
|
||||
)
|
||||
from openjarvis.agents.hybrid._prices import (
|
||||
PRICES,
|
||||
@@ -197,10 +201,23 @@ def _expert_for(slot: str, local_model: Optional[str],
|
||||
cost tier for mid OpenAI calls)
|
||||
- `*-3` (local tier) -> local vLLM (`local_model`)
|
||||
- `answer-math-*` -> same tiers as the numeric suffix
|
||||
- `search-*` -> always the Anthropic web_search tool (the
|
||||
upstream uses Tavily; we have web_search)
|
||||
- `search-*` -> provider-native web search when the cloud
|
||||
endpoint supports it; otherwise Anthropic
|
||||
"""
|
||||
if slot.startswith("search"):
|
||||
ep = (cloud_endpoint or "anthropic").lower()
|
||||
if ep == "openai":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "openai-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
if ep == "gemini":
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "gemini-web-search",
|
||||
"model": cloud_model,
|
||||
}
|
||||
return {
|
||||
"name": f"search:{slot}",
|
||||
"type": "anthropic-web-search",
|
||||
@@ -340,21 +357,18 @@ def _paper_expert_for(
|
||||
|
||||
# ---- Tavily + Modal helpers -------------------------------------------------
|
||||
|
||||
def _call_tavily_search(query: str, max_results: int = 5) -> Tuple[str, int, int]:
|
||||
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0).
|
||||
def _call_tavily_search(
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
) -> Tuple[str, int, int, float, int]:
|
||||
"""One-shot Tavily search. Returns (text, p_tok=0, c_tok=0, cost, uses).
|
||||
|
||||
Token counts are reported as zero (no LLM was billed); the OpenJarvis
|
||||
accounting layer separately tallies tool-call counts. Falls back to
|
||||
DuckDuckGo if Tavily is unreachable (see ``WebSearchTool``).
|
||||
"""
|
||||
from openjarvis.tools.web_search import WebSearchTool
|
||||
|
||||
tool = WebSearchTool(max_results=max_results)
|
||||
res = tool.execute(query=query, max_results=max_results)
|
||||
text = res.content or ""
|
||||
if not res.success and not text:
|
||||
text = "(no results)"
|
||||
return text, 0, 0
|
||||
res = tavily_search_context(query, max_results=max_results)
|
||||
return res["text"], 0, 0, float(res["cost_usd"]), int(res["n_searches"])
|
||||
|
||||
|
||||
_MODAL_APP_NAME = "openjarvis-toolorchestra-sandbox"
|
||||
@@ -674,13 +688,25 @@ def _default_pool(
|
||||
"concise extraction, formatting, arithmetic on given data."
|
||||
),
|
||||
})
|
||||
if ep == "openai":
|
||||
search_type = "openai-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "OpenAI hosted web search on the configured frontier model."
|
||||
elif ep == "gemini":
|
||||
search_type = "gemini-web-search"
|
||||
search_model = cloud_model
|
||||
search_desc = "Gemini Google Search grounding on the configured frontier model."
|
||||
else:
|
||||
search_type = "anthropic-web-search"
|
||||
search_model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
search_desc = "Anthropic server-side web_search."
|
||||
pool.append({
|
||||
"id": len(pool),
|
||||
"name": "web-search",
|
||||
"type": "anthropic-web-search",
|
||||
"model": "claude-haiku-4-5",
|
||||
"type": search_type,
|
||||
"model": search_model,
|
||||
"description": (
|
||||
"Anthropic server-side web_search. Use for facts that need a lookup "
|
||||
f"{search_desc} Use for facts that need a lookup "
|
||||
"(recent events, rare names/dates, niche sources). Returns a digest."
|
||||
),
|
||||
})
|
||||
@@ -717,8 +743,13 @@ def _default_pool(
|
||||
# `modal-python` — One-shot Python exec in a fresh Modal Sandbox (the
|
||||
# paper's "Python sandbox" inside `enhance_reasoning`).
|
||||
_TOOLORCH_VALID_TYPES = (
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search", "gemini",
|
||||
"tavily-search", "openrouter", "modal-python",
|
||||
"vllm", "openai", "anthropic", "anthropic-web-search",
|
||||
"openai-web-search", "gemini", "gemini-web-search", "tavily-search",
|
||||
"openrouter", "modal-python",
|
||||
)
|
||||
_TOOLORCH_SEARCH_TYPES = (
|
||||
"anthropic-web-search", "openai-web-search", "gemini-web-search",
|
||||
"tavily-search",
|
||||
)
|
||||
|
||||
# Default model used when an `anthropic-web-search` entry omits `model`.
|
||||
@@ -739,10 +770,12 @@ def _resolve_worker_pool(
|
||||
the override is absent.
|
||||
|
||||
Each user-supplied entry must be a dict with keys ``id``, ``name``,
|
||||
``type``, and (for non-search types) ``model``. ``type`` must be one
|
||||
of ``vllm`` / ``openai`` / ``anthropic`` / ``anthropic-web-search``.
|
||||
``anthropic-web-search`` entries may omit ``model`` — it defaults to
|
||||
``claude-haiku-4-5``.
|
||||
``type``, and (for non-search types) ``model``. Search worker types are
|
||||
``anthropic-web-search``, ``openai-web-search``, ``gemini-web-search``,
|
||||
and ``tavily-search``. ``anthropic-web-search`` entries may omit
|
||||
``model`` — it defaults to ``claude-haiku-4-5``. OpenAI and Gemini
|
||||
search workers default to the configured cloud model. Tavily does not
|
||||
require a model.
|
||||
|
||||
Substitution: ``model = "$local"`` (or ``"<local>"``) resolves to
|
||||
``local_model``; ``model = "$cloud"`` / ``"<cloud>"`` to ``cloud_model``.
|
||||
@@ -804,14 +837,24 @@ def _resolve_worker_pool(
|
||||
elif isinstance(model, str) and model in ("$cloud", "<cloud>"):
|
||||
model = cloud_model
|
||||
entry["model"] = model
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
if model in (None, ""):
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
if wtype == "anthropic-web-search":
|
||||
model = _DEFAULT_WEB_SEARCH_MODEL
|
||||
elif wtype in ("openai-web-search", "gemini-web-search"):
|
||||
model = cloud_model
|
||||
else:
|
||||
model = wtype
|
||||
entry["model"] = model
|
||||
elif not isinstance(model, str):
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: 'model' must be a string when set"
|
||||
)
|
||||
if wtype in ("openai-web-search", "gemini-web-search") and model not in PRICES:
|
||||
raise ValueError(
|
||||
f"Invalid worker_pool entry [{wid}]: model {model!r} "
|
||||
f"is not in PRICES (known: {sorted(PRICES)})"
|
||||
)
|
||||
# Search workers don't satisfy the "needs a solver" requirement.
|
||||
else:
|
||||
if not isinstance(model, str) or not model:
|
||||
@@ -843,7 +886,7 @@ def _resolve_worker_pool(
|
||||
if not has_non_search:
|
||||
raise ValueError(
|
||||
"Invalid worker_pool entry [-]: worker_pool must contain at least "
|
||||
"one non-search worker (vllm / openai / anthropic)"
|
||||
"one non-search worker (vllm / openai / anthropic / gemini)"
|
||||
)
|
||||
return resolved
|
||||
|
||||
@@ -910,13 +953,31 @@ def _call_worker(
|
||||
)
|
||||
extra = n_searches * WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openai-web-search":
|
||||
eff_temp = 1.0 if is_gpt5_family(worker["model"]) else temp
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_openai_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max(max_tok, 16384) if is_gpt5_family(worker["model"]) else max_tok,
|
||||
temperature=eff_temp,
|
||||
)
|
||||
extra = n_searches * OPENAI_WEB_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "gemini-web-search":
|
||||
text, p, c, n_searches, _ = LocalCloudAgent._call_gemini_agent(
|
||||
worker["model"],
|
||||
user=prompt,
|
||||
max_tokens=max_tok,
|
||||
temperature=temp,
|
||||
)
|
||||
extra = n_searches * GEMINI_SEARCH_COST_PER_CALL
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "tavily-search":
|
||||
# Tavily costs are flat per call; charge `WEB_SEARCH_COST_PER_CALL`
|
||||
# for parity with the Anthropic web-search worker. One call = one
|
||||
# "n_search" for accounting.
|
||||
max_results = int(cfg.get("tavily_max_results", 5))
|
||||
text, p, c = _call_tavily_search(str(prompt), max_results=max_results)
|
||||
return text, p, c, False, WEB_SEARCH_COST_PER_CALL, 1
|
||||
text, p, c, extra, n_searches = _call_tavily_search(
|
||||
str(prompt), max_results=max_results,
|
||||
)
|
||||
return text, p, c, False, extra, n_searches
|
||||
if wtype == "openrouter":
|
||||
text, p, c = LocalCloudAgent._call_openrouter(
|
||||
worker["model"],
|
||||
@@ -951,7 +1012,7 @@ def _swe_call_worker(
|
||||
caller can surface ``tool_calls`` per row. Fallbacks to one-shot
|
||||
workers return 0 bash turns (no agent loop ran)."""
|
||||
wtype = worker.get("type", "openai")
|
||||
if wtype == "anthropic-web-search":
|
||||
if wtype in _TOOLORCH_SEARCH_TYPES:
|
||||
# Search workers stay one-shot.
|
||||
text, p, c, is_local, extra, n_searches = _call_worker(worker, prompt, cfg)
|
||||
return text, p, c, is_local, extra, n_searches, 0
|
||||
@@ -1197,7 +1258,8 @@ class ToolOrchestraAgent(LocalCloudAgent):
|
||||
# Search workers are excluded — they answer fact-lookup
|
||||
# questions, not synthesis.
|
||||
non_search = [
|
||||
w for w in workers if w.get("type") != "anthropic-web-search"
|
||||
w for w in workers
|
||||
if w.get("type") not in _TOOLORCH_SEARCH_TYPES
|
||||
] or workers
|
||||
worker = max(
|
||||
non_search,
|
||||
|
||||
@@ -179,6 +179,19 @@ def chat(
|
||||
|
||||
_notifications = NotificationDispatcher(get_status())
|
||||
|
||||
# Automatic long-term memory — extracts durable facts in the background.
|
||||
memory_service = None
|
||||
try:
|
||||
from openjarvis.memory import build_memory_service
|
||||
|
||||
memory_service = build_memory_service(config, engine, model)
|
||||
if memory_service is not None:
|
||||
memory_service.start()
|
||||
console.print("[dim] Memory: active[/dim]")
|
||||
except Exception as exc:
|
||||
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
|
||||
memory_service = None
|
||||
|
||||
# Conversation state
|
||||
if not system_prompt:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
@@ -266,10 +279,17 @@ def chat(
|
||||
console.print()
|
||||
console.print(Markdown(content))
|
||||
console.print()
|
||||
|
||||
# Hand the exchange to the memory service (non-blocking).
|
||||
if memory_service is not None:
|
||||
memory_service.submit(user_input, content)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Generation interrupted.[/dim]")
|
||||
except Exception as exc:
|
||||
console.print(f"\n[red]Error: {exc}[/red]\n")
|
||||
|
||||
if memory_service is not None:
|
||||
memory_service.stop()
|
||||
|
||||
|
||||
__all__ = ["chat"]
|
||||
|
||||
@@ -223,6 +223,47 @@ def _check_optional_deps() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
def _check_speech_backend() -> CheckResult:
|
||||
"""Check whether the configured speech backend can load."""
|
||||
try:
|
||||
from openjarvis.speech._discovery import get_speech_backend
|
||||
|
||||
config = _get_config()
|
||||
backend = get_speech_backend(config)
|
||||
if backend is None:
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"warn",
|
||||
"Not configured",
|
||||
details="Install desktop dependencies with `uv sync --extra desktop`.",
|
||||
)
|
||||
|
||||
if backend.health():
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"ok",
|
||||
f"{backend.backend_id} ready",
|
||||
)
|
||||
|
||||
details = None
|
||||
last_error = getattr(backend, "last_error", None)
|
||||
if callable(last_error):
|
||||
details = last_error()
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"warn",
|
||||
f"{backend.backend_id} unavailable",
|
||||
details=details
|
||||
or "Install desktop dependencies with `uv sync --extra desktop`.",
|
||||
)
|
||||
except Exception as exc:
|
||||
return CheckResult(
|
||||
"Speech backend",
|
||||
"warn",
|
||||
f"Could not check: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def _check_security_profile() -> CheckResult:
|
||||
"""Check if a security profile is configured."""
|
||||
try:
|
||||
@@ -306,6 +347,7 @@ def _run_all_checks() -> List[CheckResult]:
|
||||
checks.extend(_check_models())
|
||||
checks.append(_check_default_model())
|
||||
checks.extend(_check_optional_deps())
|
||||
checks.append(_check_speech_backend())
|
||||
checks.append(_check_nodejs())
|
||||
checks.append(_check_security_profile())
|
||||
return checks
|
||||
|
||||
@@ -167,6 +167,66 @@ def search(
|
||||
console.print(table)
|
||||
|
||||
|
||||
def _get_fact_store():
|
||||
"""Instantiate the automatic-memory fact store from config."""
|
||||
from openjarvis.memory.store import create_fact_store
|
||||
|
||||
config = load_config()
|
||||
mem = config.memory
|
||||
return create_fact_store(
|
||||
getattr(mem, "backend", "local"),
|
||||
path=getattr(mem, "facts_path", "~/.openjarvis/memory_facts.jsonl"),
|
||||
max_facts=getattr(mem, "max_facts", 1000),
|
||||
)
|
||||
|
||||
|
||||
@memory.command(name="list")
|
||||
def list_facts() -> None:
|
||||
"""List durable facts captured by the automatic memory service."""
|
||||
console = Console()
|
||||
|
||||
store = _get_fact_store()
|
||||
facts = store.list()
|
||||
if not facts:
|
||||
console.print("[yellow]No memory facts stored yet.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title=f"Memory Facts ({len(facts)})")
|
||||
table.add_column("#", style="dim", width=4)
|
||||
table.add_column("Fact")
|
||||
table.add_column("Source", style="cyan")
|
||||
for i, fact in enumerate(facts, 1):
|
||||
table.add_row(str(i), fact.text, fact.source or "-")
|
||||
console.print(table)
|
||||
|
||||
|
||||
@memory.command()
|
||||
@click.option(
|
||||
"--yes",
|
||||
"-y",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip the confirmation prompt.",
|
||||
)
|
||||
def clear(yes: bool) -> None:
|
||||
"""Remove all durable facts captured by the automatic memory service."""
|
||||
console = Console()
|
||||
|
||||
store = _get_fact_store()
|
||||
count = store.count()
|
||||
if count == 0:
|
||||
console.print("[yellow]No memory facts to clear.[/yellow]")
|
||||
return
|
||||
|
||||
if not yes:
|
||||
if not click.confirm(f"Remove all {count} stored memory fact(s)?"):
|
||||
console.print("[dim]Aborted.[/dim]")
|
||||
return
|
||||
|
||||
removed = store.clear()
|
||||
console.print(f"[green]Cleared {removed} memory fact(s).[/green]")
|
||||
|
||||
|
||||
@memory.command()
|
||||
@click.option(
|
||||
"--backend",
|
||||
|
||||
@@ -493,6 +493,19 @@ def serve(
|
||||
except Exception as exc:
|
||||
logger.debug("Memory backend init failed: %s", exc)
|
||||
|
||||
# Automatic long-term memory service (background fact extraction).
|
||||
memory_service = None
|
||||
try:
|
||||
from openjarvis.memory import build_memory_service
|
||||
|
||||
memory_service = build_memory_service(config, engine, model_name)
|
||||
if memory_service is not None:
|
||||
memory_service.start()
|
||||
console.print(" Memory svc: [cyan]active[/cyan]")
|
||||
except Exception as exc:
|
||||
logger.debug("Memory service init failed: %s", exc)
|
||||
memory_service = None
|
||||
|
||||
# Set up agent manager
|
||||
agent_manager = None
|
||||
if config.agent_manager.enabled:
|
||||
@@ -667,6 +680,7 @@ def serve(
|
||||
channel_bridge=channel_bridge,
|
||||
config=config,
|
||||
memory_backend=memory_backend,
|
||||
memory_service=memory_service,
|
||||
speech_backend=speech_backend,
|
||||
agent_manager=agent_manager,
|
||||
agent_scheduler=agent_scheduler,
|
||||
|
||||
@@ -910,7 +910,13 @@ class LearningConfig:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StorageConfig:
|
||||
"""Storage (memory) backend settings."""
|
||||
"""Storage (memory) backend settings.
|
||||
|
||||
Covers both the retrieval/document store (``default_backend``, ``db_path``,
|
||||
chunking, context injection) and the automatic long-term memory service
|
||||
(``enabled``, ``backend``, ``extraction_model``, ``max_facts``,
|
||||
``facts_path``) configured under ``[memory]`` in ``config.toml``.
|
||||
"""
|
||||
|
||||
default_backend: str = "sqlite"
|
||||
db_path: str = field(default_factory=lambda: str(get_config_dir() / "memory.db"))
|
||||
@@ -920,6 +926,14 @@ class StorageConfig:
|
||||
chunk_size: int = 512
|
||||
chunk_overlap: int = 64
|
||||
|
||||
# Automatic memory service — extracts durable facts from conversations in
|
||||
# the background and persists them across sessions (see openjarvis.memory).
|
||||
enabled: bool = False # start the memory service with serve/chat
|
||||
backend: str = "local" # fact-store backend ("local" = on-disk JSONL)
|
||||
extraction_model: str = "" # model for fact extraction ("" = active model)
|
||||
max_facts: int = 1000 # cap on stored facts (oldest evicted past the cap)
|
||||
facts_path: str = str(DEFAULT_CONFIG_DIR / "memory_facts.jsonl")
|
||||
|
||||
|
||||
# Backward-compatibility alias
|
||||
MemoryConfig = StorageConfig
|
||||
@@ -2003,6 +2017,15 @@ context_from_memory = true
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
|
||||
# Automatic long-term memory: extracts durable facts from conversations in the
|
||||
# background and persists them. Starts/stops with `jarvis serve` and
|
||||
# `jarvis chat`; manage stored facts with `jarvis memory list` / `clear`.
|
||||
[memory]
|
||||
enabled = false # set true to enable the memory service
|
||||
backend = "local" # fact-store backend (local = on-disk JSONL)
|
||||
extraction_model = "" # model for fact extraction ("" = active model)
|
||||
max_facts = 1000 # cap on stored facts
|
||||
|
||||
[tools.mcp]
|
||||
enabled = true
|
||||
|
||||
|
||||
@@ -22,6 +22,49 @@ from openjarvis.engine._stubs import StreamChunk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Qwen3 treats ``/think`` and ``/no_think`` as soft-switch control tokens that
|
||||
# toggle reasoning mode. Small models (e.g. qwen3:14b) fed a multi-line prompt
|
||||
# sometimes emit one of these as the sole tool argument, e.g.
|
||||
# ``{"command": "/no_think"}`` instead of the real command. Ollama parses that
|
||||
# into a fully-formed tool_call via the model's chat template, so we have to
|
||||
# drop it on our side before the agent executes garbage.
|
||||
_QWEN_CONTROL_TOKENS = frozenset({"/think", "/no_think"})
|
||||
|
||||
|
||||
def _is_control_token_only_args(raw_args: Any) -> bool:
|
||||
"""Return True if tool-call arguments contain nothing but a Qwen3 token.
|
||||
|
||||
``raw_args`` may be a dict (Ollama's native shape) or a JSON / bare string.
|
||||
A call is considered degenerate only when it carries at least one control
|
||||
token and no other usable content, so legitimate calls such as
|
||||
``{"command": "date"}`` or ``{"command": "echo /no_think"}`` are kept.
|
||||
"""
|
||||
parsed: Any = raw_args
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
parsed = json.loads(raw_args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = raw_args
|
||||
|
||||
if isinstance(parsed, str):
|
||||
return parsed.strip().lower() in _QWEN_CONTROL_TOKENS
|
||||
|
||||
if not isinstance(parsed, dict) or not parsed:
|
||||
return False
|
||||
|
||||
saw_token = False
|
||||
for value in parsed.values():
|
||||
if not isinstance(value, str):
|
||||
return False # a non-string value is real content
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.lower() in _QWEN_CONTROL_TOKENS:
|
||||
saw_token = True
|
||||
else:
|
||||
return False # real string content
|
||||
return saw_token
|
||||
|
||||
|
||||
def _default_num_ctx() -> int:
|
||||
"""Default context window (tokens). Override with ``JARVIS_NUM_CTX``.
|
||||
@@ -168,14 +211,19 @@ class OllamaEngine(InferenceEngine):
|
||||
if raw_tool_calls:
|
||||
tool_calls = []
|
||||
for i, tc in enumerate(raw_tool_calls):
|
||||
raw_args = tc.get("function", {}).get(
|
||||
"arguments",
|
||||
"{}",
|
||||
)
|
||||
fn = tc.get("function", {})
|
||||
raw_args = fn.get("arguments", "{}")
|
||||
if _is_control_token_only_args(raw_args):
|
||||
logger.warning(
|
||||
"Dropping Qwen3 control-token tool call %s(%r)",
|
||||
fn.get("name", ""),
|
||||
raw_args,
|
||||
)
|
||||
continue
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": tc.get("id", f"call_{i}"),
|
||||
"name": tc.get("function", {}).get("name", ""),
|
||||
"name": fn.get("name", ""),
|
||||
"arguments": (
|
||||
json.dumps(raw_args)
|
||||
if isinstance(raw_args, dict)
|
||||
@@ -183,7 +231,8 @@ class OllamaEngine(InferenceEngine):
|
||||
),
|
||||
}
|
||||
)
|
||||
result["tool_calls"] = tool_calls
|
||||
if tool_calls:
|
||||
result["tool_calls"] = tool_calls
|
||||
return result
|
||||
|
||||
async def stream(
|
||||
@@ -340,14 +389,22 @@ class OllamaEngine(InferenceEngine):
|
||||
# OpenAI-delta fragment shape that agent_manager_routes
|
||||
# expects in _merge_tool_call_fragments.
|
||||
fragments: List[Dict[str, Any]] = []
|
||||
for i, tc in enumerate(raw_tool_calls):
|
||||
for tc in raw_tool_calls:
|
||||
fn = tc.get("function", {}) or {}
|
||||
raw_args = fn.get("arguments", "{}")
|
||||
if _is_control_token_only_args(raw_args):
|
||||
logger.warning(
|
||||
"Dropping Qwen3 control-token tool call %s(%r)",
|
||||
fn.get("name", ""),
|
||||
raw_args,
|
||||
)
|
||||
continue
|
||||
args_str = (
|
||||
json.dumps(raw_args)
|
||||
if isinstance(raw_args, dict)
|
||||
else str(raw_args)
|
||||
)
|
||||
i = len(fragments)
|
||||
fragments.append(
|
||||
{
|
||||
"index": i,
|
||||
@@ -359,8 +416,9 @@ class OllamaEngine(InferenceEngine):
|
||||
},
|
||||
}
|
||||
)
|
||||
yield StreamChunk(tool_calls=fragments)
|
||||
finish_reason = "tool_calls"
|
||||
if fragments:
|
||||
yield StreamChunk(tool_calls=fragments)
|
||||
finish_reason = "tool_calls"
|
||||
|
||||
if chunk.get("done", False):
|
||||
reported_prompt = chunk.get("prompt_eval_count", 0)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Native persistent long-term memory for OpenJarvis.
|
||||
|
||||
This package provides the automatic memory service that extracts durable facts
|
||||
from conversations in the background and persists them across sessions. It is
|
||||
started and stopped as part of the ``jarvis serve`` / ``jarvis chat`` lifecycle
|
||||
and configured via the ``[memory]`` section of ``config.toml``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.memory.extractor import FactExtractor
|
||||
from openjarvis.memory.service import MemoryService, build_memory_service
|
||||
from openjarvis.memory.store import (
|
||||
Fact,
|
||||
FactStore,
|
||||
LocalFactStore,
|
||||
create_fact_store,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Fact",
|
||||
"FactStore",
|
||||
"FactExtractor",
|
||||
"LocalFactStore",
|
||||
"MemoryService",
|
||||
"build_memory_service",
|
||||
"create_fact_store",
|
||||
]
|
||||
@@ -0,0 +1,150 @@
|
||||
"""LLM-backed extraction of durable facts from a conversation turn.
|
||||
|
||||
The extractor takes a single (user, assistant) exchange and asks a small
|
||||
local model to distill any long-term, user-specific facts worth remembering.
|
||||
It is deliberately defensive: extraction runs on a background thread far from
|
||||
the request path, so *any* failure — a dropped Ollama connection, a timeout, a
|
||||
``BrokenPipeError`` when the client went away, or simply unparseable output —
|
||||
must degrade to "no facts" rather than propagate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_SYSTEM_PROMPT = (
|
||||
"You extract durable, long-term facts about the user from a single "
|
||||
"conversation exchange. A good fact is stable over time and useful in "
|
||||
"future conversations: preferences, identity, goals, ongoing projects, "
|
||||
"constraints, or relationships. Ignore one-off task details, small talk, "
|
||||
"and anything the assistant said about itself.\n\n"
|
||||
"Respond with ONLY a JSON array of short fact strings (each under 200 "
|
||||
"characters). If there is nothing worth remembering, respond with []."
|
||||
)
|
||||
|
||||
|
||||
class FactExtractor:
|
||||
"""Extract memory-worthy facts from a conversation turn via an engine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: Any,
|
||||
model: str,
|
||||
*,
|
||||
temperature: float = 0.0,
|
||||
max_tokens: int = 512,
|
||||
max_facts_per_turn: int = 10,
|
||||
max_fact_chars: int = 200,
|
||||
system_prompt: Optional[str] = None,
|
||||
) -> None:
|
||||
self._engine = engine
|
||||
self._model = model
|
||||
self._temperature = temperature
|
||||
self._max_tokens = max_tokens
|
||||
self._max_facts_per_turn = max_facts_per_turn
|
||||
self._max_fact_chars = max_fact_chars
|
||||
self._system_prompt = system_prompt or _DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
def extract(self, user_text: str, assistant_text: str = "") -> List[str]:
|
||||
"""Return durable facts from the exchange. Never raises."""
|
||||
user_text = (user_text or "").strip()
|
||||
if not user_text:
|
||||
return []
|
||||
|
||||
exchange = f"User: {user_text}"
|
||||
if assistant_text and assistant_text.strip():
|
||||
exchange += f"\nAssistant: {assistant_text.strip()}"
|
||||
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content=self._system_prompt),
|
||||
Message(role=Role.USER, content=exchange),
|
||||
]
|
||||
|
||||
try:
|
||||
result = self._engine.generate(
|
||||
messages,
|
||||
model=self._model,
|
||||
temperature=self._temperature,
|
||||
max_tokens=self._max_tokens,
|
||||
)
|
||||
except BrokenPipeError:
|
||||
# The classic failure mode: the model call's transport died.
|
||||
# Extraction is best-effort, so swallow it.
|
||||
logger.debug("Memory extraction aborted: broken pipe", exc_info=True)
|
||||
return []
|
||||
except Exception: # noqa: BLE001 — extraction must never crash the worker
|
||||
logger.debug("Memory extraction failed", exc_info=True)
|
||||
return []
|
||||
|
||||
if isinstance(result, dict):
|
||||
content = result.get("content", "") or ""
|
||||
else:
|
||||
content = str(result)
|
||||
|
||||
return self._parse(content)
|
||||
|
||||
# -- parsing ------------------------------------------------------------
|
||||
|
||||
def _parse(self, content: str) -> List[str]:
|
||||
"""Parse model output into a clean, deduped, capped list of facts."""
|
||||
if not content or not content.strip():
|
||||
return []
|
||||
|
||||
raw = self._coerce_to_list(content)
|
||||
|
||||
facts: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw:
|
||||
fact = self._clean_fact(item)
|
||||
if not fact:
|
||||
continue
|
||||
key = fact.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
facts.append(fact)
|
||||
if len(facts) >= self._max_facts_per_turn:
|
||||
break
|
||||
return facts
|
||||
|
||||
def _coerce_to_list(self, content: str) -> List[str]:
|
||||
"""Best-effort conversion of model output to a list of strings."""
|
||||
# 1. Try to locate and parse a JSON array anywhere in the output
|
||||
# (models often wrap it in prose or code fences).
|
||||
match = re.search(r"\[.*\]", content, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(0))
|
||||
if isinstance(parsed, list):
|
||||
return [str(x) for x in parsed]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# 2. Fall back to line-based parsing (markdown bullets / numbered).
|
||||
items: List[str] = []
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
line = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", line)
|
||||
items.append(line)
|
||||
return items
|
||||
|
||||
def _clean_fact(self, item: str) -> str:
|
||||
fact = str(item).strip().strip("\"'").strip()
|
||||
# Drop obvious non-facts the model sometimes emits.
|
||||
if not fact or fact.lower() in ("[]", "none", "n/a", "null"):
|
||||
return ""
|
||||
if len(fact) > self._max_fact_chars:
|
||||
fact = fact[: self._max_fact_chars].rstrip()
|
||||
return fact
|
||||
|
||||
|
||||
__all__ = ["FactExtractor"]
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Persistent memory service: async fact extraction integrated into core.
|
||||
|
||||
``MemoryService`` runs fact extraction on a dedicated background thread so it
|
||||
never blocks ``jarvis serve`` request handling or the ``jarvis chat`` REPL.
|
||||
Callers hand off an exchange via :meth:`submit`, which enqueues the work and
|
||||
returns immediately — the slow model call and disk write happen out of band.
|
||||
The worker swallows every per-job error (including ``BrokenPipeError`` when a
|
||||
client disconnects mid-extraction), so a flaky extraction model can never take
|
||||
down the host process.
|
||||
|
||||
The service is started and stopped as part of the OpenJarvis lifecycle (see
|
||||
``cli/serve.py`` and ``cli/chat_cmd.py``) and is configured through the
|
||||
``[memory]`` section of ``config.toml``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.memory.extractor import FactExtractor
|
||||
from openjarvis.memory.store import Fact, FactStore, create_fact_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel pushed onto the queue to wake the worker for shutdown.
|
||||
_STOP = object()
|
||||
|
||||
|
||||
class MemoryService:
|
||||
"""Background long-term-memory extraction and persistence service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: FactStore,
|
||||
extractor: FactExtractor,
|
||||
*,
|
||||
max_queue: int = 256,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._extractor = extractor
|
||||
self._queue: "queue.Queue[Any]" = queue.Queue(maxsize=max(1, max_queue))
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._running = threading.Event()
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background worker thread (idempotent)."""
|
||||
if self._running.is_set():
|
||||
return
|
||||
self._running.set()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop,
|
||||
name="memory-service",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
logger.debug("Memory service started")
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
"""Signal the worker to drain and stop, then join it (idempotent)."""
|
||||
if not self._running.is_set():
|
||||
return
|
||||
self._running.clear()
|
||||
try:
|
||||
self._queue.put_nowait(_STOP)
|
||||
except queue.Full:
|
||||
pass # worker will notice the cleared flag on its next loop
|
||||
thread = self._thread
|
||||
if thread is not None:
|
||||
thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
logger.debug("Memory service stopped")
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running.is_set()
|
||||
|
||||
# -- submission ---------------------------------------------------------
|
||||
|
||||
def submit(self, user_text: str, assistant_text: str = "") -> bool:
|
||||
"""Queue an exchange for extraction. Non-blocking; never raises.
|
||||
|
||||
Returns True if the job was enqueued, False if the service is not
|
||||
running or the queue is full (in which case the exchange is dropped
|
||||
rather than blocking the caller — extraction is best-effort).
|
||||
"""
|
||||
if not self._running.is_set():
|
||||
return False
|
||||
if not user_text or not user_text.strip():
|
||||
return False
|
||||
try:
|
||||
self._queue.put_nowait((user_text, assistant_text))
|
||||
return True
|
||||
except queue.Full:
|
||||
logger.debug("Memory service queue full; dropping exchange")
|
||||
return False
|
||||
|
||||
# -- worker -------------------------------------------------------------
|
||||
|
||||
def _loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
job = self._queue.get(timeout=0.5)
|
||||
except queue.Empty:
|
||||
if not self._running.is_set():
|
||||
break
|
||||
continue
|
||||
if job is _STOP:
|
||||
self._queue.task_done()
|
||||
break
|
||||
try:
|
||||
self._process(job)
|
||||
except Exception: # noqa: BLE001 — a bad job must not kill the worker
|
||||
logger.debug("Memory extraction job failed", exc_info=True)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
if not self._running.is_set() and self._queue.empty():
|
||||
break
|
||||
|
||||
def _process(self, job: Any) -> None:
|
||||
user_text, assistant_text = job
|
||||
facts = self._extractor.extract(user_text, assistant_text)
|
||||
if facts:
|
||||
stored = self._store.add_many(facts, source="auto")
|
||||
if stored:
|
||||
logger.debug("Memory service stored %d new fact(s)", stored)
|
||||
|
||||
# -- store passthroughs -------------------------------------------------
|
||||
|
||||
def list_facts(self) -> List[Fact]:
|
||||
return self._store.list()
|
||||
|
||||
def clear_facts(self) -> int:
|
||||
return self._store.clear()
|
||||
|
||||
def fact_count(self) -> int:
|
||||
return self._store.count()
|
||||
|
||||
|
||||
def build_memory_service(
|
||||
config: Any,
|
||||
engine: Any,
|
||||
default_model: str = "",
|
||||
) -> Optional[MemoryService]:
|
||||
"""Build a :class:`MemoryService` from config, or ``None`` if disabled.
|
||||
|
||||
Reads the ``[memory]`` section (``config.memory`` / ``config.tools.storage``)
|
||||
for ``enabled``, ``backend``, ``extraction_model``, ``max_facts`` and
|
||||
``facts_path``. Returns ``None`` when memory is disabled or no engine /
|
||||
extraction model is available, so callers can simply do::
|
||||
|
||||
svc = build_memory_service(config, engine, model)
|
||||
if svc is not None:
|
||||
svc.start()
|
||||
"""
|
||||
mem = getattr(config, "memory", None)
|
||||
if mem is None or not getattr(mem, "enabled", False):
|
||||
return None
|
||||
if engine is None:
|
||||
return None
|
||||
|
||||
model = getattr(mem, "extraction_model", "") or default_model
|
||||
if not model:
|
||||
logger.debug("Memory service disabled: no extraction model available")
|
||||
return None
|
||||
|
||||
store = create_fact_store(
|
||||
getattr(mem, "backend", "local"),
|
||||
path=getattr(mem, "facts_path", "~/.openjarvis/memory_facts.jsonl"),
|
||||
max_facts=getattr(mem, "max_facts", 1000),
|
||||
)
|
||||
extractor = FactExtractor(engine, model)
|
||||
return MemoryService(store, extractor)
|
||||
|
||||
|
||||
__all__ = ["MemoryService", "build_memory_service"]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Persistent stores for automatically extracted long-term memory facts.
|
||||
|
||||
A *fact* is a short, durable statement worth remembering about the user
|
||||
(e.g. ``"Prefers concise answers"``). Facts are produced by the memory
|
||||
service's background extractor and persisted here so they survive across
|
||||
sessions. The store is intentionally small and self-contained: it dedupes,
|
||||
caps the total number of facts, and is safe to call from multiple threads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Fact:
|
||||
"""A single durable memory entry."""
|
||||
|
||||
text: str
|
||||
source: str = ""
|
||||
created_at: float = 0.0
|
||||
|
||||
|
||||
class FactStore(ABC):
|
||||
"""Abstract persistent store for extracted memory facts."""
|
||||
|
||||
@abstractmethod
|
||||
def add(self, text: str, source: str = "") -> bool:
|
||||
"""Store *text* as a fact. Returns True if a new fact was stored."""
|
||||
|
||||
def add_many(self, texts: Iterable[str], source: str = "") -> int:
|
||||
"""Store several facts, returning the count of newly stored ones."""
|
||||
added = 0
|
||||
for text in texts:
|
||||
if self.add(text, source=source):
|
||||
added += 1
|
||||
return added
|
||||
|
||||
@abstractmethod
|
||||
def list(self) -> List[Fact]:
|
||||
"""Return all stored facts, oldest first."""
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> int:
|
||||
"""Remove all stored facts, returning the number removed."""
|
||||
|
||||
@abstractmethod
|
||||
def count(self) -> int:
|
||||
"""Return the number of stored facts."""
|
||||
|
||||
|
||||
class LocalFactStore(FactStore):
|
||||
"""Append-only JSONL fact store on the local filesystem.
|
||||
|
||||
Facts are kept human-readable (one JSON object per line) so they can be
|
||||
inspected or edited by hand. Writes are atomic (temp file + rename) and
|
||||
guarded by a lock, so concurrent ``add`` calls from the extraction worker
|
||||
and ``list``/``clear`` from the CLI never corrupt the file.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
|
||||
*,
|
||||
max_facts: int = 1000,
|
||||
) -> None:
|
||||
self._path = Path(path).expanduser()
|
||||
self._max_facts = max(0, int(max_facts))
|
||||
self._lock = threading.Lock()
|
||||
self._facts: List[Fact] = self._load()
|
||||
|
||||
# -- persistence --------------------------------------------------------
|
||||
|
||||
def _load(self) -> List[Fact]:
|
||||
if not self._path.exists():
|
||||
return []
|
||||
facts: List[Fact] = []
|
||||
try:
|
||||
text = self._path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue # skip malformed lines rather than crashing
|
||||
fact_text = str(obj.get("text", "")).strip()
|
||||
if not fact_text:
|
||||
continue
|
||||
facts.append(
|
||||
Fact(
|
||||
text=fact_text,
|
||||
source=str(obj.get("source", "")),
|
||||
created_at=float(obj.get("created_at", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return facts
|
||||
|
||||
def _flush(self) -> None:
|
||||
"""Atomically rewrite the JSONL file from the in-memory list."""
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
payload = "".join(
|
||||
json.dumps(asdict(f), ensure_ascii=False) + "\n" for f in self._facts
|
||||
)
|
||||
tmp.write_text(payload, encoding="utf-8")
|
||||
os.replace(tmp, self._path)
|
||||
|
||||
# -- FactStore API ------------------------------------------------------
|
||||
|
||||
def add(self, text: str, source: str = "") -> bool:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
with self._lock:
|
||||
lowered = text.lower()
|
||||
if any(f.text.lower() == lowered for f in self._facts):
|
||||
return False # dedupe
|
||||
self._facts.append(Fact(text=text, source=source, created_at=time.time()))
|
||||
# Enforce the cap by evicting the oldest entries.
|
||||
if self._max_facts and len(self._facts) > self._max_facts:
|
||||
self._facts = self._facts[-self._max_facts :]
|
||||
self._flush()
|
||||
return True
|
||||
|
||||
def list(self) -> List[Fact]:
|
||||
with self._lock:
|
||||
return list(self._facts)
|
||||
|
||||
def clear(self) -> int:
|
||||
with self._lock:
|
||||
removed = len(self._facts)
|
||||
self._facts = []
|
||||
if self._path.exists():
|
||||
try:
|
||||
self._path.unlink()
|
||||
except OSError:
|
||||
self._flush()
|
||||
return removed
|
||||
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._facts)
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Filesystem location of the JSONL store."""
|
||||
return self._path
|
||||
|
||||
|
||||
def create_fact_store(
|
||||
backend: str = "local",
|
||||
*,
|
||||
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
|
||||
max_facts: int = 1000,
|
||||
) -> FactStore:
|
||||
"""Construct a fact store for the configured *backend*.
|
||||
|
||||
Only the ``"local"`` (on-disk JSONL) backend is supported today; the
|
||||
factory exists so additional backends can be added without changing the
|
||||
service or CLI wiring.
|
||||
"""
|
||||
key = (backend or "local").strip().lower()
|
||||
if key == "local":
|
||||
return LocalFactStore(path, max_facts=max_facts)
|
||||
raise ValueError(f"Unknown memory backend '{backend}'. Supported backends: local")
|
||||
|
||||
|
||||
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
|
||||
@@ -893,7 +893,15 @@ async def transcribe_speech(request: Request):
|
||||
filename = getattr(audio_file, "filename", "audio.wav")
|
||||
ext = filename.rsplit(".", 1)[-1] if "." in filename else "wav"
|
||||
|
||||
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
|
||||
try:
|
||||
result = backend.transcribe(audio_bytes, format=ext, language=language or None)
|
||||
except Exception as exc:
|
||||
logger.exception("Speech transcription failed")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Speech transcription failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return {
|
||||
"text": result.text,
|
||||
"language": result.language,
|
||||
@@ -908,9 +916,23 @@ async def speech_health(request: Request):
|
||||
backend = getattr(request.app.state, "speech_backend", None)
|
||||
if backend is None:
|
||||
return {"available": False, "reason": "No speech backend configured"}
|
||||
try:
|
||||
available = backend.health()
|
||||
reason = None
|
||||
except Exception as exc:
|
||||
logger.exception("Speech health check failed")
|
||||
available = False
|
||||
reason = str(exc)
|
||||
|
||||
if not available and reason is None:
|
||||
last_error = getattr(backend, "last_error", None)
|
||||
if callable(last_error):
|
||||
reason = last_error()
|
||||
|
||||
return {
|
||||
"available": backend.health(),
|
||||
"available": available,
|
||||
"backend": backend.backend_id,
|
||||
**({"reason": reason} if reason else {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ def create_app(
|
||||
channel_bridge=None,
|
||||
config=None,
|
||||
memory_backend=None,
|
||||
memory_service=None,
|
||||
speech_backend=None,
|
||||
agent_manager=None,
|
||||
agent_scheduler=None,
|
||||
@@ -221,6 +222,7 @@ def create_app(
|
||||
app.state.channel_bridge = channel_bridge
|
||||
app.state.config = config
|
||||
app.state.memory_backend = memory_backend
|
||||
app.state.memory_service = memory_service
|
||||
app.state.speech_backend = speech_backend
|
||||
app.state.agent_manager = agent_manager
|
||||
app.state.agent_scheduler = agent_scheduler
|
||||
@@ -292,6 +294,18 @@ def create_app(
|
||||
except Exception as _exc:
|
||||
logger.debug("Analytics init skipped: %s", _exc)
|
||||
|
||||
# Stop the background memory service cleanly when the server shuts down.
|
||||
if memory_service is not None:
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown_memory_service() -> None:
|
||||
svc = getattr(app.state, "memory_service", None)
|
||||
if svc is not None:
|
||||
try:
|
||||
svc.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app.include_router(router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(comparison_router)
|
||||
|
||||
@@ -223,7 +223,7 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
# the agent to execute them), add an explicit opt-in header rather
|
||||
# than removing this guard — silent re-routing is what produced #414.
|
||||
if agent is not None and not request_body.tools:
|
||||
return _handle_agent(
|
||||
response = _handle_agent(
|
||||
agent,
|
||||
model,
|
||||
request_body,
|
||||
@@ -231,16 +231,41 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
trace_store=getattr(request.app.state, "trace_store", None),
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
)
|
||||
else:
|
||||
bus = getattr(request.app.state, "bus", None)
|
||||
response = _handle_direct(
|
||||
engine,
|
||||
model,
|
||||
request_body,
|
||||
bus=bus,
|
||||
complexity_info=complexity_info,
|
||||
app_config=config,
|
||||
)
|
||||
|
||||
bus = getattr(request.app.state, "bus", None)
|
||||
return _handle_direct(
|
||||
engine,
|
||||
model,
|
||||
request_body,
|
||||
bus=bus,
|
||||
complexity_info=complexity_info,
|
||||
app_config=config,
|
||||
# Hand the completed exchange to the background memory service.
|
||||
_remember_exchange(
|
||||
getattr(request.app.state, "memory_service", None),
|
||||
query_text_for_complexity,
|
||||
response,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _remember_exchange(memory_service, user_text: str, response) -> None:
|
||||
"""Submit a completed exchange to the memory service (non-blocking)."""
|
||||
if memory_service is None or not user_text:
|
||||
return
|
||||
try:
|
||||
content = ""
|
||||
choices = getattr(response, "choices", None)
|
||||
if choices:
|
||||
content = getattr(choices[0].message, "content", "") or ""
|
||||
memory_service.submit(user_text, content)
|
||||
except Exception: # noqa: BLE001 — memory is best-effort, never fail a reply
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Memory submit failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _handle_direct(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -13,6 +14,13 @@ try:
|
||||
except ImportError:
|
||||
WhisperModel = None # type: ignore[assignment, misc]
|
||||
|
||||
try:
|
||||
import ctranslate2
|
||||
except ImportError:
|
||||
ctranslate2 = None # type: ignore[assignment]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@SpeechRegistry.register("faster-whisper")
|
||||
class FasterWhisperBackend(SpeechBackend):
|
||||
@@ -30,20 +38,60 @@ class FasterWhisperBackend(SpeechBackend):
|
||||
self._device = device
|
||||
self._compute_type = compute_type
|
||||
self._model: Optional[WhisperModel] = None
|
||||
self._last_error: Optional[str] = None
|
||||
|
||||
def _resolve_compute_type(self) -> str:
|
||||
"""Pick a CTranslate2 compute type supported by the configured device."""
|
||||
if ctranslate2 is None:
|
||||
return self._compute_type
|
||||
|
||||
try:
|
||||
supported = set(ctranslate2.get_supported_compute_types(self._device))
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not inspect CTranslate2 compute types for %s: %s",
|
||||
self._device,
|
||||
exc,
|
||||
)
|
||||
return self._compute_type
|
||||
|
||||
if self._compute_type in supported:
|
||||
return self._compute_type
|
||||
|
||||
preferences = (
|
||||
("int8", "float32", "int8_float32", "int16")
|
||||
if self._compute_type == "float16"
|
||||
else ("float32", "int8", "int8_float32", "int16")
|
||||
)
|
||||
fallback = next((value for value in preferences if value in supported), None)
|
||||
if fallback is None:
|
||||
return self._compute_type
|
||||
|
||||
logger.warning(
|
||||
"CTranslate2 compute_type=%r is not supported on device=%r; "
|
||||
"using %r instead",
|
||||
self._compute_type,
|
||||
self._device,
|
||||
fallback,
|
||||
)
|
||||
return fallback
|
||||
|
||||
def _ensure_model(self) -> WhisperModel:
|
||||
"""Lazy-load the Whisper model on first use."""
|
||||
if self._model is None:
|
||||
if WhisperModel is None:
|
||||
raise ImportError(
|
||||
self._last_error = (
|
||||
"faster-whisper is not installed. "
|
||||
"Install with: uv sync --extra speech"
|
||||
"Install with: uv sync --extra desktop"
|
||||
)
|
||||
raise ImportError(self._last_error)
|
||||
compute_type = self._resolve_compute_type()
|
||||
self._model = WhisperModel(
|
||||
self._model_size,
|
||||
device=self._device,
|
||||
compute_type=self._compute_type,
|
||||
compute_type=compute_type,
|
||||
)
|
||||
self._last_error = None
|
||||
return self._model
|
||||
|
||||
def transcribe(
|
||||
@@ -54,20 +102,24 @@ class FasterWhisperBackend(SpeechBackend):
|
||||
language: Optional[str] = None,
|
||||
) -> TranscriptionResult:
|
||||
"""Transcribe audio bytes using Faster-Whisper."""
|
||||
model = self._ensure_model()
|
||||
try:
|
||||
model = self._ensure_model()
|
||||
|
||||
# Write audio to a temp file (faster-whisper needs a file path)
|
||||
suffix = f".{format}" if not format.startswith(".") else format
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
|
||||
tmp.write(audio)
|
||||
tmp.flush()
|
||||
# Write audio to a temp file (faster-whisper needs a file path)
|
||||
suffix = f".{format}" if not format.startswith(".") else format
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
|
||||
tmp.write(audio)
|
||||
tmp.flush()
|
||||
|
||||
kwargs = {}
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
kwargs = {}
|
||||
if language:
|
||||
kwargs["language"] = language
|
||||
|
||||
segments_iter, info = model.transcribe(tmp.name, **kwargs)
|
||||
segments_list = list(segments_iter)
|
||||
segments_iter, info = model.transcribe(tmp.name, **kwargs)
|
||||
segments_list = list(segments_iter)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
raise
|
||||
|
||||
# Build result
|
||||
text = "".join(seg.text for seg in segments_list).strip()
|
||||
@@ -81,6 +133,7 @@ class FasterWhisperBackend(SpeechBackend):
|
||||
for seg in segments_list
|
||||
]
|
||||
|
||||
self._last_error = None
|
||||
return TranscriptionResult(
|
||||
text=text,
|
||||
language=getattr(info, "language", None),
|
||||
@@ -91,9 +144,17 @@ class FasterWhisperBackend(SpeechBackend):
|
||||
|
||||
def health(self) -> bool:
|
||||
"""Check if model is loaded or loadable."""
|
||||
if self._model is not None:
|
||||
try:
|
||||
self._ensure_model()
|
||||
return True
|
||||
return WhisperModel is not None
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.debug("Faster-Whisper health check failed: %s", exc)
|
||||
return False
|
||||
|
||||
def last_error(self) -> Optional[str]:
|
||||
"""Return the last model load or transcription error, if any."""
|
||||
return self._last_error
|
||||
|
||||
def supported_formats(self) -> List[str]:
|
||||
"""Supported audio formats (same as ffmpeg/Whisper)."""
|
||||
|
||||
@@ -165,7 +165,10 @@ class WebSearchTool(BaseTool):
|
||||
|
||||
client = TavilyClient(api_key=self._api_key)
|
||||
response = client.search(
|
||||
query, max_results=max_results, search_depth="advanced"
|
||||
query,
|
||||
max_results=max_results,
|
||||
search_depth="advanced",
|
||||
include_usage=True,
|
||||
)
|
||||
results = response.get("results", [])
|
||||
formatted_parts = []
|
||||
@@ -182,7 +185,11 @@ class WebSearchTool(BaseTool):
|
||||
tool_name="web_search",
|
||||
content=formatted or "No results found.",
|
||||
success=True,
|
||||
metadata={"num_results": len(results), "engine": "tavily"},
|
||||
metadata={
|
||||
"num_results": len(results),
|
||||
"engine": "tavily",
|
||||
"credits": (response.get("usage") or {}).get("credits"),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
|
||||
@@ -120,6 +120,54 @@ class TestChatAgents:
|
||||
assert "simple ok" in result.output
|
||||
assert "failed" not in result.output.lower()
|
||||
|
||||
def test_memory_service_started_fed_and_stopped(self) -> None:
|
||||
"""The REPL starts the memory service, submits each turn, and stops it."""
|
||||
|
||||
class _SpyMemoryService:
|
||||
def __init__(self) -> None:
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
self.submissions: list[tuple[str, str]] = []
|
||||
|
||||
def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
def submit(self, user_text: str, assistant_text: str = "") -> bool:
|
||||
self.submissions.append((user_text, assistant_text))
|
||||
return True
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
self.stopped = True
|
||||
|
||||
spy = _SpyMemoryService()
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = {"content": "engine fallback"}
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
|
||||
AgentRegistry.register_value("simple_chat_agent", _SimpleChatAgent)
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch(
|
||||
"openjarvis.memory.build_memory_service",
|
||||
return_value=spy,
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--agent", "simple_chat_agent", "--model", "test-model"],
|
||||
input="hello\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert spy.started is True
|
||||
assert spy.stopped is True
|
||||
assert spy.submissions == [("hello", "simple ok")]
|
||||
|
||||
def test_tool_agent_uses_legacy_agent_tools_and_prompts_confirmation(self) -> None:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
|
||||
@@ -10,10 +10,12 @@ from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
from openjarvis.cli.doctor_cmd import (
|
||||
CheckResult,
|
||||
_check_config_exists,
|
||||
_check_default_model,
|
||||
_check_nodejs,
|
||||
_check_python_version,
|
||||
_check_speech_backend,
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +41,10 @@ class TestDoctorRuns:
|
||||
),
|
||||
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
|
||||
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd._check_speech_backend",
|
||||
return_value=CheckResult("Speech backend", "ok", "mock ready"),
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["doctor"])
|
||||
assert result.exit_code == 0
|
||||
@@ -59,6 +65,10 @@ class TestDoctorJsonOutput:
|
||||
),
|
||||
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
|
||||
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd._check_speech_backend",
|
||||
return_value=CheckResult("Speech backend", "ok", "mock ready"),
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["doctor", "--json"])
|
||||
assert result.exit_code == 0
|
||||
@@ -142,6 +152,49 @@ class TestCheckDefaultModel:
|
||||
assert "auto" in result.message.lower()
|
||||
|
||||
|
||||
class TestCheckSpeechBackend:
|
||||
def test_check_speech_backend_ready(self) -> None:
|
||||
backend = MagicMock()
|
||||
backend.backend_id = "faster-whisper"
|
||||
backend.health.return_value = True
|
||||
|
||||
with patch(
|
||||
"openjarvis.speech._discovery.get_speech_backend",
|
||||
return_value=backend,
|
||||
):
|
||||
result = _check_speech_backend()
|
||||
|
||||
assert result.status == "ok"
|
||||
assert "faster-whisper" in result.message
|
||||
|
||||
def test_check_speech_backend_reports_load_error(self) -> None:
|
||||
backend = MagicMock()
|
||||
backend.backend_id = "faster-whisper"
|
||||
backend.health.return_value = False
|
||||
backend.last_error.return_value = "missing cublas64_12.dll"
|
||||
|
||||
with patch(
|
||||
"openjarvis.speech._discovery.get_speech_backend",
|
||||
return_value=backend,
|
||||
):
|
||||
result = _check_speech_backend()
|
||||
|
||||
assert result.status == "warn"
|
||||
assert "faster-whisper unavailable" in result.message
|
||||
assert result.details == "missing cublas64_12.dll"
|
||||
|
||||
def test_check_speech_backend_missing_uses_desktop_hint(self) -> None:
|
||||
with patch(
|
||||
"openjarvis.speech._discovery.get_speech_backend",
|
||||
return_value=None,
|
||||
):
|
||||
result = _check_speech_backend()
|
||||
|
||||
assert result.status == "warn"
|
||||
assert result.details is not None
|
||||
assert "uv sync --extra desktop" in result.details
|
||||
|
||||
|
||||
class TestCheckNodejs:
|
||||
def test_check_nodejs_found(self) -> None:
|
||||
"""Node.js check reports version when node is available."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
from openjarvis.memory.store import LocalFactStore
|
||||
from openjarvis.tools.storage.sqlite import SQLiteMemory
|
||||
|
||||
|
||||
@@ -108,3 +109,67 @@ def test_memory_stats_shows_count(tmp_path: Path, monkeypatch):
|
||||
assert result.exit_code == 0
|
||||
assert "2" in result.output
|
||||
backend.close()
|
||||
|
||||
|
||||
def _patch_fact_store(monkeypatch, tmp_path: Path) -> LocalFactStore:
|
||||
"""Point ``jarvis memory list/clear`` at a temp fact store."""
|
||||
mod = importlib.import_module("openjarvis.cli.memory_cmd")
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
monkeypatch.setattr(mod, "_get_fact_store", lambda: store)
|
||||
return store
|
||||
|
||||
|
||||
def test_memory_list_empty(tmp_path: Path, monkeypatch):
|
||||
_patch_fact_store(monkeypatch, tmp_path)
|
||||
result = CliRunner().invoke(cli, ["memory", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No memory facts" in result.output
|
||||
|
||||
|
||||
def test_memory_list_shows_facts(tmp_path: Path, monkeypatch):
|
||||
store = _patch_fact_store(monkeypatch, tmp_path)
|
||||
store.add("User prefers dark mode")
|
||||
store.add("User lives in Berlin")
|
||||
|
||||
result = CliRunner().invoke(cli, ["memory", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "dark mode" in result.output
|
||||
assert "Berlin" in result.output
|
||||
|
||||
|
||||
def test_memory_clear_with_confirmation(tmp_path: Path, monkeypatch):
|
||||
store = _patch_fact_store(monkeypatch, tmp_path)
|
||||
store.add("fact one")
|
||||
store.add("fact two")
|
||||
|
||||
result = CliRunner().invoke(cli, ["memory", "clear"], input="y\n")
|
||||
assert result.exit_code == 0
|
||||
assert "Cleared 2" in result.output
|
||||
assert store.count() == 0
|
||||
|
||||
|
||||
def test_memory_clear_aborted(tmp_path: Path, monkeypatch):
|
||||
store = _patch_fact_store(monkeypatch, tmp_path)
|
||||
store.add("keep me")
|
||||
|
||||
result = CliRunner().invoke(cli, ["memory", "clear"], input="n\n")
|
||||
assert result.exit_code == 0
|
||||
assert "Aborted" in result.output
|
||||
assert store.count() == 1
|
||||
|
||||
|
||||
def test_memory_clear_yes_flag(tmp_path: Path, monkeypatch):
|
||||
store = _patch_fact_store(monkeypatch, tmp_path)
|
||||
store.add("fact")
|
||||
|
||||
result = CliRunner().invoke(cli, ["memory", "clear", "--yes"])
|
||||
assert result.exit_code == 0
|
||||
assert "Cleared 1" in result.output
|
||||
assert store.count() == 0
|
||||
|
||||
|
||||
def test_memory_clear_empty(tmp_path: Path, monkeypatch):
|
||||
_patch_fact_store(monkeypatch, tmp_path)
|
||||
result = CliRunner().invoke(cli, ["memory", "clear"])
|
||||
assert result.exit_code == 0
|
||||
assert "No memory facts to clear" in result.output
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Tests for Docker and deployment files."""
|
||||
"""Tests for Docker and deployment files.
|
||||
|
||||
These are static file-content checks (no Docker daemon required) so they run in
|
||||
the default CI lane. They guard the deployment hardening from #228 and its
|
||||
sub-issues (#563 image pinning, #564 systemd hardening, #565 non-root, #566
|
||||
secure Node install, #567 frozen lockfile installs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
@@ -11,6 +18,35 @@ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DOCKER_DIR = ROOT / "deploy" / "docker"
|
||||
SYSTEMD_DIR = ROOT / "deploy" / "systemd"
|
||||
|
||||
|
||||
def _dockerfiles() -> list[Path]:
|
||||
return sorted(DOCKER_DIR.glob("Dockerfile*"))
|
||||
|
||||
|
||||
def _compose_files() -> list[Path]:
|
||||
return sorted(DOCKER_DIR.glob("docker-compose*.yml"))
|
||||
|
||||
|
||||
def _from_lines(content: str) -> list[str]:
|
||||
return [
|
||||
ln.strip()
|
||||
for ln in content.splitlines()
|
||||
if ln.strip().upper().startswith("FROM ")
|
||||
]
|
||||
|
||||
|
||||
def _image_lines(content: str) -> list[str]:
|
||||
"""`image: ...` lines from a compose file, ignoring comments."""
|
||||
out = []
|
||||
for ln in content.splitlines():
|
||||
stripped = ln.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith("image:"):
|
||||
out.append(stripped)
|
||||
return out
|
||||
|
||||
|
||||
class TestDockerFiles:
|
||||
@@ -37,10 +73,12 @@ class TestDockerFiles:
|
||||
]
|
||||
non_src_includes = [s for s in force_include if not s.startswith("src/")]
|
||||
|
||||
install_marker = 'uv pip install --system ".[server]"'
|
||||
# The project itself is built by the final `--no-deps .` install (#567);
|
||||
# the force-includes must be present before that step.
|
||||
install_marker = "uv pip install --system --no-deps ."
|
||||
wheel_dockerfiles = [
|
||||
p
|
||||
for p in sorted(DOCKER_DIR.glob("Dockerfile*"))
|
||||
for p in _dockerfiles()
|
||||
if install_marker in p.read_text() and "COPY src/ src/" in p.read_text()
|
||||
]
|
||||
# Sanity: we actually found the wheel-building Dockerfiles to guard.
|
||||
@@ -86,4 +124,142 @@ class TestDockerFiles:
|
||||
assert "ollama:" in content
|
||||
|
||||
def test_systemd_service_exists(self):
|
||||
assert (ROOT / "deploy" / "systemd" / "openjarvis.service").is_file()
|
||||
assert (SYSTEMD_DIR / "openjarvis.service").is_file()
|
||||
|
||||
|
||||
class TestImagePinning:
|
||||
"""#563 — base images and ollama pinned to fixed versions + digests."""
|
||||
|
||||
def test_no_floating_latest_tag_in_image_directives(self):
|
||||
# `:latest` only acceptable inside comments (rationale text).
|
||||
for path in _dockerfiles() + _compose_files():
|
||||
for ln in path.read_text().splitlines():
|
||||
code = ln.split("#", 1)[0]
|
||||
assert ":latest" not in code, f"{path.name}: floating :latest in {ln!r}"
|
||||
|
||||
def test_every_from_pins_a_digest(self):
|
||||
for path in _dockerfiles():
|
||||
froms = _from_lines(path.read_text())
|
||||
assert froms, f"{path.name}: no FROM lines found"
|
||||
for ln in froms:
|
||||
assert "@sha256:" in ln, (
|
||||
f"{path.name}: FROM is not digest-pinned: {ln!r}"
|
||||
)
|
||||
|
||||
def test_ollama_image_pinned_with_version_and_digest(self):
|
||||
for path in _compose_files():
|
||||
for ln in _image_lines(path.read_text()):
|
||||
if "ollama/ollama" in ln:
|
||||
assert "@sha256:" in ln, f"{path.name}: ollama not digest-pinned"
|
||||
# A concrete version tag (digits) must accompany the digest.
|
||||
assert re.search(r"ollama/ollama:\d", ln), (
|
||||
f"{path.name}: ollama missing a version tag"
|
||||
)
|
||||
|
||||
|
||||
class TestNonRootUser:
|
||||
"""#565 — GPU images (and the base image) drop root."""
|
||||
|
||||
NON_ROOT = ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]
|
||||
|
||||
def test_creates_and_switches_to_non_root_user(self):
|
||||
for name in self.NON_ROOT:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "useradd" in content, f"{name}: no useradd"
|
||||
# A USER directive switching away from root must exist and not be root.
|
||||
user_lines = [
|
||||
ln.strip()
|
||||
for ln in content.splitlines()
|
||||
if ln.strip().upper().startswith("USER ")
|
||||
]
|
||||
assert user_lines, f"{name}: no USER directive"
|
||||
assert all("root" not in ln for ln in user_lines), (
|
||||
f"{name}: USER directive still root"
|
||||
)
|
||||
|
||||
def test_user_directive_is_last_stage(self):
|
||||
# USER must appear after the final FROM so the runtime stage is non-root.
|
||||
for name in self.NON_ROOT:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
last_from = content.rfind("\nFROM ")
|
||||
user_idx = content.rfind("\nUSER ")
|
||||
assert user_idx > last_from, f"{name}: USER not in final runtime stage"
|
||||
|
||||
|
||||
class TestSandboxNodeSecurity:
|
||||
"""#566 — Node installed without an unverified curl|bash pipe."""
|
||||
|
||||
def test_no_curl_pipe_bash(self):
|
||||
content = (DOCKER_DIR / "Dockerfile.sandbox").read_text()
|
||||
for ln in content.splitlines():
|
||||
code = ln.split("#", 1)[0] # ignore the explanatory comment
|
||||
assert "| bash" not in code and "|bash" not in code, (
|
||||
f"unverified pipe-to-bash install remains: {ln!r}"
|
||||
)
|
||||
assert "nodesource.com" not in code, "still using NodeSource setup script"
|
||||
|
||||
def test_node_sourced_from_pinned_official_image(self):
|
||||
content = (DOCKER_DIR / "Dockerfile.sandbox").read_text()
|
||||
assert "COPY --from=node" in content, "Node not copied from pinned image stage"
|
||||
froms = _from_lines(content)
|
||||
assert any("node:" in ln and "@sha256:" in ln for ln in froms), (
|
||||
"no digest-pinned node base stage"
|
||||
)
|
||||
|
||||
|
||||
class TestFrozenInstall:
|
||||
"""#567 — Docker builds install from the committed, frozen uv.lock."""
|
||||
|
||||
BUILD_DOCKERFILES = [
|
||||
"Dockerfile",
|
||||
"Dockerfile.gpu",
|
||||
"Dockerfile.gpu.rocm",
|
||||
"Dockerfile.sandbox",
|
||||
]
|
||||
|
||||
def test_copies_lockfile(self):
|
||||
for name in self.BUILD_DOCKERFILES:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "uv.lock" in content, f"{name}: uv.lock not referenced"
|
||||
|
||||
def test_uses_frozen_export(self):
|
||||
for name in self.BUILD_DOCKERFILES:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert "uv export --frozen" in content, f"{name}: no frozen export"
|
||||
assert "--no-deps" in content, (
|
||||
f"{name}: deps re-resolved (missing --no-deps)"
|
||||
)
|
||||
|
||||
def test_no_unpinned_pyproject_resolution(self):
|
||||
# The old `uv pip install --system ".[server]"` re-resolved deps on every
|
||||
# build; it must not come back.
|
||||
for name in self.BUILD_DOCKERFILES:
|
||||
content = (DOCKER_DIR / name).read_text()
|
||||
assert '".[server]"' not in content, (
|
||||
f"{name}: still re-resolves from pyproject"
|
||||
)
|
||||
|
||||
|
||||
class TestSystemdHardening:
|
||||
"""#564 — systemd unit ships secrets via EnvironmentFile and is sandboxed."""
|
||||
|
||||
def _service(self) -> str:
|
||||
return (SYSTEMD_DIR / "openjarvis.service").read_text()
|
||||
|
||||
def test_environment_file_for_secrets(self):
|
||||
assert "EnvironmentFile=" in self._service()
|
||||
|
||||
def test_core_hardening_directives_present(self):
|
||||
content = self._service()
|
||||
for directive in (
|
||||
"NoNewPrivileges=true",
|
||||
"ProtectSystem=strict",
|
||||
"PrivateTmp=true",
|
||||
):
|
||||
assert directive in content, f"missing hardening directive: {directive}"
|
||||
|
||||
def test_writable_state_path_declared(self):
|
||||
# ProtectSystem=strict makes the FS read-only; the working/home dir must
|
||||
# be re-granted write access or the server cannot persist config/state.
|
||||
content = self._service()
|
||||
assert "ReadWritePaths=" in content
|
||||
|
||||
+176
-1
@@ -11,7 +11,7 @@ import respx
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.engine._base import EngineConnectionError
|
||||
from openjarvis.engine.ollama import OllamaEngine
|
||||
from openjarvis.engine.ollama import OllamaEngine, _is_control_token_only_args
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -82,6 +82,181 @@ class TestOllamaHealth:
|
||||
assert engine.health() is False
|
||||
|
||||
|
||||
class TestControlTokenFilter:
|
||||
"""Qwen3 ``/think`` / ``/no_think`` soft-switch tokens sometimes leak into
|
||||
tool-call arguments on small models (e.g. ``{"command": "/no_think"}``).
|
||||
Such a call is never valid and must be dropped before execution.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_args",
|
||||
[
|
||||
{"command": "/no_think"},
|
||||
{"command": "/think"},
|
||||
{"command": " /no_think "},
|
||||
{"command": "/NO_THINK"},
|
||||
"/no_think",
|
||||
json.dumps({"command": "/no_think"}),
|
||||
{"command": "/no_think", "note": ""},
|
||||
],
|
||||
)
|
||||
def test_detects_control_token_only(self, raw_args) -> None:
|
||||
assert _is_control_token_only_args(raw_args) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_args",
|
||||
[
|
||||
{"command": "date"},
|
||||
{"command": "echo /no_think"},
|
||||
{"query": "what is /no_think"},
|
||||
{"command": "date", "note": "/no_think"},
|
||||
{"timeout": 30},
|
||||
{},
|
||||
"date",
|
||||
"not json at all",
|
||||
],
|
||||
)
|
||||
def test_keeps_legitimate_args(self, raw_args) -> None:
|
||||
assert _is_control_token_only_args(raw_args) is False
|
||||
|
||||
|
||||
class TestOllamaGenerateControlToken:
|
||||
def test_generate_drops_control_token_tool_call(self, engine: OllamaEngine) -> None:
|
||||
with respx.mock:
|
||||
respx.post("http://testhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "shell_exec",
|
||||
"arguments": {"command": "/no_think"},
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"model": "qwen3:14b",
|
||||
},
|
||||
)
|
||||
)
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="run date")],
|
||||
model="qwen3:14b",
|
||||
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
|
||||
)
|
||||
assert not result.get("tool_calls")
|
||||
|
||||
def test_generate_keeps_valid_tool_call(self, engine: OllamaEngine) -> None:
|
||||
with respx.mock:
|
||||
respx.post("http://testhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "shell_exec",
|
||||
"arguments": {"command": "date"},
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"model": "qwen3:14b",
|
||||
},
|
||||
)
|
||||
)
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="run date")],
|
||||
model="qwen3:14b",
|
||||
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
|
||||
)
|
||||
assert len(result["tool_calls"]) == 1
|
||||
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
|
||||
|
||||
def test_generate_drops_only_control_token_among_many(
|
||||
self, engine: OllamaEngine
|
||||
) -> None:
|
||||
with respx.mock:
|
||||
respx.post("http://testhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "shell_exec",
|
||||
"arguments": {"command": "/no_think"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"function": {
|
||||
"name": "shell_exec",
|
||||
"arguments": {"command": "date"},
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
"model": "qwen3:14b",
|
||||
},
|
||||
)
|
||||
)
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="run date")],
|
||||
model="qwen3:14b",
|
||||
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
|
||||
)
|
||||
assert len(result["tool_calls"]) == 1
|
||||
assert json.loads(result["tool_calls"][0]["arguments"]) == {"command": "date"}
|
||||
|
||||
|
||||
class TestOllamaStreamFullControlToken:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_drops_control_token_tool_call(
|
||||
self, engine: OllamaEngine
|
||||
) -> None:
|
||||
lines = [
|
||||
json.dumps(
|
||||
{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "shell_exec",
|
||||
"arguments": {"command": "/no_think"},
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
),
|
||||
]
|
||||
body = "\n".join(lines)
|
||||
with respx.mock:
|
||||
respx.post("http://testhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(200, text=body)
|
||||
)
|
||||
chunks = []
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="run date")],
|
||||
model="qwen3:14b",
|
||||
tools=[{"type": "function", "function": {"name": "shell_exec"}}],
|
||||
):
|
||||
chunks.append(chunk)
|
||||
assert all(not c.tool_calls for c in chunks)
|
||||
|
||||
|
||||
class TestOllamaStream:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_yields_content(self, engine: OllamaEngine) -> None:
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for the LLM-backed fact extractor (openjarvis.memory.extractor)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.memory.extractor import FactExtractor
|
||||
|
||||
|
||||
class FakeEngine:
|
||||
"""Engine stub returning a canned completion (or raising)."""
|
||||
|
||||
def __init__(self, content="", *, raises=None):
|
||||
self._content = content
|
||||
self._raises = raises
|
||||
self.calls = []
|
||||
|
||||
def generate(self, messages, *, model, temperature=0.7, max_tokens=1024, **kwargs):
|
||||
self.calls.append((messages, model, temperature, max_tokens))
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return {"content": self._content}
|
||||
|
||||
|
||||
def test_parses_json_array():
|
||||
engine = FakeEngine('["User likes coffee", "User lives in Berlin"]')
|
||||
extractor = FactExtractor(engine, "qwen3:14b")
|
||||
facts = extractor.extract("I like coffee and live in Berlin", "Noted.")
|
||||
assert facts == ["User likes coffee", "User lives in Berlin"]
|
||||
|
||||
|
||||
def test_parses_json_array_wrapped_in_prose():
|
||||
engine = FakeEngine('Sure! Here are the facts:\n["Fact A", "Fact B"]\nDone.')
|
||||
extractor = FactExtractor(engine, "m")
|
||||
assert extractor.extract("hi", "hello") == ["Fact A", "Fact B"]
|
||||
|
||||
|
||||
def test_empty_array_returns_no_facts():
|
||||
engine = FakeEngine("[]")
|
||||
extractor = FactExtractor(engine, "m")
|
||||
assert extractor.extract("just chatting", "ok") == []
|
||||
|
||||
|
||||
def test_line_fallback_for_bullets():
|
||||
engine = FakeEngine("- User is a teacher\n- User has two kids\n")
|
||||
extractor = FactExtractor(engine, "m")
|
||||
assert extractor.extract("about me", "noted") == [
|
||||
"User is a teacher",
|
||||
"User has two kids",
|
||||
]
|
||||
|
||||
|
||||
def test_dedupe_within_turn():
|
||||
engine = FakeEngine('["likes tea", "Likes Tea", "likes tea"]')
|
||||
extractor = FactExtractor(engine, "m")
|
||||
assert extractor.extract("x", "y") == ["likes tea"]
|
||||
|
||||
|
||||
def test_cap_facts_per_turn():
|
||||
items = [f'"fact {i}"' for i in range(20)]
|
||||
engine = FakeEngine("[" + ", ".join(items) + "]")
|
||||
extractor = FactExtractor(engine, "m", max_facts_per_turn=3)
|
||||
assert len(extractor.extract("x", "y")) == 3
|
||||
|
||||
|
||||
def test_truncates_long_facts():
|
||||
long_fact = "z" * 500
|
||||
engine = FakeEngine(f'["{long_fact}"]')
|
||||
extractor = FactExtractor(engine, "m", max_fact_chars=50)
|
||||
facts = extractor.extract("x", "y")
|
||||
assert len(facts) == 1
|
||||
assert len(facts[0]) == 50
|
||||
|
||||
|
||||
def test_empty_user_text_skips_engine():
|
||||
engine = FakeEngine('["should not be called"]')
|
||||
extractor = FactExtractor(engine, "m")
|
||||
assert extractor.extract(" ", "y") == []
|
||||
assert engine.calls == []
|
||||
|
||||
|
||||
def test_broken_pipe_returns_empty():
|
||||
engine = FakeEngine(raises=BrokenPipeError("client gone"))
|
||||
extractor = FactExtractor(engine, "m")
|
||||
# Must not raise — extraction is best-effort.
|
||||
assert extractor.extract("hi", "hello") == []
|
||||
|
||||
|
||||
def test_generic_exception_returns_empty():
|
||||
engine = FakeEngine(raises=RuntimeError("ollama exploded"))
|
||||
extractor = FactExtractor(engine, "m")
|
||||
assert extractor.extract("hi", "hello") == []
|
||||
|
||||
|
||||
def test_handles_non_dict_result():
|
||||
class StrEngine:
|
||||
def generate(self, *a, **k):
|
||||
return '["plain string result"]'
|
||||
|
||||
extractor = FactExtractor(StrEngine(), "m")
|
||||
assert extractor.extract("x", "y") == ["plain string result"]
|
||||
|
||||
|
||||
def test_filters_non_fact_tokens():
|
||||
engine = FakeEngine('["none", "N/A", "Real fact"]')
|
||||
extractor = FactExtractor(engine, "m")
|
||||
assert extractor.extract("x", "y") == ["Real fact"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for the persistent fact store (openjarvis.memory.store)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.memory.store import LocalFactStore, create_fact_store
|
||||
|
||||
|
||||
def test_add_and_list(tmp_path):
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
assert store.add("User prefers concise answers") is True
|
||||
assert store.add("User lives in Berlin") is True
|
||||
|
||||
facts = store.list()
|
||||
assert [f.text for f in facts] == [
|
||||
"User prefers concise answers",
|
||||
"User lives in Berlin",
|
||||
]
|
||||
assert store.count() == 2
|
||||
|
||||
|
||||
def test_add_dedupes_case_insensitive(tmp_path):
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
assert store.add("Likes coffee") is True
|
||||
assert store.add("likes coffee") is False # duplicate
|
||||
assert store.count() == 1
|
||||
|
||||
|
||||
def test_add_skips_empty(tmp_path):
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
assert store.add("") is False
|
||||
assert store.add(" ") is False
|
||||
assert store.count() == 0
|
||||
|
||||
|
||||
def test_add_many(tmp_path):
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
added = store.add_many(["a", "b", "a", "c"]) # one dupe
|
||||
assert added == 3
|
||||
assert store.count() == 3
|
||||
|
||||
|
||||
def test_max_facts_evicts_oldest(tmp_path):
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl", max_facts=2)
|
||||
store.add("first")
|
||||
store.add("second")
|
||||
store.add("third")
|
||||
facts = [f.text for f in store.list()]
|
||||
assert facts == ["second", "third"] # oldest dropped
|
||||
|
||||
|
||||
def test_persistence_across_instances(tmp_path):
|
||||
path = tmp_path / "facts.jsonl"
|
||||
store = LocalFactStore(path)
|
||||
store.add("durable fact")
|
||||
|
||||
reloaded = LocalFactStore(path)
|
||||
assert [f.text for f in reloaded.list()] == ["durable fact"]
|
||||
|
||||
|
||||
def test_clear(tmp_path):
|
||||
path = tmp_path / "facts.jsonl"
|
||||
store = LocalFactStore(path)
|
||||
store.add("one")
|
||||
store.add("two")
|
||||
|
||||
removed = store.clear()
|
||||
assert removed == 2
|
||||
assert store.count() == 0
|
||||
# A fresh instance also sees an empty store.
|
||||
assert LocalFactStore(path).count() == 0
|
||||
|
||||
|
||||
def test_load_skips_malformed_lines(tmp_path):
|
||||
path = tmp_path / "facts.jsonl"
|
||||
path.write_text(
|
||||
'{"text": "good fact"}\n'
|
||||
"this is not json\n"
|
||||
'{"text": ""}\n' # empty text ignored
|
||||
'{"text": "another good"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = LocalFactStore(path)
|
||||
assert [f.text for f in store.list()] == ["good fact", "another good"]
|
||||
|
||||
|
||||
def test_jsonl_round_trip_is_valid_json(tmp_path):
|
||||
path = tmp_path / "facts.jsonl"
|
||||
store = LocalFactStore(path)
|
||||
store.add("fact one", source="auto")
|
||||
|
||||
lines = [
|
||||
line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()
|
||||
]
|
||||
assert len(lines) == 1
|
||||
obj = json.loads(lines[0])
|
||||
assert obj["text"] == "fact one"
|
||||
assert obj["source"] == "auto"
|
||||
assert "created_at" in obj
|
||||
|
||||
|
||||
def test_create_fact_store_local(tmp_path):
|
||||
store = create_fact_store("local", path=tmp_path / "f.jsonl", max_facts=5)
|
||||
assert isinstance(store, LocalFactStore)
|
||||
|
||||
|
||||
def test_create_fact_store_unknown_backend(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
create_fact_store("cloud", path=tmp_path / "f.jsonl")
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for the background memory service (openjarvis.memory.service)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openjarvis.core.config import StorageConfig
|
||||
from openjarvis.memory.service import MemoryService, build_memory_service
|
||||
from openjarvis.memory.store import LocalFactStore
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout=2.0, interval=0.01):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return predicate()
|
||||
|
||||
|
||||
class FakeExtractor:
|
||||
"""Extractor stub with controllable output, blocking and failures."""
|
||||
|
||||
def __init__(self, facts=None, *, raises=None, gate=None):
|
||||
self._facts = facts or []
|
||||
self._raises = raises
|
||||
self._gate = gate # optional threading.Event to block on
|
||||
self.calls = []
|
||||
|
||||
def extract(self, user_text, assistant_text=""):
|
||||
self.calls.append((user_text, assistant_text))
|
||||
if self._gate is not None:
|
||||
self._gate.wait(timeout=2.0)
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return list(self._facts)
|
||||
|
||||
|
||||
def _service(tmp_path, extractor, **kwargs):
|
||||
store = LocalFactStore(tmp_path / "facts.jsonl")
|
||||
return MemoryService(store, extractor, **kwargs)
|
||||
|
||||
|
||||
def test_start_stop_lifecycle(tmp_path):
|
||||
svc = _service(tmp_path, FakeExtractor())
|
||||
assert svc.is_running is False
|
||||
svc.start()
|
||||
assert svc.is_running is True
|
||||
svc.start() # idempotent
|
||||
assert svc.is_running is True
|
||||
svc.stop()
|
||||
assert svc.is_running is False
|
||||
svc.stop() # idempotent
|
||||
|
||||
|
||||
def test_submit_extracts_and_stores(tmp_path):
|
||||
extractor = FakeExtractor(["User likes hiking"])
|
||||
svc = _service(tmp_path, extractor)
|
||||
svc.start()
|
||||
try:
|
||||
assert svc.submit("I love hiking", "Nice!") is True
|
||||
assert _wait_until(lambda: svc.fact_count() == 1)
|
||||
assert [f.text for f in svc.list_facts()] == ["User likes hiking"]
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_submit_when_not_running_is_dropped(tmp_path):
|
||||
extractor = FakeExtractor(["x"])
|
||||
svc = _service(tmp_path, extractor)
|
||||
assert svc.submit("hi", "there") is False
|
||||
assert extractor.calls == []
|
||||
|
||||
|
||||
def test_submit_empty_user_text_dropped(tmp_path):
|
||||
extractor = FakeExtractor(["x"])
|
||||
svc = _service(tmp_path, extractor)
|
||||
svc.start()
|
||||
try:
|
||||
assert svc.submit(" ", "y") is False
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_worker_survives_extractor_broken_pipe(tmp_path):
|
||||
"""A BrokenPipeError in one job must not kill the worker."""
|
||||
extractor = FakeExtractor(raises=BrokenPipeError("client gone"))
|
||||
svc = _service(tmp_path, extractor)
|
||||
svc.start()
|
||||
try:
|
||||
svc.submit("first", "a")
|
||||
assert _wait_until(lambda: len(extractor.calls) == 1)
|
||||
# Service is still alive and accepting work.
|
||||
assert svc.is_running is True
|
||||
assert svc.submit("second", "b") is True
|
||||
assert _wait_until(lambda: len(extractor.calls) == 2)
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_worker_survives_generic_exception(tmp_path):
|
||||
extractor = FakeExtractor(raises=RuntimeError("boom"))
|
||||
svc = _service(tmp_path, extractor)
|
||||
svc.start()
|
||||
try:
|
||||
svc.submit("x", "y")
|
||||
assert _wait_until(lambda: len(extractor.calls) == 1)
|
||||
assert svc.is_running is True
|
||||
finally:
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_submit_returns_false_when_queue_full(tmp_path):
|
||||
"""Backpressure: a full queue drops work instead of blocking the caller."""
|
||||
gate = threading.Event()
|
||||
extractor = FakeExtractor(["fact"], gate=gate)
|
||||
svc = _service(tmp_path, extractor, max_queue=1)
|
||||
svc.start()
|
||||
try:
|
||||
# First submit is pulled by the worker and blocks on the gate.
|
||||
assert svc.submit("job1", "a") is True
|
||||
assert _wait_until(lambda: len(extractor.calls) == 1)
|
||||
# Fill the (size-1) queue, then the next submit must be dropped.
|
||||
assert svc.submit("job2", "b") is True
|
||||
dropped = svc.submit("job3", "c")
|
||||
assert dropped is False
|
||||
finally:
|
||||
gate.set()
|
||||
svc.stop()
|
||||
|
||||
|
||||
def test_build_memory_service_disabled_returns_none(tmp_path):
|
||||
cfg = SimpleNamespace(memory=StorageConfig(enabled=False))
|
||||
assert build_memory_service(cfg, object(), "model") is None
|
||||
|
||||
|
||||
def test_build_memory_service_no_engine_returns_none(tmp_path):
|
||||
cfg = SimpleNamespace(memory=StorageConfig(enabled=True))
|
||||
assert build_memory_service(cfg, None, "model") is None
|
||||
|
||||
|
||||
def test_build_memory_service_no_model_returns_none(tmp_path):
|
||||
cfg = SimpleNamespace(memory=StorageConfig(enabled=True, extraction_model=""))
|
||||
assert build_memory_service(cfg, object(), "") is None
|
||||
|
||||
|
||||
def test_build_memory_service_enabled(tmp_path):
|
||||
cfg = SimpleNamespace(
|
||||
memory=StorageConfig(
|
||||
enabled=True,
|
||||
extraction_model="qwen3:14b",
|
||||
facts_path=str(tmp_path / "facts.jsonl"),
|
||||
max_facts=10,
|
||||
)
|
||||
)
|
||||
svc = build_memory_service(cfg, object(), "fallback-model")
|
||||
assert isinstance(svc, MemoryService)
|
||||
|
||||
|
||||
def test_build_memory_service_falls_back_to_default_model(tmp_path):
|
||||
cfg = SimpleNamespace(
|
||||
memory=StorageConfig(
|
||||
enabled=True,
|
||||
extraction_model="",
|
||||
facts_path=str(tmp_path / "facts.jsonl"),
|
||||
)
|
||||
)
|
||||
svc = build_memory_service(cfg, object(), "active-model")
|
||||
assert isinstance(svc, MemoryService)
|
||||
@@ -74,6 +74,68 @@ def client_with_agent():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SpyMemoryService:
|
||||
"""Minimal stand-in capturing memory submissions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.submissions: list[tuple[str, str]] = []
|
||||
|
||||
def submit(self, user_text: str, assistant_text: str = "") -> bool:
|
||||
self.submissions.append((user_text, assistant_text))
|
||||
return True
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TestMemoryServiceWiring:
|
||||
def test_non_streaming_completion_feeds_memory(self):
|
||||
engine = _make_engine(content="remembered reply")
|
||||
spy = _SpyMemoryService()
|
||||
app = create_app(engine, "test-model", memory_service=spy)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "I like jazz"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert spy.submissions == [("I like jazz", "remembered reply")]
|
||||
|
||||
def test_agent_completion_feeds_memory(self):
|
||||
engine = _make_engine()
|
||||
agent = _make_agent(content="agent reply")
|
||||
spy = _SpyMemoryService()
|
||||
app = create_app(engine, "test-model", agent=agent, memory_service=spy)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "remember this"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert spy.submissions == [("remember this", "agent reply")]
|
||||
|
||||
def test_no_memory_service_is_noop(self):
|
||||
engine = _make_engine()
|
||||
app = create_app(engine, "test-model") # memory_service defaults to None
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestChatCompletions:
|
||||
def test_basic_completion(self, client):
|
||||
resp = client.post(
|
||||
|
||||
@@ -56,6 +56,18 @@ def test_transcribe_endpoint(client, mock_speech_backend):
|
||||
assert data["duration_seconds"] == 1.5
|
||||
|
||||
|
||||
def test_transcribe_endpoint_surfaces_backend_error(client, mock_speech_backend):
|
||||
mock_speech_backend.transcribe.side_effect = RuntimeError("missing cublas64_12.dll")
|
||||
|
||||
response = client.post(
|
||||
"/v1/speech/transcribe",
|
||||
files={"file": ("test.wav", b"fake audio data", "audio/wav")},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert "missing cublas64_12.dll" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_transcribe_no_file(client):
|
||||
response = client.post("/v1/speech/transcribe")
|
||||
assert response.status_code == 400 or response.status_code == 422
|
||||
@@ -69,6 +81,20 @@ def test_health_endpoint(client):
|
||||
assert data["backend"] == "mock"
|
||||
|
||||
|
||||
def test_health_endpoint_includes_unavailable_reason(client, mock_speech_backend):
|
||||
mock_speech_backend.health.return_value = False
|
||||
mock_speech_backend.last_error.return_value = (
|
||||
"Install with: uv sync --extra desktop"
|
||||
)
|
||||
|
||||
response = client.get("/v1/speech/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["available"] is False
|
||||
assert data["reason"] == "Install with: uv sync --extra desktop"
|
||||
|
||||
|
||||
def test_health_no_backend():
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -53,17 +53,61 @@ def test_faster_whisper_transcribe():
|
||||
assert result.duration_seconds == 1.5
|
||||
|
||||
|
||||
def test_faster_whisper_falls_back_from_unsupported_float16():
|
||||
mock_model = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.speech.faster_whisper.WhisperModel",
|
||||
return_value=mock_model,
|
||||
) as mock_whisper,
|
||||
patch(
|
||||
"openjarvis.speech.faster_whisper.ctranslate2",
|
||||
MagicMock(
|
||||
get_supported_compute_types=MagicMock(return_value={"float32", "int8"})
|
||||
),
|
||||
),
|
||||
):
|
||||
backend = FasterWhisperBackend(
|
||||
model_size="base",
|
||||
device="cpu",
|
||||
compute_type="float16",
|
||||
)
|
||||
assert backend._ensure_model() is mock_model
|
||||
|
||||
mock_whisper.assert_called_once_with("base", device="cpu", compute_type="int8")
|
||||
|
||||
|
||||
def test_faster_whisper_missing_dependency_hint_uses_desktop_extra():
|
||||
with patch("openjarvis.speech.faster_whisper.WhisperModel", new=None):
|
||||
backend = FasterWhisperBackend()
|
||||
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
backend._ensure_model()
|
||||
|
||||
assert "uv sync --extra desktop" in str(excinfo.value)
|
||||
assert "uv sync --extra speech" not in str(excinfo.value)
|
||||
|
||||
|
||||
def test_faster_whisper_health_no_model():
|
||||
"""Health returns False before model is loaded."""
|
||||
with patch(
|
||||
"openjarvis.speech.faster_whisper.WhisperModel",
|
||||
new=None,
|
||||
):
|
||||
from openjarvis.speech.faster_whisper import FasterWhisperBackend
|
||||
|
||||
backend = FasterWhisperBackend.__new__(FasterWhisperBackend)
|
||||
backend._model = None
|
||||
backend = FasterWhisperBackend()
|
||||
assert backend.health() is False
|
||||
assert "uv sync --extra desktop" in (backend.last_error() or "")
|
||||
|
||||
|
||||
def test_faster_whisper_health_captures_load_error():
|
||||
with patch(
|
||||
"openjarvis.speech.faster_whisper.WhisperModel",
|
||||
side_effect=RuntimeError("missing cublas64_12.dll"),
|
||||
):
|
||||
backend = FasterWhisperBackend()
|
||||
assert backend.health() is False
|
||||
assert "missing cublas64_12.dll" in (backend.last_error() or "")
|
||||
|
||||
|
||||
def test_faster_whisper_supported_formats():
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestWebSearchTool:
|
||||
tool = WebSearchTool(api_key="test-key", max_results=3)
|
||||
tool.execute(query="test", max_results=7)
|
||||
mock_client.search.assert_called_once_with(
|
||||
"test", max_results=7, search_depth="advanced"
|
||||
"test", max_results=7, search_depth="advanced", include_usage=True
|
||||
)
|
||||
|
||||
def test_to_openai_function(self):
|
||||
|
||||
@@ -5065,6 +5065,13 @@ docs = [
|
||||
{ name = "mkdocs-material" },
|
||||
{ name = "mkdocstrings", extra = ["python"] },
|
||||
]
|
||||
desktop = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "faster-whisper" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
energy-all = [
|
||||
{ name = "amdsmi" },
|
||||
{ name = "nvidia-ml-py" },
|
||||
@@ -5200,7 +5207,9 @@ requires-dist = [
|
||||
{ name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0" },
|
||||
{ name = "dspy", marker = "extra == 'learning-dspy'", specifier = ">=2.6" },
|
||||
{ name = "faiss-cpu", marker = "extra == 'memory-faiss'", specifier = ">=1.7" },
|
||||
{ name = "fastapi", marker = "extra == 'desktop'", specifier = ">=0.110" },
|
||||
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.110" },
|
||||
{ name = "faster-whisper", marker = "extra == 'desktop'", specifier = ">=1.0" },
|
||||
{ name = "faster-whisper", marker = "extra == 'speech'", specifier = ">=1.0" },
|
||||
{ name = "gepa", marker = "extra == 'learning-gepa'", specifier = ">=0.1" },
|
||||
{ name = "google-api-python-client", marker = "extra == 'channel-gmail'", specifier = ">=2.0" },
|
||||
@@ -5237,6 +5246,7 @@ requires-dist = [
|
||||
{ name = "posthog", specifier = ">=3.0" },
|
||||
{ name = "praw", marker = "extra == 'channel-reddit'", specifier = ">=7.0" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" },
|
||||
{ name = "pydantic", marker = "extra == 'desktop'", specifier = ">=2.0" },
|
||||
{ name = "pydantic", marker = "extra == 'server'", specifier = ">=2.0" },
|
||||
{ name = "pygemma", marker = "extra == 'inference-gemma'", specifier = ">=0.1.3" },
|
||||
{ name = "pymessenger", marker = "extra == 'channel-messenger'", specifier = ">=0.0.7" },
|
||||
@@ -5244,6 +5254,7 @@ requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5" },
|
||||
{ name = "python-multipart", marker = "extra == 'desktop'", specifier = ">=0.0.9" },
|
||||
{ name = "python-multipart", marker = "extra == 'server'", specifier = ">=0.0.9" },
|
||||
{ name = "python-telegram-bot", specifier = ">=22.6" },
|
||||
{ name = "python-telegram-bot", marker = "extra == 'channel-telegram'", specifier = ">=21.0" },
|
||||
@@ -5264,6 +5275,7 @@ requires-dist = [
|
||||
{ name = "transformers", marker = "extra == 'orchestrator-training'", specifier = ">=4.40" },
|
||||
{ name = "twilio", marker = "extra == 'channel-twilio'", specifier = ">=9.0" },
|
||||
{ name = "twitchio", marker = "extra == 'channel-twitch'", specifier = ">=2.6" },
|
||||
{ name = "uvicorn", marker = "extra == 'desktop'", specifier = ">=0.30" },
|
||||
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30" },
|
||||
{ name = "viberbot", marker = "extra == 'channel-viber'", specifier = ">=1.0" },
|
||||
{ name = "vllm", marker = "extra == 'inference-vllm'", specifier = ">=0.16.0" },
|
||||
@@ -5274,7 +5286,7 @@ requires-dist = [
|
||||
{ name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" },
|
||||
{ name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" },
|
||||
]
|
||||
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"]
|
||||
provides-extras = ["dev", "inference-mlx", "inference-vllm", "inference-cloud", "inference-google", "inference-litellm", "inference-gemma", "tools-search", "memory-faiss", "memory-colbert", "memory-pdf", "memory-bm25", "server", "desktop", "openhands", "gpu-metrics", "energy-amd", "energy-apple", "energy-all", "orchestrator-training", "learning-dspy", "learning-gepa", "channel-telegram", "channel-discord", "channel-slack", "channel-line", "channel-viber", "channel-messenger", "channel-reddit", "channel-mastodon", "channel-xmpp", "channel-rocketchat", "channel-zulip", "channel-twitter", "channel-twitch", "channel-nostr", "channel-twilio", "channel-gmail", "browser", "media", "mining-pearl-vllm", "pdf", "scheduler", "security-signing", "sandbox-wasm", "sandbox-docker", "dashboard", "speech", "speech-deepgram", "eval-wandb", "eval-sheets", "mining-pearl-cpu", "framework-comparison", "docs"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "maturin", specifier = ">=1.12.6" }]
|
||||
|
||||
Reference in New Issue
Block a user