Compare commits

...
Author SHA1 Message Date
Elliot SluskyandClaude Opus 4.8 904133cb25 fix(research): respect configured engine for Deep Research (#616)
Fixes #575. Web Deep Research was hardcoded to OllamaEngine + DEFAULT_PLANNER_MODEL, ignoring the user's configured/active engine and model. Resolve the planner from [deep_research] override -> live app chat engine + selected model -> config defaults -> legacy Ollama, pass the chat picker's model from the frontend into /api/research, record the actual planner engine in telemetry, and refuse to silently fall back to a different engine (raise an actionable error instead). Adds config support and focused tests for resolution and the route. Related: #576 (duplicate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:46:18 -07:00
Elliot SluskyandClaude Opus 4.8 299dee1f40 fix(desktop): verify Rust extension before server startup (#615)
Fixes #505. Make the desktop backend resilient to a missing/unbuilt openjarvis_rust extension: declare openjarvis-rust as a uv-managed desktop path dependency so 'uv sync --extra desktop' owns the PyO3 build (instead of pruning an undeclared package); add ~/.cargo/bin to the subprocess PATH and fail early with Rust / Windows Build Tools guidance when the toolchain is missing; verify 'import openjarvis_rust' before starting jarvis serve; and add a TCP bind preflight for port 8000 to catch non-HTTP listeners the /health probe can't classify. Includes the uv.lock entry for the new path dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:24:24 -07:00
github-actions[bot] 44ff286005 chore: update clone traffic data [skip ci] 2026-06-30 07:21:15 +00:00
Gilbert Barajas be51eb8684 docs(user-guide): document SOUL/MEMORY/USER.md persona files (#604) (#610)
* docs(user-guide): document SOUL/MEMORY/USER.md persona files (#604)

The persistent-memory showcase links to the User Guide: Agents page for how
SOUL.md / MEMORY.md / USER.md are loaded at conversation start, but that page
never covered them (site search for the filenames returns nothing).

Add a "Persistent Persona" section to user-guide/agents.md: the three files and
what each holds, where they live (config dir + [memory_files]), how they load
(after the agent template, cached per conversation, per-section truncation),
named personas (--persona / personas/<name>/), and editing by hand or via the
memory_manage / user_profile_manage tools. Cross-links the distinct retrieval
memory backend to resolve the reporter's confusion.

Closes #604

* docs(user-guide): clarify memory_manage/user_profile_manage target the default MEMORY.md/USER.md
2026-06-29 17:38:22 -07:00
Elliot Slusky 420908401c fix(engine): count tool call payloads in token estimates (#614)
Closes #608. Make Message.content officially Optional (str | None) with a Message.text accessor that treats None as empty, and count tool-call IDs/names/arguments, tool-result IDs, and reasoning/thinking metadata in estimate_prompt_tokens — all are replayed into later prompt turns, so they belong in the estimate. Extends estimator and message-type regression tests.
2026-06-29 17:34:10 -07:00
Elliot Slusky b70be55681 fix(openhands): handle none content in token estimates (#612)
Closes #607. Assistant tool-call turns can carry content=None, which crashed token estimation (len(m.content)) and think-tag stripping. Normalize with 'content or ""', route native OpenHands truncation through the shared estimate_prompt_tokens, and add tests for the estimator, the truncation helper, and an end-to-end tool-call run with None content.
2026-06-29 16:51:52 -07:00
Elliot Slusky a0187e40e6 Fix desktop startup fallback to installed Ollama models (#611)
Addresses #605. Prefer an already-installed Ollama model before attempting a startup download (matching the requested tag, else a preferred non-embedding installed model); fall back through installed -> FALLBACK_MODEL -> error, reusing installed models at each failure point; persist the resolved model only for first-run/default so an explicit user choice is never overwritten. Refactors the model logic into testable helpers with unit coverage.
2026-06-29 16:51:49 -07:00
Jon Saad-FalconandClaude Opus 4.8 d32f20f9b3 fix(desktop): align @tauri-apps npm packages with the 2.11 Rust crate (#613)
Desktop release builds failed on all platforms with 'Found version mismatched Tauri packages' because @tauri-apps/api and @tauri-apps/cli were pinned at 2.10.1 while the tauri Rust crate resolved to 2.11.3. Bump both npm packages to the 2.11 line (api 2.11.1, cli 2.11.4) so they share the crate's major.minor. Plugins were already aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:51:46 -07:00
github-actions[bot] 0b552cbcb5 chore: update clone traffic data [skip ci] 2026-06-29 07:46:59 +00:00
github-actions[bot] 19fd3c8d2b chore: update clone traffic data [skip ci] 2026-06-28 07:24:04 +00:00
Jon Saad-FalconandClaude Opus 4.8 1fa80d8ecd fix(docs): wire the savings leaderboard's Supabase anon key into the docs build (#596)
The docs-site leaderboard (docs/javascripts/leaderboard.js) reads the public
Supabase anon key from window.OPENJARVIS_SUPABASE_ANON_KEY, but nothing set it,
so the published leaderboard always rendered "Leaderboard not configured yet".

Add a generated config file (leaderboard-config.js) loaded before
leaderboard.js that supplies the global, and inject its value at docs-build
time from the existing VITE_SUPABASE_ANON_KEY repo secret. The committed
default is empty, so local `mkdocs build` and fork PRs (no secret) degrade
gracefully. The anon key is public by design (Supabase RLS protects the data).

- docs/javascripts/leaderboard-config.js: empty-default global declaration.
- mkdocs.yml: load leaderboard-config.js before leaderboard.js.
- docs.yml: write the config from the secret (read via env, JSON-encoded into a
  JS string literal to avoid injection) before `mkdocs build`.
- tests/deployment/test_docs_leaderboard.py: guard the wiring + load order.

Verified with a local `mkdocs build`: the generated config ships in site/ and
loads before leaderboard.js.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:49:18 -07:00
github-actions[bot] b3f90691bf chore: update clone traffic data [skip ci] 2026-06-27 07:07:41 +00:00
github-actions[bot] 4ebf0839e7 chore: update clone traffic data [skip ci] 2026-06-26 07:21:03 +00:00
github-actions[bot] b1e93d4ed0 chore: update clone traffic data [skip ci] 2026-06-25 07:14:52 +00:00
Elliot SluskyandClaude Opus 4.8 eb2b612c7c fix(memory): address service follow-ups (#591)
Closes #582. Route fact-store construction through a new FactStoreRegistry (local backend registered by default); align the default facts path with get_config_dir(); wire completed chat exchanges (streamed and non-streamed) through the EventBus so the memory service captures them consistently; reload the local fact store from disk before operations so external clears don't resurrect stale facts; make the affected config/persona/memory/CLI/route tests hermetic; and refresh uv.lock with the current resolver (locks pytest-xdist + transitive deps, drops py3.14 artifacts since the project constrains Python <3.14).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:58:49 -07:00
Elliot SluskyandClaude Opus 4.8 560ec860df fix(docker): build native Rust extension into images (#590)
Build and install the mandatory openjarvis_rust wheel in the CPU, NVIDIA, ROCm, and sandbox Docker images. Rust 1.88 (matching the workspace MSRV / rust-toolchain.toml) and maturin are installed only in the builder stage, the module's import is verified during the build, and maturin is removed before the runtime artifacts are copied so build tooling never ships. The frontend leaderboard anon key is an optional empty-by-default build arg (post-#589), so default images cleanly disable the leaderboard. Adds static deployment coverage for the native build path. Closes #584.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:27:17 -07:00
Jon Saad-FalconandClaude Opus 4.8 e7c46c1985 fix(frontend): make Supabase anon key optional to unblock PyPI publishing (#589)
PyPI publishing had been broken since v1.0.3.dev851: #587 made VITE_SUPABASE_ANON_KEY a hard build-time requirement, but no such secret exists, so the frontend build aborted every publish run before the PyPI upload. Decouple package buildability from the leaderboard credential: a missing anon key now disables the savings leaderboard at runtime instead of failing the build, and auto-enables when the secret is provided. Verified: npm run build with the key unset succeeds; tsc + vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:15:06 -07:00
github-actions[bot] 00d1e39b6d chore: update clone traffic data [skip ci] 2026-06-24 07:14:44 +00:00
49 changed files with 2184 additions and 2240 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "129,427",
"message": "137,874",
"color": "green",
"namedLogo": "git"
}
+9 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 129427,
"last_updated": "2026-06-23T07:18:12Z",
"total_clones": 137874,
"last_updated": "2026-06-30T07:21:14Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -89,6 +89,12 @@
"2026-06-19": 1350,
"2026-06-20": 1437,
"2026-06-21": 1426,
"2026-06-22": 1350
"2026-06-22": 1350,
"2026-06-23": 1468,
"2026-06-24": 1635,
"2026-06-25": 1640,
"2026-06-26": 1338,
"2026-06-27": 1338,
"2026-06-28": 1028
}
}
+20
View File
@@ -41,6 +41,26 @@ jobs:
- name: Install dependencies
run: uv sync --extra docs
# Inject the public Supabase anon key so the savings leaderboard works on
# the published docs site. Missing/empty (e.g. fork PRs) leaves the
# leaderboard gracefully disabled. The key is read from env (not inlined)
# and JSON-encoded into a JS string literal to avoid any injection.
- name: Inject leaderboard Supabase anon key
env:
OPENJARVIS_LEADERBOARD_ANON: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
run: |
python3 - <<'PY'
import json, os, pathlib
key = os.environ.get("OPENJARVIS_LEADERBOARD_ANON", "")
pathlib.Path("docs/javascripts/leaderboard-config.js").write_text(
"// Generated at docs-build time from the VITE_SUPABASE_ANON_KEY secret.\n"
"window.OPENJARVIS_SUPABASE_ANON_KEY = " + json.dumps(key) + ";\n",
encoding="utf-8",
)
print("leaderboard anon key:", "set" if key else "empty (leaderboard disabled)")
PY
- name: Build documentation
run: uv run mkdocs build
+4 -5
View File
@@ -36,8 +36,7 @@ jobs:
- run: npx tsc --noEmit
- run: npm run build
env:
# vite.config/supabase.ts require this at build time. The real value
# comes from the repo secret; CI falls back to a placeholder so the
# build check passes (the leaderboard is not exercised in CI builds).
# Release/docs builds MUST set the secret to the real anon key.
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY || 'ci-placeholder-anon-key' }}
# Optional: when the secret is unset the build still succeeds and the
# leaderboard is disabled (see src/lib/supabase.ts). No placeholder,
# so a keyless CI build doesn't bake in a bogus anon key.
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
+27 -3
View File
@@ -4,16 +4,30 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). `uv export --frozen`
@@ -24,11 +38,13 @@ WORKDIR /app
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
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
# 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 rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
@@ -36,7 +52,15 @@ COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf
+31 -4
View File
@@ -4,20 +4,37 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
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 && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
python3 \
python3-dev \
python3-pip \
python3-venv && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
@@ -25,15 +42,25 @@ WORKDIR /app
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
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04@sha256:af8bd179ed3bf69d4b63b19a763662a6141f0f62ef099283f68d0b14b4bab0e3
+31 -4
View File
@@ -4,20 +4,37 @@
# Stage 1: Build frontend SPA
FROM node:22.23.0-slim@sha256:d9f850096136edbc402debdd8729579a288aac64574ada0ff4db26b6ae58b0b2 AS frontend
# Public Supabase anon key for the savings leaderboard; empty by default so
# the image's leaderboard stays disabled (#589). Pass --build-arg to enable.
ARG OPENJARVIS_LEADERBOARD_PUBLIC_ANON=
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci --ignore-scripts 2>/dev/null || npm install
COPY frontend/ .
RUN npm run build
RUN VITE_SUPABASE_ANON_KEY="${OPENJARVIS_LEADERBOARD_PUBLIC_ANON}" npm run build
# Stage 2: Build Python package (AMD ROCm 7.2)
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 && \
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
python3 \
python3-dev \
python3-pip \
python3-venv && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
# Install dependencies from the committed lockfile (#567). See deploy/docker/Dockerfile
@@ -25,15 +42,25 @@ WORKDIR /app
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
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY src/ src/
COPY rust/ rust/
COPY scripts/install scripts/install
COPY deploy/windows deploy/windows
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust
# Stage 3: Runtime
FROM rocm/dev-ubuntu-22.04:7.2@sha256:05af5f04a06b04676d4c7438997d0deadaeb7478961ad621376e199bf3aeb644
+39 -12
View File
@@ -7,20 +7,18 @@
# 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
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS builder
# 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 && \
apt-get install -y --no-install-recommends build-essential ca-certificates curl && \
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
ENV PATH="/root/.cargo/bin:${PATH}"
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --profile minimal --default-toolchain none && \
rustup toolchain install 1.88 --profile minimal && \
rustup default 1.88
WORKDIR /app
@@ -31,12 +29,41 @@ WORKDIR /app
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
uv pip install --system --no-deps -r requirements.txt && \
uv pip install --system --no-deps "maturin>=1.12.6,<2"
COPY . .
# Install the project itself without re-resolving dependencies.
RUN uv pip install --system --no-deps .
RUN uv pip install --system --no-deps . && \
maturin build --release \
--manifest-path rust/crates/openjarvis-python/Cargo.toml \
--interpreter python3 \
--out /tmp/openjarvis-rust-wheel && \
uv pip install --system --no-deps /tmp/openjarvis-rust-wheel/*.whl && \
python3 -c "import openjarvis_rust; print('openjarvis_rust ok')" && \
python3 -m pip uninstall -y maturin && \
rm -rf /tmp/openjarvis-rust-wheel rust/target
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/*
COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app
# 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
LABEL openjarvis-sandbox=true
+12
View File
@@ -0,0 +1,12 @@
// Public Supabase config for the savings leaderboard.
//
// This file is loaded *before* leaderboard.js and supplies the anon key it
// reads from `window.OPENJARVIS_SUPABASE_ANON_KEY`. The key is injected at
// docs-build time from the VITE_SUPABASE_ANON_KEY repo secret (see
// .github/workflows/docs.yml). It is intentionally empty here so that local
// `mkdocs build` and fork pull requests — which have no secret — render the
// graceful "Leaderboard not configured yet" message instead of failing.
//
// The anon key is public by design: Supabase Row-Level Security protects the
// data, so shipping it in the public docs bundle is expected.
window.OPENJARVIS_SUPABASE_ANON_KEY = "";
+65
View File
@@ -19,6 +19,71 @@ Agents are the agentic logic layer of OpenJarvis. They determine how a query is
---
## Persistent Persona: SOUL.md, MEMORY.md, USER.md
Every agent's system prompt is assembled at conversation start by the `SystemPromptBuilder`, which injects up to three optional Markdown files -- the **persistent persona**. They are plain text you own and edit, loaded at the start of each conversation. There is no vector database or embedding cache behind them.
| File | What it holds | Example line |
|------|---------------|--------------|
| `SOUL.md` | How the agent should behave -- tone, length, what to push back on | `Be concise. Challenge weak assumptions.` |
| `MEMORY.md` | Facts about you, your projects, your preferences | `I deploy to Postgres, never MySQL.` |
| `USER.md` | Who you are -- role, team, context | `Backend engineer at Acme, on the payments team.` |
This persona is distinct from the retrieval [memory backend](memory.md): the persona is always-on Markdown context loaded into the prompt, while the memory backend is searchable long-term storage the agent queries on demand.
### Where they live
By default the files are read from the config directory:
```
~/.openjarvis/SOUL.md
~/.openjarvis/MEMORY.md
~/.openjarvis/USER.md
```
(The config directory honors `$OPENJARVIS_HOME` / `$XDG_DATA_HOME` when set.) The paths are configurable under `[memory_files]`:
```toml
[memory_files]
soul_path = "~/.openjarvis/SOUL.md"
memory_path = "~/.openjarvis/MEMORY.md"
user_path = "~/.openjarvis/USER.md"
persona_name = "" # optional named persona -- see below
```
### How they're loaded
At the start of each conversation, `SystemPromptBuilder` reads each file as UTF-8 and adds its contents as a section of the system prompt, after the agent template and before the skill catalog:
- **All three are optional.** A missing or empty file is skipped, so any subset works and an install with no persona files behaves exactly as before.
- **Edits apply to the next conversation.** The files are read once when a conversation's prompt is built, so there is no restart or re-indexing -- edit or delete a line and it takes effect the next time you start a conversation.
- **Each section is length-capped.** Files are truncated to a per-section character budget so a large `MEMORY.md` cannot crowd out the rest of the prompt.
### Named personas
A single install can answer as different personas without changing global config. A named persona lives in its own directory:
```
~/.openjarvis/personas/<name>/SOUL.md
~/.openjarvis/personas/<name>/MEMORY.md
~/.openjarvis/personas/<name>/USER.md
```
Select one per invocation, or opt out entirely:
```bash
jarvis ask --persona work "summarize my open PRs"
jarvis ask --persona none "what is 2 + 2?" # inject no persona
```
Set `persona_name` under `[memory_files]` to make a named persona the default. `persona_name = "none"` (equivalently `--persona none`) disables persona injection for that run.
### Editing them
`SOUL.md`, `MEMORY.md`, and `USER.md` are plain Markdown -- open them in any editor. `MEMORY.md` and `USER.md` can also be updated by the agent itself through the `memory_manage` and `user_profile_manage` tools when those are enabled, so the agent can record a new fact mid-conversation. These tools always target the default `MEMORY.md` and `USER.md` (under `~/.openjarvis/`), never a named persona's copies -- edit those by hand.
---
## BaseAgent ABC
All agents extend the abstract `BaseAgent` class.
+67 -52
View File
@@ -11,7 +11,7 @@
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-global-shortcut": "^2",
@@ -42,7 +42,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -3720,9 +3720,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -3730,9 +3730,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -3746,23 +3746,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1",
"@tauri-apps/cli-darwin-x64": "2.10.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
@@ -3777,9 +3777,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
@@ -3794,9 +3794,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
@@ -3811,13 +3811,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3828,13 +3831,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3845,13 +3851,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3862,13 +3871,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3879,13 +3891,16 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -3896,9 +3911,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
@@ -3913,9 +3928,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
@@ -3930,9 +3945,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
+2 -2
View File
@@ -18,7 +18,7 @@
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/api": "^2",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-global-shortcut": "^2",
@@ -49,7 +49,7 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
+405 -53
View File
@@ -9,7 +9,7 @@ use tokio::sync::Mutex;
const OLLAMA_PORT: u16 = 11434;
const JARVIS_PORT: u16 = 8000;
/// Small, fast model pulled at startup so the app opens quickly.
/// Small, fast model used when startup needs a default Ollama tag.
const STARTUP_MODEL: &str = "qwen3.5:4b";
/// Tiny fallback model if even the startup model can't be pulled.
@@ -104,7 +104,7 @@ fn default_local_model(ram_gb: f64) -> &'static str {
struct BootPlan {
/// Whether to start and wait for the bundled Ollama.
launch_ollama: bool,
/// The single Ollama model to pull (None for custom endpoints).
/// The preferred Ollama model (None for custom endpoints).
model_to_pull: Option<String>,
/// Optional `(engine_key, bare_host)` override for a custom endpoint,
/// e.g. `("lmstudio", "http://localhost:1234")`. Written into
@@ -608,6 +608,69 @@ async fn wait_for_jarvis_health(
}
async fn ollama_has_model(model: &str) -> bool {
let models = ollama_model_names().await;
matching_installed_model(&models, model).is_some()
}
fn parse_ollama_model_names(body: &serde_json::Value) -> Vec<String> {
body.get("models")
.and_then(|m| m.as_array())
.map(|models| {
models
.iter()
.filter_map(|m| {
m.get("name")
.or_else(|| m.get("model"))
.and_then(|n| n.as_str())
})
.filter(|name| !name.trim().is_empty())
.map(|name| name.to_string())
.collect()
})
.unwrap_or_default()
}
fn model_names_match(installed: &str, requested: &str) -> bool {
installed == requested
|| installed.strip_suffix(":latest") == Some(requested)
|| requested.strip_suffix(":latest") == Some(installed)
}
fn matching_installed_model(models: &[String], requested: &str) -> Option<String> {
models
.iter()
.find(|model| model_names_match(model, requested))
.cloned()
}
fn model_name_looks_embedding_only(model: &str) -> bool {
let name = model.to_ascii_lowercase();
["embed", "embedding", "rerank", "minilm", "bge-", "bge_", "e5-", "e5_"]
.iter()
.any(|marker| name.contains(marker))
}
fn preferred_installed_model(models: &[String]) -> Option<String> {
models
.iter()
.find(|model| !model.trim().is_empty() && !model_name_looks_embedding_only(model))
.or_else(|| models.iter().find(|model| !model.trim().is_empty()))
.cloned()
}
fn startup_installed_model(requested_model: &str, installed_models: &[String]) -> Option<String> {
matching_installed_model(installed_models, requested_model)
.or_else(|| preferred_installed_model(installed_models))
}
fn should_persist_resolved_model(cfg: &InferenceConfig) -> bool {
cfg.model
.as_deref()
.map(|model| model.trim().is_empty())
.unwrap_or(true)
}
async fn ollama_model_names() -> Vec<String> {
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
@@ -615,21 +678,10 @@ async fn ollama_has_model(model: &str) -> bool {
.unwrap();
if let Ok(resp) = client.get(&url).send().await {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
return models.iter().any(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|n| {
n == model
|| n.strip_suffix(":latest") == Some(model)
|| model.strip_suffix(":latest") == Some(n)
})
.unwrap_or(false)
});
}
return parse_ollama_model_names(&body);
}
}
false
Vec::new()
}
async fn pull_model(model: &str) -> Result<(), String> {
@@ -679,13 +731,20 @@ fn format_uv_sync_failure(
let code = exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "unknown".to_string());
let tail = uv_sync_stderr_tail(stderr, 800);
let rust_hint = if looks_like_rust_extension_build_error(stderr) {
format!("\n\n{}", rust_toolchain_install_hint())
} else {
String::new()
};
format!(
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
Try opening a terminal in that directory and running \
`uv sync --extra desktop` manually for the full output.",
`uv sync --extra desktop` manually for the full output.{}",
root.display(),
code,
uv_sync_stderr_tail(stderr, 800),
tail,
rust_hint,
)
}
@@ -734,6 +793,121 @@ fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -
)
}
fn rust_toolchain_install_hint() -> &'static str {
"The desktop app needs the Rust toolchain to build `openjarvis_rust`. \
Install Rust from https://rustup.rs. On Windows, also install Visual Studio \
Build Tools with the C++ workload, then relaunch."
}
fn looks_like_rust_extension_build_error(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
[
"openjarvis-rust",
"openjarvis_rust",
"maturin",
"cargo",
"rustc",
"link.exe",
"visual studio",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn format_missing_rust_toolchain() -> String {
format!(
"Could not find Rust's `cargo` command. {}\n\n\
If Rust is already installed, close and relaunch the desktop app so \
PATH includes `~/.cargo/bin`.",
rust_toolchain_install_hint(),
)
}
fn format_extension_import_failure(root: &std::path::Path, stderr: &str) -> String {
let tail = uv_sync_stderr_tail(stderr, 4000);
format!(
"`openjarvis_rust` is still not importable after building. Last output:\n\n{}\n\n\
Run these manually for the full build log:\n\n\
cd {}\n\
uv sync --extra desktop\n\
uv run python -c \"import openjarvis_rust\"",
if tail.is_empty() {
"(no stderr output)"
} else {
&tail
},
root.display(),
)
}
fn add_cargo_bin_to_path(cmd: &mut tokio::process::Command) {
let mut paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
paths.insert(
0,
std::path::PathBuf::from(home_dir())
.join(".cargo")
.join("bin"),
);
if let Ok(joined) = std::env::join_paths(paths) {
cmd.env("PATH", joined);
}
}
async fn verify_openjarvis_rust_extension(
root: &std::path::Path,
uv_bin: &str,
) -> Result<(), String> {
let mut cmd = tokio::process::Command::new(uv_bin);
cmd.args(["run", "python", "-c", "import openjarvis_rust"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.current_dir(root);
prepare_subprocess_for_appimage(&mut cmd);
add_cargo_bin_to_path(&mut cmd);
match cmd.output().await {
Ok(out) if out.status.success() => Ok(()),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(format_extension_import_failure(root, &stderr))
}
Err(e) => Err(format!(
"Could not verify `openjarvis_rust`: {}. Verify uv is installed at `{}`.",
e, uv_bin
)),
}
}
fn port_owner_hint() -> String {
if cfg!(target_os = "windows") {
format!("netstat -ano | findstr :{}", JARVIS_PORT)
} else {
format!("lsof -i :{}", JARVIS_PORT)
}
}
fn format_port_unavailable(port: u16, reason: &str) -> String {
format!(
"Port {} is not available: {}. Stop the process using that port or \
change the OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
port,
reason,
port_owner_hint(),
)
}
fn check_jarvis_port_available() -> Result<(), String> {
match std::net::TcpListener::bind(("127.0.0.1", JARVIS_PORT)) {
Ok(listener) => {
drop(listener);
Ok(())
}
Err(err) => Err(format_port_unavailable(JARVIS_PORT, &err.to_string())),
}
}
// ---------------------------------------------------------------------------
// Backend boot sequence (runs in background after app launch)
// ---------------------------------------------------------------------------
@@ -751,7 +925,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
.into();
}
// For the Ollama path, the model pull may fall back to FALLBACK_MODEL; we
// For the Ollama path, model resolution may fall back to FALLBACK_MODEL; we
// record what is actually available here so the serve command below uses
// it instead of the originally-planned tag. None on the custom path.
let mut serve_model_override: Option<String> = None;
@@ -798,8 +972,8 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
s.detail = "Inference engine ready.".into();
}
// Phase 2: Pull the single default model (see default_local_model /
// boot_plan). We deliberately do NOT pull any others.
// Phase 2: Resolve one model to serve. Prefer an installed model on
// first run so startup does not depend on a download succeeding.
let model = plan
.model_to_pull
.clone()
@@ -810,41 +984,63 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
s.detail = format!("Checking for {}...", model);
}
if !ollama_has_model(&model).await {
let installed_models = ollama_model_names().await;
let resolved_model = if let Some(installed) = startup_installed_model(&model, &installed_models) {
installed
} else {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}... (this may take a minute)", model);
}
if let Err(e) = pull_model(&model).await {
// If the chosen model fails, try the tiny fallback
eprintln!("Warning: failed to pull {}: {}", model, e);
if !ollama_has_model(FALLBACK_MODEL).await {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
}
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
let mut s = status.lock().await;
s.error = Some(format!("Failed to download model: {}", e2));
return;
match pull_model(&model).await {
Ok(()) => model.clone(),
Err(e) => {
eprintln!("Warning: failed to pull {}: {}", model, e);
// If a local model appeared while pulling, use it instead of
// making startup depend on another network pull.
if let Some(installed) = preferred_installed_model(&ollama_model_names().await) {
installed
} else if ollama_has_model(FALLBACK_MODEL).await {
FALLBACK_MODEL.to_string()
} else {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
}
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
if let Some(installed) =
preferred_installed_model(&ollama_model_names().await)
{
installed
} else {
let mut s = status.lock().await;
s.error = Some(format!("Failed to download model: {}", e2));
return;
}
} else {
FALLBACK_MODEL.to_string()
}
}
}
}
};
if resolved_model != model {
let mut s = status.lock().await;
s.detail = format!("Using installed model {}.", resolved_model);
}
// The pull may have fallen back to FALLBACK_MODEL; serve and persist
// whatever is actually available now, not the originally-planned tag.
let resolved_model = if ollama_has_model(&model).await {
model
} else {
FALLBACK_MODEL.to_string()
};
serve_model_override = Some(resolved_model.clone());
// Persist the resolved model so Settings shows it and future boots reuse it.
let mut persisted = cfg.clone();
persisted.model = Some(resolved_model);
let _ = write_inference_config(&persisted);
// Persist only first-run/default resolution. If the user explicitly
// configured a model, do not overwrite that choice with a temporary
// fallback selected just to keep startup nonfatal.
if should_persist_resolved_model(&cfg) {
let mut persisted = cfg.clone();
persisted.model = Some(resolved_model);
let _ = write_inference_config(&persisted);
}
{
let mut s = status.lock().await;
@@ -1097,11 +1293,6 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
// Something else (a different web server, a stale process,
// a 4xx-returning instance) is on our port. Don't kill it —
// give the user actionable info instead.
let lsof_hint = if cfg!(target_os = "windows") {
format!("netstat -ano | findstr :{}", JARVIS_PORT)
} else {
format!("lsof -i :{}", JARVIS_PORT)
};
let mut s = status.lock().await;
s.error = Some(format!(
"Port {} is already in use by another service (it answered \
@@ -1109,7 +1300,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
JARVIS_PORT,
resp.status(),
lsof_hint,
port_owner_hint(),
));
return;
}
@@ -1119,8 +1310,21 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
}
}
if let Err(err) = check_jarvis_port_available() {
let mut s = status.lock().await;
s.error = Some(err);
return;
}
let root = project_root.as_ref().unwrap();
let cargo_bin = resolve_bin("cargo");
if !std::path::Path::new(&cargo_bin).exists() && cargo_bin == "cargo" {
let mut s = status.lock().await;
s.error = Some(format_missing_rust_toolchain());
return;
}
// Install dependencies automatically (handles fresh clones).
//
// Previously we ran `uv sync` with both stdout AND stderr piped to
@@ -1152,6 +1356,7 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
.current_dir(root);
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
prepare_subprocess_for_appimage(&mut sync_cmd);
add_cargo_bin_to_path(&mut sync_cmd);
let sync_output = sync_cmd.output().await;
match sync_output {
Ok(out) if !out.status.success() => {
@@ -1168,6 +1373,16 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
Ok(_) => {} // success — fall through
}
{
let mut s = status.lock().await;
s.detail = "Verifying Rust extension (openjarvis_rust)...".into();
}
if let Err(err) = verify_openjarvis_rust_extension(root, &uv_bin).await {
let mut s = status.lock().await;
s.error = Some(err);
return;
}
{
let mut s = status.lock().await;
s.detail = format!("Starting API server from {}...", root.display());
@@ -2646,9 +2861,12 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::{
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
InferenceConfig, SourceKind,
boot_plan, default_local_model, format_extension_import_failure,
format_missing_rust_toolchain, format_port_unavailable, format_uv_sync_failure,
format_uv_sync_spawn_error, matching_installed_model, model_names_match, normalize_host,
parse_inference_config, parse_ollama_model_names, preferred_installed_model,
should_persist_resolved_model, startup_installed_model, upsert_engine_host,
uv_sync_stderr_tail, InferenceConfig, SourceKind,
};
use std::path::Path;
@@ -2715,6 +2933,49 @@ mod tests {
assert!(msg.contains("No such file or directory"));
}
#[test]
fn missing_rust_toolchain_message_names_cargo_and_installer() {
let msg = format_missing_rust_toolchain();
assert!(msg.contains("cargo"));
assert!(msg.contains("https://rustup.rs"));
assert!(msg.contains("openjarvis_rust"));
assert!(msg.contains("Visual Studio Build Tools"));
}
#[test]
fn uv_sync_rust_failure_mentions_toolchain() {
let msg = format_uv_sync_failure(
Path::new("C:\\Users\\me\\OpenJarvis"),
Some(1),
"maturin failed: linker `link.exe` not found while building openjarvis-rust",
);
assert!(msg.contains("exit 1"));
assert!(msg.contains("link.exe"));
assert!(msg.contains("https://rustup.rs"));
assert!(msg.contains("Visual Studio Build Tools"));
}
#[test]
fn extension_import_failure_names_verification_command() {
let msg = format_extension_import_failure(
Path::new("C:\\Users\\me\\OpenJarvis"),
"ModuleNotFoundError: No module named 'openjarvis_rust'",
);
assert!(msg.contains("openjarvis_rust"));
assert!(msg.contains("uv sync --extra desktop"));
assert!(msg.contains("uv run python -c \"import openjarvis_rust\""));
assert!(msg.contains("ModuleNotFoundError"));
}
#[test]
fn port_unavailable_message_names_port_and_owner_hint() {
let msg = format_port_unavailable(8000, "address already in use");
assert!(msg.contains("Port 8000 is not available"));
assert!(msg.contains("address already in use"));
assert!(msg.contains("To identify it"));
assert!(msg.contains("8000"));
}
#[test]
fn default_local_model_picks_second_largest_that_fits() {
// QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB
@@ -2730,6 +2991,97 @@ mod tests {
assert_eq!(default_local_model(1.0), super::FALLBACK_MODEL);
}
#[test]
fn parse_ollama_model_names_reads_nonempty_names() {
let body = serde_json::json!({
"models": [
{"name": "llama3.2:latest"},
{"name": ""},
{"name": "qwen3.5:4b"},
{"model": "mistral:latest"}
]
});
assert_eq!(
parse_ollama_model_names(&body),
vec![
"llama3.2:latest".to_string(),
"qwen3.5:4b".to_string(),
"mistral:latest".to_string()
]
);
}
#[test]
fn model_names_match_treats_latest_as_optional() {
assert!(model_names_match("llama3.2:latest", "llama3.2"));
assert!(model_names_match("llama3.2", "llama3.2:latest"));
assert!(model_names_match("qwen3.5:4b", "qwen3.5:4b"));
assert!(!model_names_match("llama3.2:latest", "qwen3.5:4b"));
}
#[test]
fn installed_model_helpers_pick_matching_or_first_model() {
let models = vec!["llama3.2:latest".to_string(), "qwen3.5:4b".to_string()];
assert_eq!(
matching_installed_model(&models, "llama3.2"),
Some("llama3.2:latest".to_string())
);
assert_eq!(
preferred_installed_model(&models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn preferred_installed_model_skips_embedding_names_when_chat_model_exists() {
let models = vec![
"nomic-embed-text:latest".to_string(),
"llama3.2:latest".to_string(),
];
assert_eq!(
preferred_installed_model(&models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn startup_installed_model_uses_existing_model_for_defaults() {
let models = vec!["llama3.2:latest".to_string()];
assert_eq!(
startup_installed_model("qwen3.5:4b", &models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn startup_installed_model_uses_existing_model_when_configured_model_missing() {
let models = vec!["llama3.2:latest".to_string()];
assert_eq!(
startup_installed_model("qwen3.5:4b", &models),
Some("llama3.2:latest".to_string())
);
}
#[test]
fn resolved_model_is_only_persisted_when_no_model_was_configured() {
let default_cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
assert!(should_persist_resolved_model(&default_cfg));
let empty_cfg = InferenceConfig {
kind: SourceKind::Ollama,
model: Some(" ".into()),
..Default::default()
};
assert!(should_persist_resolved_model(&empty_cfg));
let user_cfg = InferenceConfig {
kind: SourceKind::Ollama,
model: Some("qwen3.5:9b".into()),
..Default::default()
};
assert!(!should_persist_resolved_model(&user_cfg));
}
#[test]
fn parse_defaults_to_ollama_when_file_missing_or_garbage() {
assert!(matches!(parse_inference_config("").kind, SourceKind::Ollama));
+5 -1
View File
@@ -243,7 +243,11 @@ export function InputArea() {
try {
if (deepResearch) {
for await (const ev of streamResearch(content, controller.signal)) {
for await (const ev of streamResearch(
content,
selectedModel,
controller.signal,
)) {
if (ev.type === 'search_call') {
const trace: ResearchSearchTrace = {
id: generateId(),
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import type React from 'react';
import { invoke } from '@tauri-apps/api/core';
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
import { LEADERBOARD_ENABLED, SUPABASE_ANON_KEY, SUPABASE_URL } from '../../lib/supabase';
// ---------------------------------------------------------------------------
// Types
@@ -316,9 +316,10 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
return () => clearInterval(timer);
}, [fetchData]);
// Share savings to Supabase when opted in and data changes
// Share savings to Supabase when opted in and data changes. Skipped entirely
// when no anon key was built in (leaderboard disabled).
useEffect(() => {
if (!optInEnabled || !displayName || !data) return;
if (!LEADERBOARD_ENABLED || !optInEnabled || !displayName || !data) return;
const dollarSavings = data.per_provider.reduce((s, p) => s + p.total_cost, 0);
const energySaved = data.per_provider.reduce((s, p) => s + (p.energy_wh || 0), 0);
const flopsSaved = data.per_provider.reduce((s, p) => s + (p.flops || 0), 0);
+2 -2
View File
@@ -60,6 +60,7 @@ export async function* streamChat(
export async function* streamResearch(
query: string,
model?: string,
signal?: AbortSignal,
): AsyncGenerator<ResearchEvent> {
// /api/research is mounted at the server root — strip any trailing /v1
@@ -68,7 +69,7 @@ export async function* streamResearch(
const response = await fetch(`${base}/api/research`, {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ query }),
body: JSON.stringify({ query, ...(model ? { model } : {}) }),
signal,
});
@@ -106,4 +107,3 @@ export async function* streamResearch(
reader.releaseLock();
}
}
+7 -4
View File
@@ -1,8 +1,11 @@
export const SUPABASE_URL =
import.meta.env.VITE_SUPABASE_URL || 'https://mtbtgpwzrbostweaanpr.supabase.co';
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
// The Supabase anon key is optional at build time. When it is unset the public
// savings leaderboard is disabled rather than failing the build — this keeps
// the `openjarvis` package and desktop app buildable without coupling
// publishability to a leaderboard credential. Set VITE_SUPABASE_ANON_KEY at
// build time (from a repo secret) to enable the leaderboard.
export const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY ?? '';
if (!SUPABASE_ANON_KEY) {
throw new Error('VITE_SUPABASE_ANON_KEY is required');
}
export const LEADERBOARD_ENABLED = SUPABASE_ANON_KEY.length > 0;
+5 -9
View File
@@ -1,16 +1,13 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, __dirname, '');
if (command === 'build' && !env.VITE_SUPABASE_ANON_KEY) {
throw new Error('VITE_SUPABASE_ANON_KEY is required');
}
return {
// VITE_SUPABASE_ANON_KEY is intentionally NOT required here: a missing key
// disables the savings leaderboard at runtime (see src/lib/supabase.ts) rather
// than failing the build, so the package/app stays publishable without it.
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -62,5 +59,4 @@ export default defineConfig(({ command, mode }) => {
'/api': process.env.VITE_API_URL || 'http://localhost:8000',
},
},
};
});
+1
View File
@@ -127,6 +127,7 @@ markdown_extensions:
- pymdownx.tilde
extra_javascript:
- javascripts/leaderboard-config.js
- javascripts/leaderboard.js
- https://cdn.jsdelivr.net/npm/@docsearch/js@3
- javascripts/docsearch-init.js
+4
View File
@@ -91,6 +91,7 @@ desktop = [
"pydantic>=2.0",
"python-multipart>=0.0.9",
"faster-whisper>=1.0",
"openjarvis-rust",
]
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
gpu-metrics = ["pynvml>=12.0"]
@@ -186,6 +187,9 @@ git_describe_command = [
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
fallback_version = "0.0.0+unknown"
[tool.uv.sources]
openjarvis-rust = { path = "rust/crates/openjarvis-python" }
[tool.hatch.build.targets.wheel]
packages = ["src/openjarvis"]
+5 -5
View File
@@ -19,6 +19,7 @@ from openjarvis.agents.prompt_loader import (
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._base import estimate_prompt_tokens
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool, build_tool_descriptions
@@ -116,8 +117,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
max_prompt_tokens: int = 3000,
) -> list[Message]:
"""Truncate messages if estimated token count exceeds limit."""
total_chars = sum(len(m.content) for m in messages)
estimated_tokens = total_chars // 4
estimated_tokens = estimate_prompt_tokens(messages)
if estimated_tokens <= max_prompt_tokens:
return messages
# Find the last user message and truncate its content
@@ -125,7 +125,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
if messages[i].role == Role.USER:
excess_tokens = estimated_tokens - max_prompt_tokens
excess_chars = excess_tokens * 4
original = messages[i].content
original = messages[i].content or ""
if len(original) > excess_chars + 200:
truncated = original[: len(original) - excess_chars]
messages[i] = Message(
@@ -258,7 +258,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
# still emitted before re-raising.
self._emit_turn_end(turns=1, error=True)
raise
content = self._strip_think_tags(result.get("content", ""))
content = self._strip_think_tags(result.get("content") or "")
usage = result.get("usage", {})
self._emit_turn_end(turns=1)
return AgentResult(
@@ -315,7 +315,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
for k in total_usage:
total_usage[k] += usage.get(k, 0)
content = result.get("content", "")
content = result.get("content") or ""
# Strip think tags so they don't interfere with parsing
content = self._strip_think_tags(content)
last_content = content
+2 -1
View File
@@ -2,7 +2,8 @@
A small, self-contained planner-executor loop:
* the planner is a local Ollama chat model (default ``gemma4:31b``),
* the planner is supplied by the caller (the web endpoint resolves it from
config, falling back to ``gemma4:31b`` on Ollama for legacy installs),
* the only tool it can call is :meth:`HybridSearch.search`,
* it gets up to ``max_iterations`` tool calls,
* tool results are trimmed before re-entering the context window, and
+11 -6
View File
@@ -11,7 +11,9 @@ from rich.markdown import Markdown
from openjarvis.cli._tool_names import resolve_tool_names
from openjarvis.core.config import load_config
from openjarvis.core.events import EventBus
from openjarvis.core.types import Message, Role
from openjarvis.memory import publish_completed_exchange
def _read_input(prompt: str = "You> ") -> Optional[str]:
@@ -57,6 +59,7 @@ def chat(
console = Console(stderr=True)
config = load_config()
bus = EventBus(record_history=False)
import dataclasses as _dc
@@ -97,12 +100,11 @@ def chat(
if agent_key and agent_key != "none":
try:
import openjarvis.agents # noqa: F401 — trigger registration
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
if AgentRegistry.contains(agent_key):
agent_cls = AgentRegistry.get(agent_key)
kwargs: dict = {"bus": EventBus()}
kwargs: dict = {"bus": bus}
if getattr(agent_cls, "accepts_tools", False):
tool_names_list = resolve_tool_names(
@@ -184,7 +186,7 @@ def chat(
try:
from openjarvis.memory import build_memory_service
memory_service = build_memory_service(config, engine, model)
memory_service = build_memory_service(config, engine, model, event_bus=bus)
if memory_service is not None:
memory_service.start()
console.print("[dim] Memory: active[/dim]")
@@ -280,9 +282,12 @@ def chat(
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)
publish_completed_exchange(
bus,
user_input,
content,
source="cli.chat",
)
except KeyboardInterrupt:
console.print("\n[dim]Generation interrupted.[/dim]")
except Exception as exc:
+6 -1
View File
@@ -498,7 +498,12 @@ def serve(
try:
from openjarvis.memory import build_memory_service
memory_service = build_memory_service(config, engine, model_name)
memory_service = build_memory_service(
config,
engine,
model_name,
event_bus=bus,
)
if memory_service is not None:
memory_service.start()
console.print(" Memory svc: [cyan]active[/cyan]")
+18 -1
View File
@@ -593,6 +593,14 @@ class IntelligenceConfig:
stop_sequences: str = "" # Comma-separated stop strings
@dataclass(slots=True)
class DeepResearchConfig:
"""Planner settings for the web Deep Research endpoint."""
engine: str = "" # Empty means use the active chat engine.
model: str = "" # Empty means use the active chat model.
@dataclass(slots=True)
class RoutingLearningConfig:
"""Routing sub-policy config within Learning."""
@@ -932,7 +940,9 @@ class StorageConfig:
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")
facts_path: str = field(
default_factory=lambda: str(get_config_dir() / "memory_facts.jsonl")
)
# Backward-compatibility alias
@@ -1576,6 +1586,7 @@ class JarvisConfig:
hardware: HardwareInfo = field(default_factory=HardwareInfo)
engine: EngineConfig = field(default_factory=EngineConfig)
intelligence: IntelligenceConfig = field(default_factory=IntelligenceConfig)
deep_research: DeepResearchConfig = field(default_factory=DeepResearchConfig)
learning: LearningConfig = field(default_factory=LearningConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig)
agent: AgentConfig = field(default_factory=AgentConfig)
@@ -1837,6 +1848,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
top_sections = (
"engine",
"intelligence",
"deep_research",
"learning",
"agent",
"server",
@@ -2005,6 +2017,10 @@ max_tokens = 1024
# repetition_penalty = 1.0
# stop_sequences = ""
# [deep_research]
# engine = "" # empty = use [engine].default
# model = "" # empty = use [intelligence].default_model
[agent]
default_agent = "simple"
max_turns = 10
@@ -2175,6 +2191,7 @@ __all__ = [
"DEFAULT_CONFIG_DIR",
"DEFAULT_CONFIG_PATH",
"DiscordChannelConfig",
"DeepResearchConfig",
"get_cache_dir",
"get_config_dir",
"get_config_path",
+1
View File
@@ -27,6 +27,7 @@ class EventType(str, Enum):
TOOL_CALL_END = "tool_call_end"
MEMORY_STORE = "memory_store"
MEMORY_RETRIEVE = "memory_retrieve"
CHAT_EXCHANGE_COMPLETED = "chat_exchange_completed"
AGENT_TURN_START = "agent_turn_start"
AGENT_TURN_END = "agent_turn_end"
TELEMETRY_RECORD = "telemetry_record"
+6
View File
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Generic, Tuple, Type, Typ
if TYPE_CHECKING:
from openjarvis.agents._stubs import BaseAgent
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.memory.store import FactStore
from openjarvis.tools.storage._stubs import MemoryBackend
T = TypeVar("T")
@@ -109,6 +110,10 @@ class MemoryRegistry(RegistryBase[Type["MemoryBackend"]]):
"""Registry for memory / retrieval backends."""
class FactStoreRegistry(RegistryBase[Type["FactStore"]]):
"""Registry for automatic-memory fact store backends."""
class AgentRegistry(RegistryBase[Type["BaseAgent"]]):
"""Registry for agent implementations."""
@@ -170,6 +175,7 @@ __all__ = [
"CompressionRegistry",
"ConnectorRegistry",
"EngineRegistry",
"FactStoreRegistry",
"LearningRegistry",
"MemoryRegistry",
"MinerRegistry",
+6 -1
View File
@@ -63,7 +63,7 @@ class Message:
"""A single chat message (OpenAI-compatible structure)."""
role: Role
content: str = ""
content: str | None = ""
name: Optional[str] = None
tool_calls: Optional[List[ToolCall]] = None
tool_call_id: Optional[str] = None
@@ -73,6 +73,11 @@ class Message:
# empty for text-only messages (the common case).
images: Optional[List[str]] = None
@property
def text(self) -> str:
"""Return message content as text, treating ``None`` as empty."""
return self.content or ""
@dataclass(slots=True)
class Conversation:
+20 -2
View File
@@ -13,6 +13,22 @@ class EngineConnectionError(Exception):
"""Raised when an engine is unreachable."""
_REASONING_METADATA_KEYS = ("reasoning_content", "thinking")
def _message_estimated_chars(message: Message) -> int:
parts = [message.text]
for key in _REASONING_METADATA_KEYS:
value = message.metadata.get(key)
if isinstance(value, str):
parts.append(value)
for tc in message.tool_calls or []:
parts.extend((tc.id, tc.name, tc.arguments))
if message.tool_call_id:
parts.append(message.tool_call_id)
return sum(len(part) for part in parts)
def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
"""Convert ``Message`` objects to OpenAI-format dicts."""
out: List[Dict[str, Any]] = []
@@ -53,9 +69,11 @@ def estimate_prompt_tokens(messages: Sequence[Message]) -> int:
provider would charge.
Uses ~4 characters per token (standard BPE average for English) plus
a small per-message overhead for role markers and separators.
a small per-message overhead for role markers and separators. Counts
content, reasoning metadata, tool-call payloads, and tool result IDs
because all are replayed into later prompt turns when present.
"""
total_chars = sum(len(m.content) for m in messages)
total_chars = sum(_message_estimated_chars(m) for m in messages)
# ~4 tokens overhead per message for role markers / separators
overhead = len(messages) * 4
return max(1, total_chars // 4 + overhead)
+6 -1
View File
@@ -9,7 +9,11 @@ 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.service import (
MemoryService,
build_memory_service,
publish_completed_exchange,
)
from openjarvis.memory.store import (
Fact,
FactStore,
@@ -25,4 +29,5 @@ __all__ = [
"MemoryService",
"build_memory_service",
"create_fact_store",
"publish_completed_exchange",
]
+60 -3
View File
@@ -20,6 +20,7 @@ import queue
import threading
from typing import Any, List, Optional
from openjarvis.core.events import Event, EventBus, EventType
from openjarvis.memory.extractor import FactExtractor
from openjarvis.memory.store import Fact, FactStore, create_fact_store
@@ -37,10 +38,13 @@ class MemoryService:
store: FactStore,
extractor: FactExtractor,
*,
event_bus: EventBus | None = None,
max_queue: int = 256,
) -> None:
self._store = store
self._extractor = extractor
self._event_bus = event_bus
self._subscribed = False
self._queue: "queue.Queue[Any]" = queue.Queue(maxsize=max(1, max_queue))
self._thread: Optional[threading.Thread] = None
self._running = threading.Event()
@@ -52,6 +56,7 @@ class MemoryService:
if self._running.is_set():
return
self._running.set()
self._subscribe_events()
self._thread = threading.Thread(
target=self._loop,
name="memory-service",
@@ -73,6 +78,7 @@ class MemoryService:
if thread is not None:
thread.join(timeout=timeout)
self._thread = None
self._unsubscribe_events()
logger.debug("Memory service stopped")
@property
@@ -99,6 +105,34 @@ class MemoryService:
logger.debug("Memory service queue full; dropping exchange")
return False
def _subscribe_events(self) -> None:
"""Subscribe to lifecycle events that feed automatic memory."""
if self._event_bus is None or self._subscribed:
return
self._event_bus.subscribe(
EventType.CHAT_EXCHANGE_COMPLETED,
self._on_completed_exchange,
)
self._subscribed = True
def _unsubscribe_events(self) -> None:
"""Unsubscribe from lifecycle events (idempotent)."""
if self._event_bus is None or not self._subscribed:
return
self._event_bus.unsubscribe(
EventType.CHAT_EXCHANGE_COMPLETED,
self._on_completed_exchange,
)
self._subscribed = False
def _on_completed_exchange(self, event: Event) -> None:
"""Queue a completed chat exchange published on the event bus."""
data = event.data or {}
self.submit(
str(data.get("user_text", "") or ""),
str(data.get("assistant_text", "") or ""),
)
# -- worker -------------------------------------------------------------
def _loop(self) -> None:
@@ -145,6 +179,8 @@ def build_memory_service(
config: Any,
engine: Any,
default_model: str = "",
*,
event_bus: EventBus | None = None,
) -> Optional[MemoryService]:
"""Build a :class:`MemoryService` from config, or ``None`` if disabled.
@@ -170,11 +206,32 @@ def build_memory_service(
store = create_fact_store(
getattr(mem, "backend", "local"),
path=getattr(mem, "facts_path", "~/.openjarvis/memory_facts.jsonl"),
path=getattr(mem, "facts_path", None),
max_facts=getattr(mem, "max_facts", 1000),
)
extractor = FactExtractor(engine, model)
return MemoryService(store, extractor)
return MemoryService(store, extractor, event_bus=event_bus)
__all__ = ["MemoryService", "build_memory_service"]
def publish_completed_exchange(
bus: EventBus | None,
user_text: str,
assistant_text: str = "",
*,
source: str = "",
) -> bool:
"""Publish a completed chat exchange for lifecycle subscribers."""
if bus is None or not user_text or not user_text.strip():
return False
bus.publish(
EventType.CHAT_EXCHANGE_COMPLETED,
{
"user_text": user_text,
"assistant_text": assistant_text or "",
"source": source,
},
)
return True
__all__ = ["MemoryService", "build_memory_service", "publish_completed_exchange"]
+37 -8
View File
@@ -18,6 +18,14 @@ from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, List
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import FactStoreRegistry
def _default_fact_path() -> Path:
"""Return the env-aware default JSONL path for automatic memory facts."""
return get_config_dir() / "memory_facts.jsonl"
@dataclass(slots=True)
class Fact:
@@ -56,6 +64,7 @@ class FactStore(ABC):
"""Return the number of stored facts."""
@FactStoreRegistry.register("local")
class LocalFactStore(FactStore):
"""Append-only JSONL fact store on the local filesystem.
@@ -67,11 +76,13 @@ class LocalFactStore(FactStore):
def __init__(
self,
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
path: str | Path | None = None,
*,
max_facts: int = 1000,
) -> None:
self._path = Path(path).expanduser()
self._path = (
Path(path).expanduser() if path is not None else _default_fact_path()
)
self._max_facts = max(0, int(max_facts))
self._lock = threading.Lock()
self._facts: List[Fact] = self._load()
@@ -116,6 +127,10 @@ class LocalFactStore(FactStore):
tmp.write_text(payload, encoding="utf-8")
os.replace(tmp, self._path)
def _sync_from_disk_locked(self) -> None:
"""Refresh in-memory facts from disk while holding ``self._lock``."""
self._facts = self._load()
# -- FactStore API ------------------------------------------------------
def add(self, text: str, source: str = "") -> bool:
@@ -123,6 +138,7 @@ class LocalFactStore(FactStore):
if not text:
return False
with self._lock:
self._sync_from_disk_locked()
lowered = text.lower()
if any(f.text.lower() == lowered for f in self._facts):
return False # dedupe
@@ -135,10 +151,12 @@ class LocalFactStore(FactStore):
def list(self) -> List[Fact]:
with self._lock:
self._sync_from_disk_locked()
return list(self._facts)
def clear(self) -> int:
with self._lock:
self._sync_from_disk_locked()
removed = len(self._facts)
self._facts = []
if self._path.exists():
@@ -150,6 +168,7 @@ class LocalFactStore(FactStore):
def count(self) -> int:
with self._lock:
self._sync_from_disk_locked()
return len(self._facts)
@property
@@ -158,22 +177,32 @@ class LocalFactStore(FactStore):
return self._path
def _ensure_fact_store_backends_registered() -> None:
"""Restore built-in fact-store registrations if a test cleared registries."""
if not FactStoreRegistry.contains("local"):
FactStoreRegistry.register_value("local", LocalFactStore)
def create_fact_store(
backend: str = "local",
*,
path: str | Path = "~/.openjarvis/memory_facts.jsonl",
path: str | Path | None = None,
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.
registry-backed constructor exists so additional backends can be added
without changing the service or CLI wiring.
"""
_ensure_fact_store_backends_registered()
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")
if not FactStoreRegistry.contains(key):
supported = ", ".join(FactStoreRegistry.keys())
raise ValueError(
f"Unknown memory backend '{backend}'. Supported backends: {supported}"
)
return FactStoreRegistry.create(key, path, max_facts=max_facts)
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
+127 -20
View File
@@ -27,7 +27,7 @@ import threading
import time
from typing import Any, AsyncGenerator, Callable, Dict, List, Optional
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
@@ -38,9 +38,10 @@ from openjarvis.agents.research_loop import (
from openjarvis.connectors.embeddings import OllamaEmbedder
from openjarvis.connectors.hybrid_search import HybridSearch
from openjarvis.connectors.store import KnowledgeStore
from openjarvis.core.config import DEFAULT_CONFIG_DIR
from openjarvis.core.config import DEFAULT_CONFIG_DIR, JarvisConfig, load_config
from openjarvis.core.types import TelemetryRecord
from openjarvis.engine.ollama import OllamaEngine
from openjarvis.engine._base import InferenceEngine
from openjarvis.engine._discovery import get_engine
from openjarvis.telemetry.store import TelemetryStore
logger = logging.getLogger(__name__)
@@ -48,13 +49,99 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["research"])
_WEB_CLARIFY_RESPONSE = "no clarification available in web session"
_LEGACY_PLANNER_ENGINE = "ollama"
# Sentinel placed on the queue when the agent thread terminates.
_DONE = object()
def _first_nonempty(*values: str) -> str:
for value in values:
stripped = value.strip()
if stripped:
return stripped
return ""
def _resolve_planner_config(
config: JarvisConfig,
*,
active_engine_key: str = "",
active_model: str = "",
request_model: str = "",
) -> tuple[str, str]:
"""Resolve the planner engine/model for web Deep Research.
Resolution order:
1. explicit ``[deep_research]`` overrides,
2. the active chat engine/request model,
3. server/config defaults,
4. legacy Ollama/gemma4 fallback for unconfigured installs.
"""
engine_key = _first_nonempty(
config.deep_research.engine,
active_engine_key,
config.engine.default,
_LEGACY_PLANNER_ENGINE,
)
model = _first_nonempty(
config.deep_research.model,
request_model,
active_model,
config.server.model,
config.intelligence.default_model,
DEFAULT_PLANNER_MODEL,
)
return engine_key, model
def _build_planner_engine(
config: JarvisConfig,
*,
active_engine: InferenceEngine | None = None,
active_engine_key: str = "",
active_model: str = "",
request_model: str = "",
) -> tuple[str, InferenceEngine, str]:
"""Instantiate the exact configured planner engine.
``get_engine`` intentionally falls back to any healthy engine for general
chat routing. Deep Research must not do that here: if the configured chat
engine is LM Studio but unavailable, silently falling back to Ollama would
recreate the issue this endpoint is fixing.
"""
engine_key, model = _resolve_planner_config(
config,
active_engine_key=active_engine_key,
active_model=active_model,
request_model=request_model,
)
if active_engine is not None and not config.deep_research.engine.strip():
if model and not active_engine.can_serve(model):
raise RuntimeError(
"Deep Research planner engine "
f"{engine_key!r} cannot serve model {model!r}. "
"Choose a compatible model or set [deep_research] engine/model "
"in config.toml."
)
return engine_key, active_engine, model
resolved = get_engine(config, engine_key=engine_key, model=model)
if resolved is None or resolved[0] != engine_key:
raise RuntimeError(
"Deep Research planner engine "
f"{engine_key!r} is unavailable or cannot serve model {model!r}. "
"Start the configured engine, load the configured model, or set "
"[deep_research] engine/model in config.toml."
)
resolved_key, engine = resolved
return resolved_key, engine, model
def _record_research_telemetry(
*,
engine_key: str,
model: str,
usage: Dict[str, int],
latency_seconds: float,
@@ -86,7 +173,7 @@ def _record_research_telemetry(
rec = TelemetryRecord(
timestamp=time.time(),
model_id=model,
engine="ollama",
engine=engine_key,
agent="research",
prompt_tokens=int(usage.get("prompt_tokens", 0)),
prompt_tokens_evaluated=int(usage.get("prompt_tokens", 0)),
@@ -244,12 +331,11 @@ class _LiveGPUSampler:
class ResearchRequest(BaseModel):
query: str = Field(..., description="Natural-language question to research.")
# Deep Research has its own model requirements (function-calling support,
# sufficient reasoning capability) that the chat-model selector should not
# override. We accept the field for forward-compat with older clients but
# ignore it — the planner always runs on DEFAULT_PLANNER_MODEL.
# Preferred planner model from the active chat selector. Server-side
# [deep_research] config can still override it when a dedicated planner is
# desired.
model: Optional[str] = Field(
default=None, description="Ignored; retained for client compatibility."
default=None, description="Preferred planner model for this request."
)
@@ -290,7 +376,14 @@ def _chunk_synthesis(text: str, window_chars: int = 40) -> list[str]:
# ---------------------------------------------------------------------------
async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
async def _stream_research(
query: str,
*,
active_engine: InferenceEngine | None = None,
active_engine_key: str = "",
active_model: str = "",
request_model: str = "",
) -> AsyncGenerator[str, None]:
"""Drive ResearchAgent on a worker thread; yield SSE frames as they land.
Three error envelopes setup, worker, consumer all funnel into the
@@ -298,7 +391,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
``{"type": "done", "usage": {...}}``. The client can rely on always
seeing a ``done`` frame, even when the agent never started.
"""
# Phase 1: setup. Failures here (Ollama daemon down, DB locked, etc.)
# Phase 1: setup. Failures here (planner engine down, DB locked, etc.)
# yield error + done and return — nothing has been emitted yet so the
# client gets a clean two-frame stream instead of a dangling connection.
try:
@@ -309,6 +402,15 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
# Called from the agent's worker thread; bounce onto the event loop.
loop.call_soon_threadsafe(queue.put_nowait, event)
config = load_config()
engine_key, engine, model = _build_planner_engine(
config,
active_engine=active_engine,
active_engine_key=active_engine_key,
active_model=active_model,
request_model=request_model,
)
# Each request gets its own thin set of connectors. Constructing them
# is cheap (SQLite open + HTTP keepalive) and avoids state leaks
# between concurrent requests.
@@ -320,7 +422,6 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
)
embedder = None
engine = OllamaEngine()
agent = ResearchAgent(
engine=engine,
search=HybridSearch(store, embedder),
@@ -367,6 +468,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
# rolls research into the same Power/Energy numbers as chat —
# this is what the launch-video System panel reads.
_record_research_telemetry(
engine_key=engine_key,
model=model,
usage=usage_dict,
latency_seconds=time.time() - t0,
@@ -472,7 +574,7 @@ async def _stream_research(query: str, model: str) -> AsyncGenerator[str, None]:
@router.post("/research")
async def research(req: ResearchRequest) -> StreamingResponse:
async def research(req: ResearchRequest, request: Request) -> StreamingResponse:
"""Run a research query and stream the agent's trace + synthesis via SSE.
Response is ``text/event-stream`` with one JSON event per frame. See the
@@ -480,14 +582,19 @@ async def research(req: ResearchRequest) -> StreamingResponse:
terminates the stream so clients can detect end-of-response without
parsing the underlying ``[DONE]`` sentinel used by OpenAI-style routes.
"""
if req.model and req.model != DEFAULT_PLANNER_MODEL:
logger.info(
"research: ignoring client model=%r; using DEFAULT_PLANNER_MODEL=%r",
req.model,
DEFAULT_PLANNER_MODEL,
)
active_engine = getattr(request.app.state, "engine", None)
active_model = str(getattr(request.app.state, "model", "") or "")
active_engine_key = str(getattr(request.app.state, "engine_name", "") or "")
if active_engine is not None and not active_engine_key:
active_engine_key = str(getattr(active_engine, "engine_id", "") or "")
return StreamingResponse(
_stream_research(req.query, DEFAULT_PLANNER_MODEL),
_stream_research(
req.query,
active_engine=active_engine,
active_engine_key=active_engine_key,
active_model=active_model,
request_model=req.model or "",
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
+87 -9
View File
@@ -195,7 +195,13 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
# from the engine for true real-time output.
if request_body.tools:
return await _handle_stream_tools(
engine, model, request_body, complexity_info, app_config=config
engine,
model,
request_body,
complexity_info,
app_config=config,
bus=getattr(request.app.state, "bus", None),
memory_service=getattr(request.app.state, "memory_service", None),
)
return await _handle_stream(
engine,
@@ -204,6 +210,8 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
complexity_info,
trace_store=getattr(request.app.state, "trace_store", None),
app_config=config,
bus=getattr(request.app.state, "bus", None),
memory_service=getattr(request.app.state, "memory_service", None),
)
# Non-streaming: use agent if available, otherwise direct engine call.
@@ -247,20 +255,44 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
getattr(request.app.state, "memory_service", None),
query_text_for_complexity,
response,
bus=getattr(request.app.state, "bus", None),
source="server.chat",
)
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:
def _response_content(response) -> str:
"""Extract assistant text from an OpenAI-compatible response object."""
content = ""
choices = getattr(response, "choices", None)
if choices:
content = getattr(choices[0].message, "content", "") or ""
return content
def _record_completed_exchange(
memory_service,
user_text: str,
assistant_text: str,
*,
bus=None,
source: str = "server.chat",
) -> None:
"""Publish or submit a completed exchange without blocking a reply."""
if 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)
if bus is not None:
from openjarvis.memory import publish_completed_exchange
publish_completed_exchange(
bus,
user_text,
assistant_text,
source=source,
)
elif memory_service is not None:
memory_service.submit(user_text, assistant_text)
except Exception: # noqa: BLE001 — memory is best-effort, never fail a reply
logging.getLogger("openjarvis.server").debug(
"Memory submit failed",
@@ -268,6 +300,24 @@ def _remember_exchange(memory_service, user_text: str, response) -> None:
)
def _remember_exchange(
memory_service,
user_text: str,
response,
*,
bus=None,
source: str = "server.chat",
) -> None:
"""Record a completed non-streaming exchange."""
_record_completed_exchange(
memory_service,
user_text,
_response_content(response),
bus=bus,
source=source,
)
def _handle_direct(
engine,
model: str,
@@ -457,6 +507,8 @@ async def _handle_stream_tools(
complexity_info=None,
*,
app_config=None,
bus=None,
memory_service=None,
):
"""Stream a raw OpenAI-compat function-calling response via SSE.
@@ -477,8 +529,14 @@ async def _handle_stream_tools(
messages = _ensure_identity_prompt(messages, app_config)
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
use_cloud = is_cloud_model(model)
query_text = ""
for _m in reversed(req.messages):
if _m.role == "user" and _m.content:
query_text = _m.content
break
async def generate():
full_content = ""
# Send the role chunk first (OpenAI convention).
first_chunk = ChatCompletionChunk(
id=chunk_id,
@@ -497,6 +555,7 @@ async def _handle_stream_tools(
tools=req.tools,
):
if sc.content:
full_content += sc.content
content_chunk = ChatCompletionChunk(
id=chunk_id,
model=model,
@@ -553,6 +612,14 @@ async def _handle_stream_tools(
if complexity_info is not None:
finish_dict["complexity"] = complexity_info.model_dump()
yield f"data: {_json.dumps(finish_dict)}\n\n"
if full_content:
_record_completed_exchange(
memory_service,
query_text,
full_content,
bus=bus,
source="server.chat.stream",
)
yield "data: [DONE]\n\n"
return StreamingResponse(
@@ -570,6 +637,8 @@ async def _handle_stream(
*,
trace_store=None,
app_config=None,
bus=None,
memory_service=None,
):
"""Stream response using SSE format.
@@ -710,6 +779,15 @@ async def _handle_stream(
ended_at=time.time(),
)
if full_content:
_record_completed_exchange(
memory_service,
query_text,
full_content,
bus=bus,
source="server.chat.stream",
)
# Send finish chunk with usage data if available
import json as _json
+46 -1
View File
@@ -8,7 +8,7 @@ from openjarvis.agents._stubs import AgentContext
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Conversation, Message, Role, ToolResult
from openjarvis.core.types import Conversation, Message, Role, ToolCall, ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
# ---------------------------------------------------------------------------
@@ -118,6 +118,51 @@ class TestNativeOpenHandsRegistration:
class TestNativeOpenHandsAgent:
def test_truncate_handles_none_content_tool_call_turn(self):
"""Tool-call assistant turns may carry content=None."""
engine = MagicMock()
engine.engine_id = "mock"
agent = NativeOpenHandsAgent(engine, "test-model")
messages = [
Message(role=Role.USER, content="hi"),
Message(
role=Role.ASSISTANT,
content=None, # type: ignore[arg-type]
tool_calls=[ToolCall(id="call_1", name="calculator", arguments="{}")],
),
]
assert agent._truncate_if_needed(messages) == messages
def test_native_tool_call_with_none_content_does_not_crash(self):
"""Native tool-call responses may omit assistant text content."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
None,
tool_calls=[
{
"id": "call_1",
"name": "calculator",
"arguments": '{"expression": "2+2"}',
}
],
),
_engine_response("The result is 4."),
]
agent = NativeOpenHandsAgent(
engine,
"test-model",
tools=[_CalculatorStub()],
)
result = agent.run("What is 2+2?")
assert result.content == "The result is 4."
assert result.turns == 2
assert [tr.content for tr in result.tool_results] == ["4"]
def test_simple_response(self):
"""No code -> direct answer."""
engine = MagicMock()
+29 -7
View File
@@ -15,6 +15,7 @@ from openjarvis.agents._stubs import (
)
from openjarvis.cli.chat_cmd import _read_input, chat
from openjarvis.core.config import JarvisConfig
from openjarvis.core.events import Event, EventBus, EventType
from openjarvis.core.registry import AgentRegistry, ToolRegistry
from openjarvis.core.types import ToolCall, ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@@ -121,25 +122,45 @@ class TestChatAgents:
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."""
"""The REPL starts memory, publishes each turn, and stops it."""
class _SpyMemoryService:
def __init__(self) -> None:
def __init__(self, bus: EventBus) -> None:
self.bus = bus
self.started = False
self.stopped = False
self.submissions: list[tuple[str, str]] = []
def start(self) -> None:
self.started = True
self.bus.subscribe(
EventType.CHAT_EXCHANGE_COMPLETED,
self._on_completed_exchange,
)
def submit(self, user_text: str, assistant_text: str = "") -> bool:
self.submissions.append((user_text, assistant_text))
return True
def _on_completed_exchange(self, event: Event) -> None:
self.submissions.append(
(
event.data["user_text"],
event.data.get("assistant_text", ""),
)
)
def stop(self, timeout: float = 2.0) -> None:
self.stopped = True
self.bus.unsubscribe(
EventType.CHAT_EXCHANGE_COMPLETED,
self._on_completed_exchange,
)
spy: _SpyMemoryService | None = None
def _build_memory_service(*args, event_bus: EventBus | None = None, **kwargs):
nonlocal spy
assert event_bus is not None
spy = _SpyMemoryService(event_bus)
return spy
spy = _SpyMemoryService()
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = {"content": "engine fallback"}
@@ -154,7 +175,7 @@ class TestChatAgents:
patch("openjarvis.intelligence.register_builtin_models"),
patch(
"openjarvis.memory.build_memory_service",
return_value=spy,
side_effect=_build_memory_service,
),
):
result = CliRunner().invoke(
@@ -164,6 +185,7 @@ class TestChatAgents:
)
assert result.exit_code == 0
assert spy is not None
assert spy.started is True
assert spy.stopped is True
assert spy.submissions == [("hello", "simple ok")]
+2
View File
@@ -18,6 +18,7 @@ from openjarvis.core.registry import (
CompressionRegistry,
ConnectorRegistry,
EngineRegistry,
FactStoreRegistry,
MemoryRegistry,
MinerRegistry,
ModelRegistry,
@@ -35,6 +36,7 @@ def _clean_registries() -> None:
ModelRegistry.clear()
EngineRegistry.clear()
MemoryRegistry.clear()
FactStoreRegistry.clear()
MinerRegistry.clear()
AgentRegistry.clear()
ToolRegistry.clear()
+3 -2
View File
@@ -48,6 +48,7 @@ class TestConfigPhase5:
with pytest.raises(KeyError):
ModelRegistry.get("iso-test")
def test_load_config_default(self):
cfg = load_config()
def test_load_config_default(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
cfg = load_config(tmp_path / "missing-config.toml")
assert isinstance(cfg, JarvisConfig)
+58
View File
@@ -0,0 +1,58 @@
"""Tests for Deep Research planner configuration."""
from __future__ import annotations
from pathlib import Path
import pytest
from openjarvis.core.config import (
DeepResearchConfig,
HardwareInfo,
JarvisConfig,
generate_default_toml,
load_config,
validate_config_key,
)
def test_deep_research_config_defaults_to_chat_selection() -> None:
cfg = JarvisConfig()
assert isinstance(cfg.deep_research, DeepResearchConfig)
assert cfg.deep_research.engine == ""
assert cfg.deep_research.model == ""
def test_loads_deep_research_overrides(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
config_file = tmp_path / "config.toml"
config_file.write_text(
"\n".join(
[
"[deep_research]",
'engine = "lmstudio"',
'model = "qwen/qwen3-14b"',
]
)
)
cfg = load_config(config_file)
assert cfg.deep_research.engine == "lmstudio"
assert cfg.deep_research.model == "qwen/qwen3-14b"
def test_deep_research_keys_are_settable() -> None:
assert validate_config_key("deep_research.engine") is str
assert validate_config_key("deep_research.model") is str
def test_default_toml_documents_deep_research_override() -> None:
toml = generate_default_toml(HardwareInfo())
assert "# [deep_research]" in toml
assert '# engine = ""' in toml
assert '# model = ""' in toml
+6
View File
@@ -35,9 +35,15 @@ class TestMessage:
msg = Message(role=Role.USER, content="hello")
assert msg.role == Role.USER
assert msg.content == "hello"
assert msg.text == "hello"
assert msg.tool_calls is None
assert msg.metadata == {}
def test_none_content_text_helper(self) -> None:
msg = Message(role=Role.ASSISTANT, content=None)
assert msg.content is None
assert msg.text == ""
def test_tool_calls(self) -> None:
tc = ToolCall(id="1", name="calc", arguments='{"x": 1}')
msg = Message(role=Role.ASSISTANT, content="", tool_calls=[tc])
+29
View File
@@ -123,6 +123,35 @@ class TestDockerFiles:
assert "jarvis:" in content
assert "ollama:" in content
def test_dockerfiles_build_native_rust_extension(self):
build_dockerfiles = [
"Dockerfile",
"Dockerfile.gpu",
"Dockerfile.gpu.rocm",
"Dockerfile.sandbox",
]
required_markers = [
"rustup toolchain install 1.88",
"maturin build --release",
"rust/crates/openjarvis-python/Cargo.toml",
"/tmp/openjarvis-rust-wheel/*.whl",
"import openjarvis_rust",
]
for name in build_dockerfiles:
content = (DOCKER_DIR / name).read_text()
for marker in required_markers:
assert marker in content, (
f"{name}: missing native build marker {marker!r}"
)
for name in ["Dockerfile", "Dockerfile.gpu", "Dockerfile.gpu.rocm"]:
content = (DOCKER_DIR / name).read_text()
assert "COPY rust/ rust/" in content, f"{name}: rust workspace not copied"
assert content.index("COPY rust/ rust/") < content.index(
"maturin build --release"
), f"{name}: rust workspace copied after native build"
def test_systemd_service_exists(self):
assert (SYSTEMD_DIR / "openjarvis.service").is_file()
+45
View File
@@ -0,0 +1,45 @@
"""Static guards for the docs-site savings-leaderboard Supabase wiring.
`docs/javascripts/leaderboard.js` reads the public Supabase anon key from
`window.OPENJARVIS_SUPABASE_ANON_KEY`. That global is set by a generated
config file (`leaderboard-config.js`) which must load *before* leaderboard.js,
and whose value is injected at docs-build time from the VITE_SUPABASE_ANON_KEY
secret (see `.github/workflows/docs.yml`). These are text-only checks no
mkdocs build required so they run in the default CI lane.
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
MKDOCS = ROOT / "mkdocs.yml"
DOCS_WORKFLOW = ROOT / ".github" / "workflows" / "docs.yml"
CONFIG_JS = ROOT / "docs" / "javascripts" / "leaderboard-config.js"
LEADERBOARD_JS = ROOT / "docs" / "javascripts" / "leaderboard.js"
_ANON_GLOBAL = "window.OPENJARVIS_SUPABASE_ANON_KEY"
def test_config_js_declares_anon_key_global():
assert CONFIG_JS.is_file(), "leaderboard-config.js is missing"
assert _ANON_GLOBAL in CONFIG_JS.read_text()
def test_leaderboard_reads_the_anon_key_global():
# leaderboard.js must consume the global the config file sets.
assert _ANON_GLOBAL in LEADERBOARD_JS.read_text()
def test_config_is_loaded_before_leaderboard_in_mkdocs():
content = MKDOCS.read_text()
cfg = content.index("javascripts/leaderboard-config.js")
lb = content.index("javascripts/leaderboard.js")
assert cfg < lb, "leaderboard-config.js must be listed before leaderboard.js"
def test_docs_workflow_injects_the_anon_key():
content = DOCS_WORKFLOW.read_text()
assert "VITE_SUPABASE_ANON_KEY" in content, "workflow doesn't read the secret"
assert "leaderboard-config.js" in content, "workflow doesn't write the config file"
assert _ANON_GLOBAL in content, "workflow doesn't set the anon-key global"
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from openjarvis.core.types import Message, Role, ToolCall
from openjarvis.engine._base import estimate_prompt_tokens
def test_estimate_prompt_tokens_handles_none_content_tool_call_turn() -> None:
messages = [
Message(role=Role.USER, content="hi"),
Message(
role=Role.ASSISTANT,
content=None,
tool_calls=[ToolCall(id="call_1", name="lookup", arguments="{}")],
),
]
assert estimate_prompt_tokens(messages) == 12
def test_estimate_prompt_tokens_counts_tool_call_arguments() -> None:
base = [
Message(role=Role.USER, content="hi"),
Message(role=Role.ASSISTANT, content=None),
]
with_tool_call = [
Message(role=Role.USER, content="hi"),
Message(
role=Role.ASSISTANT,
content=None,
tool_calls=[ToolCall(id="", name="", arguments="abcdefgh")],
),
]
assert estimate_prompt_tokens(with_tool_call) - estimate_prompt_tokens(base) == 2
def test_estimate_prompt_tokens_counts_reasoning_metadata() -> None:
base = [
Message(role=Role.USER, content="hi"),
Message(role=Role.ASSISTANT, content=None),
]
with_reasoning = [
Message(role=Role.USER, content="hi"),
Message(
role=Role.ASSISTANT,
content=None,
metadata={"reasoning_content": "abcdefgh"},
),
]
assert estimate_prompt_tokens(with_reasoning) - estimate_prompt_tokens(base) == 2
def test_estimate_prompt_tokens_counts_tool_result_ids() -> None:
messages = [
Message(role=Role.USER, content="hi"),
Message(role=Role.TOOL, content="ok", tool_call_id="abcdefgh"),
]
assert estimate_prompt_tokens(messages) == 11
+35
View File
@@ -6,6 +6,7 @@ import json
import pytest
from openjarvis.core.registry import FactStoreRegistry
from openjarvis.memory.store import LocalFactStore, create_fact_store
@@ -74,6 +75,20 @@ def test_clear(tmp_path):
assert LocalFactStore(path).count() == 0
def test_external_clear_does_not_resurrect_stale_facts(tmp_path):
"""A running store instance must not re-flush facts cleared elsewhere."""
path = tmp_path / "facts.jsonl"
running = LocalFactStore(path)
cli = LocalFactStore(path)
running.add("old fact")
assert cli.clear() == 1
running.add("new fact")
assert [f.text for f in LocalFactStore(path).list()] == ["new fact"]
def test_load_skips_malformed_lines(tmp_path):
path = tmp_path / "facts.jsonl"
path.write_text(
@@ -107,6 +122,26 @@ def test_create_fact_store_local(tmp_path):
assert isinstance(store, LocalFactStore)
def test_create_fact_store_uses_fact_store_registry(tmp_path):
class CustomFactStore(LocalFactStore):
pass
FactStoreRegistry.register_value("custom", CustomFactStore)
store = create_fact_store("custom", path=tmp_path / "f.jsonl", max_facts=5)
assert isinstance(store, CustomFactStore)
def test_create_fact_store_default_path_uses_openjarvis_home(tmp_path, monkeypatch):
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path))
store = create_fact_store("local")
assert isinstance(store, LocalFactStore)
assert store.path == tmp_path / "memory_facts.jsonl"
def test_create_fact_store_unknown_backend(tmp_path):
with pytest.raises(ValueError):
create_fact_store("cloud", path=tmp_path / "f.jsonl")
+38 -1
View File
@@ -7,7 +7,12 @@ import time
from types import SimpleNamespace
from openjarvis.core.config import StorageConfig
from openjarvis.memory.service import MemoryService, build_memory_service
from openjarvis.core.events import EventBus
from openjarvis.memory.service import (
MemoryService,
build_memory_service,
publish_completed_exchange,
)
from openjarvis.memory.store import LocalFactStore
@@ -67,6 +72,38 @@ def test_submit_extracts_and_stores(tmp_path):
svc.stop()
def test_completed_exchange_event_extracts_and_stores(tmp_path):
bus = EventBus(record_history=True)
extractor = FakeExtractor(["User likes jazz"])
store = LocalFactStore(tmp_path / "facts.jsonl")
svc = MemoryService(store, extractor, event_bus=bus)
svc.start()
try:
assert publish_completed_exchange(
bus,
"I like jazz",
"Noted.",
source="test",
)
assert _wait_until(lambda: svc.fact_count() == 1)
assert extractor.calls == [("I like jazz", "Noted.")]
finally:
svc.stop()
def test_completed_exchange_event_unsubscribes_on_stop(tmp_path):
bus = EventBus(record_history=True)
extractor = FakeExtractor(["User likes jazz"])
store = LocalFactStore(tmp_path / "facts.jsonl")
svc = MemoryService(store, extractor, event_bus=bus)
svc.start()
svc.stop()
publish_completed_exchange(bus, "I like jazz", "Noted.", source="test")
assert extractor.calls == []
def test_submit_when_not_running_is_dropped(tmp_path):
extractor = FakeExtractor(["x"])
svc = _service(tmp_path, extractor)
+3 -2
View File
@@ -33,7 +33,7 @@ def test_path_traversal_rejected(bad):
SystemPromptBuilder._resolve_persona(MemoryFilesConfig(persona_name=bad))
def test_none_persona_build_does_not_raise():
def test_none_persona_build_does_not_raise(tmp_path, monkeypatch):
"""Regression (#497): `--persona none` resolves to empty file paths; building
the prompt must not raise IsADirectoryError when those empty paths are read
(Path("") is "." reading a directory raised before the empty-path guard).
@@ -42,7 +42,8 @@ def test_none_persona_build_does_not_raise():
from openjarvis.core.config import load_config
cfg = load_config()
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
cfg = load_config(tmp_path / "missing-config.toml")
mf = dataclasses.replace(cfg.memory_files, persona_name="none")
builder = SystemPromptBuilder(
agent_template=cfg.agent.default_system_prompt or "",
+285
View File
@@ -0,0 +1,285 @@
"""Tests for web Deep Research planner engine selection."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from openjarvis.agents.research_loop import DEFAULT_PLANNER_MODEL
from openjarvis.core.config import JarvisConfig
from openjarvis.server import research_router
class _DummyEngine:
def __init__(self, servable: bool = True) -> None:
self.servable = servable
def can_serve(self, model: str) -> bool:
return self.servable
def test_resolve_planner_config_uses_chat_defaults() -> None:
cfg = JarvisConfig()
cfg.engine.default = "lmstudio"
cfg.intelligence.default_model = "local-model"
assert research_router._resolve_planner_config(cfg) == (
"lmstudio",
"local-model",
)
def test_resolve_planner_config_prefers_active_chat_runtime() -> None:
cfg = JarvisConfig()
cfg.engine.default = "ollama"
cfg.intelligence.default_model = ""
assert research_router._resolve_planner_config(
cfg,
active_engine_key="lmstudio",
active_model="server-model",
request_model="selected-model",
) == (
"lmstudio",
"selected-model",
)
def test_resolve_planner_config_uses_server_model_before_legacy_default() -> None:
cfg = JarvisConfig()
cfg.engine.default = "ollama"
cfg.intelligence.default_model = ""
cfg.server.model = "serve-model"
assert research_router._resolve_planner_config(cfg) == (
"ollama",
"serve-model",
)
def test_resolve_planner_config_allows_deep_research_override() -> None:
cfg = JarvisConfig()
cfg.engine.default = "lmstudio"
cfg.intelligence.default_model = "chat-model"
cfg.deep_research.engine = "vllm"
cfg.deep_research.model = "planner-model"
assert research_router._resolve_planner_config(cfg) == (
"vllm",
"planner-model",
)
def test_resolve_planner_config_allows_partial_model_override() -> None:
cfg = JarvisConfig()
cfg.engine.default = "lmstudio"
cfg.intelligence.default_model = "chat-model"
cfg.deep_research.model = "planner-model"
assert research_router._resolve_planner_config(cfg) == (
"lmstudio",
"planner-model",
)
def test_resolve_planner_config_allows_partial_engine_override() -> None:
cfg = JarvisConfig()
cfg.engine.default = "lmstudio"
cfg.intelligence.default_model = "chat-model"
cfg.deep_research.engine = "vllm"
assert research_router._resolve_planner_config(cfg) == (
"vllm",
"chat-model",
)
def test_resolve_planner_config_keeps_legacy_fallback_when_unconfigured() -> None:
cfg = JarvisConfig()
cfg.engine.default = ""
cfg.intelligence.default_model = ""
assert research_router._resolve_planner_config(cfg) == (
"ollama",
DEFAULT_PLANNER_MODEL,
)
def test_build_planner_engine_uses_configured_engine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = JarvisConfig()
cfg.engine.default = "lmstudio"
cfg.intelligence.default_model = "local-model"
engine = _DummyEngine()
calls: list[tuple[str | None, str | None]] = []
def fake_get_engine(
config: JarvisConfig,
engine_key: str | None = None,
model: str | None = None,
) -> tuple[str, _DummyEngine]:
calls.append((engine_key, model))
return "lmstudio", engine
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
engine_key, resolved_engine, model = research_router._build_planner_engine(cfg)
assert calls == [("lmstudio", "local-model")]
assert engine_key == "lmstudio"
assert resolved_engine is engine
assert model == "local-model"
def test_build_planner_engine_uses_active_engine_without_config_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = JarvisConfig()
cfg.engine.default = "ollama"
cfg.intelligence.default_model = ""
active_engine = _DummyEngine()
def fail_get_engine(*args: object, **kwargs: object) -> None:
raise AssertionError("should use the live app engine")
monkeypatch.setattr(research_router, "get_engine", fail_get_engine)
engine_key, resolved_engine, model = research_router._build_planner_engine(
cfg,
active_engine=active_engine,
active_engine_key="lmstudio",
active_model="server-model",
request_model="selected-model",
)
assert engine_key == "lmstudio"
assert resolved_engine is active_engine
assert model == "selected-model"
def test_build_planner_engine_rejects_active_engine_that_cannot_serve_model() -> None:
cfg = JarvisConfig()
with pytest.raises(RuntimeError, match="selected-model"):
research_router._build_planner_engine(
cfg,
active_engine=_DummyEngine(servable=False),
active_engine_key="cloud",
request_model="selected-model",
)
def test_build_planner_engine_honors_explicit_deep_research_engine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = JarvisConfig()
cfg.deep_research.engine = "vllm"
cfg.deep_research.model = "planner-model"
active_engine = _DummyEngine()
planner_engine = _DummyEngine()
def fake_get_engine(
config: JarvisConfig,
engine_key: str | None = None,
model: str | None = None,
) -> tuple[str, _DummyEngine]:
assert engine_key == "vllm"
assert model == "planner-model"
return "vllm", planner_engine
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
engine_key, resolved_engine, model = research_router._build_planner_engine(
cfg,
active_engine=active_engine,
active_engine_key="lmstudio",
active_model="chat-model",
request_model="selected-model",
)
assert engine_key == "vllm"
assert resolved_engine is planner_engine
assert model == "planner-model"
def test_research_route_passes_live_engine_and_selected_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
active_engine = _DummyEngine()
def fake_stream(query: str, **kwargs: object):
captured["query"] = query
captured.update(kwargs)
async def gen():
yield "data: {\"type\":\"done\",\"usage\":{}}\n\n"
return gen()
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
engine=active_engine,
engine_name="lmstudio",
model="server-model",
)
)
)
monkeypatch.setattr(research_router, "_stream_research", fake_stream)
response = asyncio.run(
research_router.research(
research_router.ResearchRequest(
query="find notes",
model="selected-model",
),
request, # type: ignore[arg-type]
)
)
assert response.media_type == "text/event-stream"
assert captured == {
"query": "find notes",
"active_engine": active_engine,
"active_engine_key": "lmstudio",
"active_model": "server-model",
"request_model": "selected-model",
}
def test_build_planner_engine_rejects_fallback_engine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = JarvisConfig()
cfg.engine.default = "lmstudio"
cfg.intelligence.default_model = "local-model"
def fake_get_engine(
config: JarvisConfig,
engine_key: str | None = None,
model: str | None = None,
) -> tuple[str, _DummyEngine]:
return "ollama", _DummyEngine()
monkeypatch.setattr(research_router, "get_engine", fake_get_engine)
with pytest.raises(RuntimeError, match="lmstudio"):
research_router._build_planner_engine(cfg)
def test_build_planner_engine_rejects_unavailable_engine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
cfg = JarvisConfig()
cfg.engine.default = "lmstudio"
cfg.intelligence.default_model = "local-model"
monkeypatch.setattr(research_router, "get_engine", lambda *args, **kwargs: None)
with pytest.raises(RuntimeError, match="local-model"):
research_router._build_planner_engine(cfg)
+130 -19
View File
@@ -10,6 +10,7 @@ import pytest
fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
from openjarvis.core.events import EventBus, EventType # noqa: E402
from openjarvis.server.app import create_app # noqa: E402
# ---------------------------------------------------------------------------
@@ -54,10 +55,19 @@ def _make_agent(content="Hello from agent"):
return agent
def _test_config():
from openjarvis.core.config import JarvisConfig
cfg = JarvisConfig()
cfg.analytics.enabled = False
cfg.traces.enabled = False
return cfg
@pytest.fixture
def client():
engine = _make_engine()
app = create_app(engine, "test-model")
app = create_app(engine, "test-model", config=_test_config())
return TestClient(app)
@@ -65,7 +75,7 @@ def client():
def client_with_agent():
engine = _make_engine()
agent = _make_agent()
app = create_app(engine, "test-model", agent=agent)
app = create_app(engine, "test-model", agent=agent, config=_test_config())
return TestClient(app)
@@ -92,7 +102,12 @@ 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)
app = create_app(
engine,
"test-model",
memory_service=spy,
config=_test_config(),
)
client = TestClient(app)
resp = client.post(
@@ -109,7 +124,13 @@ class TestMemoryServiceWiring:
engine = _make_engine()
agent = _make_agent(content="agent reply")
spy = _SpyMemoryService()
app = create_app(engine, "test-model", agent=agent, memory_service=spy)
app = create_app(
engine,
"test-model",
agent=agent,
memory_service=spy,
config=_test_config(),
)
client = TestClient(app)
resp = client.post(
@@ -122,9 +143,83 @@ class TestMemoryServiceWiring:
assert resp.status_code == 200
assert spy.submissions == [("remember this", "agent reply")]
def test_non_streaming_completion_publishes_completed_exchange(self):
bus = EventBus(record_history=True)
engine = _make_engine(content="event reply")
app = create_app(engine, "test-model", bus=bus, config=_test_config())
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "publish this"}],
},
)
assert resp.status_code == 200
events = [
e for e in bus.history if e.event_type == EventType.CHAT_EXCHANGE_COMPLETED
]
assert len(events) == 1
assert events[0].data["user_text"] == "publish this"
assert events[0].data["assistant_text"] == "event reply"
def test_streaming_completion_feeds_memory_without_bus(self):
engine = _make_engine()
spy = _SpyMemoryService()
app = create_app(
engine,
"test-model",
memory_service=spy,
config=_test_config(),
)
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "stream remember"}],
"stream": True,
},
)
assert resp.status_code == 200
assert "data:" in resp.text
assert spy.submissions == [("stream remember", "Hello world")]
def test_streaming_completion_publishes_completed_exchange(self):
bus = EventBus(record_history=True)
engine = _make_engine()
app = create_app(engine, "test-model", bus=bus, config=_test_config())
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
json={
"model": "test-model",
"messages": [{"role": "user", "content": "stream event"}],
"stream": True,
},
)
assert resp.status_code == 200
assert "data:" in resp.text
events = [
e for e in bus.history if e.event_type == EventType.CHAT_EXCHANGE_COMPLETED
]
assert len(events) == 1
assert events[0].data["user_text"] == "stream event"
assert events[0].data["assistant_text"] == "Hello world"
def test_no_memory_service_is_noop(self):
engine = _make_engine()
app = create_app(engine, "test-model") # memory_service defaults to None
app = create_app(
engine,
"test-model",
config=_test_config(),
) # memory_service defaults to None
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
@@ -207,7 +302,7 @@ class TestChatCompletions:
"model": "test-model",
"finish_reason": "tool_calls",
}
app = create_app(engine, "test-model")
app = create_app(engine, "test-model", config=_test_config())
client = TestClient(app)
resp = client.post(
"/v1/chat/completions",
@@ -255,7 +350,7 @@ class TestChatCompletions:
"finish_reason": "tool_calls",
}
agent = _make_agent(content="GENERIC AGENT FILLER")
app = create_app(engine, "test-model", agent=agent)
app = create_app(engine, "test-model", agent=agent, config=_test_config())
client = TestClient(app)
resp = client.post(
@@ -342,7 +437,7 @@ class TestChatCompletions:
lambda data: received_records.append(data),
)
app = create_app(wrapped, "test-model")
app = create_app(wrapped, "test-model", config=_test_config())
app.state.bus = bus
client = TestClient(app)
@@ -483,7 +578,13 @@ class TestChatCompletions:
# bus present + agent registered == the exact live condition under
# which the pre-fix code routed to the (broken) agent stream bridge.
agent = _make_agent(content="GENERIC AGENT FILLER")
app = create_app(engine, "test-model", agent=agent, bus=EventBus())
app = create_app(
engine,
"test-model",
agent=agent,
bus=EventBus(),
config=_test_config(),
)
client = TestClient(app)
resp = client.post(
@@ -592,6 +693,15 @@ def _make_capturing_engine(captured: list):
return engine
def _identity_config():
from openjarvis.core.config import JarvisConfig
cfg = JarvisConfig()
cfg.agent.default_system_prompt = "You are OpenJarvis."
cfg.analytics.enabled = False
return cfg
class TestIdentityPromptInjection:
"""Regression for #540.
@@ -607,7 +717,7 @@ class TestIdentityPromptInjection:
def test_stream_injects_identity_when_absent(self):
captured: list = []
engine = _make_capturing_engine(captured)
client = TestClient(create_app(engine, "test-model"))
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
resp = client.post(
"/v1/chat/completions",
@@ -628,7 +738,7 @@ class TestIdentityPromptInjection:
def test_stream_no_double_injection_when_client_supplies_system(self):
captured: list = []
engine = _make_capturing_engine(captured)
client = TestClient(create_app(engine, "test-model"))
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
resp = client.post(
"/v1/chat/completions",
@@ -652,7 +762,7 @@ class TestIdentityPromptInjection:
captured: list = []
engine = _make_capturing_engine(captured)
# No agent -> non-stream request goes through _handle_direct.
client = TestClient(create_app(engine, "test-model"))
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
resp = client.post(
"/v1/chat/completions",
@@ -670,7 +780,7 @@ class TestIdentityPromptInjection:
def test_direct_no_double_injection_when_client_supplies_system(self):
captured: list = []
engine = _make_capturing_engine(captured)
client = TestClient(create_app(engine, "test-model"))
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
resp = client.post(
"/v1/chat/completions",
@@ -691,7 +801,7 @@ class TestIdentityPromptInjection:
def test_stream_tools_injects_identity_when_absent(self):
captured: list = []
engine = _make_capturing_engine(captured)
client = TestClient(create_app(engine, "test-model"))
client = TestClient(create_app(engine, "test-model", config=_identity_config()))
resp = client.post(
"/v1/chat/completions",
@@ -733,7 +843,7 @@ class TestModelsEndpoint:
def test_multiple_models(self):
engine = _make_engine(models=["model-a", "model-b", "model-c"])
app = create_app(engine, "model-a")
app = create_app(engine, "model-a", config=_test_config())
client = TestClient(app)
resp = client.get("/v1/models")
data = resp.json()
@@ -754,7 +864,7 @@ class TestHealthEndpoint:
def test_unhealthy(self):
engine = _make_engine()
engine.health.return_value = False
app = create_app(engine, "test-model")
app = create_app(engine, "test-model", config=_test_config())
client = TestClient(app)
resp = client.get("/health")
assert resp.status_code == 503
@@ -768,19 +878,19 @@ class TestHealthEndpoint:
class TestCreateApp:
def test_app_state(self):
engine = _make_engine()
app = create_app(engine, "test-model")
app = create_app(engine, "test-model", config=_test_config())
assert app.state.engine is engine
assert app.state.model == "test-model"
def test_app_with_agent(self):
engine = _make_engine()
agent = _make_agent()
app = create_app(engine, "test-model", agent=agent)
app = create_app(engine, "test-model", agent=agent, config=_test_config())
assert app.state.agent is agent
def test_app_without_agent(self):
engine = _make_engine()
app = create_app(engine, "test-model")
app = create_app(engine, "test-model", config=_test_config())
assert app.state.agent is None
@@ -804,6 +914,7 @@ def _traces_enabled_config(tmp_path):
cfg = JarvisConfig()
cfg.traces.enabled = True
cfg.traces.db_path = str(tmp_path / "traces.db")
cfg.analytics.enabled = False
return cfg
Generated
+282 -1992
View File
File diff suppressed because it is too large Load Diff