init commit
@@ -0,0 +1,95 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
|
||||
- name: Ruff check
|
||||
run: uv run ruff check src/ tests/
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
rust/target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }}
|
||||
restore-keys: rust-${{ runner.os }}-
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
|
||||
- name: Build Rust extension
|
||||
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest tests/ -v --tb=short
|
||||
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cargo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
rust/target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('rust/Cargo.lock') }}
|
||||
restore-keys: rust-${{ runner.os }}-
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Test
|
||||
run: cargo test --workspace
|
||||
@@ -0,0 +1,201 @@
|
||||
name: Desktop Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
tags:
|
||||
- 'desktop-v*'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm install
|
||||
|
||||
- name: TypeScript type-check
|
||||
working-directory: frontend
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'desktop/src-tauri -> target'
|
||||
|
||||
- name: Create frontend dist stub
|
||||
run: mkdir -p frontend/dist && echo '<html><body></body></html>' > frontend/dist/index.html
|
||||
|
||||
- name: Cargo check
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo check
|
||||
|
||||
build-and-release:
|
||||
needs: [validate]
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: ubuntu-22.04
|
||||
args: ''
|
||||
- platform: macos-latest
|
||||
args: '--target universal-apple-darwin'
|
||||
- platform: windows-latest
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'desktop/src-tauri -> target'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm install
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm install
|
||||
|
||||
- name: Download Ollama sidecar
|
||||
shell: bash
|
||||
run: |
|
||||
cd desktop/scripts && chmod +x download-ollama.sh
|
||||
if [[ "${{ matrix.platform }}" == "macos-latest" ]]; then
|
||||
./download-ollama.sh aarch64-apple-darwin
|
||||
./download-ollama.sh x86_64-apple-darwin
|
||||
else
|
||||
./download-ollama.sh
|
||||
fi
|
||||
|
||||
- name: Determine release info
|
||||
id: release-info
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/desktop-v* ]]; then
|
||||
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop ${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Configure Apple signing
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
CERT: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CERT_PASS: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
SIGN_ID: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
A_ID: ${{ secrets.APPLE_ID }}
|
||||
A_PASS: ${{ secrets.APPLE_PASSWORD }}
|
||||
A_TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "$CERT" ]; then
|
||||
echo "APPLE_CERTIFICATE=$CERT" >> "$GITHUB_ENV"
|
||||
echo "APPLE_CERTIFICATE_PASSWORD=$CERT_PASS" >> "$GITHUB_ENV"
|
||||
echo "APPLE_SIGNING_IDENTITY=$SIGN_ID" >> "$GITHUB_ENV"
|
||||
echo "APPLE_ID=$A_ID" >> "$GITHUB_ENV"
|
||||
echo "APPLE_PASSWORD=$A_PASS" >> "$GITHUB_ENV"
|
||||
echo "APPLE_TEAM_ID=$A_TEAM" >> "$GITHUB_ENV"
|
||||
echo "Apple signing configured"
|
||||
else
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
echo "::error::Apple signing secrets are required for release builds"
|
||||
exit 1
|
||||
fi
|
||||
echo "No Apple certificate configured, skipping code signing"
|
||||
fi
|
||||
|
||||
- name: Build and release
|
||||
timeout-minutes: 120
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_CONFIG: '{"bundle":{"externalBin":["binaries/ollama"]}}'
|
||||
with:
|
||||
projectPath: desktop
|
||||
tauriScript: npx tauri
|
||||
tagName: ${{ steps.release-info.outputs.tag }}
|
||||
releaseName: ${{ steps.release-info.outputs.name }}
|
||||
releaseBody: 'Desktop application built from ${{ github.sha }}'
|
||||
releaseDraft: false
|
||||
prerelease: ${{ steps.release-info.outputs.prerelease }}
|
||||
includeUpdaterJson: true
|
||||
args: ${{ matrix.args }}
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
- "src/openjarvis/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
- "src/openjarvis/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra docs
|
||||
|
||||
- name: Build documentation
|
||||
run: uv run mkdocs build
|
||||
|
||||
- name: Upload artifact
|
||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site/
|
||||
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Frontend CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/frontend.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/frontend.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: frontend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm run build
|
||||
@@ -0,0 +1,90 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# uv
|
||||
# Note: uv.lock is intentionally tracked — it pins all transitive deps for reproducibility.
|
||||
# Uncomment the line below only if you deliberately choose not to commit the lock file.
|
||||
# uv.lock
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Project
|
||||
*.sqlite
|
||||
*.db
|
||||
*.jsonl
|
||||
*.npz
|
||||
results/
|
||||
logs/
|
||||
traces/
|
||||
coding_task_*
|
||||
get-pip.py
|
||||
|
||||
# MkDocs build output
|
||||
site/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
src/openjarvis/server/static/
|
||||
|
||||
# Desktop (Tauri)
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/src-tauri/target/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
||||
# Rust
|
||||
target/
|
||||
|
||||
# Claude plan artifacts
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
|
||||
# Claude Code project instructions (per-developer)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# Tauri auto-generated schemas
|
||||
**/src-tauri/gen/schemas/
|
||||
|
||||
# NFS lock artifacts
|
||||
.nfs*
|
||||
**/.nfs*
|
||||
|
||||
# Research output
|
||||
research_mining_*
|
||||
@@ -0,0 +1,190 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2025 The OpenJarvis Authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,111 @@
|
||||
<div align="center">
|
||||
<img alt="OpenJarvis" src="assets/OpenJarvis_Horizontal_Logo.png" width="400">
|
||||
|
||||
<p><i>Personal AI, On Personal Devices.</i></p>
|
||||
|
||||
<p>
|
||||
<a href="https://www.intelligence-per-watt.ai/"><img src="https://img.shields.io/badge/project-intelligence--per--watt.ai-blue" alt="Project"></a>
|
||||
<a href="https://open-jarvis.github.io/OpenJarvis/"><img src="https://img.shields.io/badge/docs-mkdocs-blue" alt="Docs"></a>
|
||||
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python">
|
||||
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> **[Documentation](https://open-jarvis.github.io/OpenJarvis/)**
|
||||
>
|
||||
> **[Project Site](https://www.intelligence-per-watt.ai/)**
|
||||
>
|
||||
> **[Leaderboard](https://open-jarvis.github.io/OpenJarvis/leaderboard/)**
|
||||
|
||||
## Why OpenJarvis?
|
||||
|
||||
Personal AI agents are exploding in popularity, but nearly all of them still route intelligence through cloud APIs. Your "personal" AI continues to depend on someone else's server. At the same time, our [Intelligence Per Watt](https://www.intelligence-per-watt.ai/) research showed that local language models already handle 88.7% of single-turn chat and reasoning queries, with intelligence efficiency improving 5.3× from 2023 to 2025. The models and hardware are increasingly ready. What has been missing is the software stack to make local-first personal AI practical.
|
||||
|
||||
OpenJarvis is that stack. It is an opinionated framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync # core framework
|
||||
uv sync --extra server # + FastAPI server
|
||||
```
|
||||
|
||||
You also need a local inference backend: [Ollama](https://ollama.com), [vLLM](https://github.com/vllm-project/vllm), [SGLang](https://github.com/sgl-project/sglang), or [llama.cpp](https://github.com/ggerganov/llama.cpp).
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest path is Ollama on any machine with Python 3.10+:
|
||||
|
||||
```bash
|
||||
# 1. Install OpenJarvis
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync
|
||||
|
||||
# 2. Detect hardware and generate config
|
||||
uv run jarvis init
|
||||
|
||||
# 3. Install and start Ollama (https://ollama.com)
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
ollama serve # start the Ollama server
|
||||
|
||||
# 4. Pull a model
|
||||
ollama pull qwen3:8b
|
||||
|
||||
# 5. Ask a question
|
||||
uv run jarvis ask "What is the capital of France?"
|
||||
|
||||
# 6. Verify your setup
|
||||
uv run jarvis doctor
|
||||
```
|
||||
|
||||
`jarvis init` auto-detects your hardware and recommends the best engine. After init, it prints engine-specific next steps. Run `uv run jarvis doctor` at any time to diagnose configuration or connectivity issues.
|
||||
|
||||
## Development
|
||||
|
||||
From source, you need to make sure Rust is installed on System:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
Then, you need the Rust extension for full functionality (security, tools, agents, etc.):
|
||||
|
||||
```bash
|
||||
# 1. Clone and install Python deps
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra dev
|
||||
|
||||
# 2. Build and install the Rust extension (requires Rust toolchain)
|
||||
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
|
||||
|
||||
# 3. Run tests
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
See [Contributing](docs/development/contributing.md) for more.
|
||||
|
||||
## About
|
||||
|
||||
OpenJarvis is part of [Intelligence Per Watt](https://www.intelligence-per-watt.ai/), a research initiative studying the efficiency of on-device AI systems. The project is developed at [Hazy Research](https://hazyresearch.stanford.edu/) and the [Scaling Intelligence Lab](https://scalingintelligence.stanford.edu/) at [Stanford SAIL](https://ai.stanford.edu/).
|
||||
|
||||
## Sponsors
|
||||
|
||||
<p>
|
||||
<a href="https://www.laude.org/">Laude Institute</a> •
|
||||
<a href="https://datascience.stanford.edu/marlowe">Stanford Marlowe</a> •
|
||||
<a href="https://cloud.google.com/">Google Cloud Platform</a> •
|
||||
<a href="https://lambda.ai/">Lambda Labs</a> •
|
||||
<a href="https://ollama.com/">Ollama</a> •
|
||||
<a href="https://research.ibm.com/">IBM Research</a> •
|
||||
<a href="https://hai.stanford.edu/">Stanford HAI</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 781 KiB |
@@ -0,0 +1,114 @@
|
||||
# OpenJarvis configuration — GLM-4.7-Flash eval on 8x A100-80GB
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PILLAR 1: Intelligence — The Model
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
[intelligence]
|
||||
default_model = "zai-org/GLM-4.7-Flash" # HuggingFace model ID
|
||||
fallback_model = "glm-4.7-flash" # Catalog alias fallback
|
||||
preferred_engine = "vllm" # MoE model, vLLM is best for A100s
|
||||
provider = "local" # Running locally on this machine
|
||||
quantization = "none" # Full precision, we have 640GB VRAM
|
||||
# Generation defaults for eval
|
||||
temperature = 0.0 # Deterministic for reproducibility
|
||||
max_tokens = 2048 # Standard eval output length
|
||||
top_p = 0.9
|
||||
top_k = 40
|
||||
repetition_penalty = 1.0
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PILLAR 2: Agent — The Agentic Harness
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
[agent]
|
||||
default_agent = "native_openhands" # CodeAct-style agent
|
||||
max_turns = 10 # Up to 10 tool-calling turns
|
||||
tools = "code_interpreter,web_search,file_read,calculator,think"
|
||||
objective = "Answer questions accurately using available tools"
|
||||
context_from_memory = false # No memory injection during eval
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PILLAR 3: Tools — MCP Interface
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
db_path = "~/.openjarvis/memory.db"
|
||||
|
||||
[tools.mcp]
|
||||
enabled = true
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PILLAR 4: Engine — The Inference Runtime
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
[engine]
|
||||
default = "vllm"
|
||||
|
||||
[engine.vllm]
|
||||
host = "http://localhost:8001" # vLLM serving port
|
||||
|
||||
[engine.ollama]
|
||||
host = "http://localhost:11434"
|
||||
|
||||
[engine.sglang]
|
||||
host = "http://localhost:30000"
|
||||
|
||||
[engine.llamacpp]
|
||||
host = "http://localhost:8080"
|
||||
|
||||
[engine.exo]
|
||||
host = "http://localhost:52415"
|
||||
|
||||
[engine.nexa]
|
||||
host = "http://localhost:18181"
|
||||
# device = "npu" # optional: cpu, gpu, npu
|
||||
|
||||
[engine.uzu]
|
||||
host = "http://localhost:8080"
|
||||
|
||||
[engine.apple_fm]
|
||||
host = "http://localhost:8079"
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# PILLAR 5: Learning — Improvement Methodologies
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
[learning]
|
||||
enabled = false # No learning during eval
|
||||
|
||||
[learning.routing]
|
||||
policy = "heuristic"
|
||||
|
||||
[learning.intelligence]
|
||||
policy = "none"
|
||||
|
||||
[learning.agent]
|
||||
policy = "none"
|
||||
|
||||
[learning.metrics]
|
||||
accuracy_weight = 0.6
|
||||
latency_weight = 0.2
|
||||
cost_weight = 0.1
|
||||
efficiency_weight = 0.1
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Supporting config
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
[telemetry]
|
||||
enabled = true
|
||||
db_path = "~/.openjarvis/telemetry.db"
|
||||
gpu_metrics = true
|
||||
gpu_poll_interval_ms = 50
|
||||
energy_vendor = "" # Auto-detect; or force "nvidia"/"amd"/"apple"/"cpu_rapl"
|
||||
warmup_samples = 0 # Warmup iterations before steady-state measurement
|
||||
steady_state_window = 5 # Sliding window size for CV stability check
|
||||
steady_state_threshold = 0.05 # Coefficient of variation threshold for steady state
|
||||
|
||||
[traces]
|
||||
enabled = true # Record traces for analysis
|
||||
db_path = "~/.openjarvis/traces.db"
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8000
|
||||
agent = "native_openhands"
|
||||
|
||||
[security]
|
||||
enabled = false # Disable for eval (no PII scanning overhead)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
|
||||
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
|
||||
|
||||
# Stage 2: Build Python package
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ src/
|
||||
|
||||
# Copy built frontend into the server static directory
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM python:3.12-slim
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
CMD ["serve", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
|
||||
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
|
||||
|
||||
# Stage 2: Build Python package (NVIDIA CUDA 12.4)
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ src/
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
CMD ["serve", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Stage 1: Build frontend SPA
|
||||
FROM node:22-slim AS frontend
|
||||
|
||||
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
|
||||
|
||||
# Stage 2: Build Python package (AMD ROCm 6.2)
|
||||
FROM rocm/dev-ubuntu-22.04:6.2 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ src/
|
||||
|
||||
COPY --from=frontend /src/openjarvis/server/static src/openjarvis/server/static/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM rocm/dev-ubuntu-22.04:6.2
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
CMD ["serve", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Install Node.js 22
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir ".[server]"
|
||||
|
||||
LABEL openjarvis-sandbox=true
|
||||
|
||||
ENTRYPOINT ["python", "-m", "openjarvis.sandbox.entrypoint"]
|
||||
@@ -0,0 +1,15 @@
|
||||
# ROCm GPU override — use with:
|
||||
# docker compose -f deploy/docker/docker-compose.yml -f deploy/docker/docker-compose.gpu.rocm.yml up
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
jarvis:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile.gpu.rocm
|
||||
devices:
|
||||
- /dev/kfd
|
||||
- /dev/dri
|
||||
group_add:
|
||||
- video
|
||||
- render
|
||||
@@ -0,0 +1,26 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
jarvis:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/docker/Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- OPENJARVIS_ENGINE_DEFAULT=ollama
|
||||
- OPENJARVIS_OLLAMA_HOST=http://ollama:11434
|
||||
depends_on:
|
||||
- ollama
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama-models:/root/.ollama
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ollama-models:
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.openjarvis</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/jarvis</string>
|
||||
<string>serve</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>--port</string>
|
||||
<string>8000</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/openjarvis.stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/openjarvis.stderr.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=OpenJarvis API Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=openjarvis
|
||||
WorkingDirectory=/opt/openjarvis
|
||||
ExecStart=/opt/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=HOME=/opt/openjarvis
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,121 @@
|
||||
# OpenJarvis Desktop
|
||||
|
||||
Tauri 2.0 native desktop application for OpenJarvis with auto-updates, energy monitoring, trace debugging, and learning visualization.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Prerequisites: Node.js 22+, Rust stable, system deps (see below)
|
||||
|
||||
cd desktop
|
||||
npm install
|
||||
cargo tauri dev # Hot-reload development mode
|
||||
cargo tauri build # Production build
|
||||
```
|
||||
|
||||
### Linux System Dependencies
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libxdo-dev
|
||||
```
|
||||
|
||||
## Auto-Update Architecture
|
||||
|
||||
Every push to `main` (touching `desktop/` or the workflow) triggers a CI pipeline that:
|
||||
|
||||
1. Validates TypeScript + Rust (`validate` job)
|
||||
2. Builds for Linux, macOS (ARM + Intel), and Windows (`build-and-release` job)
|
||||
3. Creates/updates a `desktop-latest` pre-release on GitHub Releases
|
||||
4. Uploads platform installers and a signed `latest.json` manifest
|
||||
|
||||
The desktop app checks `latest.json` on startup and every 30 minutes. When a newer version is found, it shows a banner prompting the user to download and relaunch.
|
||||
|
||||
```
|
||||
Push to main -> CI builds -> desktop-latest release -> latest.json
|
||||
|
|
||||
Desktop app checks periodically <-------------------------+
|
||||
-> "Update available" banner
|
||||
-> Download in background
|
||||
-> "Relaunch now" prompt
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
### Rolling (Nightly)
|
||||
|
||||
Automatic on every push to `main`. Users on the desktop app receive updates seamlessly.
|
||||
|
||||
### Stable (Versioned)
|
||||
|
||||
```bash
|
||||
# Bump version in all 3 config files
|
||||
./scripts/bump-desktop-version.sh 1.0.1
|
||||
|
||||
# Commit and tag
|
||||
git add desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml
|
||||
git commit -m "chore(desktop): bump version to 1.0.1"
|
||||
git tag desktop-v1.0.1
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
CI creates a versioned GitHub Release (e.g., `desktop-v1.0.1`) with full installers.
|
||||
|
||||
## Code Signing
|
||||
|
||||
### Update Signing (Required for Auto-Updates)
|
||||
|
||||
Generate a key pair for signing update manifests:
|
||||
|
||||
```bash
|
||||
cargo tauri signer generate -w ~/.tauri/openjarvis.key
|
||||
```
|
||||
|
||||
Set the public key in `src-tauri/tauri.conf.json` under `plugins.updater.pubkey`, then add these GitHub Secrets:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the `.key` file |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password used during generation |
|
||||
|
||||
### macOS Code Signing & Notarization (Required for Distribution)
|
||||
|
||||
Without these secrets, macOS users will see *"OpenJarvis is damaged and can't be opened"* due to Gatekeeper. The CI workflow will **fail release builds** (tag pushes) if signing secrets are missing.
|
||||
|
||||
**Prerequisites:** Apple Developer Program membership ($99/year) — [developer.apple.com/programs](https://developer.apple.com/programs/)
|
||||
|
||||
| Secret | How to obtain |
|
||||
|--------|---------------|
|
||||
| `APPLE_CERTIFICATE` | In Keychain Access, export your **Developer ID Application** certificate as `.p12`. Then: `base64 -i cert.p12 \| pbcopy` |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | The password you set during the `.p12` export |
|
||||
| `APPLE_SIGNING_IDENTITY` | Full CN string from the certificate, e.g. `"Developer ID Application: Open Jarvis Inc (XXXXXXXXXX)"` |
|
||||
| `APPLE_ID` | The Apple ID email associated with your Developer account |
|
||||
| `APPLE_PASSWORD` | An **app-specific password** generated at [appleid.apple.com](https://appleid.apple.com) (not your account password) |
|
||||
| `APPLE_TEAM_ID` | 10-character team ID from [developer.apple.com/account](https://developer.apple.com/account) |
|
||||
|
||||
Add all 6 secrets in **GitHub → Settings → Secrets and variables → Actions**.
|
||||
|
||||
#### Local Signing Test
|
||||
|
||||
```bash
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: ..."
|
||||
cd desktop && npm run tauri build -- --target universal-apple-darwin
|
||||
```
|
||||
|
||||
### Windows Authenticode (Optional)
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `WINDOWS_CERTIFICATE` | Base64-encoded `.pfx` certificate |
|
||||
| `WINDOWS_CERTIFICATE_PASSWORD` | Certificate password |
|
||||
|
||||
Windows signing is optional — unsigned Windows builds work but show a SmartScreen warning on first launch.
|
||||
|
||||
## Dashboard Panels
|
||||
|
||||
- **Energy** — Real-time power monitoring (recharts)
|
||||
- **Traces** — Timeline inspection with step-type color coding
|
||||
- **Learning** — Policy visualization (GRPO/bandit stats)
|
||||
- **Memory** — Search and stats for memory backends
|
||||
- **Admin** — Health checks, agent management, server control
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenJarvis Desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "openjarvis-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm --prefix ../frontend run dev",
|
||||
"build": "npm --prefix ../frontend run build:tauri",
|
||||
"preview": "npm --prefix ../frontend run preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Download the Ollama binary for the target platform and place it
|
||||
# in the Tauri binaries/ directory so it can be bundled as an
|
||||
# externalBin sidecar.
|
||||
#
|
||||
# Usage:
|
||||
# ./download-ollama.sh # auto-detect current platform
|
||||
# ./download-ollama.sh aarch64-apple-darwin
|
||||
# ./download-ollama.sh x86_64-unknown-linux-gnu
|
||||
#
|
||||
# Ollama distributes platform binaries as archives (.tgz / .tar.zst).
|
||||
# This script downloads, extracts the `ollama` CLI binary, renames it
|
||||
# to the Tauri target-triple convention, and places it under
|
||||
# desktop/src-tauri/binaries/.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BINARIES_DIR="$(cd "$(dirname "$0")/../src-tauri/binaries" 2>/dev/null && pwd || echo "$(dirname "$0")/../src-tauri/binaries")"
|
||||
mkdir -p "$BINARIES_DIR"
|
||||
|
||||
# Determine target triple
|
||||
if [ "${1:-}" != "" ]; then
|
||||
TARGET="$1"
|
||||
else
|
||||
ARCH="$(uname -m)"
|
||||
OS="$(uname -s)"
|
||||
case "$OS" in
|
||||
Darwin)
|
||||
case "$ARCH" in
|
||||
arm64) TARGET="aarch64-apple-darwin" ;;
|
||||
x86_64) TARGET="x86_64-apple-darwin" ;;
|
||||
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
Linux)
|
||||
case "$ARCH" in
|
||||
x86_64) TARGET="x86_64-unknown-linux-gnu" ;;
|
||||
aarch64) TARGET="aarch64-unknown-linux-gnu" ;;
|
||||
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*|Windows_NT)
|
||||
TARGET="x86_64-pc-windows-msvc"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "Target triple: $TARGET"
|
||||
|
||||
# Tauri externalBin naming: <name>-<target-triple>[.exe]
|
||||
SUFFIX=""
|
||||
case "$TARGET" in
|
||||
*windows*) SUFFIX=".exe" ;;
|
||||
esac
|
||||
OUT_FILE="$BINARIES_DIR/ollama-${TARGET}${SUFFIX}"
|
||||
|
||||
if [ -f "$OUT_FILE" ]; then
|
||||
echo "Already exists: $OUT_FILE"
|
||||
echo "Delete it first to re-download."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map target triple to Ollama release asset
|
||||
RELEASE_URL="https://github.com/ollama/ollama/releases/latest/download"
|
||||
|
||||
case "$TARGET" in
|
||||
*apple-darwin)
|
||||
ASSET_URL="${RELEASE_URL}/ollama-darwin.tgz"
|
||||
ARCHIVE_TYPE="tgz"
|
||||
;;
|
||||
x86_64-unknown-linux-gnu)
|
||||
ASSET_URL="${RELEASE_URL}/ollama-linux-amd64.tar.zst"
|
||||
ARCHIVE_TYPE="zst"
|
||||
;;
|
||||
aarch64-unknown-linux-gnu)
|
||||
ASSET_URL="${RELEASE_URL}/ollama-linux-arm64.tar.zst"
|
||||
ARCHIVE_TYPE="zst"
|
||||
;;
|
||||
x86_64-pc-windows-msvc)
|
||||
ASSET_URL="${RELEASE_URL}/ollama-windows-amd64.zip"
|
||||
ARCHIVE_TYPE="zip"
|
||||
;;
|
||||
*)
|
||||
echo "No Ollama binary mapping for target: $TARGET"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
echo "Downloading: $ASSET_URL"
|
||||
ARCHIVE_FILE="$TMPDIR/ollama-archive"
|
||||
curl -fSL --progress-bar "$ASSET_URL" -o "$ARCHIVE_FILE"
|
||||
|
||||
echo "Extracting..."
|
||||
case "$ARCHIVE_TYPE" in
|
||||
tgz)
|
||||
tar xzf "$ARCHIVE_FILE" -C "$TMPDIR"
|
||||
;;
|
||||
zst)
|
||||
if command -v zstd &>/dev/null; then
|
||||
zstd -d "$ARCHIVE_FILE" -o "$TMPDIR/ollama.tar" --quiet
|
||||
tar xf "$TMPDIR/ollama.tar" -C "$TMPDIR"
|
||||
else
|
||||
echo "zstd not found. Install with: brew install zstd (macOS) or apt install zstd (Linux)"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
zip)
|
||||
unzip -q "$ARCHIVE_FILE" -d "$TMPDIR"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Find the ollama binary in the extracted contents
|
||||
OLLAMA_BIN=""
|
||||
for candidate in "$TMPDIR/bin/ollama" "$TMPDIR/ollama" "$TMPDIR/ollama.exe"; do
|
||||
if [ -f "$candidate" ]; then
|
||||
OLLAMA_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$OLLAMA_BIN" ]; then
|
||||
echo "Could not find ollama binary in archive. Contents:"
|
||||
find "$TMPDIR" -type f | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$OLLAMA_BIN" "$OUT_FILE"
|
||||
chmod +x "$OUT_FILE"
|
||||
|
||||
echo "Saved to: $OUT_FILE"
|
||||
ls -lh "$OUT_FILE"
|
||||
echo "Done."
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "openjarvis-desktop"
|
||||
version = "0.1.0"
|
||||
description = "OpenJarvis Desktop — Native AI assistant with energy monitoring, trace debugging, and learning visualization"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,3 @@
|
||||
# Downloaded sidecar binaries — platform-specific, not committed
|
||||
ollama-*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for OpenJarvis Desktop",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"notification:default",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"shell:allow-kill",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{ "name": "binaries/ollama", "sidecar": true, "args": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 710 KiB |
@@ -0,0 +1,652 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::Manager;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const OLLAMA_PORT: u16 = 11434;
|
||||
const JARVIS_PORT: u16 = 8222;
|
||||
const DEFAULT_MODEL: &str = "qwen3:0.6b";
|
||||
|
||||
/// Resolve full path to a binary by checking common locations.
|
||||
/// macOS .app bundles don't inherit the shell PATH, so we probe manually.
|
||||
fn resolve_bin(name: &str) -> String {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let candidates = [
|
||||
format!("/opt/homebrew/bin/{name}"),
|
||||
format!("{home}/.local/bin/{name}"),
|
||||
format!("{home}/.cargo/bin/{name}"),
|
||||
format!("/usr/local/bin/{name}"),
|
||||
format!("/usr/bin/{name}"),
|
||||
];
|
||||
for path in &candidates {
|
||||
if std::path::Path::new(path).exists() {
|
||||
return path.clone();
|
||||
}
|
||||
}
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// Find the OpenJarvis project root (contains pyproject.toml).
|
||||
/// Walks up from the executable's location, then checks common paths.
|
||||
fn find_project_root() -> Option<std::path::PathBuf> {
|
||||
// Try relative to the running executable (works in dev and .app bundle)
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut dir = exe.parent().map(|p| p.to_path_buf());
|
||||
for _ in 0..8 {
|
||||
if let Some(ref d) = dir {
|
||||
if d.join("pyproject.toml").exists() {
|
||||
return Some(d.clone());
|
||||
}
|
||||
dir = d.parent().map(|p| p.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: common clone locations
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let fallbacks = [
|
||||
format!("{home}/projects/hazy/OpenJarvis"),
|
||||
format!("{home}/OpenJarvis"),
|
||||
format!("{home}/projects/OpenJarvis"),
|
||||
format!("{home}/src/OpenJarvis"),
|
||||
];
|
||||
for p in &fallbacks {
|
||||
let path = std::path::PathBuf::from(p);
|
||||
if path.join("pyproject.toml").exists() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackendManager — owns the Ollama + Jarvis server child processes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ChildHandle {
|
||||
child: tokio::process::Child,
|
||||
}
|
||||
|
||||
impl ChildHandle {
|
||||
async fn kill(&mut self) {
|
||||
let _ = self.child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BackendManager {
|
||||
ollama: Option<ChildHandle>,
|
||||
jarvis: Option<ChildHandle>,
|
||||
}
|
||||
|
||||
impl BackendManager {
|
||||
async fn stop_all(&mut self) {
|
||||
if let Some(ref mut h) = self.jarvis {
|
||||
h.kill().await;
|
||||
}
|
||||
self.jarvis = None;
|
||||
if let Some(ref mut h) = self.ollama {
|
||||
h.kill().await;
|
||||
}
|
||||
self.ollama = None;
|
||||
}
|
||||
}
|
||||
|
||||
type SharedBackend = Arc<Mutex<BackendManager>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup status (reported to frontend)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct SetupStatus {
|
||||
phase: String,
|
||||
detail: String,
|
||||
ollama_ready: bool,
|
||||
server_ready: bool,
|
||||
model_ready: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SetupStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: "starting".into(),
|
||||
detail: "Initializing...".into(),
|
||||
ollama_ready: false,
|
||||
server_ready: false,
|
||||
model_ready: false,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SharedStatus = Arc<Mutex<SetupStatus>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health-check helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn wait_for_url(url: &str, timeout: Duration) -> bool {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Ok(resp) = client.get(url).send().await {
|
||||
if resp.status().is_success() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn ollama_has_model(model: &str) -> bool {
|
||||
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.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.starts_with(model.split(':').next().unwrap_or(model)))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn pull_model(model: &str) -> Result<(), String> {
|
||||
let url = format!("http://127.0.0.1:{}/api/pull", OLLAMA_PORT);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(600))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({"name": model, "stream": false}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Pull request failed: {}", e))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Pull returned status {}", resp.status()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend boot sequence (runs in background after app launch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
||||
// Phase 1: Start Ollama
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.phase = "ollama".into();
|
||||
s.detail = "Starting inference engine...".into();
|
||||
}
|
||||
|
||||
// Try the bundled sidecar first, fall back to system ollama
|
||||
let ollama_child = {
|
||||
let ollama_bin = resolve_bin("ollama");
|
||||
let sidecar = tokio::process::Command::new(&ollama_bin)
|
||||
.arg("serve")
|
||||
.env("OLLAMA_HOST", format!("127.0.0.1:{}", OLLAMA_PORT))
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
match sidecar {
|
||||
Ok(child) => Some(child),
|
||||
Err(_) => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(child) = ollama_child {
|
||||
backend.lock().await.ollama = Some(ChildHandle { child });
|
||||
}
|
||||
|
||||
let ollama_url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
|
||||
let ollama_ok = wait_for_url(&ollama_url, Duration::from_secs(30)).await;
|
||||
|
||||
if !ollama_ok {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some("Could not start Ollama. Install it from https://ollama.com".into());
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.ollama_ready = true;
|
||||
s.detail = "Inference engine ready.".into();
|
||||
}
|
||||
|
||||
// Phase 2: Ensure a default model exists
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.phase = "model".into();
|
||||
s.detail = format!("Checking for model {}...", DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
if !ollama_has_model(DEFAULT_MODEL).await {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.detail = format!("Downloading {}... (this may take a minute)", DEFAULT_MODEL);
|
||||
}
|
||||
if let Err(e) = pull_model(DEFAULT_MODEL).await {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!("Failed to download model: {}", e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.model_ready = true;
|
||||
s.detail = "Model ready.".into();
|
||||
}
|
||||
|
||||
// Phase 3: Start jarvis serve
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.phase = "server".into();
|
||||
s.detail = "Starting API server...".into();
|
||||
}
|
||||
|
||||
let uv_bin = resolve_bin("uv");
|
||||
let project_root = find_project_root();
|
||||
let mut cmd = tokio::process::Command::new(&uv_bin);
|
||||
cmd.args(["run", "jarvis", "serve", "--port", &JARVIS_PORT.to_string(), "--agent", "simple"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
if let Some(ref root) = project_root {
|
||||
cmd.current_dir(root);
|
||||
}
|
||||
let jarvis_child = cmd.spawn();
|
||||
|
||||
match jarvis_child {
|
||||
Ok(child) => {
|
||||
backend.lock().await.jarvis = Some(ChildHandle { child });
|
||||
}
|
||||
Err(e) => {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some(format!(
|
||||
"Could not start jarvis server: {}. \
|
||||
Make sure uv is installed (https://astral.sh/uv) and the OpenJarvis repo is cloned",
|
||||
e
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let server_url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
|
||||
let server_ok = wait_for_url(&server_url, Duration::from_secs(120)).await;
|
||||
|
||||
if !server_ok {
|
||||
let mut s = status.lock().await;
|
||||
s.error = Some("Jarvis server did not become healthy in time.".into());
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.server_ready = true;
|
||||
s.phase = "ready".into();
|
||||
s.detail = "All systems ready.".into();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn api_base() -> String {
|
||||
format!("http://127.0.0.1:{}", JARVIS_PORT)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_setup_status(
|
||||
state: tauri::State<'_, SharedStatus>,
|
||||
) -> Result<SetupStatus, String> {
|
||||
Ok(state.lock().await.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_backend(
|
||||
backend: tauri::State<'_, SharedBackend>,
|
||||
status: tauri::State<'_, SharedStatus>,
|
||||
) -> Result<(), String> {
|
||||
let b = backend.inner().clone();
|
||||
let s = status.inner().clone();
|
||||
tauri::async_runtime::spawn(boot_backend(b, s));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_backend(
|
||||
backend: tauri::State<'_, SharedBackend>,
|
||||
) -> Result<(), String> {
|
||||
backend.lock().await.stop_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_health(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/health", if api_url.is_empty() { api_base() } else { api_url });
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_energy(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/telemetry/energy", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_telemetry(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/telemetry/stats", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_traces(api_url: String, limit: u32) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/traces?limit={}", base, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_trace(api_url: String, trace_id: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/traces/{}", base, trace_id))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_learning_stats(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/learning/stats", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_learning_policy(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/learning/policy", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_memory_stats(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/memory/stats", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn search_memory(
|
||||
api_url: String,
|
||||
query: String,
|
||||
top_k: u32,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/v1/memory/search", base))
|
||||
.json(&serde_json::json!({"query": query, "top_k": top_k}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_agents(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/agents", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_models(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/models", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
|
||||
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
|
||||
cmd_args.extend(args);
|
||||
let uv_bin = resolve_bin("uv");
|
||||
let output = tokio::process::Command::new(&uv_bin)
|
||||
.args(&cmd_args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_savings(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let base = if api_url.is_empty() { api_base() } else { api_url };
|
||||
let resp = reqwest::get(format!("{}/v1/savings", base))
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
resp.json().await.map_err(|e| format!("Invalid response: {}", e))
|
||||
}
|
||||
|
||||
/// Transcribe audio via the speech API endpoint.
|
||||
#[tauri::command]
|
||||
async fn transcribe_audio(
|
||||
api_url: String,
|
||||
audio_data: Vec<u8>,
|
||||
filename: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/speech/transcribe", api_url);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(audio_data)
|
||||
.file_name(filename)
|
||||
.mime_str("audio/webm")
|
||||
.map_err(|e| format!("Failed to create multipart: {}", e))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Submit savings to Supabase leaderboard.
|
||||
#[tauri::command]
|
||||
async fn submit_savings(
|
||||
supabase_url: String,
|
||||
supabase_key: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<bool, String> {
|
||||
if supabase_url.is_empty() || supabase_key.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/rest/v1/savings_entries?on_conflict=anon_id", supabase_url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("apikey", &supabase_key)
|
||||
.header("Authorization", format!("Bearer {}", supabase_key))
|
||||
.header("Prefer", "resolution=merge-duplicates")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Supabase POST failed: {}", e))?;
|
||||
Ok(resp.status().is_success())
|
||||
}
|
||||
|
||||
/// Check speech backend health.
|
||||
#[tauri::command]
|
||||
async fn speech_health(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/speech/health", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let backend: SharedBackend = Arc::new(Mutex::new(BackendManager::default()));
|
||||
let status: SharedStatus = Arc::new(Mutex::new(SetupStatus::default()));
|
||||
|
||||
let boot_backend_ref = backend.clone();
|
||||
let boot_status_ref = status.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(backend.clone())
|
||||
.manage(status.clone())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--hidden"]),
|
||||
))
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.setup(move |app| {
|
||||
// System tray
|
||||
let show = MenuItemBuilder::with_id("show", "Show / Hide")
|
||||
.build(app)?;
|
||||
let health = MenuItemBuilder::with_id("health", "Health: starting...")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit OpenJarvis")
|
||||
.build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&show)
|
||||
.separator()
|
||||
.item(&health)
|
||||
.separator()
|
||||
.item(&quit)
|
||||
.build()?;
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.tooltip("OpenJarvis")
|
||||
.menu(&menu)
|
||||
.on_menu_event(move |app, event| {
|
||||
match event.id().as_ref() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Auto-start backend services on launch
|
||||
tauri::async_runtime::spawn(boot_backend(boot_backend_ref, boot_status_ref));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_setup_status,
|
||||
start_backend,
|
||||
stop_backend,
|
||||
check_health,
|
||||
fetch_energy,
|
||||
fetch_telemetry,
|
||||
fetch_traces,
|
||||
fetch_trace,
|
||||
fetch_learning_stats,
|
||||
fetch_learning_policy,
|
||||
fetch_memory_stats,
|
||||
search_memory,
|
||||
fetch_agents,
|
||||
fetch_models,
|
||||
run_jarvis_command,
|
||||
fetch_savings,
|
||||
submit_savings,
|
||||
transcribe_audio,
|
||||
speech_health,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building OpenJarvis Desktop")
|
||||
.run(move |_app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
let b = backend.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
b.lock().await.stop_all().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
openjarvis_desktop::run();
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenJarvis",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.openjarvis.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpenJarvis",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; img-src 'self' data: blob:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"category": "Utility",
|
||||
"shortDescription": "On-device AI assistant with energy monitoring and trace debugging",
|
||||
"longDescription": "OpenJarvis Desktop wraps the OpenJarvis research framework in a native desktop application with real-time energy monitoring, trace debugging, learning curve visualization, and memory browsing.",
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFFNzUzMzhEOEY2MjNEMDMKUldRRFBXS1BqVE4xSG8vK0lkUWN4WnZQYVIrbmc4RmpoOGlJWTBLTE15RlIya3JvQisvdUR3a0QK",
|
||||
"endpoints": [
|
||||
"https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UpdateChecker } from './components/UpdateChecker';
|
||||
import { SavingsDashboard } from './components/SavingsDashboard';
|
||||
import { EnergyDashboard } from './components/EnergyDashboard';
|
||||
import { TraceDebugger } from './components/TraceDebugger';
|
||||
import { LearningCurve } from './components/LearningCurve';
|
||||
import { MemoryBrowser } from './components/MemoryBrowser';
|
||||
import { AdminPanel } from './components/AdminPanel';
|
||||
import { SettingsPanel } from './components/SettingsPanel';
|
||||
import { AgentsPanel } from './components/AgentsPanel';
|
||||
|
||||
type TabId = 'savings' | 'energy' | 'traces' | 'learning' | 'memory' | 'agents' | 'admin' | 'settings';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'savings', label: 'Savings' },
|
||||
{ id: 'energy', label: 'Energy' },
|
||||
{ id: 'traces', label: 'Traces' },
|
||||
{ id: 'learning', label: 'Learning' },
|
||||
{ id: 'memory', label: 'Memory' },
|
||||
{ id: 'agents', label: 'Agents' },
|
||||
{ id: 'admin', label: 'Admin' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
const API_URL = 'http://localhost:8000';
|
||||
|
||||
export function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('savings');
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<header style={styles.header}>
|
||||
<h1 style={styles.title}>OpenJarvis Desktop</h1>
|
||||
<nav style={styles.nav}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
...styles.tabButton,
|
||||
...(activeTab === tab.id ? styles.activeTab : {}),
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<UpdateChecker />
|
||||
|
||||
<main style={styles.main}>
|
||||
{activeTab === 'savings' && <SavingsDashboard apiUrl={API_URL} />}
|
||||
{activeTab === 'energy' && <EnergyDashboard apiUrl={API_URL} />}
|
||||
{activeTab === 'traces' && <TraceDebugger apiUrl={API_URL} />}
|
||||
{activeTab === 'learning' && <LearningCurve apiUrl={API_URL} />}
|
||||
{activeTab === 'memory' && <MemoryBrowser apiUrl={API_URL} />}
|
||||
{activeTab === 'agents' && <AgentsPanel apiUrl={API_URL} />}
|
||||
{activeTab === 'admin' && <AdminPanel apiUrl={API_URL} />}
|
||||
{activeTab === 'settings' && <SettingsPanel />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 24px',
|
||||
borderBottom: '1px solid #313244',
|
||||
backgroundColor: '#181825',
|
||||
},
|
||||
title: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: '#89b4fa',
|
||||
},
|
||||
nav: {
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
},
|
||||
tabButton: {
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#a6adc8',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.15s ease',
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: '#313244',
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
main: {
|
||||
padding: '24px',
|
||||
height: 'calc(100vh - 60px)',
|
||||
overflow: 'auto',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,454 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
accepts_tools: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents: AgentInfo[];
|
||||
}
|
||||
|
||||
interface ServerInfo {
|
||||
model: string;
|
||||
agent: string;
|
||||
engine: string;
|
||||
version: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
minHeight: 400,
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
marginBottom: 20,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
color: '#89b4fa',
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '6px 0',
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
value: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
healthDot: {
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
marginRight: 8,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
healthDotHealthy: {
|
||||
backgroundColor: '#a6e3a1',
|
||||
boxShadow: '0 0 6px #a6e3a166',
|
||||
},
|
||||
healthDotUnhealthy: {
|
||||
backgroundColor: '#f38588',
|
||||
boxShadow: '0 0 6px #f3858866',
|
||||
},
|
||||
healthDotUnknown: {
|
||||
backgroundColor: '#a6adc8',
|
||||
},
|
||||
badge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
badgeTrue: {
|
||||
backgroundColor: '#a6e3a133',
|
||||
color: '#a6e3a1',
|
||||
},
|
||||
badgeFalse: {
|
||||
backgroundColor: '#45475a',
|
||||
color: '#a6adc8',
|
||||
},
|
||||
agentTable: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
fontSize: 13,
|
||||
},
|
||||
th: {
|
||||
textAlign: 'left' as const,
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid #45475a',
|
||||
color: '#89b4fa',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase' as const,
|
||||
},
|
||||
td: {
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid #313244',
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
buttonGroup: {
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
padding: '10px 20px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
startButton: {
|
||||
backgroundColor: '#a6e3a1',
|
||||
color: '#1e1e2e',
|
||||
},
|
||||
stopButton: {
|
||||
backgroundColor: '#f38588',
|
||||
color: '#1e1e2e',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
error: {
|
||||
color: '#f38588',
|
||||
padding: 12,
|
||||
backgroundColor: '#f3858811',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
marginBottom: 16,
|
||||
},
|
||||
commandOutput: {
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
backgroundColor: '#181825',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: '#a6adc8',
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
wordBreak: 'break-word' as const,
|
||||
},
|
||||
loading: {
|
||||
color: '#a6adc8',
|
||||
textAlign: 'center' as const,
|
||||
padding: 40,
|
||||
},
|
||||
healthStatus: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AdminPanel({ apiUrl }: { apiUrl: string }) {
|
||||
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [commandRunning, setCommandRunning] = useState(false);
|
||||
const [commandOutput, setCommandOutput] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
// Check health
|
||||
const healthResult = await invoke<HealthStatus>('check_health', { apiUrl });
|
||||
setHealthy(healthResult.status === 'ok');
|
||||
|
||||
// Fetch agents
|
||||
try {
|
||||
const agentsResult = await invoke<AgentsResponse>('fetch_agents', { apiUrl });
|
||||
setAgents(agentsResult.agents ?? []);
|
||||
} catch {
|
||||
// Agent list may not be available; keep previous state
|
||||
}
|
||||
|
||||
// Fetch server info (model, engine, uptime)
|
||||
try {
|
||||
const infoResult = await invoke<ServerInfo>('check_health', { apiUrl });
|
||||
// If server exposes extra fields, merge them
|
||||
setServerInfo((prev) => ({
|
||||
model: prev?.model ?? '',
|
||||
agent: prev?.agent ?? '',
|
||||
engine: prev?.engine ?? '',
|
||||
version: prev?.version ?? '',
|
||||
uptime_seconds: prev?.uptime_seconds ?? 0,
|
||||
...infoResult as unknown as Partial<ServerInfo>,
|
||||
}));
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setHealthy(false);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setCommandRunning(true);
|
||||
setCommandOutput(null);
|
||||
try {
|
||||
const output = await invoke<string>('run_jarvis_command', {
|
||||
args: ['serve', '--port', '8000'],
|
||||
});
|
||||
setCommandOutput(output);
|
||||
// Wait a moment then refresh health
|
||||
setTimeout(refresh, 2000);
|
||||
} catch (err) {
|
||||
setCommandOutput(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setCommandRunning(true);
|
||||
setCommandOutput(null);
|
||||
try {
|
||||
const output = await invoke<string>('run_jarvis_command', {
|
||||
args: ['stop'],
|
||||
});
|
||||
setCommandOutput(output);
|
||||
setTimeout(refresh, 2000);
|
||||
} catch (err) {
|
||||
setCommandOutput(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.loading}>Loading system status...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const healthDotStyle =
|
||||
healthy === null
|
||||
? styles.healthDotUnknown
|
||||
: healthy
|
||||
? styles.healthDotHealthy
|
||||
: styles.healthDotUnhealthy;
|
||||
|
||||
const healthLabel =
|
||||
healthy === null ? 'Unknown' : healthy ? 'Healthy' : 'Unhealthy';
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Admin Panel</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<div style={styles.grid}>
|
||||
{/* Health & Engine */}
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>System Health</div>
|
||||
<div style={{ ...styles.row, marginBottom: 8 }}>
|
||||
<div style={styles.healthStatus}>
|
||||
<span style={{ ...styles.healthDot, ...healthDotStyle }} />
|
||||
{healthLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Engine</span>
|
||||
<span style={styles.value}>{serverInfo?.engine || 'N/A'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Model</span>
|
||||
<span style={styles.value}>{serverInfo?.model || 'N/A'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Agent</span>
|
||||
<span style={styles.value}>{serverInfo?.agent || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>System Info</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Version</span>
|
||||
<span style={styles.value}>{serverInfo?.version || '0.1.0'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Uptime</span>
|
||||
<span style={styles.value}>
|
||||
{serverInfo?.uptime_seconds !== undefined
|
||||
? formatUptime(serverInfo.uptime_seconds)
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>API URL</span>
|
||||
<span style={styles.value}>{apiUrl}</span>
|
||||
</div>
|
||||
|
||||
{/* Server controls */}
|
||||
<div style={styles.buttonGroup}>
|
||||
<button
|
||||
style={{
|
||||
...styles.button,
|
||||
...styles.startButton,
|
||||
...(commandRunning ? styles.buttonDisabled : {}),
|
||||
}}
|
||||
onClick={handleStart}
|
||||
disabled={commandRunning}
|
||||
>
|
||||
Start Server
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...styles.button,
|
||||
...styles.stopButton,
|
||||
...(commandRunning ? styles.buttonDisabled : {}),
|
||||
}}
|
||||
onClick={handleStop}
|
||||
disabled={commandRunning}
|
||||
>
|
||||
Stop Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commandOutput && (
|
||||
<div style={styles.commandOutput}>{commandOutput}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Registry */}
|
||||
{agents.length > 0 && (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Agent Registry ({agents.length})</div>
|
||||
<table style={styles.agentTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Name</th>
|
||||
<th style={styles.th}>Key</th>
|
||||
<th style={styles.th}>Tools</th>
|
||||
<th style={styles.th}>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map((agent) => (
|
||||
<tr key={agent.key}>
|
||||
<td style={styles.td}>{agent.name}</td>
|
||||
<td style={{ ...styles.td, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{agent.key}
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span
|
||||
style={{
|
||||
...styles.badge,
|
||||
...(agent.accepts_tools ? styles.badgeTrue : styles.badgeFalse),
|
||||
}}
|
||||
>
|
||||
{agent.accepts_tools ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...styles.td, color: '#a6adc8', fontSize: 12 }}>
|
||||
{agent.description || '--'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agents.length === 0 && !loading && (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Agent Registry</div>
|
||||
<div style={{ color: '#a6adc8', fontSize: 13, padding: '8px 0' }}>
|
||||
No agents registered or server not reachable.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EnergySample {
|
||||
timestamp: string;
|
||||
power_w: number;
|
||||
energy_j: number;
|
||||
}
|
||||
|
||||
interface EnergyData {
|
||||
total_energy_j?: number;
|
||||
energy_per_token_j?: number;
|
||||
avg_power_w?: number;
|
||||
samples?: EnergySample[];
|
||||
}
|
||||
|
||||
interface TelemetryStats {
|
||||
total_requests?: number;
|
||||
avg_latency_ms?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
time: string;
|
||||
power: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const colors = {
|
||||
bg: '#1e1e2e',
|
||||
surface: '#282840',
|
||||
surfaceHover: '#313150',
|
||||
text: '#cdd6f4',
|
||||
textMuted: '#a6adc8',
|
||||
accent: '#89b4fa',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
red: '#f38ba8',
|
||||
border: '#45475a',
|
||||
} as const;
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
background: colors.bg,
|
||||
color: colors.text,
|
||||
padding: 24,
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: colors.text,
|
||||
},
|
||||
liveBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: colors.green,
|
||||
background: 'rgba(166,227,161,0.1)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
liveDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: colors.green,
|
||||
animation: 'pulse 2s infinite',
|
||||
},
|
||||
statsGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
statCard: {
|
||||
background: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
border: `1px solid ${colors.border}`,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 26,
|
||||
fontWeight: 700,
|
||||
color: colors.accent,
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
statUnit: {
|
||||
fontSize: 13,
|
||||
fontWeight: 400,
|
||||
color: colors.textMuted,
|
||||
marginLeft: 4,
|
||||
},
|
||||
chartContainer: {
|
||||
background: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
border: `1px solid ${colors.border}`,
|
||||
marginBottom: 24,
|
||||
},
|
||||
chartTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginBottom: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
emptyState: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 64,
|
||||
color: colors.textMuted,
|
||||
gap: 12,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 40,
|
||||
opacity: 0.4,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
errorBanner: {
|
||||
background: 'rgba(243,139,168,0.1)',
|
||||
border: `1px solid ${colors.red}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 16px',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
color: colors.red,
|
||||
},
|
||||
thermalStatus: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatEnergy(joules: number): string {
|
||||
if (joules >= 1000) {
|
||||
return `${(joules / 1000).toFixed(2)} kJ`;
|
||||
}
|
||||
return `${joules.toFixed(2)} J`;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
function thermalIndicator(avgPower: number): { label: string; color: string } {
|
||||
if (avgPower < 50) return { label: 'Cool', color: colors.green };
|
||||
if (avgPower < 150) return { label: 'Warm', color: colors.yellow };
|
||||
return { label: 'Hot', color: colors.red };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function EnergyDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
const [energyData, setEnergyData] = useState<EnergyData | null>(null);
|
||||
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [energy, telem] = await Promise.allSettled([
|
||||
invoke<EnergyData>('fetch_energy', { apiUrl }),
|
||||
invoke<TelemetryStats>('fetch_telemetry', { apiUrl }),
|
||||
]);
|
||||
|
||||
if (energy.status === 'fulfilled') {
|
||||
setEnergyData(energy.value);
|
||||
setError(null);
|
||||
} else {
|
||||
setEnergyData(null);
|
||||
setError(String(energy.reason));
|
||||
}
|
||||
|
||||
if (telem.status === 'fulfilled') {
|
||||
setTelemetry(telem.value);
|
||||
} else {
|
||||
setTelemetry(null);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const timer = setInterval(fetchData, REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
// Build chart data from samples
|
||||
const chartData: ChartPoint[] = (energyData?.samples ?? []).map((s) => ({
|
||||
time: formatTimestamp(s.timestamp),
|
||||
power: s.power_w,
|
||||
}));
|
||||
|
||||
const hasEnergyData =
|
||||
energyData !== null &&
|
||||
(energyData.total_energy_j !== undefined ||
|
||||
(energyData.samples !== undefined && energyData.samples.length > 0));
|
||||
|
||||
// --- Empty / error states ---
|
||||
|
||||
if (!loading && !hasEnergyData && !error) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Energy Monitor</h2>
|
||||
</div>
|
||||
<div style={styles.emptyState}>
|
||||
<div style={styles.emptyIcon}>⚡</div>
|
||||
<div style={styles.emptyText}>
|
||||
No energy data available.<br />
|
||||
Ensure an energy monitor backend (NVIDIA, AMD, Apple, or RAPL) is configured.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thermal = thermalIndicator(energyData?.avg_power_w ?? 0);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* Pulse animation injected once */}
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Energy Monitor</h2>
|
||||
<span style={styles.liveBadge}>
|
||||
<span style={styles.liveDot} />
|
||||
Live - {REFRESH_INTERVAL_MS / 1000}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Summary cards */}
|
||||
<div style={styles.statsGrid}>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Energy</div>
|
||||
<div style={styles.statValue}>
|
||||
{energyData?.total_energy_j !== undefined
|
||||
? formatEnergy(energyData.total_energy_j)
|
||||
: '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Energy per Token</div>
|
||||
<div style={styles.statValue}>
|
||||
{energyData?.energy_per_token_j !== undefined ? (
|
||||
<>
|
||||
{(energyData.energy_per_token_j * 1000).toFixed(3)}
|
||||
<span style={styles.statUnit}>mJ</span>
|
||||
</>
|
||||
) : (
|
||||
'--'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Avg Power Draw</div>
|
||||
<div style={styles.statValue}>
|
||||
{energyData?.avg_power_w !== undefined ? (
|
||||
<>
|
||||
{energyData.avg_power_w.toFixed(1)}
|
||||
<span style={styles.statUnit}>W</span>
|
||||
</>
|
||||
) : (
|
||||
'--'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Thermal Status</div>
|
||||
<div style={{ ...styles.thermalStatus, color: thermal.color }}>
|
||||
{thermal.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{telemetry?.total_requests !== undefined && (
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Requests</div>
|
||||
<div style={styles.statValue}>{telemetry.total_requests.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telemetry?.total_tokens !== undefined && (
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Tokens</div>
|
||||
<div style={styles.statValue}>{telemetry.total_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Power chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div style={styles.chartContainer}>
|
||||
<div style={styles.chartTitle}>Power Draw Over Time (W)</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 20, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={colors.border} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={colors.textMuted}
|
||||
tick={{ fill: colors.textMuted, fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.textMuted}
|
||||
tick={{ fill: colors.textMuted, fontSize: 11 }}
|
||||
unit=" W"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: colors.surface,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 6,
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
}}
|
||||
labelStyle={{ color: colors.textMuted }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="power"
|
||||
stroke={colors.accent}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: colors.accent }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PolicyConfig {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
update_interval: number;
|
||||
}
|
||||
|
||||
interface RoutingWeight {
|
||||
query_class: string;
|
||||
model: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
interface BanditArm {
|
||||
model: string;
|
||||
pulls: number;
|
||||
reward_mean: number;
|
||||
ucb: number;
|
||||
}
|
||||
|
||||
interface LearningStatsPoint {
|
||||
timestamp: string;
|
||||
accuracy: number;
|
||||
latency_ms: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
interface LearningStats {
|
||||
history: LearningStatsPoint[];
|
||||
icl_example_count: number;
|
||||
discovered_skills_count: number;
|
||||
total_traces: number;
|
||||
total_updates: number;
|
||||
}
|
||||
|
||||
interface LearningPolicy {
|
||||
config: PolicyConfig;
|
||||
routing_weights: RoutingWeight[];
|
||||
bandit_arms: BanditArm[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
minHeight: 400,
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
marginBottom: 20,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
color: '#89b4fa',
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px 0',
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
value: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
badge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
badgeEnabled: {
|
||||
backgroundColor: '#a6e3a133',
|
||||
color: '#a6e3a1',
|
||||
},
|
||||
badgeDisabled: {
|
||||
backgroundColor: '#f3858833',
|
||||
color: '#f38588',
|
||||
},
|
||||
chartContainer: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
fontSize: 13,
|
||||
},
|
||||
th: {
|
||||
textAlign: 'left' as const,
|
||||
padding: '6px 8px',
|
||||
borderBottom: '1px solid #45475a',
|
||||
color: '#89b4fa',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase' as const,
|
||||
},
|
||||
td: {
|
||||
padding: '6px 8px',
|
||||
borderBottom: '1px solid #313244',
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
weightBar: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#45475a',
|
||||
overflow: 'hidden' as const,
|
||||
marginTop: 4,
|
||||
},
|
||||
weightFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#89b4fa',
|
||||
},
|
||||
error: {
|
||||
color: '#f38588',
|
||||
padding: 12,
|
||||
backgroundColor: '#f3858811',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
},
|
||||
loading: {
|
||||
color: '#a6adc8',
|
||||
textAlign: 'center' as const,
|
||||
padding: 40,
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: '#89b4fa',
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#a6adc8',
|
||||
marginTop: 4,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LearningCurve({ apiUrl }: { apiUrl: string }) {
|
||||
const [stats, setStats] = useState<LearningStats | null>(null);
|
||||
const [policy, setPolicy] = useState<LearningPolicy | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [statsResult, policyResult] = await Promise.all([
|
||||
invoke<LearningStats>('fetch_learning_stats', { apiUrl }),
|
||||
invoke<LearningPolicy>('fetch_learning_policy', { apiUrl }),
|
||||
]);
|
||||
setStats(statsResult);
|
||||
setPolicy(policyResult);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 10_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.loading}>Loading learning data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !stats) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Learning Curve</div>
|
||||
<div style={styles.error}>{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = (stats?.history ?? []).map((point) => ({
|
||||
time: point.timestamp,
|
||||
accuracy: Math.round(point.accuracy * 1000) / 10,
|
||||
latency: Math.round(point.latency_ms),
|
||||
cost: Math.round(point.cost * 10000) / 10000,
|
||||
}));
|
||||
|
||||
const policyConfig = policy?.config;
|
||||
const isGrpo = policyConfig?.name?.toLowerCase().includes('grpo');
|
||||
const isBandit = policyConfig?.name?.toLowerCase().includes('bandit');
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Learning Curve</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
{/* Policy config + counters */}
|
||||
<div style={styles.grid}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Policy Config</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Policy</span>
|
||||
<span style={styles.value}>{policyConfig?.name ?? 'unknown'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Status</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.badge,
|
||||
...(policyConfig?.enabled ? styles.badgeEnabled : styles.badgeDisabled),
|
||||
}}
|
||||
>
|
||||
{policyConfig?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Update interval</span>
|
||||
<span style={styles.value}>{policyConfig?.update_interval ?? 0}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Counters</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.total_traces ?? 0}</div>
|
||||
<div style={styles.statLabel}>Total traces</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.total_updates ?? 0}</div>
|
||||
<div style={styles.statLabel}>Policy updates</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.icl_example_count ?? 0}</div>
|
||||
<div style={styles.statLabel}>ICL examples</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.discovered_skills_count ?? 0}</div>
|
||||
<div style={styles.statLabel}>Discovered skills</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accuracy / latency chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div style={styles.chartContainer}>
|
||||
<div style={styles.cardTitle}>Routing Accuracy Over Time</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#45475a" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: '#a6adc8', fontSize: 11 }}
|
||||
stroke="#45475a"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: '#a6adc8', fontSize: 11 }}
|
||||
stroke="#45475a"
|
||||
label={{
|
||||
value: 'Accuracy %',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fill: '#a6adc8', fontSize: 11 },
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: '#a6adc8', fontSize: 11 }}
|
||||
stroke="#45475a"
|
||||
label={{
|
||||
value: 'Latency (ms)',
|
||||
angle: 90,
|
||||
position: 'insideRight',
|
||||
style: { fill: '#a6adc8', fontSize: 11 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#313244',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: 6,
|
||||
color: '#cdd6f4',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: '#cdd6f4', fontSize: 12 }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="accuracy"
|
||||
name="Accuracy %"
|
||||
stroke="#89b4fa"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="latency"
|
||||
name="Latency (ms)"
|
||||
stroke="#f9e2af"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GRPO routing weights */}
|
||||
{isGrpo && (policy?.routing_weights ?? []).length > 0 && (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>GRPO Routing Weights</div>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Query Class</th>
|
||||
<th style={styles.th}>Model</th>
|
||||
<th style={styles.th}>Weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policy!.routing_weights.map((rw, i) => (
|
||||
<tr key={i}>
|
||||
<td style={styles.td}>{rw.query_class}</td>
|
||||
<td style={styles.td}>{rw.model}</td>
|
||||
<td style={styles.td}>
|
||||
<span>{(rw.weight * 100).toFixed(1)}%</span>
|
||||
<div style={styles.weightBar}>
|
||||
<div
|
||||
style={{
|
||||
...styles.weightFill,
|
||||
width: `${Math.min(rw.weight * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bandit arm stats */}
|
||||
{isBandit && (policy?.bandit_arms ?? []).length > 0 && (
|
||||
<div style={{ ...styles.card, marginTop: 16 }}>
|
||||
<div style={styles.cardTitle}>Bandit Arm Statistics</div>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Model</th>
|
||||
<th style={styles.th}>Pulls</th>
|
||||
<th style={styles.th}>Mean Reward</th>
|
||||
<th style={styles.th}>UCB</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policy!.bandit_arms.map((arm, i) => (
|
||||
<tr key={i}>
|
||||
<td style={styles.td}>{arm.model}</td>
|
||||
<td style={styles.td}>{arm.pulls}</td>
|
||||
<td style={styles.td}>{arm.reward_mean.toFixed(4)}</td>
|
||||
<td style={styles.td}>{arm.ucb.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MemoryChunk {
|
||||
content: string;
|
||||
score: number;
|
||||
metadata: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: MemoryChunk[];
|
||||
query: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface MemoryStats {
|
||||
backend: string;
|
||||
total_documents: number;
|
||||
total_chunks: number;
|
||||
index_size_bytes: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
minHeight: 400,
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
marginBottom: 20,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
searchBar: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
fontSize: 14,
|
||||
backgroundColor: '#313244',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: 8,
|
||||
color: '#cdd6f4',
|
||||
outline: 'none',
|
||||
},
|
||||
button: {
|
||||
padding: '10px 20px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
backgroundColor: '#89b4fa',
|
||||
color: '#1e1e2e',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
statsPanel: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
statCard: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: '#89b4fa',
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#a6adc8',
|
||||
marginTop: 4,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
resultsList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 12,
|
||||
},
|
||||
resultCard: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderLeft: '3px solid #89b4fa',
|
||||
},
|
||||
resultContent: {
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
color: '#cdd6f4',
|
||||
marginBottom: 10,
|
||||
wordBreak: 'break-word' as const,
|
||||
},
|
||||
scoreContainer: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
scoreHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
scoreLabel: {
|
||||
fontSize: 12,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
scoreValue: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#89b4fa',
|
||||
},
|
||||
scoreBar: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#45475a',
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
scoreFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#89b4fa',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
metadataRow: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap' as const,
|
||||
gap: 6,
|
||||
marginTop: 8,
|
||||
},
|
||||
metaTag: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
backgroundColor: '#45475a',
|
||||
color: '#a6adc8',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center' as const,
|
||||
padding: 40,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
error: {
|
||||
color: '#f38588',
|
||||
padding: 12,
|
||||
backgroundColor: '#f3858811',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
marginBottom: 16,
|
||||
},
|
||||
resultCount: {
|
||||
fontSize: 13,
|
||||
color: '#a6adc8',
|
||||
marginBottom: 12,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(1) + ' GB';
|
||||
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max) + '...';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MemoryBrowser({ apiUrl }: { apiUrl: string }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<MemoryChunk[]>([]);
|
||||
const [resultTotal, setResultTotal] = useState(0);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [stats, setStats] = useState<MemoryStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch stats on mount
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<MemoryStats>('fetch_memory_stats', { apiUrl });
|
||||
setStats(result);
|
||||
} catch (err) {
|
||||
// Stats are non-critical; silently ignore
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await invoke<SearchResponse>('search_memory', {
|
||||
apiUrl,
|
||||
query: query.trim(),
|
||||
topK: 10,
|
||||
});
|
||||
setResults(response.results ?? []);
|
||||
setResultTotal(response.total ?? (response.results ?? []).length);
|
||||
setHasSearched(true);
|
||||
// Refresh stats after search
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setResults([]);
|
||||
setHasSearched(true);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [apiUrl, query, loadStats]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Memory Browser</div>
|
||||
|
||||
{/* Stats panel */}
|
||||
{stats && (
|
||||
<div style={styles.statsPanel}>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{stats.backend}</div>
|
||||
<div style={styles.statLabel}>Backend</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{stats.total_documents.toLocaleString()}</div>
|
||||
<div style={styles.statLabel}>Documents</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{stats.total_chunks.toLocaleString()}</div>
|
||||
<div style={styles.statLabel}>Chunks</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{formatBytes(stats.index_size_bytes)}</div>
|
||||
<div style={styles.statLabel}>Index Size</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<div style={styles.searchBar}>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="text"
|
||||
placeholder="Search memory..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
...styles.button,
|
||||
...(searching ? styles.buttonDisabled : {}),
|
||||
}}
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !query.trim()}
|
||||
>
|
||||
{searching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && results.length === 0 && !error && (
|
||||
<div style={styles.emptyState}>No results found for "{query}"</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
<div style={styles.resultCount}>
|
||||
Showing {results.length} of {resultTotal} results
|
||||
</div>
|
||||
<div style={styles.resultsList}>
|
||||
{results.map((chunk, i) => {
|
||||
const scorePercent = Math.round(chunk.score * 100);
|
||||
return (
|
||||
<div key={i} style={styles.resultCard}>
|
||||
{/* Score bar */}
|
||||
<div style={styles.scoreContainer}>
|
||||
<div style={styles.scoreHeader}>
|
||||
<span style={styles.scoreLabel}>Relevance</span>
|
||||
<span style={styles.scoreValue}>{scorePercent}%</span>
|
||||
</div>
|
||||
<div style={styles.scoreBar}>
|
||||
<div
|
||||
style={{
|
||||
...styles.scoreFill,
|
||||
width: `${Math.min(scorePercent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content preview */}
|
||||
<div style={styles.resultContent}>
|
||||
{truncate(chunk.content, 200)}
|
||||
</div>
|
||||
|
||||
{/* Metadata tags */}
|
||||
{Object.keys(chunk.metadata).length > 0 && (
|
||||
<div style={styles.metadataRow}>
|
||||
{Object.entries(chunk.metadata).map(([key, val]) => (
|
||||
<span key={key} style={styles.metaTag}>
|
||||
{key}: {String(val)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasSearched && !stats && (
|
||||
<div style={styles.emptyState}>Enter a query to search memory</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProviderSavings {
|
||||
provider: string;
|
||||
label: string;
|
||||
input_cost: number;
|
||||
output_cost: number;
|
||||
total_cost: number;
|
||||
energy_wh: number;
|
||||
energy_joules: number;
|
||||
flops: number;
|
||||
}
|
||||
|
||||
interface SavingsData {
|
||||
total_calls: number;
|
||||
total_prompt_tokens: number;
|
||||
total_completion_tokens: number;
|
||||
total_tokens: number;
|
||||
local_cost: number;
|
||||
per_provider: ProviderSavings[];
|
||||
monthly_projection: Record<string, number>;
|
||||
session_start_ts: number;
|
||||
session_duration_hours: number;
|
||||
avg_cost_per_query: Record<string, number>;
|
||||
cloud_agent_equivalent: {
|
||||
moderate_low: number;
|
||||
moderate_high: number;
|
||||
heavy_low: number;
|
||||
heavy_high: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles (Catppuccin)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const colors = {
|
||||
bg: '#1e1e2e',
|
||||
surface: '#282840',
|
||||
text: '#cdd6f4',
|
||||
textMuted: '#a6adc8',
|
||||
accent: '#89b4fa',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
red: '#f38ba8',
|
||||
purple: '#cba6f7',
|
||||
border: '#45475a',
|
||||
} as const;
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
background: colors.bg,
|
||||
color: colors.text,
|
||||
padding: 24,
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: colors.text,
|
||||
},
|
||||
liveBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: colors.green,
|
||||
background: 'rgba(166,227,161,0.1)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
liveDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: colors.green,
|
||||
animation: 'pulse 2s infinite',
|
||||
},
|
||||
statsGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
statCard: {
|
||||
background: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
border: `1px solid ${colors.border}`,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 26,
|
||||
fontWeight: 700,
|
||||
color: colors.accent,
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
statUnit: {
|
||||
fontSize: 13,
|
||||
fontWeight: 400,
|
||||
color: colors.textMuted,
|
||||
marginLeft: 4,
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: 12,
|
||||
},
|
||||
providersGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
providerCard: {
|
||||
background: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
border: `1px solid ${colors.border}`,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
providerName: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
},
|
||||
providerModel: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
marginBottom: 14,
|
||||
},
|
||||
savingsAmount: {
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: colors.green,
|
||||
marginBottom: 8,
|
||||
},
|
||||
breakdown: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 12,
|
||||
marginTop: 14,
|
||||
paddingTop: 14,
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
},
|
||||
breakdownLabel: {
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
breakdownValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
marginTop: 2,
|
||||
},
|
||||
cloudAgentCard: {
|
||||
background: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderTop: `3px solid ${colors.purple}`,
|
||||
marginBottom: 24,
|
||||
},
|
||||
cloudAgentGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: 20,
|
||||
marginTop: 16,
|
||||
},
|
||||
emptyState: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 64,
|
||||
color: colors.textMuted,
|
||||
gap: 12,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
errorBanner: {
|
||||
background: 'rgba(243,139,168,0.1)',
|
||||
border: `1px solid ${colors.red}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 16px',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
color: colors.red,
|
||||
},
|
||||
};
|
||||
|
||||
const PROVIDER_COLORS: Record<string, string> = {
|
||||
'gpt-5.3': colors.green,
|
||||
'claude-opus-4.6': colors.yellow,
|
||||
'gemini-3.1-pro': colors.accent,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmtDollar(n: number): string {
|
||||
if (n >= 1000) return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
if (n >= 1) return '$' + n.toFixed(2);
|
||||
if (n >= 0.01) return '$' + n.toFixed(3);
|
||||
if (n > 0) return '$' + n.toFixed(4);
|
||||
return '$0.00';
|
||||
}
|
||||
|
||||
function fmtDuration(hours: number): string {
|
||||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||||
if (hours < 24) return `${hours.toFixed(1)}h`;
|
||||
return `${(hours / 24).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BLOCKED_WORDS = new Set([
|
||||
"ass","asshole","bastard","bitch","bollocks","bullshit","cock","crap",
|
||||
"cunt","damn","dick","douchebag","fag","faggot","fuck","fucker","fucking",
|
||||
"goddamn","hell","jackass","jerk","motherfucker","nigga","nigger","penis",
|
||||
"piss","prick","pussy","retard","shit","slut","twat","vagina","wanker","whore",
|
||||
]);
|
||||
|
||||
function isProfane(text: string): boolean {
|
||||
const words = text.toLowerCase().replace(/[^a-z]/g, ' ').split(/\s+/);
|
||||
for (const w of words) {
|
||||
if (BLOCKED_WORDS.has(w)) return true;
|
||||
for (const b of BLOCKED_WORDS) {
|
||||
if (w.includes(b)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const OPTIN_KEY = 'openjarvis-desktop-optin';
|
||||
const OPTIN_NAME_KEY = 'openjarvis-desktop-display-name';
|
||||
const OPTIN_ANONID_KEY = 'openjarvis-desktop-anon-id';
|
||||
|
||||
function getOrCreateAnonId(): string {
|
||||
const stored = localStorage.getItem(OPTIN_ANONID_KEY);
|
||||
if (stored) return stored;
|
||||
const id = crypto.randomUUID();
|
||||
localStorage.setItem(OPTIN_ANONID_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
const SUPABASE_URL = 'https://mtbtgpwzrbostweaanpr.supabase.co';
|
||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
const [data, setData] = useState<SavingsData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [optInEnabled, setOptInEnabled] = useState(localStorage.getItem(OPTIN_KEY) === 'true');
|
||||
const [displayName, setDisplayName] = useState(localStorage.getItem(OPTIN_NAME_KEY) || '');
|
||||
const [nameInput, setNameInput] = useState(localStorage.getItem(OPTIN_NAME_KEY) || '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [showOptIn, setShowOptIn] = useState(false);
|
||||
const anonId = getOrCreateAnonId();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<SavingsData>('fetch_savings', { apiUrl });
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const timer = setInterval(fetchData, REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
// Share savings to Supabase when opted in and data changes
|
||||
useEffect(() => {
|
||||
if (!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);
|
||||
invoke('submit_savings', {
|
||||
supabaseUrl: SUPABASE_URL,
|
||||
supabaseKey: SUPABASE_KEY,
|
||||
payload: {
|
||||
anon_id: anonId,
|
||||
display_name: displayName,
|
||||
total_calls: data.total_calls,
|
||||
total_tokens: data.total_tokens,
|
||||
dollar_savings: dollarSavings,
|
||||
energy_wh_saved: energySaved,
|
||||
flops_saved: flopsSaved,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}, [data, optInEnabled, displayName, anonId]);
|
||||
|
||||
const handleOptInJoin = () => {
|
||||
const trimmed = nameInput.trim();
|
||||
if (!trimmed || trimmed.length < 2 || trimmed.length > 30) {
|
||||
setNameError('Name must be 2-30 characters');
|
||||
return;
|
||||
}
|
||||
if (isProfane(trimmed)) {
|
||||
setNameError('Please choose a different name');
|
||||
return;
|
||||
}
|
||||
setNameError('');
|
||||
localStorage.setItem(OPTIN_KEY, 'true');
|
||||
localStorage.setItem(OPTIN_NAME_KEY, trimmed);
|
||||
setOptInEnabled(true);
|
||||
setDisplayName(trimmed);
|
||||
setShowOptIn(false);
|
||||
};
|
||||
|
||||
const handleOptOut = () => {
|
||||
localStorage.removeItem(OPTIN_KEY);
|
||||
localStorage.removeItem(OPTIN_NAME_KEY);
|
||||
setOptInEnabled(false);
|
||||
setDisplayName('');
|
||||
setShowOptIn(false);
|
||||
};
|
||||
|
||||
if (!loading && !data && !error) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Savings Dashboard</h2>
|
||||
</div>
|
||||
<div style={styles.emptyState}>
|
||||
<div style={{ fontSize: 40, opacity: 0.4 }}>$</div>
|
||||
<div style={styles.emptyText}>
|
||||
No savings data available.<br />
|
||||
Start making inference requests to see savings vs cloud providers.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const providers = data?.per_provider ?? [];
|
||||
const projection = data?.monthly_projection ?? {};
|
||||
const cloudAgent = data?.cloud_agent_equivalent;
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Savings Dashboard</h2>
|
||||
<span style={styles.liveBadge}>
|
||||
<span style={styles.liveDot} />
|
||||
Live - {REFRESH_INTERVAL_MS / 1000}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Leaderboard Opt-in */}
|
||||
{showOptIn ? (
|
||||
<div style={{ ...styles.statCard, marginBottom: 24, padding: 20 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 8, color: colors.text }}>
|
||||
Share Your Savings
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: colors.textMuted, marginBottom: 14, lineHeight: 1.5 }}>
|
||||
Opt in to privately share your savings for the chance to win a Mac Mini!
|
||||
</div>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => { setNameInput(e.target.value); setNameError(''); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleOptInJoin(); }}
|
||||
placeholder="Display name for leaderboard"
|
||||
maxLength={30}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
background: colors.bg,
|
||||
border: nameError ? `1px solid ${colors.red}` : `1px solid ${colors.border}`,
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{nameError && (
|
||||
<div style={{ fontSize: 12, color: colors.red, marginTop: 4 }}>{nameError}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleOptInJoin}
|
||||
style={{
|
||||
padding: '8px 18px',
|
||||
borderRadius: 8,
|
||||
background: colors.accent,
|
||||
color: '#1e1e2e',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Join Leaderboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowOptIn(false)}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
background: 'transparent',
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
border: `1px solid ${colors.border}`,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{optInEnabled && (
|
||||
<button
|
||||
onClick={handleOptOut}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
background: 'transparent',
|
||||
color: colors.red,
|
||||
fontSize: 13,
|
||||
border: `1px solid ${colors.border}`,
|
||||
cursor: 'pointer',
|
||||
marginLeft: 'auto',
|
||||
}}
|
||||
>
|
||||
Opt Out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<button
|
||||
onClick={() => setShowOptIn(true)}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: 8,
|
||||
background: optInEnabled ? 'rgba(166,227,161,0.15)' : colors.surface,
|
||||
border: optInEnabled ? `1px solid ${colors.green}` : `1px solid ${colors.border}`,
|
||||
color: optInEnabled ? colors.green : colors.textMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{optInEnabled ? `Sharing as "${displayName}"` : 'Share Your Savings'}
|
||||
</button>
|
||||
<a
|
||||
href="https://open-jarvis.github.io/OpenJarvis/leaderboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 12, color: colors.accent, textDecoration: 'none' }}
|
||||
>
|
||||
View Leaderboard ↗
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat cards row */}
|
||||
<div style={styles.statsGrid}>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Requests</div>
|
||||
<div style={styles.statValue}>
|
||||
{(data?.total_calls ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Tokens</div>
|
||||
<div style={styles.statValue}>
|
||||
{(data?.total_tokens ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Session Duration</div>
|
||||
<div style={styles.statValue}>
|
||||
{fmtDuration(data?.session_duration_hours ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Local Cost</div>
|
||||
<div style={{ ...styles.statValue, color: colors.green }}>
|
||||
{fmtDollar(data?.local_cost ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider savings cards */}
|
||||
<div style={styles.sectionHeading}>Savings vs Cloud Providers</div>
|
||||
<div style={styles.providersGrid}>
|
||||
{providers.map((p) => (
|
||||
<div
|
||||
key={p.provider}
|
||||
style={{
|
||||
...styles.providerCard,
|
||||
borderTop: `3px solid ${PROVIDER_COLORS[p.provider] ?? colors.accent}`,
|
||||
}}
|
||||
>
|
||||
<div style={styles.providerName}>{p.label}</div>
|
||||
<div style={styles.providerModel}>{p.provider}</div>
|
||||
<div style={styles.savingsAmount}>{fmtDollar(p.total_cost)}</div>
|
||||
<div style={styles.breakdown}>
|
||||
<div>
|
||||
<div style={styles.breakdownLabel}>Input Saved</div>
|
||||
<div style={styles.breakdownValue}>{fmtDollar(p.input_cost)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.breakdownLabel}>Output Saved</div>
|
||||
<div style={styles.breakdownValue}>{fmtDollar(p.output_cost)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cloud Agent Platforms */}
|
||||
{cloudAgent && (
|
||||
<>
|
||||
<div style={styles.sectionHeading}>vs Cloud Agent Platforms</div>
|
||||
<div style={styles.cloudAgentCard}>
|
||||
<div style={styles.providerName}>Typical Cloud Agent Platform</div>
|
||||
<div style={styles.providerModel}>based on published API pricing tiers</div>
|
||||
<div style={styles.cloudAgentGrid}>
|
||||
<div>
|
||||
<div style={styles.breakdownLabel}>MODERATE USE</div>
|
||||
<div style={{ ...styles.breakdownValue, color: colors.yellow, fontSize: 20 }}>
|
||||
${cloudAgent.moderate_low}–{cloudAgent.moderate_high}/mo
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.breakdownLabel}>HEAVY USE</div>
|
||||
<div style={{ ...styles.breakdownValue, color: colors.red, fontSize: 20 }}>
|
||||
${cloudAgent.heavy_low}–{cloudAgent.heavy_high}+/mo
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.breakdownLabel}>YOUR COST</div>
|
||||
<div style={{ ...styles.breakdownValue, color: colors.green, fontSize: 24 }}>
|
||||
$0.00
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: colors.textMuted, marginTop: 2 }}>
|
||||
local inference
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Monthly Projection */}
|
||||
<div style={styles.sectionHeading}>Monthly Projection</div>
|
||||
<div style={styles.providersGrid}>
|
||||
{providers.map((p) => (
|
||||
<div
|
||||
key={`proj-${p.provider}`}
|
||||
style={{
|
||||
...styles.providerCard,
|
||||
borderTop: `3px solid ${PROVIDER_COLORS[p.provider] ?? colors.accent}`,
|
||||
}}
|
||||
>
|
||||
<div style={styles.providerName}>vs {p.label}</div>
|
||||
<div style={styles.providerModel}>projected monthly savings</div>
|
||||
<div style={styles.savingsAmount}>
|
||||
{fmtDollar(projection[p.provider] ?? 0)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: colors.textMuted }}>
|
||||
per month at current rate
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Settings {
|
||||
apiUrl: string;
|
||||
refreshInterval: number; // seconds
|
||||
theme: 'dark' | 'light';
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
apiUrl: 'http://localhost:8000',
|
||||
refreshInterval: 5,
|
||||
theme: 'dark',
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'openjarvis-settings';
|
||||
|
||||
function loadSettings(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
function saveSettings(settings: Settings): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: 24,
|
||||
maxWidth: 600,
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
marginBottom: 24,
|
||||
color: '#89b4fa',
|
||||
},
|
||||
fieldGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
display: 'block',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#a6adc8',
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #313244',
|
||||
backgroundColor: '#181825',
|
||||
color: '#cdd6f4',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
select: {
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #313244',
|
||||
backgroundColor: '#181825',
|
||||
color: '#cdd6f4',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
toggleRow: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
},
|
||||
toggleButton: {
|
||||
padding: '8px 16px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #313244',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#a6adc8',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.15s ease',
|
||||
},
|
||||
toggleActive: {
|
||||
backgroundColor: '#313244',
|
||||
color: '#cdd6f4',
|
||||
borderColor: '#89b4fa',
|
||||
},
|
||||
savedNotice: {
|
||||
marginTop: 16,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#1e3a2f',
|
||||
color: '#a6e3a1',
|
||||
fontSize: 13,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SettingsPanelProps {
|
||||
onSettingsChange?: (settings: Settings) => void;
|
||||
}
|
||||
|
||||
export function SettingsPanel({ onSettingsChange }: SettingsPanelProps) {
|
||||
const [settings, setSettings] = useState<Settings>(loadSettings);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
saveSettings(settings);
|
||||
onSettingsChange?.(settings);
|
||||
setSaved(true);
|
||||
const timer = setTimeout(() => setSaved(false), 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [settings, onSettingsChange]);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h2 style={styles.heading}>Settings</h2>
|
||||
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.label}>API URL</label>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="text"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({ ...s, apiUrl: e.target.value }))
|
||||
}
|
||||
placeholder="http://localhost:8000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.label}>Auto-refresh interval</label>
|
||||
<select
|
||||
style={styles.select}
|
||||
value={settings.refreshInterval}
|
||||
onChange={(e) =>
|
||||
setSettings((s) => ({
|
||||
...s,
|
||||
refreshInterval: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value={1}>1 second</option>
|
||||
<option value={2}>2 seconds</option>
|
||||
<option value={5}>5 seconds</option>
|
||||
<option value={10}>10 seconds</option>
|
||||
<option value={30}>30 seconds</option>
|
||||
<option value={60}>60 seconds</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={styles.fieldGroup}>
|
||||
<label style={styles.label}>Theme</label>
|
||||
<div style={styles.toggleRow}>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.toggleButton,
|
||||
...(settings.theme === 'dark' ? styles.toggleActive : {}),
|
||||
}}
|
||||
onClick={() => setSettings((s) => ({ ...s, theme: 'dark' }))}
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.toggleButton,
|
||||
...(settings.theme === 'light' ? styles.toggleActive : {}),
|
||||
}}
|
||||
onClick={() => setSettings((s) => ({ ...s, theme: 'light' }))}
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saved && <div style={styles.savedNotice}>Settings saved</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { Settings };
|
||||
@@ -0,0 +1,607 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TraceStepData {
|
||||
model?: string;
|
||||
tokens?: number;
|
||||
tool?: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
backend?: string;
|
||||
results?: number;
|
||||
policy?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TraceStep {
|
||||
step_type: string;
|
||||
duration_ms: number;
|
||||
data: TraceStepData;
|
||||
}
|
||||
|
||||
interface TraceSummary {
|
||||
id: string;
|
||||
query: string;
|
||||
steps: TraceStep[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TraceListResponse {
|
||||
traces: TraceSummary[];
|
||||
}
|
||||
|
||||
interface TraceDetail {
|
||||
id: string;
|
||||
query: string;
|
||||
steps: TraceStep[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STEP_COLORS: Record<string, string> = {
|
||||
route: '#89b4fa',
|
||||
retrieve: '#a6e3a1',
|
||||
generate: '#f9e2af',
|
||||
tool_call: '#cba6f7',
|
||||
respond: '#f38ba8',
|
||||
};
|
||||
|
||||
const DEFAULT_STEP_COLOR = '#9399b2';
|
||||
|
||||
const colors = {
|
||||
bg: '#1e1e2e',
|
||||
surface: '#282840',
|
||||
surfaceHover: '#313150',
|
||||
text: '#cdd6f4',
|
||||
textMuted: '#a6adc8',
|
||||
accent: '#89b4fa',
|
||||
border: '#45475a',
|
||||
red: '#f38ba8',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
background: colors.bg,
|
||||
color: colors.text,
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
|
||||
// Left panel - trace list
|
||||
listPanel: {
|
||||
width: 320,
|
||||
minWidth: 280,
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
listHeader: {
|
||||
padding: '20px 16px 12px',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
listTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
marginBottom: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
listSubtitle: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
margin: 0,
|
||||
},
|
||||
listScroll: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '8px 0',
|
||||
},
|
||||
traceItem: {
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
traceItemSelected: {
|
||||
background: colors.surfaceHover,
|
||||
},
|
||||
traceItemId: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
color: colors.accent,
|
||||
marginBottom: 3,
|
||||
},
|
||||
traceItemQuery: {
|
||||
fontSize: 12,
|
||||
color: colors.text,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginBottom: 3,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
traceItemMeta: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
|
||||
// Right panel - detail
|
||||
detailPanel: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
detailHeader: {
|
||||
padding: '20px 24px 16px',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
detailTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
marginBottom: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
detailQuery: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
margin: 0,
|
||||
marginBottom: 8,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
detailStats: {
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
detailStatValue: {
|
||||
fontWeight: 600,
|
||||
color: colors.accent,
|
||||
},
|
||||
detailScroll: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: 24,
|
||||
},
|
||||
timelineContainer: {
|
||||
position: 'relative',
|
||||
paddingLeft: 24,
|
||||
},
|
||||
timelineLine: {
|
||||
position: 'absolute',
|
||||
left: 7,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 2,
|
||||
background: colors.border,
|
||||
},
|
||||
|
||||
// Timeline step
|
||||
stepContainer: {
|
||||
position: 'relative',
|
||||
marginBottom: 16,
|
||||
},
|
||||
stepDot: {
|
||||
position: 'absolute',
|
||||
left: -20,
|
||||
top: 8,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${colors.bg}`,
|
||||
},
|
||||
stepCard: {
|
||||
background: colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
border: `1px solid ${colors.border}`,
|
||||
},
|
||||
stepHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
stepBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: 10,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
stepDuration: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
},
|
||||
stepDetails: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
stepDetailRow: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
},
|
||||
stepDetailKey: {
|
||||
color: colors.textMuted,
|
||||
minWidth: 60,
|
||||
flexShrink: 0,
|
||||
},
|
||||
stepDetailValue: {
|
||||
color: colors.text,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: 11,
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
expandButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: colors.accent,
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
textAlign: 'left',
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: colors.textMuted,
|
||||
gap: 12,
|
||||
padding: 32,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 40,
|
||||
opacity: 0.4,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorBanner: {
|
||||
background: 'rgba(243,139,168,0.1)',
|
||||
border: `1px solid ${colors.red}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 16px',
|
||||
margin: 16,
|
||||
fontSize: 13,
|
||||
color: colors.red,
|
||||
},
|
||||
placeholder: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncateId(id: string, len: number = 12): string {
|
||||
if (id.length <= len) return id;
|
||||
return id.slice(0, len) + '...';
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1) return '<1ms';
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function stepColor(stepType: string): string {
|
||||
return STEP_COLORS[stepType] ?? DEFAULT_STEP_COLOR;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StepDataView({ data }: { data: TraceStepData }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const entries = Object.entries(data).filter(
|
||||
([, v]) => v !== undefined && v !== null && v !== '',
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show up to 3 entries by default; expand to show all
|
||||
const displayEntries = expanded ? entries : entries.slice(0, 3);
|
||||
const hasMore = entries.length > 3;
|
||||
|
||||
return (
|
||||
<div style={styles.stepDetails}>
|
||||
{displayEntries.map(([key, value]) => (
|
||||
<div key={key} style={styles.stepDetailRow}>
|
||||
<span style={styles.stepDetailKey}>{key}:</span>
|
||||
<span style={styles.stepDetailValue}>
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
style={styles.expandButton}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? 'Show less' : `Show ${entries.length - 3} more fields...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimelineStepProps {
|
||||
step: TraceStep;
|
||||
}
|
||||
|
||||
function TimelineStep({ step }: TimelineStepProps) {
|
||||
const color = stepColor(step.step_type);
|
||||
|
||||
return (
|
||||
<div style={styles.stepContainer}>
|
||||
<div style={{ ...styles.stepDot, background: color }} />
|
||||
<div style={styles.stepCard}>
|
||||
<div style={styles.stepHeader}>
|
||||
<span
|
||||
style={{
|
||||
...styles.stepBadge,
|
||||
background: `${color}22`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{step.step_type}
|
||||
</span>
|
||||
<span style={styles.stepDuration}>{formatDuration(step.duration_ms)}</span>
|
||||
</div>
|
||||
{step.data && <StepDataView data={step.data} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TraceDebugger({ apiUrl }: { apiUrl: string }) {
|
||||
const [traces, setTraces] = useState<TraceSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [traceDetail, setTraceDetail] = useState<TraceDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
|
||||
// Fetch trace list
|
||||
const fetchTraces = useCallback(async () => {
|
||||
try {
|
||||
const response = await invoke<TraceListResponse>('fetch_traces', {
|
||||
apiUrl,
|
||||
limit: 50,
|
||||
});
|
||||
setTraces(response.traces ?? []);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
setTraces([]);
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTraces();
|
||||
}, [fetchTraces]);
|
||||
|
||||
// Fetch trace detail when selection changes
|
||||
const fetchDetail = useCallback(
|
||||
async (traceId: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await invoke<TraceDetail>('fetch_trace', {
|
||||
apiUrl,
|
||||
traceId,
|
||||
});
|
||||
setTraceDetail(detail);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
setTraceDetail(null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[apiUrl],
|
||||
);
|
||||
|
||||
const handleSelectTrace = useCallback(
|
||||
(traceId: string) => {
|
||||
setSelectedId(traceId);
|
||||
fetchDetail(traceId);
|
||||
},
|
||||
[fetchDetail],
|
||||
);
|
||||
|
||||
// Compute totals for detail header
|
||||
const totalDuration =
|
||||
traceDetail?.steps.reduce((sum, s) => sum + s.duration_ms, 0) ?? 0;
|
||||
|
||||
// --- Empty state ---
|
||||
if (!listLoading && traces.length === 0 && !error) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.emptyState}>
|
||||
<div style={styles.emptyIcon}>🔍</div>
|
||||
<div style={styles.emptyText}>
|
||||
No traces available.<br />
|
||||
Traces are recorded when queries are processed through the system.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* Left panel - trace list */}
|
||||
<div style={styles.listPanel}>
|
||||
<div style={styles.listHeader}>
|
||||
<h2 style={styles.listTitle}>Traces</h2>
|
||||
<p style={styles.listSubtitle}>{traces.length} recent traces</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<div style={styles.listScroll}>
|
||||
{traces.map((trace) => {
|
||||
const isSelected = trace.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={trace.id}
|
||||
style={{
|
||||
...styles.traceItem,
|
||||
...(isSelected ? styles.traceItemSelected : {}),
|
||||
}}
|
||||
onClick={() => handleSelectTrace(trace.id)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background =
|
||||
colors.surfaceHover;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={styles.traceItemId}>{truncateId(trace.id)}</div>
|
||||
<div style={styles.traceItemQuery}>{trace.query}</div>
|
||||
<div style={styles.traceItemMeta}>
|
||||
<span>{trace.steps.length} steps</span>
|
||||
<span>{formatTimestamp(trace.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - trace detail */}
|
||||
<div style={styles.detailPanel}>
|
||||
{!selectedId && (
|
||||
<div style={styles.placeholder}>
|
||||
Select a trace from the list to inspect its steps.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedId && detailLoading && (
|
||||
<div style={styles.placeholder}>Loading trace...</div>
|
||||
)}
|
||||
|
||||
{selectedId && !detailLoading && traceDetail && (
|
||||
<>
|
||||
<div style={styles.detailHeader}>
|
||||
<h3 style={styles.detailTitle}>
|
||||
Trace {truncateId(traceDetail.id)}
|
||||
</h3>
|
||||
<p style={styles.detailQuery}>
|
||||
Query: "{traceDetail.query}"
|
||||
</p>
|
||||
<div style={styles.detailStats}>
|
||||
<span>
|
||||
Steps:{' '}
|
||||
<span style={styles.detailStatValue}>
|
||||
{traceDetail.steps.length}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Total:{' '}
|
||||
<span style={styles.detailStatValue}>
|
||||
{formatDuration(totalDuration)}
|
||||
</span>
|
||||
</span>
|
||||
{traceDetail.created_at && (
|
||||
<span>
|
||||
Created:{' '}
|
||||
<span style={styles.detailStatValue}>
|
||||
{formatTimestamp(traceDetail.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.detailScroll}>
|
||||
{traceDetail.steps.length === 0 ? (
|
||||
<div style={styles.placeholder}>
|
||||
This trace contains no steps.
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.timelineContainer}>
|
||||
<div style={styles.timelineLine} />
|
||||
{traceDetail.steps.map((step, idx) => (
|
||||
<TimelineStep key={idx} step={step} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
type UpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export function UpdateChecker() {
|
||||
const [state, setState] = useState<UpdateState>('idle');
|
||||
const [version, setVersion] = useState('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const updateRef = useRef<any>(null);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
if (update) {
|
||||
updateRef.current = update;
|
||||
setVersion(update.version);
|
||||
setState('available');
|
||||
setDismissed(false);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — likely running in browser or no update available
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in a Tauri environment
|
||||
if (typeof window === 'undefined' || !(window as any).__TAURI_INTERNALS__) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkForUpdate();
|
||||
const interval = setInterval(checkForUpdate, CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkForUpdate]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
|
||||
setState('downloading');
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
let downloaded = 0;
|
||||
const contentLength = update.contentLength ?? 0;
|
||||
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
if (event.event === 'Started' && event.data?.contentLength) {
|
||||
// Content length received
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data?.chunkLength ?? 0;
|
||||
if (contentLength > 0) {
|
||||
setProgress(Math.min(100, Math.round((downloaded / contentLength) * 100)));
|
||||
}
|
||||
} else if (event.event === 'Finished') {
|
||||
setProgress(100);
|
||||
}
|
||||
});
|
||||
|
||||
setState('ready');
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e?.message || 'Download failed');
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRelaunch = useCallback(async () => {
|
||||
try {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
await relaunch();
|
||||
} catch {
|
||||
// Fallback: inform user to restart manually
|
||||
setErrorMsg('Please restart the application manually');
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (state === 'idle' || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.banner}>
|
||||
{state === 'available' && (
|
||||
<div style={styles.row}>
|
||||
<span>Update available: <strong>v{version}</strong></span>
|
||||
<div style={styles.actions}>
|
||||
<button style={styles.primaryBtn} onClick={handleDownload}>Download</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'downloading' && (
|
||||
<div style={styles.row}>
|
||||
<span>Downloading update... {progress}%</span>
|
||||
<div style={styles.progressBar}>
|
||||
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && (
|
||||
<div style={styles.row}>
|
||||
<span style={{ color: '#a6e3a1' }}>Update installed.</span>
|
||||
<div style={styles.actions}>
|
||||
<button style={styles.successBtn} onClick={handleRelaunch}>Relaunch now</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Later</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div style={styles.row}>
|
||||
<span style={{ color: '#f38ba8' }}>Update error: {errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
banner: {
|
||||
padding: '10px 24px',
|
||||
backgroundColor: '#181825',
|
||||
borderBottom: '1px solid #313244',
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
fontSize: '13px',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
},
|
||||
primaryBtn: {
|
||||
padding: '4px 14px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#89b4fa',
|
||||
color: '#1e1e2e',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
successBtn: {
|
||||
padding: '4px 14px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#a6e3a1',
|
||||
color: '#1e1e2e',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
secondaryBtn: {
|
||||
padding: '4px 14px',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#a6adc8',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
progressBar: {
|
||||
flex: 1,
|
||||
maxWidth: '300px',
|
||||
height: '6px',
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#89b4fa',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Detect whether we're running inside Tauri or in a browser.
|
||||
* In Tauri, `window.__TAURI_INTERNALS__` is set.
|
||||
*/
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke a Tauri command if in Tauri, otherwise fall back to fetch.
|
||||
*/
|
||||
export async function tauriInvoke<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (isTauri()) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<T>(command, args);
|
||||
}
|
||||
// Browser fallback: map command to REST API
|
||||
return browserFallback<T>(command, args);
|
||||
}
|
||||
|
||||
async function browserFallback<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const apiUrl = (args.apiUrl as string) || 'http://localhost:8000';
|
||||
const urlMap: Record<string, string> = {
|
||||
check_health: '/health',
|
||||
fetch_energy: '/v1/telemetry/energy',
|
||||
fetch_telemetry: '/v1/telemetry/stats',
|
||||
fetch_traces: `/v1/traces?limit=${args.limit || 20}`,
|
||||
fetch_trace: `/v1/traces/${args.traceId}`,
|
||||
fetch_learning_stats: '/v1/learning/stats',
|
||||
fetch_learning_policy: '/v1/learning/policy',
|
||||
fetch_memory_stats: '/v1/memory/stats',
|
||||
fetch_agents: '/v1/agents',
|
||||
};
|
||||
|
||||
const path = urlMap[command];
|
||||
if (!path) {
|
||||
throw new Error(`No browser fallback for command: ${command}`);
|
||||
}
|
||||
|
||||
const resp = await fetch(`${apiUrl}${path}`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for polling a Tauri command at a regular interval.
|
||||
*/
|
||||
export function usePolling<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
intervalMs: number,
|
||||
): { data: T | null; error: string | null; loading: boolean; refresh: () => void } {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await tauriInvoke<T>(command, args);
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [command, JSON.stringify(args)]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Desktop API helpers — thin wrappers around the OpenJarvis REST API.
|
||||
// All functions accept an explicit apiUrl so the desktop can be pointed at any server.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ManagedAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
agent_type: string;
|
||||
config: Record<string, unknown>;
|
||||
status: 'idle' | 'running' | 'paused' | 'error' | 'archived' | 'needs_attention' | 'budget_exceeded' | 'stalled';
|
||||
summary_memory: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
total_runs?: number;
|
||||
total_cost?: number;
|
||||
total_tokens?: number;
|
||||
last_run_at?: number | null;
|
||||
schedule_type?: string;
|
||||
schedule_value?: string;
|
||||
budget?: number;
|
||||
learning_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentTask {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
description: string;
|
||||
status: 'pending' | 'active' | 'completed' | 'failed';
|
||||
progress: Record<string, unknown>;
|
||||
findings: unknown[];
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface AgentMessage {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
direction: 'user_to_agent' | 'agent_to_user';
|
||||
content: string;
|
||||
mode: 'immediate' | 'queued';
|
||||
status: 'pending' | 'delivered' | 'responded';
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface AgentTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source: 'built-in' | 'user';
|
||||
agent_type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function request<T>(apiUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${apiUrl}${path}`, init);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function fetchManagedAgents(apiUrl: string): Promise<ManagedAgent[]> {
|
||||
const data = await request<{ agents: ManagedAgent[] }>(apiUrl, '/v1/managed-agents');
|
||||
return data.agents || [];
|
||||
}
|
||||
|
||||
export async function fetchAgentTasks(apiUrl: string, agentId: string): Promise<AgentTask[]> {
|
||||
const data = await request<{ tasks: AgentTask[] }>(apiUrl, `/v1/managed-agents/${agentId}/tasks`);
|
||||
return data.tasks || [];
|
||||
}
|
||||
|
||||
export async function fetchAgentMessages(apiUrl: string, agentId: string): Promise<AgentMessage[]> {
|
||||
const data = await request<{ messages: AgentMessage[] }>(apiUrl, `/v1/managed-agents/${agentId}/messages`);
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function fetchTemplates(apiUrl: string): Promise<AgentTemplate[]> {
|
||||
const data = await request<{ templates: AgentTemplate[] }>(apiUrl, '/v1/templates');
|
||||
return data.templates || [];
|
||||
}
|
||||
|
||||
export async function createManagedAgent(
|
||||
apiUrl: string,
|
||||
body: { name: string; template_id?: string; config?: Record<string, unknown> },
|
||||
): Promise<ManagedAgent> {
|
||||
return request<ManagedAgent>(apiUrl, '/v1/managed-agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function pauseManagedAgent(apiUrl: string, agentId: string): Promise<void> {
|
||||
await fetch(`${apiUrl}/v1/managed-agents/${agentId}/pause`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function resumeManagedAgent(apiUrl: string, agentId: string): Promise<void> {
|
||||
await fetch(`${apiUrl}/v1/managed-agents/${agentId}/resume`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function runManagedAgent(apiUrl: string, agentId: string): Promise<void> {
|
||||
await fetch(`${apiUrl}/v1/managed-agents/${agentId}/run`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function recoverManagedAgent(apiUrl: string, agentId: string): Promise<unknown> {
|
||||
return request<unknown>(apiUrl, `/v1/managed-agents/${agentId}/recover`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function deleteManagedAgent(apiUrl: string, agentId: string): Promise<void> {
|
||||
await fetch(`${apiUrl}/v1/managed-agents/${agentId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function sendAgentMessage(
|
||||
apiUrl: string,
|
||||
agentId: string,
|
||||
content: string,
|
||||
mode: 'immediate' | 'queued' = 'queued',
|
||||
): Promise<AgentMessage> {
|
||||
return request<AgentMessage>(apiUrl, `/v1/managed-agents/${agentId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mode }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Learning + Traces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LearningLogEntry {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
event_type: string;
|
||||
description: string;
|
||||
data: Record<string, unknown>;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface AgentTrace {
|
||||
id: string;
|
||||
outcome: string;
|
||||
duration: number;
|
||||
started_at: number;
|
||||
steps: number;
|
||||
}
|
||||
|
||||
export async function fetchLearningLog(apiUrl: string, agentId: string): Promise<LearningLogEntry[]> {
|
||||
const data = await request<{ learning_log: LearningLogEntry[] }>(apiUrl, `/v1/managed-agents/${agentId}/learning`);
|
||||
return data.learning_log || [];
|
||||
}
|
||||
|
||||
export async function triggerLearning(apiUrl: string, agentId: string): Promise<void> {
|
||||
await fetch(`${apiUrl}/v1/managed-agents/${agentId}/learning/run`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export interface AgentTraceDetail {
|
||||
id: string;
|
||||
agent: string;
|
||||
outcome: string;
|
||||
duration: number;
|
||||
started_at: number;
|
||||
steps: Array<{
|
||||
step_type: string;
|
||||
input: unknown;
|
||||
output: string;
|
||||
duration: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function fetchAgentTraces(apiUrl: string, agentId: string, limit = 20): Promise<AgentTrace[]> {
|
||||
const data = await request<{ traces: AgentTrace[] }>(apiUrl, `/v1/managed-agents/${agentId}/traces?limit=${limit}`);
|
||||
return data.traces || [];
|
||||
}
|
||||
|
||||
export async function fetchAgentTrace(apiUrl: string, agentId: string, traceId: string): Promise<AgentTraceDetail> {
|
||||
return request<AgentTraceDetail>(apiUrl, `/v1/managed-agents/${agentId}/traces/${traceId}`);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AdminPanel.tsx","./src/components/EnergyDashboard.tsx","./src/components/LearningCurve.tsx","./src/components/MemoryBrowser.tsx","./src/components/TraceDebugger.tsx","./src/components/UpdateChecker.tsx","./src/hooks/useTauriApi.ts"],"version":"5.7.3"}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/v1': 'http://localhost:8000',
|
||||
'/health': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
# Agentic Logic Primitive
|
||||
|
||||
The Agentic Logic primitive provides **pluggable agents** that handle queries with varying levels of sophistication -- from simple single-turn responses to multi-turn tool-calling loops, ReAct-style reasoning, CodeAct code execution, recursive decomposition, and external agent communication.
|
||||
|
||||
---
|
||||
|
||||
## BaseAgent ABC
|
||||
|
||||
All agents implement the `BaseAgent` abstract base class, which provides both the `run()` contract and concrete helper methods that eliminate boilerplate in subclasses:
|
||||
|
||||
```python
|
||||
class BaseAgent(ABC):
|
||||
agent_id: str
|
||||
accepts_tools: bool = False # overridden by ToolUsingAgent
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
"""Execute the agent on *input* and return an AgentResult."""
|
||||
```
|
||||
|
||||
### Class Attribute: `accepts_tools`
|
||||
|
||||
The `accepts_tools` class attribute (default `False`) enables the CLI and SDK to auto-detect which agents support tool-passing. Agents that set `accepts_tools = True` can receive `--tools` on the CLI and `tools=` in the SDK.
|
||||
|
||||
### Concrete Helper Methods
|
||||
|
||||
`BaseAgent` provides five concrete helpers that subclasses use to avoid duplicating common logic:
|
||||
|
||||
| Helper | Purpose |
|
||||
|--------|---------|
|
||||
| `_emit_turn_start(input)` | Publish `AGENT_TURN_START` on the event bus |
|
||||
| `_emit_turn_end(**data)` | Publish `AGENT_TURN_END` on the event bus |
|
||||
| `_build_messages(input, context, *, system_prompt)` | Assemble the message list from optional system prompt, conversation context, and user input |
|
||||
| `_generate(messages, **extra_kwargs)` | Call `engine.generate()` with stored defaults (model, temperature, max_tokens) |
|
||||
| `_max_turns_result(tool_results, turns, content)` | Build the standard `AgentResult` for when `max_turns` is exceeded |
|
||||
| `_strip_think_tags(text)` | Remove `<think>...</think>` blocks from model output (static method) |
|
||||
|
||||
### The `run()` Contract
|
||||
|
||||
The `run()` method is the single entry point for all agent implementations. It receives:
|
||||
|
||||
- **`input`** -- The user's query text
|
||||
- **`context`** -- An optional `AgentContext` with conversation history, tool names, and memory results
|
||||
- **`**kwargs`** -- Additional implementation-specific parameters
|
||||
|
||||
It returns an `AgentResult` containing the response content, any tool results, the number of turns taken, and metadata.
|
||||
|
||||
### Supporting Dataclasses
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class AgentContext:
|
||||
conversation: Conversation # Prior messages for multi-turn context
|
||||
tools: List[str] # Available tool names
|
||||
memory_results: List[Any] # Pre-fetched memory search results
|
||||
metadata: Dict[str, Any] # Arbitrary key-value pairs
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentResult:
|
||||
content: str # The agent's response text
|
||||
tool_results: List[ToolResult] # Results from tool invocations
|
||||
turns: int # Number of inference turns taken
|
||||
metadata: Dict[str, Any] # Arbitrary metadata
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ToolUsingAgent
|
||||
|
||||
`ToolUsingAgent` is an intermediate base class for agents that accept and use tools. It extends `BaseAgent` with:
|
||||
|
||||
- **`accepts_tools = True`** -- Enables CLI/SDK tool introspection
|
||||
- **`ToolExecutor`** -- Initialized from the provided tool list, handles dispatch with JSON argument parsing, latency tracking, and event bus integration
|
||||
- **`max_turns`** -- Configurable loop iteration limit (default: 10)
|
||||
|
||||
```python
|
||||
class ToolUsingAgent(BaseAgent):
|
||||
accepts_tools: bool = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
tools: Optional[List[BaseTool]] = None,
|
||||
bus: Optional[EventBus] = None,
|
||||
max_turns: int = 10,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> None: ...
|
||||
```
|
||||
|
||||
All tool-using agents (`OrchestratorAgent`, `NativeReActAgent`, `NativeOpenHandsAgent`, `RLMAgent`) extend this class.
|
||||
|
||||
!!! info "Agents that bypass ToolUsingAgent"
|
||||
Some agents extend `BaseAgent` directly and set `accepts_tools = False`: `SimpleAgent` (single-turn, no tools), `OpenHandsAgent` (tool management is handled by the openhands-sdk), and `ClaudeCodeAgent` (tools are managed by the Claude Agent SDK). `SandboxedAgent` also extends `BaseAgent` directly because it wraps another agent rather than calling tools itself.
|
||||
|
||||
---
|
||||
|
||||
## Choosing an Agent
|
||||
|
||||
Start here. Pick the simplest agent that handles your task — simpler agents are faster, use fewer tokens, and are easier to debug. Reach for more complex agents only when the task demands it.
|
||||
|
||||
| Use case | Agent | Why |
|
||||
|---|---|---|
|
||||
| Simple Q&A, single-turn | `simple` | No overhead, one inference call |
|
||||
| Multi-step with tools (calculator, search, files) | `orchestrator` | Function-calling loop, most compatible with OpenAI-format models |
|
||||
| Explicit reasoning chains | `native_react` | Thought-Action-Observation loop based on [ReAct (Yao et al., 2023)](https://arxiv.org/abs/2210.03629); reasoning traces are visible and debuggable |
|
||||
| Code generation + execution | `native_openhands` | CodeAct pattern inspired by [OpenHands (Wang et al., 2024)](https://arxiv.org/abs/2407.16741); generates and executes Python inline |
|
||||
| Long documents, recursive decomposition | `rlm` | Stores context in a persistent REPL, decomposes via recursive sub-LM calls |
|
||||
| Untrusted inputs | `sandboxed` wrapping any agent | Container isolation with network disabled and mount allowlists |
|
||||
|
||||
**General guidance:** `orchestrator` is the default for most tool-using tasks. Use `native_react` when you want visible reasoning traces (e.g., for debugging or auditing agent behavior). Use `native_openhands` when the task involves writing and running code. Use `rlm` when context is too long to fit in a single prompt window.
|
||||
|
||||
---
|
||||
|
||||
## Agent Implementations
|
||||
|
||||
### SimpleAgent
|
||||
|
||||
**Registry key:** `simple`
|
||||
|
||||
The simplest agent implementation -- a single-turn, no-tool query-to-response pipeline. Extends `BaseAgent` directly (does not accept tools).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Q["User Query"] --> M["Build Messages"]
|
||||
M --> E["Engine.generate()"]
|
||||
E --> R["AgentResult"]
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
1. Calls `_emit_turn_start()` to publish `AGENT_TURN_START` on the event bus
|
||||
2. Calls `_build_messages()` to assemble the message list from conversation context plus user input
|
||||
3. Calls `_generate()` to invoke the engine with stored defaults
|
||||
4. Calls `_emit_turn_end()` and returns an `AgentResult` with `turns=1`
|
||||
|
||||
```python
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
|
||||
agent = SimpleAgent(engine, model="qwen3:8b", bus=bus)
|
||||
result = agent.run("What is the capital of France?")
|
||||
print(result.content) # "The capital of France is Paris."
|
||||
```
|
||||
|
||||
### OrchestratorAgent
|
||||
|
||||
**Registry key:** `orchestrator`
|
||||
|
||||
A multi-turn agent that implements a **tool-calling loop**. Extends `ToolUsingAgent`. The LLM can request tool invocations, and the results are fed back for further processing until the model produces a final text response.
|
||||
|
||||
Supports two modes:
|
||||
|
||||
- **`function_calling`** (default) -- Uses OpenAI function-calling format via `ToolExecutor.get_openai_tools()`
|
||||
- **`structured`** -- Uses structured output format for models that support it
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Q["User Query"] --> BUILD["Build messages +<br/>tool definitions"]
|
||||
BUILD --> GEN["Engine.generate()<br/>with tools"]
|
||||
GEN --> CHECK{"Tool calls<br/>in response?"}
|
||||
CHECK -->|No| DONE["Return final answer"]
|
||||
CHECK -->|Yes| EXEC["Execute each tool<br/>via ToolExecutor"]
|
||||
EXEC --> APPEND["Append tool results<br/>to messages"]
|
||||
APPEND --> MAXCHECK{"Max turns<br/>exceeded?"}
|
||||
MAXCHECK -->|No| GEN
|
||||
MAXCHECK -->|Yes| TIMEOUT["Return with<br/>max_turns_exceeded"]
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
1. Builds initial messages from context and user input
|
||||
2. Converts available tools to OpenAI function-calling format via `ToolExecutor.get_openai_tools()`
|
||||
3. Enters a loop (up to `max_turns` iterations):
|
||||
- Calls `engine.generate()` with messages and tool definitions
|
||||
- If the response contains `tool_calls`, executes each tool and appends the results as `TOOL` messages
|
||||
- If no `tool_calls` are present, returns the content as the final answer
|
||||
4. If `max_turns` is exceeded, returns the last content or a warning message
|
||||
|
||||
```python
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
from openjarvis.tools.calculator import CalculatorTool
|
||||
from openjarvis.tools.think import ThinkTool
|
||||
|
||||
agent = OrchestratorAgent(
|
||||
engine,
|
||||
model="qwen3:8b",
|
||||
tools=[CalculatorTool(), ThinkTool()],
|
||||
bus=bus,
|
||||
max_turns=10,
|
||||
)
|
||||
result = agent.run("What is 2^10 + 3^5?")
|
||||
# The agent may call the calculator tool, get "1267", then respond
|
||||
```
|
||||
|
||||
### NativeReActAgent
|
||||
|
||||
**Registry key:** `native_react` (alias: `react`)
|
||||
|
||||
A ReAct (Reasoning + Acting) agent that implements a **Thought-Action-Observation** loop. Extends `ToolUsingAgent`. The LLM is prompted to output structured text with `Thought:`, `Action:`, `Action Input:`, and `Final Answer:` fields, which the agent parses to drive tool execution.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Q["User Query"] --> SYS["Build system prompt<br/>with tool descriptions"]
|
||||
SYS --> GEN["Generate response"]
|
||||
GEN --> PARSE["Parse ReAct output"]
|
||||
PARSE --> FINAL{"Final Answer?"}
|
||||
FINAL -->|Yes| DONE["Return answer"]
|
||||
FINAL -->|No| ACTION{"Has Action?"}
|
||||
ACTION -->|No| DONE2["Return content as-is"]
|
||||
ACTION -->|Yes| EXEC["Execute tool<br/>via ToolExecutor<br/>(case-insensitive)"]
|
||||
EXEC --> OBS["Append Observation"]
|
||||
OBS --> MAXCHECK{"Max turns<br/>exceeded?"}
|
||||
MAXCHECK -->|No| GEN
|
||||
MAXCHECK -->|Yes| TIMEOUT["Return max_turns_result"]
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
1. Builds a system prompt with enriched tool descriptions via `build_tool_descriptions()`. Parsing is case-insensitive.
|
||||
2. Generates a response and parses the ReAct-structured output
|
||||
3. If a `Final Answer:` is found, returns it
|
||||
4. If an `Action:` is found, executes the tool and feeds the result back as an `Observation:`
|
||||
5. Loops until a final answer is produced or `max_turns` is exceeded
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old `from openjarvis.agents.react import ReActAgent` import path still works via a backward-compat shim. The registry alias `"react"` also maps to `NativeReActAgent`.
|
||||
|
||||
```python
|
||||
from openjarvis.agents.native_react import NativeReActAgent
|
||||
|
||||
agent = NativeReActAgent(
|
||||
engine,
|
||||
model="qwen3:8b",
|
||||
tools=[CalculatorTool(), ThinkTool()],
|
||||
max_turns=10,
|
||||
)
|
||||
result = agent.run("What is the square root of 256?")
|
||||
```
|
||||
|
||||
### NativeOpenHandsAgent
|
||||
|
||||
**Registry key:** `native_openhands`
|
||||
|
||||
A CodeAct-style agent that generates and executes Python code. Extends `ToolUsingAgent`. It can also invoke tools via structured `Action:` / `Action Input:` output. URLs in the input are automatically pre-fetched and inlined for the LLM.
|
||||
|
||||
How it works:
|
||||
|
||||
1. Builds a detailed system prompt with enriched tool descriptions (via shared `build_tool_descriptions()` builder) and code execution instructions
|
||||
2. Pre-fetches any URLs in the user input, inlining the content directly
|
||||
3. For each turn:
|
||||
- Generates a response and strips `<think>` tags
|
||||
- If a `\`\`\`python` code block is found, executes it via `code_interpreter`
|
||||
- If an `Action:` / `Action Input:` is found, dispatches the tool
|
||||
- If neither is found, returns the content as the final answer
|
||||
4. Handles context window overflow with automatic truncation
|
||||
|
||||
```python
|
||||
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
|
||||
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine,
|
||||
model="qwen3:8b",
|
||||
tools=[CalculatorTool(), WebSearchTool()],
|
||||
max_turns=3,
|
||||
max_tokens=2048,
|
||||
)
|
||||
result = agent.run("Summarize https://example.com/article")
|
||||
```
|
||||
|
||||
### RLMAgent
|
||||
|
||||
**Registry key:** `rlm`
|
||||
|
||||
A Recursive Language Model agent based on the [RLM paper](https://arxiv.org/abs/2512.24601). Instead of passing long context directly in the LLM prompt, RLM stores context as a Python variable in a persistent REPL. A "Root LM" writes Python code to inspect, decompose, and process context using recursive sub-LM calls via `llm_query()` and `llm_batch()`. Extends `ToolUsingAgent`.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Q["User Query +<br/>Context"] --> REPL["Create persistent REPL<br/>(context stored as variable)"]
|
||||
REPL --> GEN["Generate code"]
|
||||
GEN --> CODE{"Code block<br/>found?"}
|
||||
CODE -->|No| DONE["Return content<br/>as final answer"]
|
||||
CODE -->|Yes| EXEC["Execute in REPL"]
|
||||
EXEC --> TERM{"FINAL() called?"}
|
||||
TERM -->|Yes| RESULT["Return final answer"]
|
||||
TERM -->|No| FEED["Feed output back<br/>as user message"]
|
||||
FEED --> MAXCHECK{"Max turns<br/>exceeded?"}
|
||||
MAXCHECK -->|No| GEN
|
||||
MAXCHECK -->|Yes| TIMEOUT["Return max_turns_result"]
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
1. Creates a persistent REPL with `llm_query()` and `llm_batch()` callbacks. Tool descriptions are injected via the shared `build_tool_descriptions()` builder when tools are provided.
|
||||
2. Injects context from `AgentContext` metadata or memory results into the REPL as a variable
|
||||
3. Generates code and executes it in the REPL
|
||||
4. If `FINAL(value)` or `FINAL_VAR("name")` is called, returns the final answer
|
||||
5. If no code block is found, treats the content as a direct answer
|
||||
|
||||
The agent supports configurable sub-model parameters for recursive calls:
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `sub_model` | same as `model` | Model for sub-LM calls |
|
||||
| `sub_temperature` | `0.3` | Temperature for sub-LM calls |
|
||||
| `sub_max_tokens` | `1024` | Max tokens for sub-LM calls |
|
||||
| `max_output_chars` | `10000` | Max REPL output characters |
|
||||
| `system_prompt` | `RLM_SYSTEM_PROMPT` | Override the system prompt |
|
||||
|
||||
```python
|
||||
from openjarvis.agents.rlm import RLMAgent
|
||||
|
||||
agent = RLMAgent(
|
||||
engine,
|
||||
model="qwen3:8b",
|
||||
max_turns=10,
|
||||
sub_model="qwen3:1.7b", # smaller model for sub-queries
|
||||
sub_temperature=0.3,
|
||||
)
|
||||
result = agent.run("Summarize this document", context=ctx)
|
||||
```
|
||||
|
||||
### OpenHandsAgent (SDK)
|
||||
|
||||
**Registry key:** `openhands`
|
||||
|
||||
A thin wrapper around the real `openhands-sdk` package for AI-driven software development tasks. Extends `BaseAgent` directly (does not use `ToolUsingAgent` since tool management is handled by the SDK).
|
||||
|
||||
!!! warning "Optional dependency"
|
||||
This agent requires the `openhands-sdk` package (`uv sync --extra openhands`). The SDK requires Python 3.12+.
|
||||
|
||||
How it works:
|
||||
|
||||
1. Imports `openhands.sdk` at runtime (lazy import)
|
||||
2. Creates an LLM, Agent, and Conversation from the SDK
|
||||
3. Sends the user input as a message and runs the conversation
|
||||
4. Extracts the final message content from the conversation
|
||||
|
||||
```python
|
||||
from openjarvis.agents.openhands import OpenHandsAgent
|
||||
|
||||
agent = OpenHandsAgent(
|
||||
engine,
|
||||
model="gpt-4",
|
||||
workspace="/path/to/project",
|
||||
api_key="sk-...",
|
||||
)
|
||||
result = agent.run("Fix the failing test in test_utils.py")
|
||||
```
|
||||
|
||||
### ClaudeCodeAgent
|
||||
|
||||
**Registry key:** `claude_code`
|
||||
|
||||
Wraps the `@anthropic-ai/claude-code` SDK via a bundled Node.js subprocess bridge. Unlike every other agent, inference is handled entirely by the Claude Agent SDK -- the OpenJarvis inference engine is not used. This makes `ClaudeCodeAgent` a true external agent, similar in spirit to `OpenHandsAgent` but implemented via subprocess rather than an importable Python SDK.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Q["User Query"] --> PY["Python: build JSON request"]
|
||||
PY --> SPAWN["Spawn: node dist/index.js"]
|
||||
SPAWN --> NODE["Node.js runner<br/>@anthropic-ai/claude-code SDK"]
|
||||
NODE --> SDK["Claude Agent SDK<br/>(cloud inference)"]
|
||||
SDK --> NODE
|
||||
NODE --> JSON["Sentinel-delimited JSON<br/>on stdout"]
|
||||
JSON --> PARSE["Python: parse output"]
|
||||
PARSE --> R["AgentResult"]
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
1. On first call, copies the bundled `claude_code_runner/` to `~/.openjarvis/claude_code_runner/` and runs `npm install --production` if `node_modules` is absent
|
||||
2. Builds a JSON request with `prompt`, `api_key`, `workspace`, `allowed_tools`, `system_prompt`, and `session_id`
|
||||
3. Spawns `node dist/index.js` and writes the request to stdin
|
||||
4. Reads stdout and extracts the JSON payload between `---OPENJARVIS_OUTPUT_START---` and `---OPENJARVIS_OUTPUT_END---` sentinels
|
||||
5. Falls back to treating all stdout as plain text content if sentinels are absent
|
||||
|
||||
!!! warning "Requires Node.js 22+"
|
||||
`ClaudeCodeAgent` raises `RuntimeError` at `run()` time if `node` is not found on `PATH`. An `ANTHROPIC_API_KEY` environment variable is required for the Claude Agent SDK to authenticate.
|
||||
|
||||
```python
|
||||
from openjarvis.agents.claude_code import ClaudeCodeAgent
|
||||
|
||||
agent = ClaudeCodeAgent(
|
||||
engine=None, # not used
|
||||
model="", # not used
|
||||
workspace="/path/to/project",
|
||||
timeout=120,
|
||||
)
|
||||
result = agent.run("Add type hints to all functions in utils.py")
|
||||
```
|
||||
|
||||
### SandboxedAgent and ContainerRunner
|
||||
|
||||
`SandboxedAgent` and `ContainerRunner` together implement **container-isolated agent execution** following the `GuardrailsEngine` wrapper pattern. `SandboxedAgent` wraps any `BaseAgent` and delegates execution to a Docker (or Podman) container managed by `ContainerRunner`.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Q["User Query"] --> SA["SandboxedAgent.run()"]
|
||||
SA --> CR["ContainerRunner.run()"]
|
||||
CR --> VALIDATE["Validate mounts<br/>vs allowlist"]
|
||||
VALIDATE --> DOCKER["docker run --rm<br/>--network none<br/>-i image"]
|
||||
DOCKER --> STDIN["Write JSON payload<br/>to stdin"]
|
||||
STDIN --> CONTAINER["Container: run agent,<br/>write output to stdout"]
|
||||
CONTAINER --> PARSE["Parse sentinel-<br/>delimited JSON"]
|
||||
PARSE --> R["AgentResult"]
|
||||
```
|
||||
|
||||
**ContainerRunner** manages the full container lifecycle:
|
||||
|
||||
- Validates mount paths against a `MountAllowlist` before container start (raises `ValueError` for blocked or out-of-root paths)
|
||||
- Constructs `docker run --rm --network none -i <image>` with validated read-only bind mounts
|
||||
- Sends a JSON payload to container stdin (prompt, agent ID, model, and optional secrets)
|
||||
- Reads stdout and parses sentinel-delimited JSON output
|
||||
- On timeout, force-kills the container via `docker rm -f`
|
||||
- `cleanup_orphans()` removes any stale containers labelled `openjarvis-sandbox=true`
|
||||
|
||||
**Mount security** (`sandbox/mount_security.py`) enforces two independent checks on every mount path:
|
||||
|
||||
1. **Blocked patterns:** Path components are matched against `DEFAULT_BLOCKED_PATTERNS` (`.ssh`, `.env`, `*.pem`, `*.key`, cloud configs, etc.). A match raises `ValueError`.
|
||||
2. **Allowed roots:** If `roots` are configured in the allowlist, the resolved path must be under one of them. An empty `roots` list allows any non-blocked path.
|
||||
|
||||
```python
|
||||
from openjarvis.sandbox import ContainerRunner, SandboxedAgent
|
||||
|
||||
runner = ContainerRunner(
|
||||
image="openjarvis-sandbox:latest",
|
||||
timeout=60,
|
||||
runtime="docker",
|
||||
)
|
||||
# Wrap any BaseAgent
|
||||
inner = SimpleAgent(engine, model="qwen3:8b")
|
||||
sandboxed = SandboxedAgent(
|
||||
agent=inner,
|
||||
runner=runner,
|
||||
mounts=["/home/user/data"],
|
||||
)
|
||||
result = sandboxed.run("Summarize the reports in /home/user/data")
|
||||
```
|
||||
|
||||
!!! warning "accepts_tools = False"
|
||||
`SandboxedAgent` does not accept tools via `--tools` or `tools=`. Tool calling within the sandbox is the responsibility of the wrapped inner agent.
|
||||
|
||||
---
|
||||
|
||||
## Tool System Integration
|
||||
|
||||
All `ToolUsingAgent` subclasses use the `ToolExecutor` to dispatch tool calls. The tool system is built on the `BaseTool` ABC:
|
||||
|
||||
```python
|
||||
class BaseTool(ABC):
|
||||
tool_id: str
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def spec(self) -> ToolSpec:
|
||||
"""Return the tool specification."""
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
"""Execute the tool with the given parameters."""
|
||||
|
||||
def to_openai_function(self) -> Dict[str, Any]:
|
||||
"""Convert to OpenAI function-calling format."""
|
||||
```
|
||||
|
||||
### Built-in Tools
|
||||
|
||||
| Tool | Registry Key | Description |
|
||||
|------|-------------|-------------|
|
||||
| `CalculatorTool` | `calculator` | AST-based safe expression evaluator |
|
||||
| `ThinkTool` | `think` | Reasoning scratchpad (returns input as-is) |
|
||||
| `RetrievalTool` | `retrieval` | Memory search via a memory backend |
|
||||
| `LLMTool` | `llm` | Sub-model calls (query a different model) |
|
||||
| `FileReadTool` | `file_read` | Safe file reading with path validation |
|
||||
|
||||
### ToolExecutor
|
||||
|
||||
The `ToolExecutor` handles tool dispatch with JSON argument parsing, latency tracking, and event bus integration:
|
||||
|
||||
```python
|
||||
class ToolExecutor:
|
||||
def __init__(self, tools: List[BaseTool], bus: Optional[EventBus] = None):
|
||||
self._tools = {t.spec.name: t for t in tools}
|
||||
self._bus = bus
|
||||
|
||||
def execute(self, tool_call: ToolCall) -> ToolResult:
|
||||
"""Parse arguments, dispatch to tool, measure latency, emit events."""
|
||||
|
||||
def get_openai_tools(self) -> List[Dict[str, Any]]:
|
||||
"""Return tools in OpenAI function-calling format."""
|
||||
```
|
||||
|
||||
For each tool call:
|
||||
|
||||
1. Looks up the tool by name
|
||||
2. Parses the JSON arguments string
|
||||
3. Publishes `TOOL_CALL_START` on the event bus
|
||||
4. Executes the tool with timing
|
||||
5. Publishes `TOOL_CALL_END` with success status and latency
|
||||
6. Returns the `ToolResult`
|
||||
|
||||
---
|
||||
|
||||
## Event Bus Integration
|
||||
|
||||
All agents integrate with the `EventBus` for telemetry and trace collection:
|
||||
|
||||
| Event | Published By | When |
|
||||
|-------|-------------|------|
|
||||
| `AGENT_TURN_START` | All agents (via `_emit_turn_start` helper) | Before starting query processing |
|
||||
| `AGENT_TURN_END` | All agents (via `_emit_turn_end` helper) | After producing a response |
|
||||
| `TOOL_CALL_START` | ToolExecutor (all `ToolUsingAgent` subclasses) | Before executing a tool |
|
||||
| `TOOL_CALL_END` | ToolExecutor (all `ToolUsingAgent` subclasses) | After executing a tool |
|
||||
|
||||
!!! info "Inference events"
|
||||
`INFERENCE_START` and `INFERENCE_END` events are published by the `InstrumentedEngine` wrapper (in `telemetry/instrumented_engine.py`), not by agents directly. This keeps telemetry opt-in and transparent to agent code.
|
||||
|
||||
These events are consumed by the `TelemetryStore` (for metrics) and `TraceCollector` (for interaction traces).
|
||||
|
||||
---
|
||||
|
||||
## Agent Registration
|
||||
|
||||
Agents are registered via the `@AgentRegistry.register("name")` decorator:
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.agents._stubs import BaseAgent
|
||||
|
||||
@AgentRegistry.register("my-agent")
|
||||
class MyAgent(BaseAgent):
|
||||
agent_id = "my-agent"
|
||||
|
||||
def run(self, input, context=None, **kwargs):
|
||||
...
|
||||
```
|
||||
|
||||
To list all registered agents:
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
print(AgentRegistry.keys())
|
||||
# ("simple", "orchestrator", "native_react", "react", "native_openhands", "rlm", "openhands")
|
||||
```
|
||||
|
||||
To instantiate an agent by key:
|
||||
|
||||
```python
|
||||
agent = AgentRegistry.create("orchestrator", engine, model, tools=tools, bus=bus)
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# Channels Architecture
|
||||
|
||||
The channels module provides a transport-agnostic messaging layer for receiving and sending messages through external platforms. The design follows the same registry-plus-ABC pattern used throughout OpenJarvis: a `BaseChannel` interface defines the contract, and concrete implementations for each platform (Telegram, Discord, Slack, WhatsApp, etc.) are registered for runtime discovery.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Transport-agnostic ABC.** `BaseChannel` defines six abstract methods covering the full lifecycle: connect, disconnect, send, status, list channels, and message handler registration.
|
||||
- **Direct platform integration.** Each channel connects directly to its platform API -- there is no intermediate gateway.
|
||||
- **Background listener thread.** Incoming messages are delivered via a daemon thread, not an event loop, so channels work from synchronous code without requiring async infrastructure.
|
||||
- **Registry-driven discovery.** All channel implementations self-register via `@ChannelRegistry.register("name")` and are discoverable at runtime.
|
||||
|
||||
---
|
||||
|
||||
## BaseChannel ABC
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class BaseChannel {
|
||||
<<abstract>>
|
||||
+channel_id str
|
||||
+connect() None
|
||||
+disconnect() None
|
||||
+send(channel, content, conversation_id, metadata) bool
|
||||
+status() ChannelStatus
|
||||
+list_channels() list~str~
|
||||
+on_message(handler) None
|
||||
}
|
||||
class TelegramChannel {
|
||||
-_token str
|
||||
-_handlers list
|
||||
-_listener_thread Thread
|
||||
-_stop_event Event
|
||||
+connect() None
|
||||
+disconnect() None
|
||||
+send(...) bool
|
||||
+status() ChannelStatus
|
||||
+list_channels() list~str~
|
||||
+on_message(handler) None
|
||||
}
|
||||
class DiscordChannel {
|
||||
-_token str
|
||||
-_handlers list
|
||||
-_listener_thread Thread
|
||||
-_stop_event Event
|
||||
}
|
||||
class SlackChannel {
|
||||
-_bot_token str
|
||||
-_app_token str
|
||||
-_handlers list
|
||||
}
|
||||
BaseChannel <|-- TelegramChannel
|
||||
BaseChannel <|-- DiscordChannel
|
||||
BaseChannel <|-- SlackChannel
|
||||
```
|
||||
|
||||
All `BaseChannel` subclasses must be registered via `@ChannelRegistry.register("name")` to be discoverable at runtime. For example, `TelegramChannel` is registered as `"telegram"`, `DiscordChannel` as `"discord"`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Channel Lifecycle
|
||||
|
||||
The connection lifecycle for a typical channel implementation, from instantiation through to disconnection:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DISCONNECTED: __init__
|
||||
|
||||
DISCONNECTED --> CONNECTING: connect() called
|
||||
CONNECTING --> CONNECTED: Platform connection OK\nlistener thread started
|
||||
CONNECTING --> CONNECTED: Platform SDK not installed\nsend-only mode
|
||||
CONNECTING --> ERROR: Exception during connect
|
||||
|
||||
CONNECTED --> CONNECTING: listener loop error\nreconnect attempt
|
||||
CONNECTING --> CONNECTED: reconnect successful
|
||||
CONNECTING --> ERROR: reconnect failed
|
||||
|
||||
CONNECTED --> DISCONNECTED: disconnect() called\nstop_event set\nthread joined
|
||||
ERROR --> DISCONNECTED: disconnect() called
|
||||
```
|
||||
|
||||
The `ChannelStatus` enum (`CONNECTED`, `DISCONNECTED`, `CONNECTING`, `ERROR`) tracks this state and is exposed via `status()`.
|
||||
|
||||
---
|
||||
|
||||
## Listener Loop Pattern
|
||||
|
||||
Most channel implementations use a background daemon thread for receiving messages. The pattern is consistent across channels:
|
||||
|
||||
1. The listener thread is started in `connect()`.
|
||||
2. It polls or listens for messages from the platform API.
|
||||
3. Incoming messages are parsed into `ChannelMessage` dataclass instances.
|
||||
4. All registered handlers are called sequentially.
|
||||
5. If an `EventBus` is provided, a `CHANNEL_MESSAGE_RECEIVED` event is published.
|
||||
6. On disconnect or error, the thread handles reconnection or exits cleanly.
|
||||
|
||||
Handler exceptions are caught individually so that a failing handler does not prevent subsequent handlers from running:
|
||||
|
||||
```python
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(msg)
|
||||
except Exception:
|
||||
logger.exception("Channel handler error")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Flow
|
||||
|
||||
Channel events are published to the `EventBus` using two event types:
|
||||
|
||||
| Event | Published By | When | Payload |
|
||||
|-------|-------------|------|---------|
|
||||
| `CHANNEL_MESSAGE_RECEIVED` | Listener loop | Message received from platform | `channel`, `sender`, `content`, `message_id` |
|
||||
| `CHANNEL_MESSAGE_SENT` | `send()` | Message successfully delivered | `channel`, `content`, `conversation_id` |
|
||||
|
||||
These events allow other modules to react to channel activity without depending on the channel implementation directly. For example, a logging subscriber can record all sent and received messages, or an agent can be wired to respond to incoming channel messages by subscribing to `CHANNEL_MESSAGE_RECEIVED`.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[TelegramChannel / DiscordChannel / ...] -->|CHANNEL_MESSAGE_RECEIVED| B[EventBus]
|
||||
A -->|CHANNEL_MESSAGE_SENT| B
|
||||
B --> C[TelemetryStore\nor other subscriber]
|
||||
B --> D[Custom handler\nvia bus.subscribe]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handler Registration
|
||||
|
||||
Multiple handlers can be registered. They are stored in a list and called sequentially within the listener thread. Returning a value from a handler has no effect on message routing -- the return type `Optional[str]` is reserved for future use (for example, auto-reply routing).
|
||||
|
||||
```python
|
||||
# ChannelHandler type alias
|
||||
ChannelHandler = Callable[[ChannelMessage], Optional[str]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threading Model
|
||||
|
||||
Channel implementations use Python's `threading` module rather than asyncio. This is a deliberate choice: OpenJarvis's core inference path is synchronous, and daemon threads are simpler to compose with synchronous code than coroutines.
|
||||
|
||||
| Component | Thread | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `connect()`, `send()`, `disconnect()` | Caller thread | All public methods are thread-safe |
|
||||
| Listener loop | Background daemon thread | Started in `connect()`, joined in `disconnect()` |
|
||||
| Handler callbacks | Background daemon thread | Called from listener thread -- use thread-safe data structures |
|
||||
|
||||
!!! warning "Handler thread safety"
|
||||
Handler callbacks run on the listener thread, not the thread that called `connect()`. If your handler modifies shared state, protect it with a lock or use thread-safe data structures such as `queue.Queue`.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Channel Backend
|
||||
|
||||
To add a new channel backend:
|
||||
|
||||
1. Create a new file in `src/openjarvis/channels/`.
|
||||
2. Subclass `BaseChannel` and implement all six abstract methods.
|
||||
3. Set `channel_id` as a class attribute.
|
||||
4. Decorate with `@ChannelRegistry.register("name")`.
|
||||
5. Add the module name to `_CHANNEL_MODULES` in `channels/__init__.py`.
|
||||
|
||||
```python
|
||||
from openjarvis.channels._stubs import BaseChannel, ChannelMessage, ChannelStatus
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
@ChannelRegistry.register("my_platform")
|
||||
class MyPlatformChannel(BaseChannel):
|
||||
channel_id = "my_platform"
|
||||
|
||||
def connect(self) -> None: ...
|
||||
def disconnect(self) -> None: ...
|
||||
def send(self, channel, content, *, conversation_id="", metadata=None) -> bool: ...
|
||||
def status(self) -> ChannelStatus: ...
|
||||
def list_channels(self) -> list[str]: ...
|
||||
def on_message(self, handler) -> None: ...
|
||||
```
|
||||
|
||||
After registration, the backend is discoverable via `ChannelRegistry.get("my_platform")`.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [User Guide: Channels](../user-guide/channels.md) -- how to use channels in practice
|
||||
- [API Reference: Channels](../api-reference/openjarvis/channels/index.md) -- complete class and type signatures
|
||||
- [Architecture: Overview](overview.md) -- where channels fit in the overall system
|
||||
- [Architecture: Design Principles](design-principles.md) -- registry pattern and ABC conventions
|
||||
@@ -0,0 +1,299 @@
|
||||
# Design Principles
|
||||
|
||||
OpenJarvis follows a set of design principles that guide every architectural decision. These principles ensure the framework remains extensible, portable, and easy to work with.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pluggable Everything
|
||||
|
||||
Every major component in OpenJarvis is defined as an **abstract base class** (ABC) with concrete implementations registered at runtime. This means you can swap, extend, or replace any part of the system without modifying existing code.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "ABC Interface"
|
||||
ABC["InferenceEngine ABC<br/><code>generate(), stream(),<br/>list_models(), health()</code>"]
|
||||
end
|
||||
|
||||
subgraph "Implementations"
|
||||
A["OllamaEngine"]
|
||||
B["VLLMEngine"]
|
||||
C["SGLangEngine"]
|
||||
D["LlamaCppEngine"]
|
||||
E["CloudEngine"]
|
||||
F["YourCustomEngine"]
|
||||
end
|
||||
|
||||
ABC --> A
|
||||
ABC --> B
|
||||
ABC --> C
|
||||
ABC --> D
|
||||
ABC --> E
|
||||
ABC -.->|"extend"| F
|
||||
```
|
||||
|
||||
This pattern applies across all five primitives:
|
||||
|
||||
| Primitive | ABC | Implementations |
|
||||
|--------|-----|----------------|
|
||||
| Engine | `InferenceEngine` | Ollama, vLLM, SGLang, llama.cpp, Cloud |
|
||||
| Memory | `MemoryBackend` | SQLite, FAISS, ColBERT, BM25, Hybrid |
|
||||
| Agents | `BaseAgent` | Simple, Orchestrator, NativeReAct, NativeOpenHands, RLM, OpenHands, ClaudeCode, Operative, MonitorOperative |
|
||||
| Learning | `RouterPolicy` | Heuristic, TraceDriven, GRPO |
|
||||
| Tools | `BaseTool` | Calculator, Think, Retrieval, LLM, FileRead |
|
||||
|
||||
Adding a new implementation requires two things: implement the ABC and register it. The rest of the system discovers and uses it automatically.
|
||||
|
||||
---
|
||||
|
||||
## 2. Registry-Driven
|
||||
|
||||
All extensible components use the **`@XRegistry.register("name")` decorator** pattern. Registration happens at import time, and no factory function or configuration file needs modification.
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
|
||||
@EngineRegistry.register("my-engine")
|
||||
class MyEngine(InferenceEngine):
|
||||
engine_id = "my-engine"
|
||||
|
||||
def generate(self, messages, *, model, **kwargs):
|
||||
...
|
||||
def stream(self, messages, *, model, **kwargs):
|
||||
...
|
||||
def list_models(self):
|
||||
...
|
||||
def health(self):
|
||||
...
|
||||
```
|
||||
|
||||
The `RegistryBase[T]` generic base class provides:
|
||||
|
||||
- **Class-specific isolation** -- Each typed subclass (`EngineRegistry`, `MemoryRegistry`, etc.) has its own entry storage, so registrations never leak between registries
|
||||
- **Duplicate detection** -- Registering the same key twice raises `ValueError`
|
||||
- **Runtime instantiation** -- `Registry.create(key, *args)` looks up and instantiates in one step
|
||||
- **Introspection** -- `keys()`, `items()`, `contains()` for discovering available components
|
||||
|
||||
!!! info "Why decorators instead of configuration files?"
|
||||
The decorator pattern means that adding a new component is a single-file change.
|
||||
There is no central registry file to edit, no YAML to update, and no factory to modify.
|
||||
The component self-registers simply by being imported.
|
||||
|
||||
---
|
||||
|
||||
## 3. Offline-First
|
||||
|
||||
OpenJarvis is designed to work **entirely without network access**. All core functionality -- inference, memory, agents, tools, telemetry -- operates locally. Cloud APIs are optional extensions, never requirements.
|
||||
|
||||
| Feature | Offline Behavior |
|
||||
|---------|-----------------|
|
||||
| Inference | Ollama, vLLM, SGLang, llama.cpp all run locally |
|
||||
| Memory | SQLite/FTS5 uses built-in Python `sqlite3` module |
|
||||
| Embeddings | `sentence-transformers` models run locally |
|
||||
| Telemetry | SQLite-based, fully local |
|
||||
| Traces | SQLite-based, fully local |
|
||||
| Tools | Calculator, Think, FileRead all local |
|
||||
| Configuration | TOML file on disk |
|
||||
|
||||
Cloud engines (OpenAI, Anthropic, Google) are available through the optional `cloud` backend, but they are:
|
||||
|
||||
- Only registered if the corresponding SDK packages are installed
|
||||
- Only activated if API keys are set as environment variables
|
||||
- Never required for any core functionality
|
||||
|
||||
```python
|
||||
# This works without any network connection
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis(engine_key="ollama") # Local Ollama server
|
||||
response = j.ask("Hello")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Hardware-Aware
|
||||
|
||||
OpenJarvis **auto-detects system hardware** at startup and recommends the optimal inference engine. The `detect_hardware()` function probes:
|
||||
|
||||
| Hardware | Detection Method |
|
||||
|----------|-----------------|
|
||||
| NVIDIA GPUs | `nvidia-smi` (name, VRAM, count) |
|
||||
| AMD GPUs | `rocm-smi` (product name) |
|
||||
| Apple Silicon | `system_profiler SPDisplaysDataType` |
|
||||
| CPU | `/proc/cpuinfo` or `sysctl` (brand string) |
|
||||
| RAM | `/proc/meminfo` or `sysctl hw.memsize` |
|
||||
|
||||
The `recommend_engine()` function maps hardware to engines:
|
||||
|
||||
| Hardware | Recommended Engine |
|
||||
|----------|-------------------|
|
||||
| No GPU | `llamacpp` (CPU-optimized) |
|
||||
| Apple Silicon | `ollama` (Metal acceleration) |
|
||||
| NVIDIA datacenter (A100, H100, etc.) | `vllm` (high throughput) |
|
||||
| NVIDIA consumer | `ollama` (easy setup) |
|
||||
| AMD GPU | `vllm` (ROCm support) |
|
||||
|
||||
This recommendation is written to `config.toml` during `jarvis init` and used as the default engine:
|
||||
|
||||
```bash
|
||||
jarvis init --force
|
||||
# Detects hardware, writes ~/.openjarvis/config.toml with:
|
||||
# [engine]
|
||||
# default = "vllm" # (for A100)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Telemetry-Native
|
||||
|
||||
Every inference call automatically records timing, token counts, energy usage, and cost to a local SQLite database. Telemetry is a **first-class concern**, not an afterthought.
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class TelemetryRecord:
|
||||
timestamp: float
|
||||
model_id: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
latency_seconds: float
|
||||
ttft: float # Time to first token
|
||||
cost_usd: float
|
||||
energy_joules: float
|
||||
power_watts: float
|
||||
engine: str
|
||||
agent: str
|
||||
```
|
||||
|
||||
The `instrumented_generate()` wrapper handles all telemetry transparently:
|
||||
|
||||
1. Records start time
|
||||
2. Calls the engine's `generate()` method
|
||||
3. Records end time and extracts token counts
|
||||
4. Publishes a `TELEMETRY_RECORD` event on the EventBus
|
||||
5. The `TelemetryStore` (subscribed to the bus) persists the record
|
||||
|
||||
The `TelemetryAggregator` provides read-only queries over stored records:
|
||||
|
||||
```bash
|
||||
jarvis telemetry stats # Aggregated statistics
|
||||
jarvis telemetry export --json # Export all records
|
||||
```
|
||||
|
||||
!!! note "Telemetry is best-effort"
|
||||
If telemetry setup fails (e.g., database is locked), the system continues
|
||||
without telemetry rather than raising an error. Telemetry never blocks
|
||||
the query flow.
|
||||
|
||||
---
|
||||
|
||||
## 6. Python-First
|
||||
|
||||
OpenJarvis provides a **clean Python API** through the `Jarvis` class. There is no framework lock-in -- the SDK is a standard Python package with dataclass-based types and no required web framework.
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
response = j.ask("Hello")
|
||||
|
||||
# Full control
|
||||
result = j.ask_full(
|
||||
"Explain quantum computing",
|
||||
model="qwen3:8b",
|
||||
agent="orchestrator",
|
||||
tools=["think"],
|
||||
temperature=0.5,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
# Memory operations
|
||||
j.memory.index("./docs/")
|
||||
results = j.memory.search("quantum computing")
|
||||
|
||||
# Resource cleanup
|
||||
j.close()
|
||||
```
|
||||
|
||||
Design choices that support this principle:
|
||||
|
||||
- **Dataclasses** for all structured types (`Message`, `ModelSpec`, `Trace`, etc.)
|
||||
- **Type hints** throughout the codebase
|
||||
- **No magic** -- explicit initialization, clear method signatures
|
||||
- **Optional dependencies** via extras (`openjarvis[server]`, `openjarvis[memory-colbert]`, etc.)
|
||||
- **Standard packaging** with `hatchling` build backend and `uv` package manager
|
||||
|
||||
---
|
||||
|
||||
## 7. OpenAI-Compatible
|
||||
|
||||
The API server (`jarvis serve`) implements the **OpenAI chat completions API format**, making OpenJarvis a drop-in replacement for OpenAI in existing applications.
|
||||
|
||||
Supported endpoints:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/v1/chat/completions` | POST | Chat completions (streaming and non-streaming) |
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/health` | GET | Health check |
|
||||
|
||||
Request and response formats match the OpenAI API specification:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "qwen3:8b",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1024,
|
||||
"stream": false
|
||||
}'
|
||||
```
|
||||
|
||||
Streaming responses use Server-Sent Events (SSE) with `data: [DONE]` termination, matching the OpenAI streaming protocol.
|
||||
|
||||
Any OpenAI client library can connect to OpenJarvis:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
|
||||
response = client.chat.completions.create(
|
||||
model="qwen3:8b",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Standalone
|
||||
|
||||
OpenJarvis requires **no external services** for core functionality. Everything needed to run the system is included or uses standard system libraries.
|
||||
|
||||
| Component | Dependency |
|
||||
|-----------|-----------|
|
||||
| Configuration | TOML file, built-in `tomllib` (Python 3.11+) or `tomli` |
|
||||
| Memory (default) | Built-in `sqlite3` module |
|
||||
| Telemetry | Built-in `sqlite3` module |
|
||||
| Traces | Built-in `sqlite3` module |
|
||||
| HTTP client | `httpx` (lightweight, pure Python) |
|
||||
| CLI | `click` + `rich` |
|
||||
| Event bus | Built-in `threading` module |
|
||||
|
||||
The only external requirement is a running inference engine (Ollama, vLLM, etc.), which is the model server itself -- not a dependency of OpenJarvis.
|
||||
|
||||
Optional features that require additional packages:
|
||||
|
||||
| Feature | Extra | Packages |
|
||||
|---------|-------|----------|
|
||||
| FAISS memory | `openjarvis[memory-faiss]` | `faiss-cpu`, `sentence-transformers` |
|
||||
| ColBERT memory | `openjarvis[memory-colbert]` | `colbert-ai`, `torch` |
|
||||
| BM25 memory | `openjarvis[memory-bm25]` | `rank-bm25` |
|
||||
| API server | `openjarvis[server]` | `fastapi`, `uvicorn` |
|
||||
| Cloud inference | `openjarvis[inference-cloud]` | `openai`, `anthropic`, `google-genai` |
|
||||
| vLLM engine | `openjarvis[inference-vllm]` | `vllm` |
|
||||
| PDF ingestion | `openjarvis[memory-pdf]` | `pdfplumber` |
|
||||
| WhatsApp Baileys | `openjarvis[channel-whatsapp-baileys]` | Node.js 22+ |
|
||||
|
||||
This design ensures that a minimal installation (`uv sync`) gives you a fully functional system with SQLite memory, local inference, and the complete CLI -- no Docker, no external databases, no cloud accounts required.
|
||||
@@ -0,0 +1,390 @@
|
||||
# Inference Engine Primitive
|
||||
|
||||
The Engine primitive provides the **inference runtime** -- the layer that connects OpenJarvis to language model servers. All backends implement a uniform interface, making it straightforward to swap between local and cloud inference without changing application code.
|
||||
|
||||
---
|
||||
|
||||
## InferenceEngine ABC
|
||||
|
||||
Every engine backend extends the `InferenceEngine` abstract base class:
|
||||
|
||||
```python
|
||||
class InferenceEngine(ABC):
|
||||
engine_id: str
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Synchronous completion -- returns a dict with 'content' and 'usage'."""
|
||||
|
||||
@abstractmethod
|
||||
async def stream(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield token strings as they are generated."""
|
||||
|
||||
@abstractmethod
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return identifiers of models available on this engine."""
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> bool:
|
||||
"""Return True when the engine is reachable and healthy."""
|
||||
|
||||
def prepare(self, model: str) -> None:
|
||||
"""Optional warm-up hook called before the first request."""
|
||||
```
|
||||
|
||||
### Return Format
|
||||
|
||||
The `generate()` method returns a dictionary with the following structure:
|
||||
|
||||
```python
|
||||
{
|
||||
"content": "The model's response text",
|
||||
"usage": {
|
||||
"prompt_tokens": 42,
|
||||
"completion_tokens": 128,
|
||||
"total_tokens": 170,
|
||||
},
|
||||
"model": "qwen3:8b",
|
||||
"finish_reason": "stop",
|
||||
"tool_calls": [...] # Optional, present if model requested tool calls
|
||||
}
|
||||
```
|
||||
|
||||
When the model requests tool calls, they are extracted and passed through in OpenAI format:
|
||||
|
||||
```python
|
||||
{
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"name": "calculator",
|
||||
"arguments": "{\"expression\": \"2 + 2\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Provider Tool Call Extraction
|
||||
|
||||
Engine backends normalize tool calls from different providers into the standard flat format used by agents:
|
||||
|
||||
| Provider | Source Format | Extraction Logic |
|
||||
|----------|-------------|-----------------|
|
||||
| **OpenAI** | `choices[0].message.tool_calls[].function.{name, arguments}` | Direct extraction, add `id` from `tool_calls[].id` |
|
||||
| **Anthropic** | `content[]` blocks with `type: "tool_use"` | Filter `tool_use` blocks, map `input` dict to JSON `arguments` |
|
||||
| **Google** | `candidates[0].content.parts[]` with `function_call` | Extract `function_call.name` and `function_call.args`, serialize args to JSON |
|
||||
| **LiteLLM** | Flat `{id, name, arguments}` dicts (proxy pre-normalizes) | Pass through directly |
|
||||
| **Ollama** | `message.tool_calls[].function.{name, arguments}` | Extract from Ollama native format, serialize arguments dict to JSON |
|
||||
|
||||
All providers produce the same output format consumed by agents:
|
||||
|
||||
```python
|
||||
{
|
||||
"tool_calls": [
|
||||
{"id": "call_abc", "name": "calculator", "arguments": "{\"expression\": \"2+2\"}"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Comparison
|
||||
|
||||
| Backend | Registry Key | Protocol | Default Port | GPU Required | Best For |
|
||||
|---------|-------------|----------|-------------|-------------|----------|
|
||||
| **Ollama** | `ollama` | Native HTTP API | 11434 | No (GPU optional) | Getting started, consumer GPUs, Apple Silicon |
|
||||
| **vLLM** | `vllm` | OpenAI-compatible | 8000 | NVIDIA recommended | Datacenter GPUs (A100, H100), high throughput |
|
||||
| **SGLang** | `sglang` | OpenAI-compatible | 30000 | NVIDIA recommended | Structured generation, speculative decoding |
|
||||
| **llama.cpp** | `llamacpp` | OpenAI-compatible | 8080 | No (CPU-optimized) | CPU-only systems, GGUF models, edge devices |
|
||||
| **MLX** | `mlx` | OpenAI-compatible | 8080 | Apple Silicon | Apple Silicon native inference via MLX |
|
||||
| **LM Studio** | `lmstudio` | OpenAI-compatible | 1234 | No (GPU optional) | Desktop GUI, easy model management |
|
||||
| **Exo** | `exo` | OpenAI-compatible | 52415 | No (distributed) | Distributed inference across heterogeneous devices |
|
||||
| **Nexa** | `nexa` | OpenAI-compatible | 18181 | No (CPU/GPU) | On-device inference with GGUF models |
|
||||
| **Uzu** | `uzu` | OpenAI-compatible | 8000 | Varies | Uzu inference runtime |
|
||||
| **Apple FM** | `apple_fm` | OpenAI-compatible | 8079 | Apple Silicon | Apple Foundation Model on-device inference |
|
||||
| **LiteLLM** | `litellm` | OpenAI-compatible | — | No | Unified proxy to 100+ LLM providers |
|
||||
| **Cloud** | `cloud` | Provider SDKs | — | No | OpenAI, Anthropic, Google API access |
|
||||
|
||||
### Ollama
|
||||
|
||||
The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and `/api/tags`. It is the default engine on Apple Silicon and consumer NVIDIA GPUs.
|
||||
|
||||
- **Default host:** `http://localhost:11434`
|
||||
- **Health check:** `GET /api/tags`
|
||||
- **Model listing:** `GET /api/tags` (extracts model names)
|
||||
- **Tool support:** Passes `tools` in the request payload and extracts `tool_calls` from responses
|
||||
|
||||
### vLLM
|
||||
|
||||
The vLLM backend uses the OpenAI-compatible `/v1/chat/completions` API. It is recommended for datacenter GPUs (A100, H100, L40, A10, A30) and AMD GPUs.
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Tool fallback:** If the server returns HTTP 400 when tools are included, the engine automatically retries without tools
|
||||
|
||||
### SGLang
|
||||
|
||||
The SGLang backend also uses the OpenAI-compatible API. It shares the same `_OpenAICompatibleEngine` base class as vLLM and llama.cpp.
|
||||
|
||||
- **Default host:** `http://localhost:30000`
|
||||
- **Health check:** `GET /v1/models`
|
||||
|
||||
### llama.cpp
|
||||
|
||||
The llama.cpp backend connects to a `llama-server` instance via the OpenAI-compatible API. It is recommended for CPU-only systems and GGUF-quantized models.
|
||||
|
||||
- **Default host:** `http://localhost:8080`
|
||||
- **Health check:** `GET /v1/models`
|
||||
|
||||
### Cloud
|
||||
|
||||
The Cloud backend provides access to OpenAI, Anthropic, and Google models via their respective Python SDKs. It automatically detects the provider based on the model name:
|
||||
|
||||
- Models containing `"claude"` route to the **Anthropic** client
|
||||
- Models containing `"gemini"` route to the **Google** client
|
||||
- All other models route to the **OpenAI** client
|
||||
|
||||
!!! info "API Keys"
|
||||
Cloud models require API keys set as environment variables:
|
||||
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` (or `GOOGLE_API_KEY`).
|
||||
The cloud engine is only registered if the corresponding SDK packages are installed.
|
||||
|
||||
### MLX
|
||||
|
||||
The MLX backend serves models via the MLX framework on Apple Silicon. It uses the OpenAI-compatible `/v1/chat/completions` API.
|
||||
|
||||
- **Default host:** `http://localhost:8080`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Best for:** Apple Silicon Macs (M1/M2/M3/M4) running MLX-format or GGUF models natively
|
||||
|
||||
### LM Studio
|
||||
|
||||
The LM Studio backend connects to the LM Studio desktop application's built-in server, which exposes an OpenAI-compatible API.
|
||||
|
||||
- **Default host:** `http://localhost:1234`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Best for:** Users who prefer a GUI for model management and want a zero-configuration local server
|
||||
|
||||
### Exo
|
||||
|
||||
The Exo backend connects to the Exo distributed inference runtime, which partitions model layers across multiple heterogeneous devices (e.g., a Mac and a Linux box). Exo supports Apple Silicon, NVIDIA, and AMD GPUs.
|
||||
|
||||
- **Default host:** `http://localhost:52415`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Install:** `pip install exo` or from source at [github.com/exo-explore/exo](https://github.com/exo-explore/exo)
|
||||
- **Best for:** Running models too large for a single device by distributing across multiple Apple Silicon or heterogeneous machines
|
||||
|
||||
### Nexa
|
||||
|
||||
The Nexa backend connects to the Nexa SDK on-device inference server via a FastAPI shim (`nexa_shim.py`). It wraps `nexaai.LLM` as an OpenAI-compatible API on port 18181.
|
||||
|
||||
- **Default host:** `http://localhost:18181`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Install:** `pip install nexaai`
|
||||
- **Best for:** On-device inference with GGUF models on Apple Silicon or CPU
|
||||
|
||||
### Uzu
|
||||
|
||||
The Uzu backend connects to the Uzu inference runtime. Unlike other OpenAI-compatible engines, Uzu serves its API at the root path (no `/v1` prefix).
|
||||
|
||||
- **Default host:** `http://localhost:8000`
|
||||
- **API prefix:** (none — endpoints are `/chat/completions`, `/models`)
|
||||
- **Health check:** `GET /models`
|
||||
- **Best for:** Uzu-optimized inference workloads
|
||||
|
||||
### Apple FM
|
||||
|
||||
The Apple FM backend connects to Apple's Foundation Model SDK via a FastAPI shim (`apple_fm_shim.py`). It wraps `python-apple-fm-sdk` as an OpenAI-compatible API. Requires macOS 15+ with Apple Silicon.
|
||||
|
||||
!!! note "Token counts"
|
||||
The Apple FM SDK does not expose token counts. The shim returns 0 for all token counts. Benchmark throughput and energy-per-token metrics will reflect this limitation.
|
||||
|
||||
- **Default host:** `http://localhost:8079`
|
||||
- **Health check:** `GET /v1/models`
|
||||
- **Install:** `pip install python-apple-fm-sdk`
|
||||
- **Best for:** Running Apple Foundation Models natively on Apple Silicon hardware
|
||||
|
||||
### LiteLLM
|
||||
|
||||
The LiteLLM backend connects to a LiteLLM proxy server, which provides a unified OpenAI-compatible interface to 100+ LLM providers (OpenAI, Anthropic, Google, Azure, AWS Bedrock, Groq, Together, and more).
|
||||
|
||||
- **Registry key:** `litellm`
|
||||
- **Best for:** Teams that need a single endpoint to route across multiple cloud providers with unified logging and cost tracking
|
||||
|
||||
---
|
||||
|
||||
## Hardware Auto-Detection
|
||||
|
||||
OpenJarvis automatically detects system hardware to recommend the best engine. Detection runs at config load time via `detect_hardware()`:
|
||||
|
||||
| Detection | Method | Information Extracted |
|
||||
|-----------|--------|---------------------|
|
||||
| NVIDIA GPU | `nvidia-smi` | GPU name, VRAM (GB), count |
|
||||
| AMD GPU | `rocm-smi` | GPU name |
|
||||
| Apple Silicon | `system_profiler SPDisplaysDataType` | Chipset model name |
|
||||
| CPU | `/proc/cpuinfo` or `sysctl` | Brand string |
|
||||
| RAM | `/proc/meminfo` or `sysctl hw.memsize` | Total GB |
|
||||
|
||||
### Engine Recommendation Logic
|
||||
|
||||
The `recommend_engine()` function maps hardware to the best engine:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["detect_hardware()"] --> B{"GPU detected?"}
|
||||
B -->|No| C["llamacpp"]
|
||||
B -->|Yes| D{"GPU vendor?"}
|
||||
D -->|Apple| E["ollama"]
|
||||
D -->|NVIDIA| F{"Datacenter card?<br/>(A100, H100, H200,<br/>L40, A10, A30)"}
|
||||
F -->|Yes| G["vllm"]
|
||||
F -->|No| H["ollama"]
|
||||
D -->|AMD| I["vllm"]
|
||||
D -->|Other| J["llamacpp"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Engine Discovery
|
||||
|
||||
The `_discovery.py` module provides three functions for finding and instantiating engines at runtime.
|
||||
|
||||
### `get_engine(config, engine_key=None)`
|
||||
|
||||
Returns a `(key, engine_instance)` tuple for the requested engine, or `None` if unavailable:
|
||||
|
||||
1. If `engine_key` is specified, try to instantiate and health-check that specific engine
|
||||
2. Otherwise, try the default engine from config
|
||||
3. If the default is unhealthy, fall back to any healthy engine via `discover_engines()`
|
||||
|
||||
### `discover_engines(config)`
|
||||
|
||||
Probes all registered engines for health and returns a sorted list of healthy `(key, engine)` pairs. The config default engine is sorted first.
|
||||
|
||||
```python
|
||||
from openjarvis.engine import discover_engines
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
config = load_config()
|
||||
healthy = discover_engines(config)
|
||||
# [("ollama", OllamaEngine(...)), ("vllm", VLLMEngine(...))]
|
||||
```
|
||||
|
||||
### `discover_models(engines)`
|
||||
|
||||
Calls `list_models()` on each engine and returns a dictionary mapping engine keys to model ID lists:
|
||||
|
||||
```python
|
||||
from openjarvis.engine import discover_engines, discover_models
|
||||
|
||||
engines = discover_engines(config)
|
||||
models = discover_models(engines)
|
||||
# {"ollama": ["qwen3:8b", "llama3.2:3b"], "vllm": ["mistral:7b"]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenAI Compatibility Layer
|
||||
|
||||
The `_OpenAICompatibleEngine` base class provides a shared implementation for engines that serve the standard `/v1/chat/completions` endpoint. vLLM, SGLang, and llama.cpp all extend this base class with minimal overrides -- typically just setting `engine_id` and `_default_host`.
|
||||
|
||||
```python
|
||||
class _OpenAICompatibleEngine(InferenceEngine):
|
||||
engine_id: str = ""
|
||||
_default_host: str = "http://localhost:8000"
|
||||
|
||||
def __init__(self, host: str | None = None, *, timeout: float = 120.0):
|
||||
self._host = (host or self._default_host).rstrip("/")
|
||||
self._client = httpx.Client(base_url=self._host, timeout=timeout)
|
||||
```
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- **Synchronous generation:** `POST /v1/chat/completions` with `stream=False`
|
||||
- **Streaming:** `POST /v1/chat/completions` with `stream=True`, parsing SSE `data:` lines
|
||||
- **Model listing:** `GET /v1/models`, extracting `data[].id`
|
||||
- **Health check:** `GET /v1/models` with a 2-second timeout
|
||||
- **Tool call fallback:** On HTTP 400 with tools in the payload, retries without tools (handles engines that do not support function calling)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Engine hosts and defaults are configured in `~/.openjarvis/config.toml` using **nested per-engine sub-sections**:
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[engine.ollama]
|
||||
host = "http://localhost:11434"
|
||||
|
||||
[engine.vllm]
|
||||
host = "http://localhost:8000"
|
||||
|
||||
[engine.sglang]
|
||||
host = "http://localhost:30000"
|
||||
|
||||
# [engine.llamacpp]
|
||||
# host = "http://localhost:8080"
|
||||
# binary_path = ""
|
||||
```
|
||||
|
||||
The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settings:
|
||||
|
||||
| Config Class | Field | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `EngineConfig` | `default` | `"ollama"` (hardware-dependent) | Preferred engine backend |
|
||||
| `OllamaEngineConfig` | `host` | `http://localhost:11434` | Ollama server URL |
|
||||
| `VLLMEngineConfig` | `host` | `http://localhost:8000` | vLLM server URL |
|
||||
| `SGLangEngineConfig` | `host` | `http://localhost:30000` | SGLang server URL |
|
||||
| `LlamaCppEngineConfig` | `host` | `http://localhost:8080` | llama.cpp server URL |
|
||||
| `LlamaCppEngineConfig` | `binary_path` | `""` | Path to llama.cpp binary (for managed mode) |
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old flat field names `ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, and `sglang_host` under `[engine]` are still accepted as backward-compatible properties on `EngineConfig`. New configurations should use the nested sub-section format.
|
||||
|
||||
---
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### `messages_to_dicts()`
|
||||
|
||||
Converts a sequence of `Message` objects to OpenAI-format dictionaries, handling tool calls and tool call IDs:
|
||||
|
||||
```python
|
||||
from openjarvis.engine._base import messages_to_dicts
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
dicts = messages_to_dicts(messages)
|
||||
# [{"role": "user", "content": "Hello"}]
|
||||
```
|
||||
|
||||
### `EngineConnectionError`
|
||||
|
||||
A custom exception raised when an engine is unreachable. All engine backends catch `httpx.ConnectError` and `httpx.TimeoutException` and re-raise as `EngineConnectionError`:
|
||||
|
||||
```python
|
||||
from openjarvis.engine import EngineConnectionError
|
||||
|
||||
try:
|
||||
result = engine.generate(messages, model="qwen3:8b")
|
||||
except EngineConnectionError as exc:
|
||||
print(f"Engine unavailable: {exc}")
|
||||
```
|
||||
@@ -0,0 +1,258 @@
|
||||
# Intelligence Primitive
|
||||
|
||||
The Intelligence primitive represents **the model** — its identity, weights, quantization format, fallback chain, and the catalog of well-known models with detailed metadata. It no longer contains routing logic; query analysis and model selection have moved to the [Learning primitive](learning.md).
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The Intelligence primitive answers a single question: *what is the model?* It maintains a catalog of known models with metadata (parameter count, context length, VRAM requirements, supported engines) and provides helpers for registering built-in models and merging models discovered from running engines at runtime.
|
||||
|
||||
The primitive provides three key capabilities:
|
||||
|
||||
1. **Model catalog** -- a registry of well-known models with metadata (parameter count, context length, VRAM requirements, supported engines)
|
||||
2. **Auto-discovery** -- merging models discovered from running engines into the catalog
|
||||
3. **Model configuration** -- `IntelligenceConfig` captures the local model's identity, weight paths, quantization, and preferred engine
|
||||
|
||||
!!! info "Routing has moved"
|
||||
Query analysis (`build_routing_context`) and model selection (`HeuristicRouter`, `RouterPolicy` ABC) now live in the [Learning primitive](learning.md). Backward-compatible re-exports remain in `intelligence/_stubs.py` and `intelligence/router.py` so existing code continues to work.
|
||||
|
||||
---
|
||||
|
||||
## ModelSpec
|
||||
|
||||
Every model in the system is described by a `ModelSpec` dataclass, defined in `core/types.py`:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class ModelSpec:
|
||||
model_id: str # Unique identifier (e.g., "qwen3:8b")
|
||||
name: str # Human-readable name
|
||||
parameter_count_b: float # Total parameters in billions
|
||||
context_length: int # Maximum context window (tokens)
|
||||
active_parameter_count_b: Optional[float] # MoE active params (None for dense)
|
||||
quantization: Quantization # Quantization format (none, fp8, int4, etc.)
|
||||
min_vram_gb: float # Minimum VRAM required
|
||||
supported_engines: Sequence[str] # Which engines can run this model
|
||||
provider: str # Model provider (e.g., "alibaba", "meta")
|
||||
requires_api_key: bool # Whether cloud API key is needed
|
||||
metadata: Dict[str, Any] # Additional metadata (pricing, architecture)
|
||||
```
|
||||
|
||||
Models are registered in the `ModelRegistry`:
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import ModelRegistry
|
||||
|
||||
# Register a model
|
||||
ModelRegistry.register_value("qwen3:8b", ModelSpec(
|
||||
model_id="qwen3:8b",
|
||||
name="Qwen3 8B",
|
||||
parameter_count_b=8.2,
|
||||
context_length=32768,
|
||||
supported_engines=("vllm", "ollama", "llamacpp", "sglang"),
|
||||
provider="alibaba",
|
||||
))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Catalog
|
||||
|
||||
The built-in model catalog is defined in `intelligence/model_catalog.py` as the `BUILTIN_MODELS` list. It includes models across three categories:
|
||||
|
||||
### Local Models -- Dense
|
||||
|
||||
| Model ID | Name | Parameters | Context | Supported Engines |
|
||||
|----------|------|-----------|---------|-------------------|
|
||||
| `qwen3:8b` | Qwen3 8B | 8.2B | 32K | vLLM, Ollama, llama.cpp, SGLang |
|
||||
| `qwen3:32b` | Qwen3 32B | 32B | 32K | Ollama, vLLM |
|
||||
| `llama3.3:70b` | Llama 3.3 70B | 70B | 128K | Ollama, vLLM |
|
||||
| `llama3.2:3b` | Llama 3.2 3B | 3B | 128K | Ollama, vLLM, llama.cpp |
|
||||
| `deepseek-coder-v2:16b` | DeepSeek Coder V2 16B | 16B | 128K | Ollama, vLLM |
|
||||
| `mistral:7b` | Mistral 7B | 7B | 32K | Ollama, vLLM, llama.cpp |
|
||||
|
||||
### Local Models -- Mixture of Experts (MoE)
|
||||
|
||||
| Model ID | Name | Total / Active Params | Context | Min VRAM |
|
||||
|----------|------|----------------------|---------|----------|
|
||||
| `gpt-oss:120b` | GPT-OSS 120B | 117B / 5.1B | 128K | 12 GB |
|
||||
| `glm-4.7-flash` | GLM 4.7 Flash | 30B / 3B | 128K | 8 GB |
|
||||
| `trinity-mini` | Trinity Mini | 26B / 3B | 128K | 8 GB |
|
||||
|
||||
### Cloud Models
|
||||
|
||||
| Model ID | Provider | Context | Pricing (input/output per 1M tokens) |
|
||||
|----------|----------|---------|--------------------------------------|
|
||||
| `gpt-4o` | OpenAI | 128K | $2.50 / $10.00 |
|
||||
| `gpt-4o-mini` | OpenAI | 128K | $0.15 / $0.60 |
|
||||
| `gpt-5-mini` | OpenAI | 400K | $0.25 / $2.00 |
|
||||
| `claude-sonnet-4-20250514` | Anthropic | 200K | $3.00 / $15.00 |
|
||||
| `claude-opus-4-20250514` | Anthropic | 200K | $15.00 / $75.00 |
|
||||
| `claude-opus-4-6` | Anthropic | 200K | $5.00 / $25.00 |
|
||||
| `gemini-2.5-pro` | Google | 1M | $1.25 / $10.00 |
|
||||
| `gemini-2.5-flash` | Google | 1M | $0.30 / $2.50 |
|
||||
|
||||
### Registering Built-in Models
|
||||
|
||||
The `register_builtin_models()` function populates the `ModelRegistry` with all built-in models. It skips models that are already registered, making it safe to call multiple times:
|
||||
|
||||
```python
|
||||
from openjarvis.intelligence import register_builtin_models
|
||||
|
||||
register_builtin_models()
|
||||
# All BUILTIN_MODELS are now in ModelRegistry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auto-Discovery: Merging Runtime Models
|
||||
|
||||
When engines are discovered at runtime, they report models that may not be in the built-in catalog. The `merge_discovered_models()` function creates minimal `ModelSpec` entries for these:
|
||||
|
||||
```python
|
||||
from openjarvis.intelligence import merge_discovered_models
|
||||
|
||||
# Models reported by Ollama that aren't in the catalog
|
||||
merge_discovered_models("ollama", ["phi3:3.8b", "codellama:7b"])
|
||||
```
|
||||
|
||||
For each model ID not already in the registry, a `ModelSpec` is created with the model ID as both the `model_id` and `name`, with zero-value defaults for unknown fields. This ensures the routing system can still select from all available models, even ones it has no metadata for.
|
||||
|
||||
---
|
||||
|
||||
## IntelligenceConfig
|
||||
|
||||
The `IntelligenceConfig` dataclass (in `core/config.py`) captures the full identity of the model the system is configured to use, as well as the default sampling parameters for generation:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class IntelligenceConfig:
|
||||
"""The model — identity, paths, quantization, fallback chain, and generation defaults."""
|
||||
|
||||
default_model: str = "" # Primary model key (e.g., "qwen3:8b")
|
||||
fallback_model: str = "" # Fallback when default is unavailable
|
||||
model_path: str = "" # Local weights (HF repo, GGUF file, etc.)
|
||||
checkpoint_path: str = "" # Checkpoint/adapter path (e.g., LoRA)
|
||||
quantization: str = "none" # none, fp8, int8, int4, gguf_q4, gguf_q8
|
||||
preferred_engine: str = "" # Override engine for this model (e.g., "vllm")
|
||||
provider: str = "" # local, openai, anthropic, google
|
||||
# Generation defaults (overridable per-call)
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 1024
|
||||
top_p: float = 0.9
|
||||
top_k: int = 40
|
||||
repetition_penalty: float = 1.0
|
||||
stop_sequences: str = "" # Comma-separated stop strings
|
||||
```
|
||||
|
||||
### Model Identity Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `default_model` | `str` | `""` | Primary model registry key. Resolved at startup; overrides any engine default. |
|
||||
| `fallback_model` | `str` | `""` | Used when the default model is not available on any running engine. |
|
||||
| `model_path` | `str` | `""` | Path or HuggingFace repo ID for local weights (e.g., `"./models/qwen3-8b.gguf"` or `"Qwen/Qwen3-8B"`). |
|
||||
| `checkpoint_path` | `str` | `""` | Path to a fine-tuned checkpoint or LoRA adapter directory. |
|
||||
| `quantization` | `str` | `"none"` | Quantization format. Accepted values: `none`, `fp8`, `int8`, `int4`, `gguf_q4`, `gguf_q8`. |
|
||||
| `preferred_engine` | `str` | `""` | When set, `SystemBuilder`, `sdk.py`, and `cli/ask.py` use this engine key instead of `config.engine.default`. |
|
||||
| `provider` | `str` | `""` | Model provider hint: `local`, `openai`, `anthropic`, `google`. Used by the Cloud engine backend to route API calls. |
|
||||
|
||||
### Generation Default Fields
|
||||
|
||||
These fields set the default sampling parameters for every inference call. Individual calls can override them by passing keyword arguments to `engine.generate()`.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature. Lower values produce more deterministic output; higher values increase diversity. |
|
||||
| `max_tokens` | `int` | `1024` | Maximum number of tokens to generate per call. |
|
||||
| `top_p` | `float` | `0.9` | Nucleus sampling probability mass. At each step, only tokens comprising the top-p probability mass are considered. |
|
||||
| `top_k` | `int` | `40` | Top-k sampling: only consider the top-k most likely tokens at each step. |
|
||||
| `repetition_penalty` | `float` | `1.0` | Penalize repeated token sequences. Values greater than 1.0 reduce repetition. |
|
||||
| `stop_sequences` | `str` | `""` | Comma-separated stop strings. Generation halts when any stop string appears in the output. |
|
||||
|
||||
!!! note "Moved from Agent"
|
||||
Generation parameters (`temperature`, `max_tokens`) previously lived under `[agent]` in the config file. They now live under `[intelligence]`. Old configs with these fields under `[agent]` are automatically migrated at load time. See the [configuration migration guide](../getting-started/configuration.md#migration-guide) for details.
|
||||
|
||||
### TOML Configuration
|
||||
|
||||
```toml
|
||||
[intelligence]
|
||||
default_model = "qwen3:8b"
|
||||
fallback_model = "llama3.2:3b"
|
||||
temperature = 0.7
|
||||
max_tokens = 1024
|
||||
# top_p = 0.9
|
||||
# top_k = 40
|
||||
# repetition_penalty = 1.0
|
||||
# stop_sequences = ""
|
||||
|
||||
# Local weight overrides (optional)
|
||||
# model_path = "./models/qwen3-8b-instruct.gguf"
|
||||
# checkpoint_path = "./checkpoints/my-lora"
|
||||
# quantization = "gguf_q4"
|
||||
|
||||
# Engine selection for this model (takes priority over [engine].default)
|
||||
# preferred_engine = "vllm"
|
||||
|
||||
# Provider for cloud models
|
||||
# provider = "openai"
|
||||
```
|
||||
|
||||
### Engine Selection Priority
|
||||
|
||||
When resolving which engine to use, `SystemBuilder`, `sdk.py`, and `cli/ask.py` check `config.intelligence.preferred_engine` before `config.engine.default`:
|
||||
|
||||
```
|
||||
1. Explicit --engine CLI flag or engine_key= SDK parameter
|
||||
2. config.intelligence.preferred_engine ← new field
|
||||
3. config.engine.default
|
||||
4. First healthy engine discovered at runtime
|
||||
```
|
||||
|
||||
This lets you pin a specific model to a specific engine without changing the global engine default. For example, a GGUF quantized model can be pinned to `llamacpp` while the global default remains `ollama`:
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "llama3.2:3b"
|
||||
model_path = "./models/llama-3.2-3b.Q4_K_M.gguf"
|
||||
quantization = "gguf_q4"
|
||||
preferred_engine = "llamacpp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
`intelligence/__init__.py` exports exactly three names:
|
||||
|
||||
```python
|
||||
from openjarvis.intelligence import (
|
||||
BUILTIN_MODELS, # List[ModelSpec] — the full built-in catalog
|
||||
merge_discovered_models, # (engine_key, model_ids) -> None
|
||||
register_builtin_models, # () -> None
|
||||
)
|
||||
```
|
||||
|
||||
### Backward-Compatibility Shims
|
||||
|
||||
The following names are still importable from `openjarvis.intelligence` via shim modules, but their canonical locations have moved:
|
||||
|
||||
| Name | Old location | Canonical location |
|
||||
|------|-------------|-------------------|
|
||||
| `RouterPolicy` | `intelligence/_stubs.py` | `learning/_stubs.py` |
|
||||
| `QueryAnalyzer` | `intelligence/_stubs.py` | `learning/_stubs.py` |
|
||||
| `HeuristicRouter` | `intelligence/router.py` | `learning/router.py` |
|
||||
| `build_routing_context` | `intelligence/router.py` | `learning/router.py` |
|
||||
| `DefaultQueryAnalyzer` | `intelligence/router.py` | `learning/router.py` |
|
||||
|
||||
New code should import from the canonical `learning.*` locations. The shims in `intelligence/_stubs.py` and `intelligence/router.py` are retained for backward compatibility only.
|
||||
|
||||
---
|
||||
|
||||
## Integration with Learning
|
||||
|
||||
The Learning primitive consumes the model catalog to make routing decisions. The `HeuristicRouter` and `TraceDrivenPolicy` both read `ModelRegistry` to compare model sizes when selecting between candidates. See the [Learning & Traces](learning.md) documentation for full details on routing policies, the `RouterPolicy` ABC, and the trace-driven feedback loop.
|
||||
@@ -0,0 +1,555 @@
|
||||
# Learning & Traces
|
||||
|
||||
The Learning system is a **cross-cutting concern** that connects all five primitives through trace-driven feedback. It determines which model handles each query (router policies), records the full interaction as a trace, analyzes outcomes, and updates policies based on what worked.
|
||||
|
||||
---
|
||||
|
||||
## LearningPolicy ABC Taxonomy
|
||||
|
||||
The learning system defines a hierarchy of learning policy ABCs. The base `LearningPolicy` ABC is specialized into two sub-ABCs corresponding to the two learnable concerns:
|
||||
|
||||
| ABC | Concern | Description |
|
||||
|-----|---------|-------------|
|
||||
| `IntelligenceLearningPolicy` | Model routing | Determines which model handles a query (replaces the legacy `RouterPolicy`) |
|
||||
| `AgentLearningPolicy` | Agent behavior | Advises on agent strategy (e.g., ICL examples, tool selection, turn limits) |
|
||||
|
||||
All learning policies are registered in the `LearningRegistry` (in `core/registry.py`).
|
||||
|
||||
## RouterPolicy ABC
|
||||
|
||||
The `RouterPolicy` ABC and the `QueryAnalyzer` ABC are defined in `learning/_stubs.py`:
|
||||
|
||||
```python
|
||||
# learning/_stubs.py
|
||||
class RouterPolicy(ABC):
|
||||
@abstractmethod
|
||||
def select_model(self, context: RoutingContext) -> str:
|
||||
"""Return the model registry key best suited for *context*."""
|
||||
|
||||
class QueryAnalyzer(ABC):
|
||||
@abstractmethod
|
||||
def analyze(self, query: str) -> RoutingContext:
|
||||
"""Analyze a raw query string and return a RoutingContext."""
|
||||
```
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The canonical locations are now `openjarvis.learning._stubs` (for `RouterPolicy` and `QueryAnalyzer`) and `openjarvis.core.types` (for `RoutingContext`). The old `openjarvis.intelligence._stubs` import path still works via a backward-compatibility shim, but new code should import from `openjarvis.learning._stubs`.
|
||||
|
||||
### RoutingContext
|
||||
|
||||
The `RoutingContext` dataclass is now defined in `core/types.py` (moved from `learning/_stubs.py`):
|
||||
|
||||
```python
|
||||
# core/types.py
|
||||
@dataclass(slots=True)
|
||||
class RoutingContext:
|
||||
query: str = "" # The raw query text
|
||||
query_length: int = 0 # Character count
|
||||
has_code: bool = False # Whether code patterns were detected
|
||||
has_math: bool = False # Whether math keywords were detected
|
||||
language: str = "en" # Detected language
|
||||
urgency: float = 0.5 # 0 = low priority, 1 = real-time
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RouterPolicyRegistry & LearningRegistry
|
||||
|
||||
Router policies are registered in the `RouterPolicyRegistry` and selected at runtime. Additionally, the `LearningRegistry` (in `core/registry.py`) manages the broader set of learning policies across the taxonomy.
|
||||
|
||||
The system ships with these router policies:
|
||||
|
||||
| Registry Key | Policy Class | Status | Description |
|
||||
|-------------|-------------|--------|-------------|
|
||||
| `heuristic` | `HeuristicRouter` | Active | Rule-based routing with 6 priority rules |
|
||||
| `learned` | `TraceDrivenPolicy` | Active | Learns from trace outcomes |
|
||||
| `grpo` | `GRPORouterPolicy` | Stub | Placeholder for future RL training |
|
||||
| `sft` | `SFTRouterPolicy` | Active | Trace-driven routing policy (learns query→model mapping); `SFTPolicy` is a backward-compat alias |
|
||||
|
||||
And these additional learning policies (registered in `LearningRegistry`):
|
||||
|
||||
| Registry Key | Policy Class | Taxonomy | Description |
|
||||
|-------------|-------------|----------|-------------|
|
||||
| `agent_advisor` | `AgentAdvisorPolicy` | `AgentLearningPolicy` | Advises on agent strategy based on trace patterns |
|
||||
| `icl_updater` | `ICLUpdaterPolicy` | `AgentLearningPolicy` | In-context learning updater — discovers ICL examples and multi-tool skills from traces |
|
||||
|
||||
Users select a policy via `config.toml` or the `--router` CLI flag:
|
||||
|
||||
```toml
|
||||
[learning.routing]
|
||||
policy = "heuristic"
|
||||
```
|
||||
|
||||
```bash
|
||||
jarvis ask --router learned "What is the capital of France?"
|
||||
```
|
||||
|
||||
### The `ensure_registered()` Pattern
|
||||
|
||||
Learning modules use a lazy registration pattern to survive registry clearing in tests:
|
||||
|
||||
```python
|
||||
def ensure_registered() -> None:
|
||||
"""Register TraceDrivenPolicy if not already present."""
|
||||
if not RouterPolicyRegistry.contains("learned"):
|
||||
RouterPolicyRegistry.register_value("learned", TraceDrivenPolicy)
|
||||
|
||||
ensure_registered() # Called at module import time
|
||||
```
|
||||
|
||||
This ensures that policies are available even after `RouterPolicyRegistry.clear()` is called in test teardown, because re-importing the module re-registers them.
|
||||
|
||||
---
|
||||
|
||||
## HeuristicRouter (Heuristic Policy)
|
||||
|
||||
The `HeuristicRouter` is the default routing policy. It is defined in `learning/router.py` and applies six static priority rules to select the best model based on query characteristics.
|
||||
|
||||
### Routing Rules
|
||||
|
||||
| Priority | Rule | Condition | Action |
|
||||
|----------|------|-----------|--------|
|
||||
| 1 | Code detection | Query contains code patterns (backticks, `def`, `class`, `import`, `function`, `=>`, etc.) | Prefer model with "code" or "coder" in name; fall back to largest model |
|
||||
| 2 | Math detection | Query contains math keywords (`solve`, `integral`, `equation`, `calculate`, `compute`, etc.) | Select the largest available model |
|
||||
| 3 | Short query | Query length < 50 characters, no code/math | Select the smallest available model (faster response) |
|
||||
| 4 | Long/complex query | Query length > 500 characters OR contains reasoning keywords (`explain`, `analyze`, `compare`, `step-by-step`, etc.) | Select the largest available model |
|
||||
| 5 | High urgency | `urgency > 0.8` | Override to smallest model (fastest response) |
|
||||
| 6 | Default fallback | None of the above match | Use `default_model`, then `fallback_model`, then first available |
|
||||
|
||||
!!! note "Priority 5 overrides all others"
|
||||
The urgency check (rule 5) is evaluated **first** in the code — if urgency exceeds 0.8, the router immediately returns the smallest model regardless of query content.
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
from openjarvis.learning.router import HeuristicRouter, build_routing_context
|
||||
|
||||
router = HeuristicRouter(
|
||||
available_models=["qwen3:8b", "llama3.2:3b", "deepseek-coder-v2:16b"],
|
||||
default_model="qwen3:8b",
|
||||
fallback_model="llama3.2:3b",
|
||||
)
|
||||
|
||||
ctx = build_routing_context("Write a Python function to sort a list")
|
||||
model = router.select_model(ctx) # Returns "deepseek-coder-v2:16b" (has "coder")
|
||||
```
|
||||
|
||||
### build_routing_context()
|
||||
|
||||
The `build_routing_context()` function (in `learning/router.py`) analyzes a raw query string and produces a `RoutingContext` dataclass:
|
||||
|
||||
```python
|
||||
from openjarvis.learning.router import build_routing_context
|
||||
|
||||
ctx = build_routing_context("Solve the integral of x^2 dx")
|
||||
# ctx.has_math = True, ctx.has_code = False, ctx.query_length = 32
|
||||
|
||||
ctx = build_routing_context("```python\ndef hello():\n pass\n```")
|
||||
# ctx.has_code = True, ctx.has_math = False
|
||||
```
|
||||
|
||||
**Code detection** uses regex patterns matching:
|
||||
|
||||
- Backtick code blocks (` ``` ` or `` `inline` ``)
|
||||
- Language keywords (`def`, `class`, `import`, `function`, `const`, `var`, `let`)
|
||||
- Syntax patterns (`if (`, `->`, `=>`, `{ }`, `for x in`, `#include`, `System.out`)
|
||||
|
||||
**Math detection** uses regex patterns matching:
|
||||
|
||||
- Mathematical terms (`solve`, `integral`, `equation`, `proof`, `derivative`, `matrix`)
|
||||
- Computational keywords (`calculate`, `compute`, `sigma`, `sum`, `limit`, `probability`)
|
||||
|
||||
### Registration
|
||||
|
||||
The `heuristic_policy.py` module wires `HeuristicRouter` into the `RouterPolicyRegistry`:
|
||||
|
||||
```python
|
||||
# learning/heuristic_policy.py
|
||||
def ensure_registered() -> None:
|
||||
if not RouterPolicyRegistry.contains("heuristic"):
|
||||
RouterPolicyRegistry.register_value("heuristic", HeuristicRouter)
|
||||
|
||||
ensure_registered()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TraceDrivenPolicy (Learned Policy)
|
||||
|
||||
The `TraceDrivenPolicy` learns from historical traces to determine which model performs best for different types of queries. Unlike the heuristic router's static rules, this policy adapts based on actual outcomes.
|
||||
|
||||
### Query Classification
|
||||
|
||||
Queries are classified into broad categories for grouping:
|
||||
|
||||
| Category | Condition |
|
||||
|----------|-----------|
|
||||
| `code` | Contains code patterns (backticks, `def`, `class`, `import`, `function`) |
|
||||
| `math` | Contains math keywords (`solve`, `integral`, `equation`, `calculate`, `compute`) |
|
||||
| `short` | Query length < 50 characters |
|
||||
| `long` | Query length > 500 characters |
|
||||
| `general` | None of the above |
|
||||
|
||||
### Model Selection
|
||||
|
||||
When `select_model()` is called:
|
||||
|
||||
1. Classify the query into a category
|
||||
2. If the policy map has an entry for this category **and** the confidence (sample count) exceeds `min_samples` (default: 5), use the learned model
|
||||
3. Otherwise, fall back to: `default_model` -> `fallback_model` -> first available model
|
||||
|
||||
### Batch Updates via `update_from_traces()`
|
||||
|
||||
The primary update mechanism reads all traces from a `TraceAnalyzer` and recomputes the policy map:
|
||||
|
||||
```python
|
||||
from openjarvis.learning.trace_policy import TraceDrivenPolicy
|
||||
from openjarvis.traces.analyzer import TraceAnalyzer
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
store = TraceStore("traces.db")
|
||||
analyzer = TraceAnalyzer(store)
|
||||
policy = TraceDrivenPolicy(
|
||||
analyzer=analyzer,
|
||||
available_models=["qwen3:8b", "llama3.2:3b", "deepseek-coder-v2:16b"],
|
||||
default_model="qwen3:8b",
|
||||
)
|
||||
|
||||
# Recompute routing decisions from trace history
|
||||
result = policy.update_from_traces()
|
||||
# {"updated": True, "query_classes": 3, "total_traces": 150, "changes": {...}}
|
||||
```
|
||||
|
||||
The update algorithm:
|
||||
|
||||
1. Fetches all traces (optionally filtered by time range)
|
||||
2. Groups traces by query classification
|
||||
3. For each query class, scores each model using a **composite score**:
|
||||
- 60% success rate (fraction of traces with `outcome="success"`)
|
||||
- 40% average feedback score (user quality ratings)
|
||||
4. Selects the model with the highest composite score for each query class
|
||||
5. Returns a summary of changes
|
||||
|
||||
### Online Updates via `observe()`
|
||||
|
||||
For real-time policy updates after every interaction:
|
||||
|
||||
```python
|
||||
policy.observe(
|
||||
query="Write a Python function",
|
||||
model="deepseek-coder-v2:16b",
|
||||
outcome="success",
|
||||
feedback=0.9,
|
||||
)
|
||||
```
|
||||
|
||||
The online update uses a conservative strategy: it only switches the preferred model for a query class when the new model shows clearly better outcomes (`feedback > 0.7`) and the existing policy has fewer than `min_samples` observations.
|
||||
|
||||
---
|
||||
|
||||
## SFTRouterPolicy (Trace-Driven Router)
|
||||
|
||||
The `SFTRouterPolicy` (in `learning/sft_policy.py`) is an `IntelligenceLearningPolicy` that learns routing decisions from historical traces. It analyzes trace outcomes, groups by query class (code, math, short, long, general), and builds a `query_class → model` mapping from the highest-scoring model per class. A backward-compatible alias `SFTPolicy = SFTRouterPolicy` is provided for code that used the old name.
|
||||
|
||||
```python
|
||||
from openjarvis.learning.sft_policy import SFTRouterPolicy
|
||||
# or via the backward-compat alias:
|
||||
from openjarvis.learning.sft_policy import SFTPolicy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AgentAdvisorPolicy
|
||||
|
||||
The `AgentAdvisorPolicy` (in `learning/agent_advisor.py`) is an `AgentLearningPolicy` that advises on agent strategy -- for example, recommending tool sets, turn limits, or agent type -- based on patterns observed in historical traces.
|
||||
|
||||
```python
|
||||
from openjarvis.learning.agent_advisor import AgentAdvisorPolicy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ICLUpdaterPolicy
|
||||
|
||||
The `ICLUpdaterPolicy` (in `learning/icl_updater.py`) is an `AgentLearningPolicy` that uses in-context learning to discover reusable examples and multi-tool skill sequences from traces. It analyzes successful tool-call patterns to recommend ICL examples and skill libraries that update agent behavior.
|
||||
|
||||
```python
|
||||
from openjarvis.learning.icl_updater import ICLUpdaterPolicy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GRPORouterPolicy (Stub)
|
||||
|
||||
The `GRPORouterPolicy` is a placeholder for future reinforcement learning-based routing. Currently, calling `select_model()` raises `NotImplementedError`:
|
||||
|
||||
```python
|
||||
class GRPORouterPolicy(RouterPolicy):
|
||||
def select_model(self, context: RoutingContext) -> str:
|
||||
raise NotImplementedError(
|
||||
"GRPORouterPolicy is not yet implemented. "
|
||||
"GRPO training will be available in a future phase."
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RewardFunction ABC
|
||||
|
||||
The `RewardFunction` ABC defines how to score completed inferences for use in training:
|
||||
|
||||
```python
|
||||
class RewardFunction(ABC):
|
||||
@abstractmethod
|
||||
def compute(
|
||||
self,
|
||||
context: RoutingContext,
|
||||
model_key: str,
|
||||
response: str,
|
||||
**kwargs: Any,
|
||||
) -> float:
|
||||
"""Return a reward in [0, 1]."""
|
||||
```
|
||||
|
||||
### HeuristicRewardFunction
|
||||
|
||||
The built-in reward function computes a weighted combination of three factors:
|
||||
|
||||
| Factor | Weight (default) | Normalization | Score Range |
|
||||
|--------|-----------------|---------------|-------------|
|
||||
| **Latency** | 0.4 | `1 - (latency / max_latency)` | 0 = 30s+, 1 = instant |
|
||||
| **Cost** | 0.3 | `1 - (cost / max_cost)` | 0 = $0.01+, 1 = free |
|
||||
| **Efficiency** | 0.3 | `completion_tokens / total_tokens` | 0 = all prompt, 1 = all completion |
|
||||
|
||||
```python
|
||||
from openjarvis.learning.heuristic_reward import HeuristicRewardFunction
|
||||
|
||||
reward_fn = HeuristicRewardFunction(
|
||||
weight_latency=0.4,
|
||||
weight_cost=0.3,
|
||||
weight_efficiency=0.3,
|
||||
max_latency=30.0, # seconds
|
||||
max_cost=0.01, # USD
|
||||
)
|
||||
|
||||
reward = reward_fn.compute(
|
||||
context=routing_context,
|
||||
model_key="qwen3:8b",
|
||||
response="The answer is 42.",
|
||||
latency_seconds=1.2,
|
||||
cost_usd=0.0,
|
||||
prompt_tokens=50,
|
||||
completion_tokens=10,
|
||||
)
|
||||
# Returns a float in [0, 1]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Trace System
|
||||
|
||||
The trace system records the full sequence of steps in every agent interaction, providing the raw data that the learning system uses to improve.
|
||||
|
||||
### TraceStore
|
||||
|
||||
`TraceStore` is an append-only SQLite store for interaction traces:
|
||||
|
||||
```python
|
||||
from openjarvis.traces.store import TraceStore
|
||||
|
||||
store = TraceStore("~/.openjarvis/traces.db")
|
||||
store.save(trace) # Persist a complete trace
|
||||
trace = store.get("abc123") # Retrieve by trace ID
|
||||
traces = store.list_traces( # Query with filters
|
||||
agent="orchestrator",
|
||||
model="qwen3:8b",
|
||||
outcome="success",
|
||||
since=1700000000.0,
|
||||
limit=100,
|
||||
)
|
||||
count = store.count() # Total trace count
|
||||
```
|
||||
|
||||
**Database schema:**
|
||||
|
||||
- `traces` table -- one row per interaction (trace_id, query, agent, model, engine, result, outcome, feedback, timing, tokens, metadata)
|
||||
- `trace_steps` table -- one row per step within a trace (step_type, timestamp, duration, input, output, metadata)
|
||||
|
||||
**EventBus integration:** The store can subscribe to `TRACE_COMPLETE` events for automatic persistence:
|
||||
|
||||
```python
|
||||
store.subscribe_to_bus(bus)
|
||||
# Any TRACE_COMPLETE event will now auto-save the trace
|
||||
```
|
||||
|
||||
### TraceCollector
|
||||
|
||||
`TraceCollector` wraps any `BaseAgent` and automatically records a `Trace` for every `run()` call:
|
||||
|
||||
```python
|
||||
from openjarvis.traces.collector import TraceCollector
|
||||
|
||||
agent = OrchestratorAgent(engine, model, tools=tools, bus=bus)
|
||||
collector = TraceCollector(agent, store=trace_store, bus=bus)
|
||||
|
||||
result = collector.run("What is 2+2?")
|
||||
# Trace is automatically saved to trace_store
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
1. Subscribes to EventBus events before running the agent:
|
||||
- `INFERENCE_START` / `INFERENCE_END` -- creates `GENERATE` steps
|
||||
- `TOOL_CALL_START` / `TOOL_CALL_END` -- creates `TOOL_CALL` steps
|
||||
- `MEMORY_RETRIEVE` -- creates `RETRIEVE` steps
|
||||
2. Runs the wrapped agent's `run()` method
|
||||
3. Unsubscribes from events
|
||||
4. Adds a final `RESPOND` step
|
||||
5. Builds a `Trace` object with all collected steps
|
||||
6. Saves to the `TraceStore` and publishes `TRACE_COMPLETE`
|
||||
|
||||
### TraceAnalyzer
|
||||
|
||||
`TraceAnalyzer` provides a read-only query layer over stored traces, computing aggregated statistics:
|
||||
|
||||
```python
|
||||
from openjarvis.traces.analyzer import TraceAnalyzer
|
||||
|
||||
analyzer = TraceAnalyzer(store)
|
||||
|
||||
# Overall summary
|
||||
summary = analyzer.summary()
|
||||
# TraceSummary(total_traces=150, avg_latency=2.3, success_rate=0.85, ...)
|
||||
|
||||
# Stats grouped by (model, agent) routing decisions
|
||||
route_stats = analyzer.per_route_stats()
|
||||
# [RouteStats(model="qwen3:8b", agent="orchestrator", count=45, avg_latency=1.8, ...), ...]
|
||||
|
||||
# Stats grouped by tool
|
||||
tool_stats = analyzer.per_tool_stats()
|
||||
# [ToolStats(tool_name="calculator", call_count=23, avg_latency=0.01, success_rate=1.0), ...]
|
||||
|
||||
# Find traces matching query characteristics
|
||||
code_traces = analyzer.traces_for_query_type(has_code=True)
|
||||
|
||||
# Export traces as plain dicts (for JSON serialization)
|
||||
exported = analyzer.export_traces(limit=1000)
|
||||
```
|
||||
|
||||
**Computed statistics:**
|
||||
|
||||
| Dataclass | Fields |
|
||||
|-----------|--------|
|
||||
| `TraceSummary` | total_traces, total_steps, avg_steps_per_trace, avg_latency, avg_tokens, success_rate, step_type_distribution |
|
||||
| `RouteStats` | model, agent, count, avg_latency, avg_tokens, success_rate, avg_feedback |
|
||||
| `ToolStats` | tool_name, call_count, avg_latency, success_rate |
|
||||
|
||||
---
|
||||
|
||||
## The Learning Loop
|
||||
|
||||
The trace-driven learning loop connects all the pieces:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Runtime"
|
||||
Q["User Query"] --> AGT["Agent executes"]
|
||||
AGT --> ENG["Engine generates"]
|
||||
ENG --> RESP["Response returned"]
|
||||
end
|
||||
|
||||
subgraph "Recording"
|
||||
AGT -.->|"events"| COL["TraceCollector"]
|
||||
ENG -.->|"events"| COL
|
||||
COL -->|"save"| STO["TraceStore<br/>(SQLite)"]
|
||||
end
|
||||
|
||||
subgraph "Analysis"
|
||||
STO -->|"read"| ANA["TraceAnalyzer"]
|
||||
ANA -->|"summary(),<br/>per_route_stats()"| STATS["Aggregated<br/>Statistics"]
|
||||
end
|
||||
|
||||
subgraph "Learning"
|
||||
STATS -->|"update_from_traces()"| POL["TraceDrivenPolicy"]
|
||||
POL -->|"select_model()"| Q
|
||||
end
|
||||
|
||||
style Q fill:#e1f5fe
|
||||
style RESP fill:#e8f5e9
|
||||
style POL fill:#fff3e0
|
||||
```
|
||||
|
||||
### Step-by-step cycle:
|
||||
|
||||
1. **Query arrives** -- The system needs to select a model
|
||||
2. **Router policy selects model** -- `TraceDrivenPolicy.select_model()` checks the learned policy map; falls back to heuristic if insufficient data
|
||||
3. **Agent executes** -- The agent processes the query, calling tools and memory as needed
|
||||
4. **Events captured** -- The `TraceCollector` captures all events (inference, tool calls, memory retrieval) during execution
|
||||
5. **Trace saved** -- A complete `Trace` with all `TraceStep` objects is saved to `TraceStore`
|
||||
6. **Analysis** -- Periodically, `TraceAnalyzer` computes aggregate statistics from stored traces
|
||||
7. **Policy update** -- `TraceDrivenPolicy.update_from_traces()` recomputes the `query_class -> model` mapping based on success rates and feedback scores
|
||||
8. **Better routing** -- The next query benefits from the updated routing decisions
|
||||
|
||||
### Trace Data Model
|
||||
|
||||
Each interaction produces a `Trace` containing multiple `TraceStep` objects:
|
||||
|
||||
```
|
||||
Trace
|
||||
trace_id: "a1b2c3d4e5f6"
|
||||
query: "What is 2+2?"
|
||||
agent: "orchestrator"
|
||||
model: "qwen3:8b"
|
||||
engine: "ollama"
|
||||
steps:
|
||||
[0] GENERATE -- model inference, 0.8s, 150 tokens
|
||||
[1] TOOL_CALL -- calculator, 0.01s, success
|
||||
[2] GENERATE -- model inference, 0.5s, 80 tokens
|
||||
[3] RESPOND -- final answer
|
||||
result: "2+2 = 4"
|
||||
outcome: "success"
|
||||
feedback: 1.0
|
||||
total_latency_seconds: 1.31
|
||||
total_tokens: 230
|
||||
```
|
||||
|
||||
**Step types:**
|
||||
|
||||
| StepType | Description | Created By |
|
||||
|----------|-------------|------------|
|
||||
| `ROUTE` | Model selection decision | Router policy |
|
||||
| `RETRIEVE` | Memory search | Memory backend |
|
||||
| `GENERATE` | LLM inference call | Engine |
|
||||
| `TOOL_CALL` | Tool execution | ToolExecutor |
|
||||
| `RESPOND` | Final response | TraceCollector |
|
||||
|
||||
---
|
||||
|
||||
## Optimization Framework
|
||||
|
||||
The optimization subsystem (`learning/optimize/`) provides LLM-guided search
|
||||
over OpenJarvis's 5-primitive configuration space. It automates finding optimal
|
||||
configurations for accuracy, latency, cost, and energy consumption.
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `SearchSpace` | Defines tunable dimensions across all 5 primitives |
|
||||
| `LLMOptimizer` | Proposes configurations using an LLM backend |
|
||||
| `OptimizationEngine` | Orchestrates the propose-evaluate-analyze loop |
|
||||
| `OptimizationStore` | SQLite-backed persistence for trials and runs |
|
||||
| `TrialRunner` | Evaluates proposed configurations against benchmarks |
|
||||
|
||||
### Pareto Frontier
|
||||
|
||||
The engine computes a Pareto frontier across multiple objectives
|
||||
(accuracy vs latency vs cost), identifying configurations where no single
|
||||
metric can be improved without degrading another.
|
||||
|
||||
### Rust Backend
|
||||
|
||||
The optimization framework has full Rust parity via the `openjarvis-learning`
|
||||
crate, with PyO3 bindings exposing `OptimizationStore` and `LLMOptimizer`
|
||||
to Python.
|
||||
@@ -0,0 +1,355 @@
|
||||
# Memory Primitive
|
||||
|
||||
The Memory primitive provides **persistent, searchable storage** for documents and knowledge. It enables context injection -- retrieving relevant information from indexed documents and prepending it to prompts so the LLM can answer questions grounded in specific content.
|
||||
|
||||
---
|
||||
|
||||
## MemoryBackend ABC
|
||||
|
||||
All memory backends implement the `MemoryBackend` abstract base class:
|
||||
|
||||
```python
|
||||
class MemoryBackend(ABC):
|
||||
backend_id: str
|
||||
|
||||
@abstractmethod
|
||||
def store(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
source: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Persist *content* and return a unique document id."""
|
||||
|
||||
@abstractmethod
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
**kwargs: Any,
|
||||
) -> List[RetrievalResult]:
|
||||
"""Search for *query* and return the top-k results."""
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, doc_id: str) -> bool:
|
||||
"""Delete a document by id. Return True if it existed."""
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Remove all stored documents."""
|
||||
```
|
||||
|
||||
### RetrievalResult
|
||||
|
||||
Search results are returned as `RetrievalResult` objects:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class RetrievalResult:
|
||||
content: str # The document text
|
||||
score: float = 0.0 # Relevance score (higher is better)
|
||||
source: str = "" # Originating file path or identifier
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Comparison
|
||||
|
||||
| Backend | Registry Key | Index Type | Extra Dependencies | GPU Required | Quality | Speed | Persistence |
|
||||
|---------|-------------|-----------|-------------------|-------------|---------|-------|-------------|
|
||||
| **SQLite/FTS5** | `sqlite` | Full-text (BM25) | None | No | Good | Fast | Disk (SQLite) |
|
||||
| **FAISS** | `faiss` | Dense vector | `faiss-cpu`, `sentence-transformers` | Optional | Very Good | Fast | In-memory |
|
||||
| **ColBERTv2** | `colbert` | Late interaction | `colbert-ai`, `torch` | Optional | Excellent | Slower | In-memory |
|
||||
| **BM25** | `bm25` | Term-frequency | `rank-bm25` | No | Good | Fast | In-memory |
|
||||
| **Hybrid** | `hybrid` | RRF fusion | Depends on sub-backends | Depends | Best | Moderate | Depends |
|
||||
|
||||
### SQLite/FTS5 (Default)
|
||||
|
||||
The zero-dependency default backend. Uses SQLite's built-in FTS5 extension for full-text search with BM25 ranking.
|
||||
|
||||
- **Storage:** Documents stored in a `documents` table with automatic FTS5 indexing via triggers
|
||||
- **Search:** FTS5 `MATCH` queries with BM25 ranking (more negative rank = better match, converted to positive scores)
|
||||
- **Query escaping:** Each word is quoted to avoid FTS5 syntax errors
|
||||
- **Persistence:** Data persists across restarts in `~/.openjarvis/memory.db`
|
||||
|
||||
### FAISS
|
||||
|
||||
Dense retrieval using Facebook AI Similarity Search. Documents are embedded into vector space and searched via cosine similarity.
|
||||
|
||||
- **Index type:** `IndexFlatIP` (inner-product, equivalent to cosine similarity when vectors are L2-normalized)
|
||||
- **Embedding model:** `all-MiniLM-L6-v2` by default (384-dim, ~22 MB)
|
||||
- **Deletion:** Soft-delete (documents are marked as deleted but remain in the index)
|
||||
- **Persistence:** In-memory only -- data is lost on restart
|
||||
|
||||
### ColBERTv2
|
||||
|
||||
Late interaction retrieval using token-level embeddings with MaxSim scoring. Provides the highest retrieval quality at the cost of higher latency.
|
||||
|
||||
- **Scoring:** For each query token, finds the maximum cosine similarity across all document tokens, then sums across query tokens
|
||||
- **Checkpoint:** `colbert-ir/colbertv2.0` (lazily loaded on first use)
|
||||
- **Persistence:** In-memory only
|
||||
|
||||
!!! warning "Heavy dependencies"
|
||||
ColBERTv2 requires `colbert-ai` and `torch`, which are large packages. Install with:
|
||||
`uv sync --extra memory-colbert`
|
||||
|
||||
### BM25
|
||||
|
||||
Classic Okapi BM25 probabilistic ranking function using the `rank_bm25` library.
|
||||
|
||||
- **Tokenization:** Lowercase whitespace split
|
||||
- **Index:** Rebuilt on every `store()` and `delete()` operation
|
||||
- **Filtering:** Results are filtered to require at least one shared token with the query (handles edge cases where BM25 assigns IDF=0)
|
||||
- **Persistence:** In-memory only
|
||||
|
||||
### Hybrid (RRF Fusion)
|
||||
|
||||
Combines a sparse retriever and a dense retriever using Reciprocal Rank Fusion:
|
||||
|
||||
$$\text{RRF}(d) = \sum_{i} \frac{w_i}{k + \text{rank}_i(d)}$$
|
||||
|
||||
- **Sub-backends:** Any two `MemoryBackend` implementations (e.g., SQLite + FAISS)
|
||||
- **Over-fetch:** Retrieves `top_k * 3` results from each sub-backend for better fusion
|
||||
- **Configurable:** RRF constant `k` (default 60) and per-backend weights
|
||||
|
||||
```python
|
||||
from openjarvis.tools.storage.sqlite import SQLiteMemory
|
||||
from openjarvis.tools.storage.faiss_backend import FAISSMemory
|
||||
from openjarvis.tools.storage.hybrid import HybridMemory
|
||||
|
||||
hybrid = HybridMemory(
|
||||
sparse=SQLiteMemory(db_path="memory.db"),
|
||||
dense=FAISSMemory(),
|
||||
sparse_weight=1.0,
|
||||
dense_weight=1.5, # Weight dense retrieval more heavily
|
||||
)
|
||||
```
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old imports (e.g., `from openjarvis.memory.sqlite import SQLiteMemory`) still work via backward-compatibility shims in the `memory/` package, but the canonical location is now `openjarvis.tools.storage.*`.
|
||||
|
||||
---
|
||||
|
||||
## Chunking Pipeline
|
||||
|
||||
Large documents are split into manageable chunks before storage. The chunking pipeline is defined in `tools/storage/chunking.py` (previously `memory/chunking.py`).
|
||||
|
||||
### ChunkConfig
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class ChunkConfig:
|
||||
chunk_size: int = 512 # Maximum tokens per chunk (whitespace-split)
|
||||
chunk_overlap: int = 64 # Tokens to overlap between consecutive chunks
|
||||
min_chunk_size: int = 50 # Minimum tokens for a chunk to be kept
|
||||
```
|
||||
|
||||
### Chunk
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class Chunk:
|
||||
content: str # The chunk text
|
||||
source: str = "" # Originating file path
|
||||
offset: int = 0 # Token offset within the original document
|
||||
index: int = 0 # Chunk index (0, 1, 2, ...)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
### Chunking Algorithm
|
||||
|
||||
The `chunk_text()` function splits text using paragraph boundaries:
|
||||
|
||||
1. Split the document on double newlines (`\n\n`) into paragraphs
|
||||
2. Accumulate paragraphs into the current chunk until `chunk_size` is exceeded
|
||||
3. When a chunk is full, flush it and keep the last `chunk_overlap` tokens as overlap for the next chunk
|
||||
4. If a single paragraph exceeds `chunk_size`, split it into fixed-size windows with overlap
|
||||
5. Discard chunks smaller than `min_chunk_size`
|
||||
|
||||
```python
|
||||
from openjarvis.tools.storage.chunking import chunk_text, ChunkConfig
|
||||
|
||||
config = ChunkConfig(chunk_size=256, chunk_overlap=32)
|
||||
chunks = chunk_text(document_text, source="docs/guide.md", config=config)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Document Ingestion
|
||||
|
||||
The `tools/storage/ingest.py` module (previously `memory/ingest.py`) handles reading files and directories into chunks.
|
||||
|
||||
### File Type Detection
|
||||
|
||||
| Extension | Detected Type |
|
||||
|-----------|--------------|
|
||||
| `.md`, `.markdown`, `.mdx` | `markdown` |
|
||||
| `.pdf` | `pdf` |
|
||||
| `.py`, `.js`, `.ts`, `.rs`, `.go`, `.java`, `.c`, `.cpp`, `.yaml`, `.json`, `.html`, `.css`, ... | `code` |
|
||||
| Everything else | `text` |
|
||||
|
||||
### `ingest_path(path, config=None)`
|
||||
|
||||
Ingests a file or directory into chunks:
|
||||
|
||||
- **Single file:** Reads the file, detects its type, and chunks the content
|
||||
- **Directory:** Recursively walks the tree, skipping:
|
||||
- Hidden directories (starting with `.`)
|
||||
- Common non-content directories (`__pycache__`, `node_modules`, `.git`, `.venv`, etc.)
|
||||
- Binary files (images, audio, video, archives, compiled files)
|
||||
- Hidden files (starting with `.`)
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from openjarvis.tools.storage.ingest import ingest_path
|
||||
|
||||
# Ingest a single file
|
||||
chunks = ingest_path(Path("docs/guide.md"))
|
||||
|
||||
# Ingest an entire directory
|
||||
chunks = ingest_path(Path("./docs/"))
|
||||
```
|
||||
|
||||
### PDF Support
|
||||
|
||||
PDF files are read using `pdfplumber`, extracting text from each page and joining with double newlines. This requires the optional `pdfplumber` dependency:
|
||||
|
||||
```bash
|
||||
uv sync --extra memory-pdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Embeddings
|
||||
|
||||
Dense retrieval backends (FAISS, ColBERT) require text embeddings. The `tools/storage/embeddings.py` module (previously `memory/embeddings.py`) provides the `Embedder` ABC and a default implementation.
|
||||
|
||||
### Embedder ABC
|
||||
|
||||
```python
|
||||
class Embedder(ABC):
|
||||
@abstractmethod
|
||||
def embed(self, texts: list[str]) -> Any:
|
||||
"""Embed texts and return a numpy array of shape (n, dim)."""
|
||||
|
||||
@abstractmethod
|
||||
def dim(self) -> int:
|
||||
"""Return the dimensionality of the embedding vectors."""
|
||||
```
|
||||
|
||||
### SentenceTransformerEmbedder
|
||||
|
||||
The default embedder wraps the `sentence-transformers` library:
|
||||
|
||||
- **Default model:** `all-MiniLM-L6-v2` (384 dimensions, ~22 MB)
|
||||
- **Output:** NumPy arrays of shape `(n, dim)`
|
||||
|
||||
```python
|
||||
from openjarvis.tools.storage.embeddings import SentenceTransformerEmbedder
|
||||
|
||||
embedder = SentenceTransformerEmbedder(model_name="all-MiniLM-L6-v2")
|
||||
vectors = embedder.embed(["Hello world", "How are you?"])
|
||||
# Shape: (2, 384)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Injection
|
||||
|
||||
The context injection pipeline retrieves relevant documents and prepends them to the prompt with source attribution. This is defined in `tools/storage/context.py` (previously `memory/context.py`).
|
||||
|
||||
### ContextConfig
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class ContextConfig:
|
||||
enabled: bool = True # Whether context injection is active
|
||||
top_k: int = 5 # Maximum results to retrieve
|
||||
min_score: float = 0.1 # Minimum relevance score threshold
|
||||
max_context_tokens: int = 2048 # Maximum tokens of context to inject
|
||||
```
|
||||
|
||||
### `inject_context()`
|
||||
|
||||
The main function for context injection:
|
||||
|
||||
```python
|
||||
def inject_context(
|
||||
query: str,
|
||||
messages: List[Message],
|
||||
backend: MemoryBackend,
|
||||
*,
|
||||
config: Optional[ContextConfig] = None,
|
||||
) -> List[Message]:
|
||||
```
|
||||
|
||||
How it works:
|
||||
|
||||
1. Retrieves results from the memory backend using the query
|
||||
2. Filters results below `min_score`
|
||||
3. Truncates to `max_context_tokens` (approximate token count via whitespace split)
|
||||
4. Formats results with source attribution tags: `[Source: docs/guide.md] The content...`
|
||||
5. Creates a system message with the formatted context
|
||||
6. Returns a **new** message list with the context message prepended
|
||||
|
||||
```python
|
||||
from openjarvis.tools.storage.context import inject_context, ContextConfig
|
||||
|
||||
config = ContextConfig(top_k=3, min_score=0.2)
|
||||
messages = inject_context("What is the API?", messages, backend, config=config)
|
||||
```
|
||||
|
||||
### Source Attribution
|
||||
|
||||
Context is injected as a system message with clear source tags:
|
||||
|
||||
```
|
||||
The following context was retrieved from the knowledge base. Use it to
|
||||
inform your response, citing sources where applicable:
|
||||
|
||||
[Source: docs/api.md] The API exposes a /v1/chat/completions endpoint...
|
||||
|
||||
[Source: docs/setup.md] To configure the API server, edit config.toml...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Registration
|
||||
|
||||
Memory backends are registered via the `@MemoryRegistry.register("name")` decorator:
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend
|
||||
|
||||
@MemoryRegistry.register("my-backend")
|
||||
class MyMemoryBackend(MemoryBackend):
|
||||
backend_id = "my-backend"
|
||||
|
||||
def store(self, content, *, source="", metadata=None) -> str: ...
|
||||
def retrieve(self, query, *, top_k=5, **kwargs) -> list: ...
|
||||
def delete(self, doc_id) -> bool: ...
|
||||
def clear(self) -> None: ...
|
||||
```
|
||||
|
||||
The default backend is configured in `~/.openjarvis/config.toml`. Storage settings live under `[tools.storage]`, and context injection is controlled by `agent.context_from_memory`:
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
context_from_memory = true
|
||||
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
db_path = "~/.openjarvis/memory.db"
|
||||
context_top_k = 5
|
||||
context_min_score = 0.1
|
||||
context_max_tokens = 2048
|
||||
chunk_size = 512
|
||||
chunk_overlap = 64
|
||||
```
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The `[memory]` TOML section is still accepted as a backward-compatible alias for `[tools.storage]`. The old `context_injection` field is automatically migrated to `agent.context_from_memory` at load time.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Architecture Overview
|
||||
|
||||
OpenJarvis is a research framework for studying on-device AI systems. Its architecture is organized around **five core abstractions** -- Intelligence, Engine, Agentic Logic, Memory, and Learning -- that work together through trace-driven feedback.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Primitive Descriptions
|
||||
|
||||
### Intelligence
|
||||
|
||||
The Intelligence primitive handles **model definition and catalog**. It maintains a catalog of known models (`BUILTIN_MODELS`) with metadata such as parameter count, context length, VRAM requirements, and supported engines. The `IntelligenceConfig` captures the full identity of the configured model — its weight path, quantization format, preferred engine, fallback chain, and generation defaults (`temperature`, `max_tokens`, `top_p`, `top_k`, `repetition_penalty`, `stop_sequences`).
|
||||
|
||||
Models discovered at runtime from running engines are automatically merged into the `ModelRegistry`, so the system always has an up-to-date view of what is available. Query routing has moved to the Learning primitive — see the [Learning & Traces](learning.md) documentation.
|
||||
|
||||
### Engine
|
||||
|
||||
The Engine primitive provides the **inference runtime** — the layer that actually runs language models. All backends implement the `InferenceEngine` ABC with a uniform interface: `generate()`, `stream()`, `list_models()`, and `health()`. Supported backends include Ollama, vLLM, SGLang, llama.cpp, and Cloud (OpenAI, Anthropic, Google).
|
||||
|
||||
Each engine is configured via its own sub-section in `config.toml` (e.g., `[engine.ollama]`, `[engine.vllm]`, `[engine.llamacpp]`). Engine discovery probes all registered backends for health, returning healthy engines sorted with the user's configured default first. The system automatically falls back to any available engine if the preferred one is unavailable.
|
||||
|
||||
### Agentic Logic
|
||||
|
||||
The Agentic Logic primitive implements **pluggable agents** that handle queries with varying levels of sophistication. The agent hierarchy is organized around `BaseAgent` (ABC with concrete helpers) and `ToolUsingAgent` (intermediate base for agents that accept tools, with `accepts_tools = True`). Nine agent types are available: `SimpleAgent` (single-turn, no tools), `OrchestratorAgent` (multi-turn tool-calling loop with function_calling and structured modes), `NativeReActAgent` (Thought-Action-Observation loop), `NativeOpenHandsAgent` (CodeAct-style code execution), `RLMAgent` (recursive LM with persistent REPL), `OpenHandsAgent` (wraps real `openhands-sdk`), `ClaudeCodeAgent` (Claude Agent SDK via Node.js subprocess), `OperativeAgent` (persistent scheduled agent with state management), and `MonitorOperativeAgent` (long-horizon agent with configurable strategy axes).
|
||||
|
||||
The sandbox module (`openjarvis.sandbox`) adds a `SandboxedAgent` wrapper that runs any `BaseAgent` inside a Docker or Podman container with mount-security enforcement, and a `ContainerRunner` that manages the container lifecycle.
|
||||
|
||||
Agent behavior is configured through `[agent]` in `config.toml`, including the default agent, turn limits, tool list, optional system prompt, and the `context_from_memory` flag (previously `context_injection`) that controls automatic memory context injection. Sandbox configuration lives in `[sandbox]`. All agents implement the `BaseAgent` ABC with a `run()` method, and are registered via `@AgentRegistry.register("name")`.
|
||||
|
||||
### Memory
|
||||
|
||||
The Memory primitive provides **persistent, searchable storage** for documents and knowledge. Five backends are available: SQLite/FTS5 (zero-dependency default), FAISS (dense vector retrieval), ColBERTv2 (late interaction), BM25 (classic term-frequency), and Hybrid (Reciprocal Rank Fusion of sparse + dense). Storage backends are configured under `[tools.storage]` in `config.toml` (the `[memory]` section is still accepted as a backward-compatible alias).
|
||||
|
||||
The memory pipeline includes document ingestion, chunking, embedding generation, and context injection. When a user sends a query and `agent.context_from_memory` is enabled, relevant documents are retrieved and prepended to the prompt with source attribution.
|
||||
|
||||
### Learning & Traces
|
||||
|
||||
The Learning system is the fifth primitive, connecting the other four through **trace-driven feedback**. Every agent interaction can produce a `Trace` capturing the full sequence of steps — routing decisions, memory retrieval, inference calls, tool invocations, and final responses. The `TraceAnalyzer` computes statistics from accumulated traces, and the `TraceDrivenPolicy` uses these statistics to learn which model/agent/tool combinations produce the best outcomes for different query types.
|
||||
|
||||
The learning system is configured through nested sub-sections in `config.toml`: `[learning.routing]` controls the router policy (heuristic, learned, sft, grpo), `[learning.intelligence]` controls the model-level learning policy, `[learning.agent]` controls agent advisor and ICL updater policies, and `[learning.metrics]` sets the composite reward function weights.
|
||||
|
||||
---
|
||||
|
||||
## The Registry Pattern
|
||||
|
||||
All extensible components in OpenJarvis use a **decorator-based registry** for runtime discovery. The pattern is implemented in `RegistryBase[T]`, a generic base class that provides isolated storage per typed subclass.
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
|
||||
@EngineRegistry.register("ollama")
|
||||
class OllamaEngine(InferenceEngine):
|
||||
...
|
||||
```
|
||||
|
||||
Each registry provides:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `register(key)` | Decorator that registers a class under a key |
|
||||
| `register_value(key, value)` | Imperative registration |
|
||||
| `get(key)` | Retrieve by key (raises `KeyError` if missing) |
|
||||
| `create(key, *args, **kwargs)` | Look up and instantiate |
|
||||
| `items()` | All `(key, entry)` pairs |
|
||||
| `keys()` | All registered keys |
|
||||
| `contains(key)` | Check if key exists |
|
||||
| `clear()` | Remove all entries (for tests) |
|
||||
|
||||
**Typed registries** in the system:
|
||||
|
||||
| Registry | Type Parameter | Purpose |
|
||||
|----------|---------------|---------|
|
||||
| `ModelRegistry` | `Any` (ModelSpec) | Model metadata |
|
||||
| `EngineRegistry` | `Type[InferenceEngine]` | Inference backends |
|
||||
| `MemoryRegistry` | `Type[MemoryBackend]` | Memory backends |
|
||||
| `AgentRegistry` | `Type[BaseAgent]` | Agent implementations |
|
||||
| `ToolRegistry` | `Any` (BaseTool classes) | Tool implementations |
|
||||
| `RouterPolicyRegistry` | `Any` (RouterPolicy classes) | Router policies |
|
||||
| `BenchmarkRegistry` | `Any` (BaseBenchmark classes) | Benchmark implementations |
|
||||
| `ChannelRegistry` | `Any` (BaseChannel classes) | Channel implementations |
|
||||
|
||||
!!! info "Adding a new component"
|
||||
To add a new backend, implement the appropriate ABC and decorate it with
|
||||
the corresponding registry decorator. No factory modifications are needed --
|
||||
the component becomes automatically discoverable at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Source Directory Layout
|
||||
|
||||
```
|
||||
src/openjarvis/
|
||||
core/ Core infrastructure shared by all primitives
|
||||
registry.py RegistryBase[T] and typed subclass registries
|
||||
types.py Message, ModelSpec, Trace, TelemetryRecord, etc.
|
||||
config.py JarvisConfig, hardware detection, TOML loading
|
||||
events.py EventBus pub/sub system (EventType, Event)
|
||||
|
||||
intelligence/ Intelligence primitive -- model definition & catalog
|
||||
model_catalog.py BUILTIN_MODELS list, merge_discovered_models()
|
||||
_stubs.py (backward-compat shim -- re-exports from learning._stubs)
|
||||
router.py (backward-compat shim -- re-exports from learning.router)
|
||||
|
||||
engine/ Engine primitive -- inference runtime backends
|
||||
_stubs.py InferenceEngine ABC
|
||||
_base.py EngineConnectionError, messages_to_dicts()
|
||||
_openai_compat.py Shared base for OpenAI-compatible engines
|
||||
_discovery.py discover_engines(), discover_models(), get_engine()
|
||||
ollama.py Ollama backend (native HTTP API)
|
||||
openai_compat_engines.py Data-driven registration (vLLM, SGLang, llama.cpp, MLX, LM Studio)
|
||||
cloud.py Cloud backend (OpenAI, Anthropic, Google SDKs)
|
||||
|
||||
agents/ Agentic Logic primitive -- pluggable agents
|
||||
_stubs.py BaseAgent ABC, ToolUsingAgent, AgentContext, AgentResult
|
||||
simple.py SimpleAgent (single-turn, no tools)
|
||||
orchestrator.py OrchestratorAgent (multi-turn tool loop, function_calling + structured)
|
||||
native_react.py NativeReActAgent (Thought-Action-Observation loop)
|
||||
native_openhands.py NativeOpenHandsAgent (CodeAct-style code execution)
|
||||
rlm.py RLMAgent (recursive LM with persistent REPL)
|
||||
openhands.py OpenHandsAgent (wraps real openhands-sdk)
|
||||
react.py Backward-compat shim (re-exports NativeReActAgent as ReActAgent)
|
||||
claude_code.py ClaudeCodeAgent (Claude Agent SDK via Node.js subprocess)
|
||||
claude_code_runner/ Bundled Node.js runner for the Claude Agent SDK
|
||||
|
||||
sandbox/ Container sandbox for isolated agent execution
|
||||
runner.py ContainerRunner (Docker/Podman lifecycle), SandboxedAgent wrapper
|
||||
mount_security.py MountAllowlist, validate_mounts() (path security)
|
||||
|
||||
memory/ Memory primitive -- persistent searchable storage
|
||||
_stubs.py MemoryBackend ABC, RetrievalResult
|
||||
sqlite.py SQLite/FTS5 backend (zero-dependency default)
|
||||
faiss_backend.py FAISS dense retrieval backend
|
||||
colbert_backend.py ColBERTv2 late interaction backend
|
||||
bm25.py BM25 (Okapi) term-frequency backend
|
||||
hybrid.py Hybrid RRF fusion backend
|
||||
chunking.py ChunkConfig, Chunk, chunk_text()
|
||||
ingest.py Document ingestion (file reading, directory walking)
|
||||
context.py Context injection (inject_context, source attribution)
|
||||
embeddings.py Embedder ABC, SentenceTransformerEmbedder
|
||||
|
||||
learning/ Learning system -- router policies & rewards
|
||||
_stubs.py RouterPolicy ABC, QueryAnalyzer ABC, RewardFunction ABC, RoutingContext
|
||||
router.py HeuristicRouter, DefaultQueryAnalyzer, build_routing_context()
|
||||
heuristic_policy.py Wires HeuristicRouter into RouterPolicyRegistry
|
||||
trace_policy.py TraceDrivenPolicy (learns from trace outcomes)
|
||||
grpo_policy.py GRPORouterPolicy (stub for future RL)
|
||||
heuristic_reward.py HeuristicRewardFunction (latency/cost/efficiency)
|
||||
|
||||
traces/ Trace system -- interaction recording
|
||||
store.py TraceStore (SQLite persistence)
|
||||
collector.py TraceCollector (wraps agents, records traces)
|
||||
analyzer.py TraceAnalyzer (aggregated statistics)
|
||||
|
||||
tools/ Tool system -- pluggable tool implementations
|
||||
_stubs.py BaseTool ABC, ToolSpec, ToolExecutor
|
||||
calculator.py CalculatorTool (ast-based safe eval)
|
||||
think.py ThinkTool (reasoning scratchpad)
|
||||
retrieval.py RetrievalTool (memory search)
|
||||
llm.py LLMTool (sub-model calls)
|
||||
file_read.py FileReadTool (safe file reading)
|
||||
|
||||
telemetry/ Telemetry -- inference metrics recording
|
||||
store.py TelemetryStore (SQLite, EventBus subscription)
|
||||
aggregator.py TelemetryAggregator (per-model/engine stats)
|
||||
wrapper.py instrumented_generate() wrapper
|
||||
|
||||
server/ API server -- OpenAI-compatible HTTP API
|
||||
app.py FastAPI application factory
|
||||
routes.py /v1/chat/completions, /v1/models, /health
|
||||
|
||||
bench/ Benchmarking framework
|
||||
_stubs.py BaseBenchmark ABC, BenchmarkSuite
|
||||
latency.py LatencyBenchmark (per-call latency)
|
||||
throughput.py ThroughputBenchmark (tokens/second)
|
||||
|
||||
security/ Security guardrails
|
||||
_stubs.py BaseScanner ABC
|
||||
types.py ThreatLevel, RedactionMode, ScanFinding, ScanResult
|
||||
scanner.py SecretScanner, PIIScanner
|
||||
guardrails.py GuardrailsEngine (wraps InferenceEngine)
|
||||
file_policy.py is_sensitive_file(), DEFAULT_SENSITIVE_PATTERNS
|
||||
audit.py AuditLogger (SQLite security events)
|
||||
|
||||
channels/ Channel messaging
|
||||
_stubs.py BaseChannel ABC, ChannelMessage, ChannelStatus
|
||||
whatsapp_baileys.py WhatsAppBaileysChannel (Baileys protocol via Node.js bridge)
|
||||
whatsapp_baileys_bridge/ Bundled Node.js Baileys bridge
|
||||
|
||||
scheduler/ Task scheduling system
|
||||
scheduler.py TaskScheduler (cron/interval/once, background polling)
|
||||
store.py SchedulerStore (SQLite persistence + run logs)
|
||||
tools.py MCP scheduler tools (schedule_task, list, pause, resume, cancel)
|
||||
|
||||
cli/ CLI commands (Click-based)
|
||||
ask.py jarvis ask -- query the assistant
|
||||
serve.py jarvis serve -- start API server
|
||||
|
||||
sdk.py Jarvis class -- high-level Python SDK
|
||||
mcp/ MCP (Model Context Protocol) layer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How the Primitives Interact
|
||||
|
||||
### EventBus: The Connective Tissue
|
||||
|
||||
All primitives communicate through a **thread-safe pub/sub EventBus** defined in `core/events.py`. The bus uses synchronous dispatch -- subscribers are called in registration order within the publishing thread.
|
||||
|
||||
**Event types** in the system:
|
||||
|
||||
| Event | Publisher | Purpose |
|
||||
|-------|----------|---------|
|
||||
| `INFERENCE_START` / `INFERENCE_END` | Engine / Agent | Track inference calls |
|
||||
| `TOOL_CALL_START` / `TOOL_CALL_END` | ToolExecutor | Track tool usage |
|
||||
| `MEMORY_STORE` / `MEMORY_RETRIEVE` | Memory backends | Track memory operations |
|
||||
| `AGENT_TURN_START` / `AGENT_TURN_END` | Agents | Track agent lifecycle |
|
||||
| `TELEMETRY_RECORD` | TelemetryStore | Publish telemetry records |
|
||||
| `TRACE_STEP` / `TRACE_COMPLETE` | TraceCollector | Trace lifecycle events |
|
||||
| `CHANNEL_MESSAGE_RECEIVED` / `CHANNEL_MESSAGE_SENT` | WhatsAppBaileysChannel | Track channel messaging |
|
||||
| `SECURITY_SCAN` / `SECURITY_ALERT` / `SECURITY_BLOCK` | GuardrailsEngine | Track security scanning |
|
||||
| `scheduler_task_start` / `scheduler_task_end` | TaskScheduler | Track scheduled task execution |
|
||||
|
||||
### Dependency Flow
|
||||
|
||||
The primitives form a directed dependency graph:
|
||||
|
||||
1. **Agentic Logic** depends on Engine (for inference) and Memory (for context)
|
||||
2. **Intelligence** provides model selection to agents via Learning policies
|
||||
3. **Learning** reads from Traces, which are produced by Agentic Logic
|
||||
4. **Memory** is independent but consumed by agents and tools
|
||||
5. **Engine** is independent but consumed by agents and the SDK
|
||||
|
||||
This creates a feedback loop: agents produce traces, traces inform learning, learning improves routing, and better routing improves agent performance.
|
||||
@@ -0,0 +1,319 @@
|
||||
# Query Flow
|
||||
|
||||
This page traces the end-to-end journey of a user query through the OpenJarvis system, from the moment it enters the CLI or SDK to the final response and telemetry recording.
|
||||
|
||||
---
|
||||
|
||||
## Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant CLI as CLI / SDK
|
||||
participant CFG as Config & Discovery
|
||||
participant LRN as Learning (Router)
|
||||
participant AGT as Agent
|
||||
participant MEM as Memory Backend
|
||||
participant CTX as Context Injection
|
||||
participant ENG as Inference Engine
|
||||
participant TEL as Telemetry
|
||||
participant TRC as Trace Collector
|
||||
|
||||
User->>CLI: jarvis ask "query" / j.ask("query")
|
||||
CLI->>CFG: load_config()
|
||||
CFG-->>CLI: JarvisConfig (hardware, engine defaults)
|
||||
|
||||
CLI->>CFG: get_engine(config)
|
||||
CFG-->>CLI: (engine_key, engine_instance)
|
||||
|
||||
CLI->>CFG: discover_engines() + discover_models()
|
||||
CFG-->>CLI: available models per engine
|
||||
|
||||
alt Model not specified
|
||||
CLI->>LRN: select_model(RoutingContext)
|
||||
LRN-->>CLI: model_key (e.g., "qwen3:8b")
|
||||
end
|
||||
|
||||
alt Agent mode (--agent flag)
|
||||
CLI->>AGT: agent.run(query, context)
|
||||
AGT->>MEM: retrieve(query, top_k=5)
|
||||
MEM-->>AGT: RetrievalResult[]
|
||||
AGT->>CTX: inject_context(query, messages, backend)
|
||||
CTX-->>AGT: messages with context prepended
|
||||
|
||||
loop Tool-calling loop (max_turns)
|
||||
AGT->>ENG: generate(messages, model, tools)
|
||||
ENG-->>AGT: {content, tool_calls, usage}
|
||||
opt Tool calls present
|
||||
AGT->>AGT: ToolExecutor.execute(tool_call)
|
||||
AGT->>AGT: Append tool results to messages
|
||||
end
|
||||
end
|
||||
|
||||
AGT-->>CLI: AgentResult(content, tool_results, turns)
|
||||
else Direct mode (no agent)
|
||||
CLI->>MEM: retrieve(query)
|
||||
MEM-->>CLI: RetrievalResult[]
|
||||
CLI->>CTX: inject_context(query, messages, backend)
|
||||
CTX-->>CLI: messages with context
|
||||
|
||||
CLI->>ENG: instrumented_generate(messages, model)
|
||||
ENG-->>CLI: {content, usage}
|
||||
end
|
||||
|
||||
CLI->>TEL: TelemetryStore records metrics
|
||||
CLI->>TRC: TraceCollector saves Trace
|
||||
CLI-->>User: Response text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Direct Mode vs Agent Mode
|
||||
|
||||
OpenJarvis supports two query processing paths, selected by the `--agent` CLI flag or the `agent` parameter in the SDK.
|
||||
|
||||
### Direct Mode (Default)
|
||||
|
||||
In direct mode, the query goes straight to the inference engine with optional memory context. This is the simplest path -- one inference call, no tool loop.
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
jarvis ask "What is the capital of France?"
|
||||
|
||||
# SDK
|
||||
j = Jarvis()
|
||||
response = j.ask("What is the capital of France?")
|
||||
```
|
||||
|
||||
### Agent Mode
|
||||
|
||||
In agent mode, the query is handled by a named agent that can perform multiple inference rounds and invoke tools. The `OrchestratorAgent` is the most common choice, enabling a multi-turn tool-calling loop.
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
jarvis ask --agent orchestrator --tools calculator,think "What is 2^10 + 3^5?"
|
||||
|
||||
# SDK
|
||||
response = j.ask("What is 2^10 + 3^5?", agent="orchestrator", tools=["calculator"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Walkthrough
|
||||
|
||||
### Step 1: Configuration Loading
|
||||
|
||||
The journey begins with loading the system configuration:
|
||||
|
||||
```python
|
||||
config = load_config() # Reads ~/.openjarvis/config.toml
|
||||
```
|
||||
|
||||
This step:
|
||||
|
||||
- Detects system hardware (GPU vendor/model, CPU, RAM)
|
||||
- Recommends the best inference engine for the detected hardware
|
||||
- Overlays any user overrides from the TOML file
|
||||
- Returns a `JarvisConfig` dataclass with all settings
|
||||
|
||||
### Step 2: Engine Discovery
|
||||
|
||||
Next, the system finds a running inference engine:
|
||||
|
||||
```python
|
||||
resolved = get_engine(config, engine_key)
|
||||
# Returns (engine_key, engine_instance) or None
|
||||
```
|
||||
|
||||
The discovery process:
|
||||
|
||||
1. If a specific engine was requested (`--engine` flag), try that engine
|
||||
2. Otherwise, try the default engine from config (e.g., `"ollama"`)
|
||||
3. If the default is unhealthy, probe all registered engines and use the first healthy one
|
||||
4. If no engine is available, exit with an error message
|
||||
|
||||
### Step 3: Model Discovery and Registration
|
||||
|
||||
Once an engine is found, the system discovers available models:
|
||||
|
||||
```python
|
||||
register_builtin_models() # Register known models (catalog)
|
||||
all_engines = discover_engines(config)
|
||||
all_models = discover_models(all_engines)
|
||||
for ek, model_ids in all_models.items():
|
||||
merge_discovered_models(ek, model_ids) # Register runtime-discovered models
|
||||
```
|
||||
|
||||
### Step 4: Model Routing
|
||||
|
||||
If no model was explicitly specified, the router policy selects one:
|
||||
|
||||
```python
|
||||
from openjarvis.learning import ensure_registered
|
||||
from openjarvis.learning.router import build_routing_context
|
||||
ensure_registered() # Ensure learning policies are registered
|
||||
|
||||
policy_key = router_policy or config.learning.routing.policy
|
||||
router_cls = RouterPolicyRegistry.get(policy_key)
|
||||
router = router_cls(
|
||||
available_models=all_models.get(engine_name, []),
|
||||
default_model=config.intelligence.default_model,
|
||||
fallback_model=config.intelligence.fallback_model,
|
||||
)
|
||||
|
||||
ctx = build_routing_context(query_text)
|
||||
model_name = router.select_model(ctx)
|
||||
```
|
||||
|
||||
The `build_routing_context()` function (in `learning/router.py`) analyzes the query for code patterns, math keywords, length, and urgency. The router then applies its rules (heuristic or learned) to select the optimal model.
|
||||
|
||||
### Step 5: Memory Context Injection
|
||||
|
||||
If memory context injection is enabled (default: `true`) and the memory backend has indexed documents:
|
||||
|
||||
```python
|
||||
backend = _get_memory_backend(config)
|
||||
if backend is not None:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k, # Default: 5
|
||||
min_score=config.memory.context_min_score, # Default: 0.1
|
||||
max_context_tokens=config.memory.context_max_tokens, # Default: 2048
|
||||
)
|
||||
messages = inject_context(query_text, messages, backend, config=ctx_cfg)
|
||||
```
|
||||
|
||||
This retrieves relevant chunks from the memory backend and prepends a system message with the retrieved context and source attribution.
|
||||
|
||||
!!! tip "Disabling context injection"
|
||||
Use `--no-context` on the CLI or `context=False` in the SDK to skip memory context injection.
|
||||
|
||||
### Step 6: Inference Generation
|
||||
|
||||
**In direct mode**, the query is sent to the engine via the instrumented wrapper:
|
||||
|
||||
```python
|
||||
result = instrumented_generate(
|
||||
engine, messages,
|
||||
model=model_name,
|
||||
bus=bus,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
```
|
||||
|
||||
The `instrumented_generate()` wrapper:
|
||||
|
||||
1. Publishes `INFERENCE_START` on the event bus
|
||||
2. Records the start time
|
||||
3. Calls `engine.generate()`
|
||||
4. Records end time, calculates latency
|
||||
5. Publishes `INFERENCE_END` with timing and token counts
|
||||
6. Publishes `TELEMETRY_RECORD` with the full `TelemetryRecord`
|
||||
|
||||
**In agent mode**, the agent manages inference calls internally, potentially making multiple rounds with tool calls in between.
|
||||
|
||||
### Step 7: Tool Execution (Agent Mode Only)
|
||||
|
||||
When the `OrchestratorAgent` receives tool calls in the model's response:
|
||||
|
||||
1. Each tool call is dispatched to the `ToolExecutor`
|
||||
2. The executor publishes `TOOL_CALL_START`, executes the tool, publishes `TOOL_CALL_END`
|
||||
3. Tool results are appended to the message history as `TOOL` messages
|
||||
4. The updated messages are sent back to the engine for the next round
|
||||
5. This loop continues until the model responds without tool calls or `max_turns` is reached
|
||||
|
||||
### Step 8: Telemetry Recording
|
||||
|
||||
After every inference call, a `TelemetryRecord` is created and persisted:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class TelemetryRecord:
|
||||
timestamp: float
|
||||
model_id: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
latency_seconds: float
|
||||
ttft: float # Time to first token
|
||||
cost_usd: float
|
||||
energy_joules: float
|
||||
power_watts: float
|
||||
engine: str
|
||||
agent: str
|
||||
metadata: Dict[str, Any]
|
||||
```
|
||||
|
||||
The `TelemetryStore` subscribes to `TELEMETRY_RECORD` events on the EventBus and writes records to `~/.openjarvis/telemetry.db`.
|
||||
|
||||
### Step 9: Trace Recording
|
||||
|
||||
When a `TraceCollector` is wrapping the agent, a complete `Trace` is built from the events captured during execution:
|
||||
|
||||
1. All `INFERENCE_START`/`END` events become `GENERATE` steps
|
||||
2. All `TOOL_CALL_START`/`END` events become `TOOL_CALL` steps
|
||||
3. All `MEMORY_RETRIEVE` events become `RETRIEVE` steps
|
||||
4. A final `RESPOND` step captures the output
|
||||
5. The trace is saved to the `TraceStore` and `TRACE_COMPLETE` is published
|
||||
|
||||
### Step 10: Response Delivery
|
||||
|
||||
The final response is delivered to the user:
|
||||
|
||||
- **CLI:** Printed to stdout (or as JSON with `--json`)
|
||||
- **SDK:** Returned as a string from `ask()` or as a dict from `ask_full()`
|
||||
|
||||
---
|
||||
|
||||
## EventBus Activity During a Query
|
||||
|
||||
The following events are published during a typical query in agent mode:
|
||||
|
||||
```
|
||||
AGENT_TURN_START {agent: "orchestrator", input: "What is 2+2?"}
|
||||
INFERENCE_START {model: "qwen3:8b", engine: "ollama", turn: 1}
|
||||
INFERENCE_END {model: "qwen3:8b", engine: "ollama", turn: 1}
|
||||
TELEMETRY_RECORD {model_id: "qwen3:8b", latency: 0.8, tokens: 150}
|
||||
TOOL_CALL_START {tool: "calculator", arguments: {expression: "2+2"}}
|
||||
TOOL_CALL_END {tool: "calculator", success: true, latency: 0.01}
|
||||
INFERENCE_START {model: "qwen3:8b", engine: "ollama", turn: 2}
|
||||
INFERENCE_END {model: "qwen3:8b", engine: "ollama", turn: 2}
|
||||
TELEMETRY_RECORD {model_id: "qwen3:8b", latency: 0.5, tokens: 80}
|
||||
AGENT_TURN_END {agent: "orchestrator", turns: 2, content_length: 12}
|
||||
TRACE_COMPLETE {trace: Trace(...)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SDK Query Flow
|
||||
|
||||
The `Jarvis` class in `sdk.py` provides the same query flow through a Python API:
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis(model="qwen3:8b", engine_key="ollama")
|
||||
|
||||
# Direct mode
|
||||
response = j.ask("Hello")
|
||||
|
||||
# Agent mode with tools
|
||||
response = j.ask(
|
||||
"What is 2^10?",
|
||||
agent="orchestrator",
|
||||
tools=["calculator"],
|
||||
)
|
||||
|
||||
# Full result with metadata
|
||||
result = j.ask_full("Hello")
|
||||
# {
|
||||
# "content": "Hello! How can I help you?",
|
||||
# "usage": {"prompt_tokens": 10, "completion_tokens": 15, "total_tokens": 25},
|
||||
# "model": "qwen3:8b",
|
||||
# "engine": "ollama",
|
||||
# }
|
||||
|
||||
j.close()
|
||||
```
|
||||
|
||||
The SDK handles lazy engine initialization, telemetry setup, memory context injection, and resource cleanup internally. The `ask()` method delegates to `ask_full()` and extracts just the content string.
|
||||
@@ -0,0 +1,211 @@
|
||||
# Security Architecture
|
||||
|
||||
The security module is a cross-cutting concern that wraps the inference pipeline rather than replacing it. Scanners run on raw text strings independently of any model or agent, and the `GuardrailsEngine` decorator composes them around any `InferenceEngine` backend without changing the engine's public interface.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Composable, not mandatory.** Security scanning is opt-in and composable. You wrap an engine with `GuardrailsEngine`; you do not configure a global interceptor.
|
||||
- **Scanner-agnostic.** The `BaseScanner` ABC defines a two-method interface (`scan`, `redact`). Any scanner can be plugged in, including user-defined ones.
|
||||
- **Fail-safe modes.** The three redaction modes (WARN, REDACT, BLOCK) cover a spectrum from visibility to enforcement, allowing gradual tightening without code changes.
|
||||
- **Audit by default.** The `AuditLogger` records security events to SQLite so that findings are traceable after the fact.
|
||||
|
||||
---
|
||||
|
||||
## Scanner Pipeline
|
||||
|
||||
Each scan pass runs all registered scanners sequentially and merges their findings into a single `ScanResult`. The order of scanner execution does not affect correctness, only which patterns are reported first.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Raw Text] --> B[SecretScanner.scan]
|
||||
A --> C[PIIScanner.scan]
|
||||
B --> D{Merge findings}
|
||||
C --> D
|
||||
D --> E[ScanResult]
|
||||
E --> F{result.clean?}
|
||||
F -- Yes --> G[Return text unchanged]
|
||||
F -- No --> H{RedactionMode}
|
||||
H -- WARN --> I[Publish SECURITY_ALERT\nReturn text unchanged]
|
||||
H -- REDACT --> J[Run redact on all scanners\nReturn sanitized text]
|
||||
H -- BLOCK --> K[Publish SECURITY_BLOCK\nRaise SecurityBlockError]
|
||||
```
|
||||
|
||||
The redaction step in REDACT mode applies each scanner's `redact()` method in sequence. Later scanners see the already-redacted output of earlier ones, so patterns do not interfere.
|
||||
|
||||
---
|
||||
|
||||
## GuardrailsEngine Wrapper Pattern
|
||||
|
||||
`GuardrailsEngine` implements the full `InferenceEngine` ABC and delegates every call to a wrapped engine instance. This means any engine — `OllamaEngine`, `VLLMEngine`, `LlamaCppEngine` — can be made security-aware without modifying the engine itself.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class InferenceEngine {
|
||||
<<abstract>>
|
||||
+generate(messages, model) dict
|
||||
+stream(messages, model) AsyncIterator
|
||||
+list_models() list
|
||||
+health() bool
|
||||
}
|
||||
class OllamaEngine {
|
||||
+generate(...)
|
||||
+stream(...)
|
||||
}
|
||||
class GuardrailsEngine {
|
||||
-_engine InferenceEngine
|
||||
-_scanners list
|
||||
-_mode RedactionMode
|
||||
+generate(messages, model) dict
|
||||
+stream(messages, model) AsyncIterator
|
||||
+list_models() list
|
||||
+health() bool
|
||||
}
|
||||
InferenceEngine <|-- OllamaEngine
|
||||
InferenceEngine <|-- GuardrailsEngine
|
||||
GuardrailsEngine o-- InferenceEngine : wraps
|
||||
```
|
||||
|
||||
Because `GuardrailsEngine` is itself an `InferenceEngine`, it can be nested arbitrarily (for example, wrapped again in an instrumented engine) or passed to any code that accepts an engine.
|
||||
|
||||
### generate() Call Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant G as GuardrailsEngine
|
||||
participant S as Scanners
|
||||
participant E as Wrapped Engine
|
||||
|
||||
C->>G: generate(messages, model)
|
||||
G->>S: scan(message.content) for each message
|
||||
S-->>G: ScanResult
|
||||
alt findings detected
|
||||
G->>G: _handle_findings(text, result, "input")
|
||||
note over G: WARN: publish event, pass through
|
||||
note over G: REDACT: run redact(), replace content
|
||||
note over G: BLOCK: raise SecurityBlockError
|
||||
end
|
||||
G->>E: generate(messages, model)
|
||||
E-->>G: response dict
|
||||
G->>S: scan(response["content"])
|
||||
S-->>G: ScanResult
|
||||
alt findings detected
|
||||
G->>G: _handle_findings(content, result, "output")
|
||||
end
|
||||
G-->>C: response dict (possibly sanitized)
|
||||
```
|
||||
|
||||
### stream() Behavior
|
||||
|
||||
For streaming, the engine yields tokens to the caller in real time. The security layer accumulates the full output and scans it after the stream ends. Because the scan is post-hoc, BLOCK mode cannot prevent delivery of streamed tokens — it only applies to the input side.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Caller
|
||||
participant G as GuardrailsEngine
|
||||
participant E as Wrapped Engine
|
||||
participant S as Scanners
|
||||
|
||||
C->>G: stream(messages, model)
|
||||
G->>S: scan inputs (before streaming)
|
||||
G->>E: stream(messages, model)
|
||||
loop each token
|
||||
E-->>G: token
|
||||
G-->>C: yield token
|
||||
end
|
||||
G->>S: scan(accumulated output)
|
||||
alt findings detected
|
||||
G->>G: publish SECURITY_ALERT (stream_post_hoc)
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Flow
|
||||
|
||||
Security events flow through the `EventBus` using three event types:
|
||||
|
||||
| Event | When Published | Payload Keys |
|
||||
|-------|----------------|--------------|
|
||||
| `SECURITY_SCAN` | (Reserved for future use) | — |
|
||||
| `SECURITY_ALERT` | Findings detected in WARN or REDACT mode | `direction`, `findings`, `mode` |
|
||||
| `SECURITY_BLOCK` | Findings detected in BLOCK mode | `direction`, `findings`, `mode` |
|
||||
|
||||
The `direction` field is either `"input"` or `"output"`. The `findings` value is a list of dicts with keys `pattern`, `threat`, and `description`.
|
||||
|
||||
The `AuditLogger` subscribes to all three event types and writes them to SQLite. This subscription is established at construction time:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[GuardrailsEngine] -->|SECURITY_ALERT| B[EventBus]
|
||||
A -->|SECURITY_BLOCK| B
|
||||
B --> C[AuditLogger._on_event]
|
||||
C --> D[SQLite audit.db]
|
||||
B --> E[Other subscribers\ne.g. logging, alerting]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Policy Integration
|
||||
|
||||
The file policy (`file_policy.py`) operates independently of the scanner pipeline. It answers a single yes/no question: is this file path considered sensitive?
|
||||
|
||||
### Integration Points
|
||||
|
||||
**FileReadTool** calls `is_sensitive_file()` before reading any path. If the path matches a sensitive pattern, the tool returns an error rather than the file contents. This cannot be bypassed at the tool level.
|
||||
|
||||
**Memory ingest path** (`memory/ingest.py`) uses `filter_sensitive_paths()` to remove sensitive files from a directory listing before indexing. Files matching sensitive patterns are silently skipped.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[FileReadTool.execute] --> B{is_sensitive_file?}
|
||||
B -- Yes --> C[Return error: sensitive file blocked]
|
||||
B -- No --> D[Read and return file contents]
|
||||
|
||||
E[memory ingest_path] --> F[glob directory]
|
||||
F --> G[filter_sensitive_paths]
|
||||
G --> H[Index remaining files]
|
||||
```
|
||||
|
||||
The file policy does not publish events or use the event bus. It is a pure function — deterministic, stateless, and side-effect-free.
|
||||
|
||||
---
|
||||
|
||||
## Audit Logging Architecture
|
||||
|
||||
`AuditLogger` maintains a single SQLite table (`security_events`) with the following schema:
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | `INTEGER PRIMARY KEY` | Auto-increment row ID |
|
||||
| `timestamp` | `REAL` | Unix timestamp of the event |
|
||||
| `event_type` | `TEXT` | `SecurityEventType` value string |
|
||||
| `findings_json` | `TEXT` | JSON-encoded list of `ScanFinding` dicts |
|
||||
| `content_preview` | `TEXT` | Short preview of the scanned content |
|
||||
| `action_taken` | `TEXT` | Mode string (`warn`, `redact`, `block`) |
|
||||
|
||||
The database is written in append-only mode. There is no built-in rotation or truncation — manage retention externally by deleting old entries with SQLite tooling or by using a path-per-session audit log.
|
||||
|
||||
The default path is `~/.openjarvis/audit.db`, configurable via `security.audit_log_path` in `config.toml`.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Other Modules
|
||||
|
||||
| Module | How Security Integrates |
|
||||
|--------|------------------------|
|
||||
| Engine | `GuardrailsEngine` wraps any `InferenceEngine` |
|
||||
| Tools | `FileReadTool` calls `is_sensitive_file()` |
|
||||
| Memory | Ingest path calls `filter_sensitive_paths()` |
|
||||
| EventBus | Security events published to `SECURITY_ALERT`, `SECURITY_BLOCK` |
|
||||
| Config | `SecurityConfig` dataclass loaded from `[security]` in `config.toml` |
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [User Guide: Security](../user-guide/security.md) — how to configure and use the security system
|
||||
- [API Reference: Security](../api-reference/openjarvis/security/index.md) — complete class and function signatures
|
||||
- [Architecture: Query Flow](query-flow.md) — where security sits in the overall request lifecycle
|
||||
|
After Width: | Height: | Size: 235 KiB |
@@ -0,0 +1,510 @@
|
||||
# API Server
|
||||
|
||||
OpenJarvis includes an OpenAI-compatible API server built on FastAPI and uvicorn. It exposes chat completion, model listing, and health check endpoints, making it a drop-in replacement for the OpenAI API when working with local models.
|
||||
|
||||
## Starting the Server
|
||||
|
||||
The server requires the `[server]` extra (FastAPI + uvicorn):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
```
|
||||
|
||||
Start with default settings:
|
||||
|
||||
```bash
|
||||
jarvis serve
|
||||
```
|
||||
|
||||
The server reads defaults from `~/.openjarvis/config.toml` and auto-detects available engines and models. Override any option via CLI flags:
|
||||
|
||||
```bash
|
||||
jarvis serve --host 0.0.0.0 --port 8000 --engine ollama --model qwen3:8b --agent orchestrator
|
||||
```
|
||||
|
||||
### CLI Options
|
||||
|
||||
| Option | Description | Default |
|
||||
|----------------------|------------------------------------------------------------------------------|--------------------|
|
||||
| `--host` | Network address to bind to | From config (`0.0.0.0`) |
|
||||
| `--port` | Port number to listen on | From config (`8000`) |
|
||||
| `-e` / `--engine` | Inference engine backend (`ollama`, `vllm`, `llamacpp`, `sglang`) | Auto-detected |
|
||||
| `-m` / `--model` | Default model for completions | First available |
|
||||
| `-a` / `--agent` | Agent for non-streaming requests (`simple`, `orchestrator`, `react`, `openhands`) | From config (`orchestrator`) |
|
||||
|
||||
On startup, the server prints a summary:
|
||||
|
||||
```
|
||||
Starting OpenJarvis API server
|
||||
Engine: ollama
|
||||
Model: qwen3:8b
|
||||
Agent: orchestrator
|
||||
URL: http://0.0.0.0:8000
|
||||
```
|
||||
|
||||
!!! warning "Server dependency check"
|
||||
If the `[server]` extra is not installed, `jarvis serve` exits with a clear error message explaining how to install the required dependencies.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `POST /v1/chat/completions`
|
||||
|
||||
The primary endpoint for generating chat completions. Accepts the same request format as the OpenAI Chat Completions API.
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "qwen3:8b",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is the capital of France?"}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1024,
|
||||
"stream": false,
|
||||
"tools": null
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------|-------------------|---------|--------------------------------------------------------------|
|
||||
| `model` | `string` | -- | **Required.** Model identifier to use for generation. |
|
||||
| `messages` | `array` | -- | **Required.** Array of message objects with `role` and `content`. |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature (0.0 to 2.0). |
|
||||
| `max_tokens` | `integer` | `1024` | Maximum number of tokens to generate. |
|
||||
| `stream` | `boolean` | `false` | Whether to stream the response via SSE. |
|
||||
| `tools` | `array` or `null` | `null` | Tool definitions in OpenAI function-calling format. |
|
||||
|
||||
Each message object:
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|-------------------|-------------------------------------------------------|
|
||||
| `role` | `string` | One of `system`, `user`, `assistant`, or `tool`. |
|
||||
| `content` | `string` | The message content. |
|
||||
| `name` | `string` or `null`| Optional name for the message author. |
|
||||
| `tool_calls` | `array` or `null` | Tool calls made by the assistant (in assistant messages). |
|
||||
| `tool_call_id` | `string` or `null`| ID of the tool call this message responds to (in tool messages). |
|
||||
|
||||
#### Response (Non-Streaming)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-abc123def456",
|
||||
"object": "chat.completion",
|
||||
"created": 1740100800,
|
||||
"model": "qwen3:8b",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "The capital of France is Paris.",
|
||||
"tool_calls": null
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 25,
|
||||
"completion_tokens": 8,
|
||||
"total_tokens": 33
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When an agent is configured on the server, non-streaming requests are routed through the agent, which can perform multi-turn reasoning with tool calls before returning a final response. When no agent is configured, requests go directly to the inference engine.
|
||||
|
||||
#### Tool Calls
|
||||
|
||||
When `tools` are provided in the request, the engine may return `tool_calls` in the assistant message:
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": "{\"expression\": \"2 + 2\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /v1/models`
|
||||
|
||||
Lists all models available on the configured inference engine.
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "qwen3:8b",
|
||||
"object": "model",
|
||||
"created": 1740100800,
|
||||
"owned_by": "openjarvis"
|
||||
},
|
||||
{
|
||||
"id": "llama3.1:8b",
|
||||
"object": "model",
|
||||
"created": 1740100800,
|
||||
"owned_by": "openjarvis"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Health check endpoint that verifies the inference engine is responsive.
|
||||
|
||||
#### Response (Healthy)
|
||||
|
||||
HTTP 200:
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
#### Response (Unhealthy)
|
||||
|
||||
HTTP 503:
|
||||
|
||||
```json
|
||||
{"detail": "Engine unhealthy"}
|
||||
```
|
||||
|
||||
### `GET /dashboard`
|
||||
|
||||
Serves the built-in Savings Dashboard, an HTML page that displays real-time statistics on inference calls served locally and estimated cost savings compared to cloud API providers. The dashboard auto-refreshes every 5 seconds by polling the `/v1/savings` endpoint.
|
||||
|
||||
### `GET /v1/channels`
|
||||
|
||||
List registered channel backends and their connection status.
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": ["slack", "discord", "telegram"]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /v1/channels/send`
|
||||
|
||||
Send a message to a specific channel.
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "slack",
|
||||
"message": "Hello from Jarvis!"
|
||||
}
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "sent",
|
||||
"target": "slack"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /v1/channels/status`
|
||||
|
||||
Show connection status for all configured channels.
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"slack": "connected",
|
||||
"discord": "connected",
|
||||
"telegram": "disconnected"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
!!! note "Channel endpoints"
|
||||
Channel endpoints require `[channel] enabled = true` in your config and platform-specific credentials configured in `[channel.<platform>]` sub-sections. When not configured, `GET /v1/channels` returns an empty list and other channel endpoints return 503.
|
||||
|
||||
## Streaming via SSE
|
||||
|
||||
When `"stream": true` is set in the request, the server returns a `text/event-stream` response using Server-Sent Events (SSE). The response follows the same format as the OpenAI streaming API.
|
||||
|
||||
Each event is a `data:` line containing a JSON chunk, followed by a blank line:
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1740100800,"model":"qwen3:8b","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1740100800,"model":"qwen3:8b","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1740100800,"model":"qwen3:8b","choices":[{"index":0,"delta":{"content":" capital"},"finish_reason":null}]}
|
||||
|
||||
...
|
||||
|
||||
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1740100800,"model":"qwen3:8b","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
The stream follows this sequence:
|
||||
|
||||
1. **Role chunk** -- first chunk contains `"delta": {"role": "assistant"}` with no content.
|
||||
2. **Content chunks** -- subsequent chunks each contain a `"delta": {"content": "..."}` with one or more tokens.
|
||||
3. **Finish chunk** -- a chunk with an empty `delta` and `"finish_reason": "stop"`.
|
||||
4. **Done signal** -- the literal string `data: [DONE]` indicates the stream is complete.
|
||||
|
||||
Response headers include `Cache-Control: no-cache` and `Connection: keep-alive` for proper SSE behavior.
|
||||
|
||||
## Client Examples
|
||||
|
||||
=== "curl"
|
||||
|
||||
**Non-streaming request:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "qwen3:8b",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Explain quantum computing in one paragraph."}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 256
|
||||
}'
|
||||
```
|
||||
|
||||
**Streaming request:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-N \
|
||||
-d '{
|
||||
"model": "qwen3:8b",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Write a haiku about programming."}
|
||||
],
|
||||
"stream": true
|
||||
}'
|
||||
```
|
||||
|
||||
**List models:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/models
|
||||
```
|
||||
|
||||
**Health check:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
=== "Python (openai)"
|
||||
|
||||
The OpenAI Python library works as a drop-in client by pointing `base_url` at the local server:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="not-needed", # Required by the library but not validated
|
||||
)
|
||||
|
||||
# Non-streaming
|
||||
response = client.chat.completions.create(
|
||||
model="qwen3:8b",
|
||||
messages=[
|
||||
{"role": "user", "content": "What is the capital of France?"}
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=256,
|
||||
)
|
||||
print(response.choices[0].message.content)
|
||||
|
||||
# Streaming
|
||||
stream = client.chat.completions.create(
|
||||
model="qwen3:8b",
|
||||
messages=[
|
||||
{"role": "user", "content": "Write a short poem about AI."}
|
||||
],
|
||||
stream=True,
|
||||
)
|
||||
for chunk in stream:
|
||||
if chunk.choices[0].delta.content:
|
||||
print(chunk.choices[0].delta.content, end="", flush=True)
|
||||
print()
|
||||
|
||||
# List models
|
||||
models = client.models.list()
|
||||
for model in models.data:
|
||||
print(model.id)
|
||||
```
|
||||
|
||||
=== "Python (httpx)"
|
||||
|
||||
Using `httpx` for direct HTTP requests:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
import json
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
# Non-streaming request
|
||||
response = httpx.post(
|
||||
f"{BASE_URL}/v1/chat/completions",
|
||||
json={
|
||||
"model": "qwen3:8b",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What is the capital of France?"}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 256,
|
||||
},
|
||||
)
|
||||
data = response.json()
|
||||
print(data["choices"][0]["message"]["content"])
|
||||
|
||||
# Streaming request
|
||||
with httpx.stream(
|
||||
"POST",
|
||||
f"{BASE_URL}/v1/chat/completions",
|
||||
json={
|
||||
"model": "qwen3:8b",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Write a haiku about code."}
|
||||
],
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
for line in response.iter_lines():
|
||||
if line.startswith("data: ") and line != "data: [DONE]":
|
||||
chunk = json.loads(line[6:])
|
||||
content = chunk["choices"][0]["delta"].get("content", "")
|
||||
if content:
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
|
||||
# List models
|
||||
response = httpx.get(f"{BASE_URL}/v1/models")
|
||||
for model in response.json()["data"]:
|
||||
print(model["id"])
|
||||
|
||||
# Health check
|
||||
response = httpx.get(f"{BASE_URL}/health")
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## Configuration via `config.toml`
|
||||
|
||||
The `[server]` section of `~/.openjarvis/config.toml` controls default server behavior:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8000
|
||||
agent = "orchestrator"
|
||||
model = ""
|
||||
workers = 1
|
||||
```
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----------|-----------|-----------------|----------------------------------------------------------------------------|
|
||||
| `host` | `string` | `"0.0.0.0"` | Network address to bind to. Use `"127.0.0.1"` for localhost-only access. |
|
||||
| `port` | `integer` | `8000` | Port number. |
|
||||
| `agent` | `string` | `"orchestrator"`| Default agent for non-streaming requests. Set to `""` for direct engine mode. |
|
||||
| `model` | `string` | `""` | Default model name. When empty, falls back to `[intelligence] default_model` or the first model discovered on the engine. |
|
||||
| `workers` | `integer` | `1` | Number of uvicorn workers (for future use). |
|
||||
|
||||
CLI flags override config file values. For example, `jarvis serve --port 9000` overrides the `port` setting in the config file.
|
||||
|
||||
The server also reads from other config sections at startup:
|
||||
|
||||
- **`[engine]`** -- determines which inference backend to connect to and its host URL.
|
||||
- **`[intelligence]`** -- provides the fallback `default_model` when no model is specified.
|
||||
- **`[agent]`** -- supplies `max_turns` for multi-turn agents like `orchestrator`.
|
||||
|
||||
## Running Behind a Reverse Proxy
|
||||
|
||||
For production deployments, run OpenJarvis behind a reverse proxy like Nginx or Caddy for TLS termination, rate limiting, and authentication.
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name jarvis.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/jarvis.pem;
|
||||
ssl_certificate_key /etc/ssl/private/jarvis.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE streaming support
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
!!! important "Disable buffering for SSE"
|
||||
The `proxy_buffering off` directive is critical for streaming responses. Without it, Nginx buffers the SSE chunks and delivers them in batches, defeating the purpose of streaming.
|
||||
|
||||
### Caddy
|
||||
|
||||
```
|
||||
jarvis.example.com {
|
||||
reverse_proxy 127.0.0.1:8000 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `flush_interval -1` setting disables response buffering, which is required for SSE streaming.
|
||||
|
||||
### Bind to Localhost
|
||||
|
||||
When running behind a reverse proxy, bind the server to `127.0.0.1` so it only accepts connections from the proxy:
|
||||
|
||||
```bash
|
||||
jarvis serve --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Or in `config.toml`:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
```
|
||||
@@ -0,0 +1,337 @@
|
||||
# Docker Deployment
|
||||
|
||||
OpenJarvis provides Docker images for both CPU-only and GPU-accelerated deployments, along with a Docker Compose configuration that bundles the API server with an Ollama inference backend.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get OpenJarvis running in Docker is with Docker Compose, which starts both the API server and an Ollama backend:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This brings up two services:
|
||||
|
||||
| Service | Port | Description |
|
||||
|----------|-------|------------------------------------|
|
||||
| `jarvis` | 8000 | OpenJarvis API server |
|
||||
| `ollama` | 11434 | Ollama inference engine |
|
||||
|
||||
Verify the server is running:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
## Docker Images
|
||||
|
||||
### CPU-Only Image (`Dockerfile`)
|
||||
|
||||
The default `Dockerfile` uses a multi-stage build based on `python:3.12-slim` to produce a minimal image.
|
||||
|
||||
**Build stages:**
|
||||
|
||||
1. **Builder stage** -- installs `uv` and the `openjarvis[server]` package (which includes FastAPI, uvicorn, and all server dependencies) from the project source.
|
||||
2. **Runtime stage** -- copies only the installed Python packages and application code from the builder, keeping the final image small.
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ src/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
CMD ["serve", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build it manually:
|
||||
|
||||
```bash
|
||||
docker build -t openjarvis:latest .
|
||||
```
|
||||
|
||||
Run it standalone:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8000:8000 openjarvis:latest
|
||||
```
|
||||
|
||||
### GPU Image (`Dockerfile.gpu`)
|
||||
|
||||
The GPU image is built on `nvidia/cuda:12.4.0-runtime-ubuntu22.04` and includes the CUDA 12.4 runtime libraries, enabling GPU-accelerated inference when paired with a GPU-capable engine like vLLM or SGLang.
|
||||
|
||||
```dockerfile
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 AS builder
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src/ src/
|
||||
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server]"
|
||||
|
||||
FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local /usr/local
|
||||
COPY --from=builder /app /app
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["jarvis"]
|
||||
CMD ["serve", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build the GPU image:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.gpu -t openjarvis:gpu .
|
||||
```
|
||||
|
||||
Run with GPU access (requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)):
|
||||
|
||||
```bash
|
||||
docker run -d --gpus all -p 8000:8000 openjarvis:gpu
|
||||
```
|
||||
|
||||
!!! note "NVIDIA Container Toolkit required"
|
||||
The host machine must have the NVIDIA Container Toolkit installed for `--gpus` to work. See the [NVIDIA installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) for setup instructions.
|
||||
|
||||
## Docker Compose Configuration
|
||||
|
||||
The `docker-compose.yml` defines a complete deployment with the OpenJarvis API server and an Ollama backend:
|
||||
|
||||
```yaml
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
jarvis:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- OPENJARVIS_ENGINE_DEFAULT=ollama
|
||||
- OPENJARVIS_OLLAMA_HOST=http://ollama:11434
|
||||
depends_on:
|
||||
- ollama
|
||||
restart: unless-stopped
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ollama-models:/root/.ollama
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ollama-models:
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The `jarvis` service is configured through environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-------------------------------|---------------------------------------------------------|----------------------------|
|
||||
| `OPENJARVIS_ENGINE_DEFAULT` | Inference engine backend to use | `ollama` |
|
||||
| `OPENJARVIS_OLLAMA_HOST` | URL of the Ollama server (uses Docker service name) | `http://ollama:11434` |
|
||||
|
||||
### Volumes
|
||||
|
||||
The `ollama-models` named volume persists downloaded models across container restarts, so models do not need to be re-pulled after a `docker compose down` / `docker compose up` cycle.
|
||||
|
||||
### Service Dependencies
|
||||
|
||||
The `jarvis` service declares `depends_on: ollama`, ensuring the Ollama container starts before the API server. Both services use `restart: unless-stopped` to automatically recover from crashes.
|
||||
|
||||
## Custom Configuration
|
||||
|
||||
### Mounting a Configuration File
|
||||
|
||||
To use a custom `config.toml`, mount it into the container at the expected path (`~/.openjarvis/config.toml`, which is `/root/.openjarvis/config.toml` in the container):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jarvis:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./my-config.toml:/root/.openjarvis/config.toml:ro
|
||||
environment:
|
||||
- OPENJARVIS_ENGINE_DEFAULT=ollama
|
||||
- OPENJARVIS_OLLAMA_HOST=http://ollama:11434
|
||||
depends_on:
|
||||
- ollama
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### Persisting Data
|
||||
|
||||
To persist telemetry data, memory databases, and trace records across container restarts, mount the entire OpenJarvis data directory:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jarvis:
|
||||
# ... other config ...
|
||||
volumes:
|
||||
- openjarvis-data:/root/.openjarvis
|
||||
|
||||
volumes:
|
||||
ollama-models:
|
||||
openjarvis-data:
|
||||
```
|
||||
|
||||
This preserves:
|
||||
|
||||
- `telemetry.db` -- inference call telemetry records
|
||||
- `memory.db` -- the default SQLite memory backend
|
||||
- `traces.db` -- interaction trace records
|
||||
- `config.toml` -- user configuration
|
||||
|
||||
### Using the GPU Image with Compose
|
||||
|
||||
To use the GPU Dockerfile in your Compose setup, change the `dockerfile` field and add GPU resource reservations:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jarvis:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.gpu
|
||||
ports:
|
||||
- "8000:8000"
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
- OPENJARVIS_ENGINE_DEFAULT=ollama
|
||||
- OPENJARVIS_OLLAMA_HOST=http://ollama:11434
|
||||
depends_on:
|
||||
- ollama
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The API server exposes a `GET /health` endpoint that checks whether the underlying inference engine is responsive:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
A healthy response returns HTTP 200:
|
||||
|
||||
```json
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
An unhealthy engine returns HTTP 503:
|
||||
|
||||
```json
|
||||
{"detail": "Engine unhealthy"}
|
||||
```
|
||||
|
||||
You can integrate this into your Docker Compose healthcheck:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jarvis:
|
||||
# ... other config ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
## Building Custom Images
|
||||
|
||||
### Adding Extra Dependencies
|
||||
|
||||
To include additional engine backends (such as vLLM or ColBERT memory), modify the install command in the Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
RUN pip install --no-cache-dir uv && \
|
||||
uv pip install --system ".[server,inference-vllm,memory-colbert]"
|
||||
```
|
||||
|
||||
### Overriding the Default Command
|
||||
|
||||
The entrypoint is `jarvis` and the default command is `serve --host 0.0.0.0 --port 8000`. Override the command to change server options:
|
||||
|
||||
```bash
|
||||
docker run -d -p 9000:9000 openjarvis:latest \
|
||||
serve --host 0.0.0.0 --port 9000 --engine ollama --model qwen3:8b
|
||||
```
|
||||
|
||||
Or in Docker Compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jarvis:
|
||||
build: .
|
||||
command: ["serve", "--host", "0.0.0.0", "--port", "9000", "--model", "qwen3:8b"]
|
||||
ports:
|
||||
- "9000:9000"
|
||||
```
|
||||
|
||||
### Available CLI Options for `jarvis serve`
|
||||
|
||||
| Option | Description |
|
||||
|----------------------|-----------------------------------------------------|
|
||||
| `--host` | Bind address (default: from config, typically `0.0.0.0`) |
|
||||
| `--port` | Port number (default: from config, typically `8000`) |
|
||||
| `-e` / `--engine` | Engine backend (`ollama`, `vllm`, `llamacpp`, `sglang`) |
|
||||
| `-m` / `--model` | Default model name |
|
||||
| `-a` / `--agent` | Agent for non-streaming requests (`simple`, `orchestrator`, `react`, `openhands`) |
|
||||
|
||||
## Pulling Models
|
||||
|
||||
After starting the Ollama container, you need to pull at least one model before the API server can serve requests:
|
||||
|
||||
```bash
|
||||
docker compose exec ollama ollama pull qwen3:8b
|
||||
```
|
||||
|
||||
Verify models are available through the API:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/models
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: Deployment
|
||||
description: Deploy OpenJarvis in production environments
|
||||
---
|
||||
|
||||
# Deployment
|
||||
|
||||
OpenJarvis supports multiple deployment strategies for different environments
|
||||
and scales.
|
||||
|
||||
## Docker
|
||||
|
||||
The recommended way to deploy OpenJarvis in production. Multi-stage builds
|
||||
with CPU and GPU (NVIDIA CUDA, AMD ROCm) variants.
|
||||
|
||||
[:octicons-arrow-right-24: Docker deployment](docker.md)
|
||||
|
||||
## systemd (Linux)
|
||||
|
||||
Run OpenJarvis as a managed system service on Linux servers.
|
||||
|
||||
[:octicons-arrow-right-24: systemd setup](systemd.md)
|
||||
|
||||
## launchd (macOS)
|
||||
|
||||
Register OpenJarvis as a launch agent on macOS.
|
||||
|
||||
[:octicons-arrow-right-24: launchd setup](launchd.md)
|
||||
|
||||
## API Server
|
||||
|
||||
Run OpenJarvis as an OpenAI-compatible HTTP server via `jarvis serve`.
|
||||
|
||||
[:octicons-arrow-right-24: API server guide](api-server.md)
|
||||
@@ -0,0 +1,251 @@
|
||||
# launchd Service (macOS)
|
||||
|
||||
OpenJarvis includes a launchd property list (plist) for running the API server as a background service on macOS. This provides automatic startup at login, automatic restart if the process exits, and log capture.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing the service, ensure that OpenJarvis is installed and the `jarvis` command is available at `/usr/local/bin/jarvis`. If you installed via `uv` or `pip` with a different prefix, adjust the path in the plist accordingly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git && cd OpenJarvis && uv sync --extra server
|
||||
which jarvis # Verify the installation path
|
||||
```
|
||||
|
||||
Also ensure that an inference engine (such as Ollama) is running and accessible on the machine.
|
||||
|
||||
## Installing the Service
|
||||
|
||||
Copy the plist file to `~/Library/LaunchAgents` and load it:
|
||||
|
||||
```bash
|
||||
cp deploy/launchd/com.openjarvis.plist ~/Library/LaunchAgents/
|
||||
launchctl load ~/Library/LaunchAgents/com.openjarvis.plist
|
||||
```
|
||||
|
||||
The service starts immediately (due to `RunAtLoad`) and will automatically restart at each login.
|
||||
|
||||
Verify it is running:
|
||||
|
||||
```bash
|
||||
launchctl list | grep openjarvis
|
||||
```
|
||||
|
||||
You should see a line with the PID and the label `com.openjarvis`. A `0` in the status column indicates the service is running normally.
|
||||
|
||||
Confirm the server is responding:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
## Plist Reference
|
||||
|
||||
The provided plist file at `deploy/launchd/com.openjarvis.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.openjarvis</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/jarvis</string>
|
||||
<string>serve</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>--port</string>
|
||||
<string>8000</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/openjarvis.stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/openjarvis.stderr.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Key-by-Key Explanation
|
||||
|
||||
| Key | Value | Description |
|
||||
|----------------------|--------------------------------|------------------------------------------------------------------------------------------------------|
|
||||
| `Label` | `com.openjarvis` | Unique identifier for the service. Used with `launchctl` commands to manage the service. |
|
||||
| `ProgramArguments` | `["/usr/local/bin/jarvis", "serve", "--host", "0.0.0.0", "--port", "8000"]` | The command and arguments to execute. Each element of the command line is a separate string in the array. |
|
||||
| `RunAtLoad` | `true` | Start the service immediately when the plist is loaded (and on each login). |
|
||||
| `KeepAlive` | `true` | Automatically restart the service if it exits for any reason. launchd monitors the process and relaunches it. |
|
||||
| `StandardOutPath` | `/tmp/openjarvis.stdout.log` | File where standard output is written. Contains server startup messages and access logs. |
|
||||
| `StandardErrorPath` | `/tmp/openjarvis.stderr.log` | File where standard error is written. Contains error messages and stack traces. |
|
||||
|
||||
## Viewing Logs
|
||||
|
||||
Server output is written to the two log files specified in the plist:
|
||||
|
||||
```bash
|
||||
# View standard output (startup messages, access logs)
|
||||
cat /tmp/openjarvis.stdout.log
|
||||
|
||||
# View standard error (errors, warnings)
|
||||
cat /tmp/openjarvis.stderr.log
|
||||
|
||||
# Follow logs in real time
|
||||
tail -f /tmp/openjarvis.stdout.log /tmp/openjarvis.stderr.log
|
||||
```
|
||||
|
||||
!!! tip "Persistent log location"
|
||||
Files in `/tmp` may be cleared on reboot. For persistent logs, change the paths in the plist to a permanent location:
|
||||
|
||||
```xml
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/yourname/.openjarvis/openjarvis.stdout.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/yourname/.openjarvis/openjarvis.stderr.log</string>
|
||||
```
|
||||
|
||||
After changing the plist, unload and reload the service for the changes to take effect.
|
||||
|
||||
## Managing the Service
|
||||
|
||||
### Loading and Unloading
|
||||
|
||||
```bash
|
||||
# Load the service (starts it due to RunAtLoad)
|
||||
launchctl load ~/Library/LaunchAgents/com.openjarvis.plist
|
||||
|
||||
# Unload the service (stops it and prevents it from starting at login)
|
||||
launchctl unload ~/Library/LaunchAgents/com.openjarvis.plist
|
||||
```
|
||||
|
||||
### Starting and Stopping
|
||||
|
||||
If the service is loaded but you want to manually stop or start it without unloading:
|
||||
|
||||
```bash
|
||||
# Stop the service
|
||||
launchctl stop com.openjarvis
|
||||
|
||||
# Start the service
|
||||
launchctl start com.openjarvis
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Because `KeepAlive` is set to `true`, using `launchctl stop` will cause launchd to restart the service almost immediately. To fully stop the service, use `launchctl unload` instead.
|
||||
|
||||
### Checking Status
|
||||
|
||||
```bash
|
||||
# List all loaded services matching "openjarvis"
|
||||
launchctl list | grep openjarvis
|
||||
```
|
||||
|
||||
The output columns are:
|
||||
|
||||
| Column | Description |
|
||||
|--------|----------------------------------------------------------------|
|
||||
| PID | Process ID (or `-` if not running) |
|
||||
| Status | Last exit status (`0` = normal) |
|
||||
| Label | The service label (`com.openjarvis`) |
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
### Changing the Port or Host
|
||||
|
||||
Edit the `ProgramArguments` array in the plist. Each argument must be a separate `<string>` element:
|
||||
|
||||
```xml
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/jarvis</string>
|
||||
<string>serve</string>
|
||||
<string>--host</string>
|
||||
<string>127.0.0.1</string>
|
||||
<string>--port</string>
|
||||
<string>9000</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Specifying an Engine and Model
|
||||
|
||||
Add additional arguments to the array:
|
||||
|
||||
```xml
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/jarvis</string>
|
||||
<string>serve</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>--port</string>
|
||||
<string>8000</string>
|
||||
<string>--engine</string>
|
||||
<string>ollama</string>
|
||||
<string>--model</string>
|
||||
<string>qwen3:8b</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Setting Environment Variables
|
||||
|
||||
Add an `EnvironmentVariables` dictionary to the plist:
|
||||
|
||||
```xml
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>OPENJARVIS_ENGINE_DEFAULT</key>
|
||||
<string>ollama</string>
|
||||
<key>OPENJARVIS_OLLAMA_HOST</key>
|
||||
<string>http://localhost:11434</string>
|
||||
</dict>
|
||||
```
|
||||
|
||||
### Using a Different `jarvis` Binary Path
|
||||
|
||||
If `jarvis` is installed in a virtual environment or a non-standard location, update the first element of `ProgramArguments`:
|
||||
|
||||
```xml
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/yourname/.local/bin/jarvis</string>
|
||||
<string>serve</string>
|
||||
<string>--host</string>
|
||||
<string>0.0.0.0</string>
|
||||
<string>--port</string>
|
||||
<string>8000</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Applying Changes
|
||||
|
||||
After editing the plist file, unload and reload the service:
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.openjarvis.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.openjarvis.plist
|
||||
```
|
||||
|
||||
## System-Wide Installation
|
||||
|
||||
The instructions above install the service as a **user agent** (runs only when you are logged in). To run OpenJarvis as a system-wide daemon that starts at boot regardless of user login:
|
||||
|
||||
1. Copy the plist to `/Library/LaunchDaemons/` (requires `sudo`).
|
||||
2. Set the file ownership to `root:wheel`.
|
||||
3. Optionally add a `UserName` key to run as a specific user.
|
||||
|
||||
```bash
|
||||
sudo cp deploy/launchd/com.openjarvis.plist /Library/LaunchDaemons/
|
||||
sudo chown root:wheel /Library/LaunchDaemons/com.openjarvis.plist
|
||||
sudo launchctl load /Library/LaunchDaemons/com.openjarvis.plist
|
||||
```
|
||||
|
||||
!!! note
|
||||
System daemons in `/Library/LaunchDaemons/` run as root by default. Add a `UserName` key to run as a less-privileged user:
|
||||
|
||||
```xml
|
||||
<key>UserName</key>
|
||||
<string>openjarvis</string>
|
||||
```
|
||||
@@ -0,0 +1,243 @@
|
||||
# systemd Service (Linux)
|
||||
|
||||
OpenJarvis includes a systemd unit file for running the API server as a managed background service on Linux. This provides automatic startup on boot, crash recovery, and integration with standard Linux service management tools.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing the service, ensure that:
|
||||
|
||||
1. OpenJarvis is installed in a virtual environment at `/opt/openjarvis/.venv` (or adjust paths accordingly).
|
||||
2. A dedicated `openjarvis` system user exists (recommended for security).
|
||||
3. An inference engine (such as Ollama) is running and accessible.
|
||||
|
||||
Create the user and installation directory:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --create-home --home-dir /opt/openjarvis openjarvis
|
||||
sudo -u openjarvis python3 -m venv /opt/openjarvis/.venv
|
||||
sudo -u openjarvis git clone https://github.com/open-jarvis/OpenJarvis.git /opt/openjarvis/OpenJarvis
|
||||
cd /opt/openjarvis/OpenJarvis && sudo -u openjarvis uv sync --extra server
|
||||
```
|
||||
|
||||
## Installing the Service
|
||||
|
||||
Copy the unit file to the systemd directory, reload the daemon, and enable the service:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/systemd/openjarvis.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable openjarvis
|
||||
sudo systemctl start openjarvis
|
||||
```
|
||||
|
||||
Verify it is running:
|
||||
|
||||
```bash
|
||||
sudo systemctl status openjarvis
|
||||
```
|
||||
|
||||
## Service File Reference
|
||||
|
||||
The provided unit file at `deploy/systemd/openjarvis.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=OpenJarvis API Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=openjarvis
|
||||
WorkingDirectory=/opt/openjarvis
|
||||
ExecStart=/opt/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=HOME=/opt/openjarvis
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### `[Unit]` Section
|
||||
|
||||
| Directive | Value | Description |
|
||||
|---------------|--------------------|-----------------------------------------------------------------------------|
|
||||
| `Description` | `OpenJarvis API Server` | Human-readable name shown in `systemctl status` and logs. |
|
||||
| `After` | `network.target` | Delays startup until the network stack is available, since the server binds to a network socket and may need to reach a remote engine. |
|
||||
|
||||
### `[Service]` Section
|
||||
|
||||
| Directive | Value | Description |
|
||||
|--------------------|--------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `Type` | `simple` | The process started by `ExecStart` is the main service process. systemd considers the service started immediately. |
|
||||
| `User` | `openjarvis` | Runs the server as the `openjarvis` user rather than root, limiting the blast radius of any security issue. |
|
||||
| `WorkingDirectory` | `/opt/openjarvis` | Sets the working directory for the process. This is where OpenJarvis looks for local files and writes data. |
|
||||
| `ExecStart` | `/opt/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000` | The command to start the server. Uses the full path to the `jarvis` binary inside the virtual environment. |
|
||||
| `Restart` | `on-failure` | Automatically restarts the service if it exits with a non-zero exit code. Does not restart on clean shutdown (`systemctl stop`). |
|
||||
| `RestartSec` | `5` | Waits 5 seconds before attempting a restart, preventing rapid restart loops if the service crashes immediately on startup. |
|
||||
| `Environment` | `HOME=/opt/openjarvis` | Sets the `HOME` environment variable so OpenJarvis finds its configuration at `~/.openjarvis/config.toml` (resolving to `/opt/openjarvis/.openjarvis/config.toml`). |
|
||||
|
||||
### `[Install]` Section
|
||||
|
||||
| Directive | Value | Description |
|
||||
|--------------|---------------------|---------------------------------------------------------------------------------------------|
|
||||
| `WantedBy` | `multi-user.target` | The service starts when the system reaches multi-user mode (standard boot target for servers). `systemctl enable` creates a symlink under this target. |
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Changing the Bind Address and Port
|
||||
|
||||
Edit the `ExecStart` line to change the host or port:
|
||||
|
||||
```ini
|
||||
ExecStart=/opt/openjarvis/.venv/bin/jarvis serve --host 127.0.0.1 --port 9000
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Binding to `127.0.0.1` restricts access to localhost only. Use this when running behind a reverse proxy like Nginx or Caddy.
|
||||
|
||||
### Setting the Engine and Model
|
||||
|
||||
Pass additional flags to `jarvis serve`:
|
||||
|
||||
```ini
|
||||
ExecStart=/opt/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000 --engine ollama --model qwen3:8b
|
||||
```
|
||||
|
||||
### Adding Environment Variables
|
||||
|
||||
Add multiple `Environment` directives or use `EnvironmentFile` for complex configurations:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment=HOME=/opt/openjarvis
|
||||
Environment=OPENJARVIS_ENGINE_DEFAULT=vllm
|
||||
Environment=OPENJARVIS_OLLAMA_HOST=http://localhost:11434
|
||||
```
|
||||
|
||||
Or load from a file:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
EnvironmentFile=/opt/openjarvis/.env
|
||||
```
|
||||
|
||||
### Changing the User
|
||||
|
||||
If you prefer a different service user, update both the `User` directive and the paths:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
User=myuser
|
||||
WorkingDirectory=/home/myuser/openjarvis
|
||||
ExecStart=/home/myuser/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000
|
||||
Environment=HOME=/home/myuser/openjarvis
|
||||
```
|
||||
|
||||
### Using a Configuration File
|
||||
|
||||
Ensure the configuration file exists at the path where `HOME` points:
|
||||
|
||||
```bash
|
||||
sudo -u openjarvis mkdir -p /opt/openjarvis/.openjarvis
|
||||
sudo -u openjarvis cp config.toml /opt/openjarvis/.openjarvis/config.toml
|
||||
```
|
||||
|
||||
The server reads `~/.openjarvis/config.toml` on startup, where `~` resolves from the `HOME` environment variable.
|
||||
|
||||
## Viewing Logs
|
||||
|
||||
OpenJarvis logs are captured by journald. View them with `journalctl`:
|
||||
|
||||
```bash
|
||||
# View all logs for the service
|
||||
sudo journalctl -u openjarvis
|
||||
|
||||
# Follow logs in real time
|
||||
sudo journalctl -u openjarvis -f
|
||||
|
||||
# View logs since the last boot
|
||||
sudo journalctl -u openjarvis -b
|
||||
|
||||
# View logs from the last hour
|
||||
sudo journalctl -u openjarvis --since "1 hour ago"
|
||||
|
||||
# View only error-level messages
|
||||
sudo journalctl -u openjarvis -p err
|
||||
```
|
||||
|
||||
## Managing the Service
|
||||
|
||||
### Start, Stop, and Restart
|
||||
|
||||
```bash
|
||||
# Start the service
|
||||
sudo systemctl start openjarvis
|
||||
|
||||
# Stop the service
|
||||
sudo systemctl stop openjarvis
|
||||
|
||||
# Restart the service (stop + start)
|
||||
sudo systemctl restart openjarvis
|
||||
|
||||
# Reload configuration without full restart (sends SIGHUP)
|
||||
sudo systemctl reload-or-restart openjarvis
|
||||
```
|
||||
|
||||
### Check Status
|
||||
|
||||
```bash
|
||||
sudo systemctl status openjarvis
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
● openjarvis.service - OpenJarvis API Server
|
||||
Loaded: loaded (/etc/systemd/system/openjarvis.service; enabled; preset: enabled)
|
||||
Active: active (running) since Fri 2026-02-21 10:00:00 UTC; 2h ago
|
||||
Main PID: 12345 (jarvis)
|
||||
Tasks: 4 (limit: 4915)
|
||||
Memory: 256.0M
|
||||
CPU: 1min 23s
|
||||
CGroup: /system.slice/openjarvis.service
|
||||
└─12345 /opt/openjarvis/.venv/bin/python /opt/openjarvis/.venv/bin/jarvis serve --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Enable and Disable on Boot
|
||||
|
||||
```bash
|
||||
# Enable automatic start on boot
|
||||
sudo systemctl enable openjarvis
|
||||
|
||||
# Disable automatic start on boot
|
||||
sudo systemctl disable openjarvis
|
||||
```
|
||||
|
||||
### Apply Changes After Editing the Unit File
|
||||
|
||||
After modifying `/etc/systemd/system/openjarvis.service`, reload the systemd daemon and restart the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart openjarvis
|
||||
```
|
||||
|
||||
## Running Alongside Ollama
|
||||
|
||||
If Ollama is also managed via systemd, you can add an ordering dependency so the OpenJarvis service waits for Ollama to start:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=OpenJarvis API Server
|
||||
After=network.target ollama.service
|
||||
Requires=ollama.service
|
||||
```
|
||||
|
||||
| Directive | Description |
|
||||
|------------|--------------------------------------------------------------------------|
|
||||
| `After` | Ensures OpenJarvis starts after Ollama. |
|
||||
| `Requires` | If Ollama fails to start, OpenJarvis will not start either. |
|
||||
|
||||
!!! note
|
||||
Use `Wants` instead of `Requires` if you want OpenJarvis to start even when Ollama is unavailable (for example, if you plan to start Ollama manually later).
|
||||
@@ -0,0 +1,330 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to OpenJarvis are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased — Phase 11 (NanoClaw Subsumption)
|
||||
|
||||
*27 new files, ~3,565 lines, 147+ new tests. Full suite: 2059+ tests pass.*
|
||||
|
||||
### Added
|
||||
|
||||
- **`ClaudeCodeAgent`** (`agents/claude_code.py`) -- Wraps the
|
||||
`@anthropic-ai/claude-code` SDK via a bundled Node.js subprocess bridge.
|
||||
Communicates over stdin/stdout using sentinel-delimited JSON
|
||||
(`---OPENJARVIS_OUTPUT_START---` / `---OPENJARVIS_OUTPUT_END---`). The
|
||||
bundled runner is auto-installed to `~/.openjarvis/claude_code_runner/` via
|
||||
`npm install --production` on first use. Registered as `"claude_code"` with
|
||||
`accepts_tools = False`. Requires Node.js 22+ and `ANTHROPIC_API_KEY`.
|
||||
- **`WhatsAppBaileysChannel`** (`channels/whatsapp_baileys.py`) -- Bidirectional
|
||||
WhatsApp messaging using the Baileys protocol. Spawns a Node.js bridge
|
||||
subprocess (`whatsapp_baileys_bridge/`) for QR-code authentication, incoming
|
||||
message forwarding, and outbound delivery via JID addressing. Registered as
|
||||
`"whatsapp_baileys"` in `ChannelRegistry`. Authentication state is persisted
|
||||
to `~/.openjarvis/whatsapp_baileys_bridge/auth/`. New config section:
|
||||
`[channel.whatsapp_baileys]`.
|
||||
- **`ContainerRunner`** (`sandbox/runner.py`) -- Manages Docker (or Podman)
|
||||
container lifecycle for sandboxed agent execution. Builds `docker run --rm
|
||||
--network none -i` commands with allowlist-validated read-only bind mounts.
|
||||
Supports configurable image, timeout, concurrent container limit, and runtime
|
||||
binary. Uses the same sentinel-delimited JSON protocol as `ClaudeCodeAgent`.
|
||||
- **`SandboxedAgent`** (`sandbox/runner.py`) -- Transparent wrapper that runs
|
||||
any `BaseAgent` inside a container via `ContainerRunner`. Follows the
|
||||
`GuardrailsEngine` wrapper pattern. `accepts_tools = False`.
|
||||
- **`MountAllowlist` / `validate_mounts()`** (`sandbox/mount_security.py`) --
|
||||
Port of NanoClaw's `mount-security.ts`. Validates bind mounts against a JSON
|
||||
allowlist (allowed root directories + blocked filename patterns). Raises
|
||||
`ValueError` for blocked or out-of-root paths before the container starts.
|
||||
Default blocked patterns include `.ssh`, `.env`, `*.pem`, `*.key`, credential
|
||||
files, and cloud config directories.
|
||||
- **`TaskScheduler`** (`scheduler/scheduler.py`) -- Background polling scheduler
|
||||
supporting three schedule types: `cron` (via `croniter` or built-in fallback),
|
||||
`interval` (seconds), and `once` (ISO 8601 datetime). Runs a daemon thread
|
||||
(`jarvis-scheduler`) polling SQLite every 60 seconds (configurable). Executes
|
||||
due tasks via `JarvisSystem.ask()` with optional agent and tool selection.
|
||||
Publishes `scheduler_task_start` / `scheduler_task_end` events on the
|
||||
`EventBus`. New config section: `[scheduler]`.
|
||||
- **`SchedulerStore`** (`scheduler/store.py`) -- SQLite CRUD backend for
|
||||
scheduled tasks and run logs. Two tables: `scheduled_tasks` (task state) and
|
||||
`task_run_logs` (execution history). Supports task filtering by status and
|
||||
due-time polling via `get_due_tasks()`.
|
||||
- **Scheduler MCP tools** (`scheduler/tools.py`) -- Five new MCP-discoverable
|
||||
tools registered in `ToolRegistry`:
|
||||
- `schedule_task` -- Create a new scheduled task
|
||||
- `list_scheduled_tasks` -- List tasks filtered by status
|
||||
- `pause_scheduled_task` -- Pause an active task
|
||||
- `resume_scheduled_task` -- Resume a paused task (recomputes `next_run`)
|
||||
- `cancel_scheduled_task` -- Permanently cancel a task
|
||||
- **Scheduler CLI commands** -- `jarvis scheduler` subcommand group:
|
||||
- `jarvis scheduler create` -- Create a new scheduled task
|
||||
- `jarvis scheduler list` -- List all or filtered tasks
|
||||
- `jarvis scheduler pause <id>` -- Pause a task
|
||||
- `jarvis scheduler resume <id>` -- Resume a task
|
||||
- `jarvis scheduler cancel <id>` -- Cancel a task
|
||||
- `jarvis scheduler logs <id>` -- Show run history for a task
|
||||
- `jarvis scheduler start` -- Start the background scheduler daemon
|
||||
|
||||
### Changed
|
||||
|
||||
- `ChannelRegistry` now includes `WhatsAppBaileysChannel`.
|
||||
- `AgentRegistry` now includes `ClaudeCodeAgent` (`"claude_code"`).
|
||||
- Architecture overview and source directory layout updated to reflect new
|
||||
`sandbox/` and `scheduler/` modules.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased — Phase 10 Tooling Updates
|
||||
|
||||
### Added
|
||||
|
||||
- **`build_tool_descriptions()` shared builder** -- Single source of truth for
|
||||
generating enriched tool descriptions in agent system prompts. Produces
|
||||
Markdown sections with name, description, category, and parameter schemas.
|
||||
- **Enriched agent prompts** -- `NativeReActAgent`, `NativeOpenHandsAgent`,
|
||||
`RLMAgent`, and `OrchestratorAgent` (structured mode) now inject detailed
|
||||
tool descriptions into their system prompts via the shared builder.
|
||||
- **Case-insensitive parsing** -- ReAct (`Action:` / `Final Answer:`) and
|
||||
Orchestrator structured-mode parsing (`TOOL:` / `FINAL_ANSWER:`) are now
|
||||
case-insensitive.
|
||||
- **Multi-provider tool_calls extraction** -- `CloudEngine` now extracts
|
||||
`tool_calls` from Anthropic (`tool_use` content blocks) and Google
|
||||
(`function_call` parts), normalizing to the flat `{id, name, arguments}`
|
||||
format. `LiteLLM` engine handles the flat-format tool calls returned by
|
||||
the LiteLLM proxy.
|
||||
- **RLM tool awareness** -- `RLMAgent` injects an `## Available Tools`
|
||||
section into its system prompt when tools are provided.
|
||||
- **Orchestrator structured tool descriptions** -- Structured mode passes
|
||||
`tools=self._tools` to `build_system_prompt()` for enriched descriptions.
|
||||
- **Telemetry modules** -- `EfficiencyMetrics`, `GPUMonitor`, `VLLMMetrics`
|
||||
for energy, GPU utilization, and vLLM server-side metrics collection.
|
||||
- **Eval TOML config** -- TOML-based eval suite configuration system for
|
||||
defining models x benchmarks matrices.
|
||||
|
||||
### Changed
|
||||
|
||||
- Agent prompt generation now uses `build_tool_descriptions()` instead of
|
||||
inline tool name listing.
|
||||
- `build_system_prompt()` in `prompt_registry.py` accepts an optional `tools`
|
||||
parameter for enriched descriptions from `BaseTool` instances.
|
||||
- ReAct and OpenHands regex patterns updated for case-insensitive matching.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Engine `tool_calls` normalization -- Anthropic `tool_use` blocks and Google
|
||||
`function_call` parts are now correctly extracted and converted to the
|
||||
standard flat format used by agents.
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0
|
||||
|
||||
*Phase 5 -- SDK, Production Readiness, and Documentation*
|
||||
|
||||
### Added
|
||||
|
||||
- **Python SDK** -- `Jarvis` class providing a high-level sync API for
|
||||
programmatic use
|
||||
- `ask()` / `ask_full()` methods for direct engine and agent mode queries
|
||||
- `MemoryHandle` proxy for lazy memory backend initialization
|
||||
- `list_models()` and `list_engines()` for runtime introspection
|
||||
- Router policy selection via config (`learning.default_policy`)
|
||||
- Lazy engine initialization with automatic discovery and health probing
|
||||
- Resource cleanup via `close()`
|
||||
- **Benchmarking framework**
|
||||
- `BaseBenchmark` ABC and `BenchmarkSuite` runner
|
||||
- `LatencyBenchmark` measuring per-call latency (mean, p50, p95, min, max)
|
||||
- `ThroughputBenchmark` measuring tokens-per-second throughput
|
||||
- `BenchmarkResult` dataclass with JSONL export
|
||||
- `jarvis bench run` CLI with options for model, engine, sample count,
|
||||
benchmark selection, and JSON/JSONL output
|
||||
- **Docker deployment**
|
||||
- `Dockerfile` -- Multi-stage Python 3.12-slim build with `[server]` extra
|
||||
- `Dockerfile.gpu` -- NVIDIA CUDA 12.4 runtime variant
|
||||
- `docker-compose.yml` -- Services for `jarvis` (port 8000) and `ollama`
|
||||
(port 11434)
|
||||
- `deploy/systemd/openjarvis.service` -- systemd unit file for Linux
|
||||
- `deploy/launchd/com.openjarvis.plist` -- launchd plist for macOS
|
||||
- **Documentation site** -- MkDocs Material with mkdocstrings, covering
|
||||
getting started, user guide, architecture, API reference, deployment, and
|
||||
development
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0
|
||||
|
||||
*Phase 4 -- Learning, Telemetry, and Router Policies*
|
||||
|
||||
### Added
|
||||
|
||||
- **Learning system**
|
||||
- `RouterPolicy` ABC and `RoutingContext` dataclass
|
||||
- `RewardFunction` ABC for scoring inference results
|
||||
- `HeuristicRewardFunction` scoring on latency, cost, and efficiency
|
||||
- `RouterPolicyRegistry` for pluggable routing strategies
|
||||
- `HeuristicRouter` registered as `"heuristic"` policy (6 priority rules:
|
||||
code detection, math detection, short/long queries, urgency override,
|
||||
default fallback)
|
||||
- `TraceDrivenPolicy` registered as `"learned"` policy with batch updates
|
||||
via `update_from_traces()` and online updates via `observe()`
|
||||
- `GRPORouterPolicy` stub registered as `"grpo"` for future RL training
|
||||
- `ensure_registered()` pattern for lazy, test-safe registration
|
||||
- **Telemetry aggregation**
|
||||
- `TelemetryAggregator` with `per_model_stats()`, `per_engine_stats()`,
|
||||
`top_models()`, `summary()`, `export_records()`, and `clear()` methods
|
||||
- Time-range filtering via `since` / `until` parameters
|
||||
- `ModelStats` and `EngineStats` dataclasses
|
||||
- `AggregatedStats` summary dataclass
|
||||
- **CLI enhancements**
|
||||
- `--router` flag on `jarvis ask` for explicit policy selection
|
||||
- `jarvis telemetry stats` -- display aggregated telemetry statistics
|
||||
- `jarvis telemetry export --format json|csv` -- export telemetry records
|
||||
- `jarvis telemetry clear --yes` -- delete all telemetry records
|
||||
|
||||
---
|
||||
|
||||
## v0.4.0
|
||||
|
||||
*Phase 3 -- Agents, Tools, and API Server*
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent system**
|
||||
- `BaseAgent` ABC with `run()` method returning `AgentResult`
|
||||
- `AgentContext` dataclass with conversation, tools, and memory results
|
||||
- `AgentResult` dataclass with content, tool results, turns, and metadata
|
||||
- `AgentRegistry` for pluggable agent implementations
|
||||
- `SimpleAgent` -- single-turn query-to-response, no tool calling
|
||||
- `OrchestratorAgent` -- multi-turn tool-calling loop with `ToolExecutor`,
|
||||
configurable `max_turns`
|
||||
- `CustomAgent` -- template for user-defined agent behavior
|
||||
- **Tool system**
|
||||
- `BaseTool` ABC with `spec` property and `execute()` method
|
||||
- `ToolSpec` dataclass describing tool interface and characteristics
|
||||
- `ToolExecutor` dispatch engine with JSON argument parsing, latency
|
||||
tracking, and event bus integration (`TOOL_CALL_START` / `TOOL_CALL_END`)
|
||||
- `ToolRegistry` for tool discovery
|
||||
- `to_openai_function()` method for OpenAI function calling format
|
||||
- Built-in tools:
|
||||
- `CalculatorTool` -- safe math evaluation via AST parsing
|
||||
- `ThinkTool` -- reasoning scratchpad for chain-of-thought
|
||||
- `RetrievalTool` -- memory search integration
|
||||
- `LLMTool` -- sub-model calls within agent loops
|
||||
- `FileReadTool` -- safe file reading with path validation
|
||||
- **OpenAI-compatible API server** (`jarvis serve`)
|
||||
- FastAPI + Uvicorn with optional `[server]` extra
|
||||
- `POST /v1/chat/completions` -- non-streaming and SSE streaming
|
||||
- `GET /v1/models` -- list available models
|
||||
- `GET /health` -- health check endpoint
|
||||
- Pydantic request/response models matching OpenAI API format
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0
|
||||
|
||||
*Phase 2 -- Memory System*
|
||||
|
||||
### Added
|
||||
|
||||
- **Memory backends**
|
||||
- `MemoryBackend` ABC with `store()`, `retrieve()`, `delete()`, `clear()`
|
||||
- `RetrievalResult` dataclass with content, score, source, and metadata
|
||||
- `MemoryRegistry` for backend discovery
|
||||
- `SQLiteMemory` -- zero-dependency default using SQLite FTS5 with BM25
|
||||
ranking and FTS5 query escaping
|
||||
- `FAISSMemory` -- vector search using FAISS with sentence-transformers
|
||||
embeddings (optional `[memory-faiss]` extra)
|
||||
- `ColBERTMemory` -- ColBERTv2 neural retrieval backend (optional
|
||||
`[memory-colbert]` extra)
|
||||
- `BM25Memory` -- BM25 ranking backend using rank-bm25 (optional
|
||||
`[memory-bm25]` extra)
|
||||
- `HybridMemory` -- Reciprocal Rank Fusion combining multiple backends
|
||||
- **Document processing**
|
||||
- `ChunkConfig` dataclass for chunk size and overlap settings
|
||||
- `chunk_text()` for splitting documents into overlapping chunks
|
||||
- `ingest_path()` for recursively indexing files and directories
|
||||
- `read_document()` with support for plain text, Markdown, and PDF
|
||||
(optional `[memory-pdf]` extra)
|
||||
- **Context injection**
|
||||
- `ContextConfig` with top-k, minimum score, and max context token settings
|
||||
- `inject_context()` for prepending memory results as system messages with
|
||||
source attribution
|
||||
- `--no-context` flag on `jarvis ask` to disable injection
|
||||
- **CLI commands**
|
||||
- `jarvis memory index <path>` -- index documents into memory
|
||||
- `jarvis memory search <query>` -- search memory for relevant chunks
|
||||
- `jarvis memory stats` -- show backend statistics
|
||||
- **Event bus integration** -- `MEMORY_STORE` and `MEMORY_RETRIEVE` events
|
||||
|
||||
---
|
||||
|
||||
## v0.2.0
|
||||
|
||||
*Phase 1 -- Intelligence and Inference*
|
||||
|
||||
### Added
|
||||
|
||||
- **Intelligence primitive**
|
||||
- `ModelSpec` dataclass with parameter count, context length, quantization,
|
||||
VRAM requirements, and supported engines
|
||||
- `ModelRegistry` for model metadata storage
|
||||
- `BUILTIN_MODELS` catalog with pre-defined model specifications
|
||||
- `register_builtin_models()` and `merge_discovered_models()` helpers
|
||||
- `HeuristicRouter` with rule-based model selection
|
||||
- `build_routing_context()` for query analysis (code detection, math
|
||||
detection, length classification)
|
||||
- **Inference engines**
|
||||
- `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`,
|
||||
and `health()` methods
|
||||
- `EngineRegistry` for engine discovery
|
||||
- `OllamaEngine` -- Ollama backend via native HTTP API with tool call
|
||||
extraction
|
||||
- `VllmEngine` -- vLLM backend via OpenAI-compatible API
|
||||
- `LlamaCppEngine` -- llama.cpp server backend
|
||||
- `EngineConnectionError` for unreachable engines
|
||||
- `messages_to_dicts()` for Message-to-OpenAI-format conversion
|
||||
- **Engine discovery**
|
||||
- `discover_engines()` -- probe all registered engines for health
|
||||
- `discover_models()` -- aggregate model lists across engines
|
||||
- `get_engine()` -- get configured default with automatic fallback
|
||||
- **Hardware detection**
|
||||
- NVIDIA GPU detection via `nvidia-smi`
|
||||
- AMD GPU detection via `rocm-smi`
|
||||
- Apple Silicon detection via `system_profiler`
|
||||
- CPU brand detection via `/proc/cpuinfo` and `sysctl`
|
||||
- `recommend_engine()` mapping hardware to best engine
|
||||
- **Telemetry**
|
||||
- `TelemetryRecord` dataclass with timing, tokens, energy, and cost
|
||||
- `TelemetryStore` with SQLite persistence and EventBus subscription
|
||||
- `instrumented_generate()` wrapper for automatic telemetry recording
|
||||
- **CLI**
|
||||
- `jarvis ask <query>` -- query via discovered engine
|
||||
- `jarvis ask --agent simple <query>` -- route through SimpleAgent
|
||||
- `jarvis model list` -- list models from running engines
|
||||
- `jarvis model info <model>` -- show model details
|
||||
|
||||
---
|
||||
|
||||
## v0.1.0
|
||||
|
||||
*Phase 0 -- Project Scaffolding*
|
||||
|
||||
### Added
|
||||
|
||||
- **Project structure** -- `hatchling` build backend, `uv` package manager,
|
||||
`pyproject.toml` with extras for optional backends
|
||||
- **Registry system** -- `RegistryBase[T]` generic base class with
|
||||
class-specific entry isolation, `register()` decorator, `get()`, `create()`,
|
||||
`items()`, `keys()`, `contains()`, `clear()` methods
|
||||
- **Typed registries** -- `ModelRegistry`, `EngineRegistry`, `MemoryRegistry`,
|
||||
`AgentRegistry`, `ToolRegistry`, `RouterPolicyRegistry`, `BenchmarkRegistry`
|
||||
- **Core types** -- `Role` enum, `Message`, `Conversation` (with sliding
|
||||
window), `ModelSpec`, `Quantization` enum, `ToolCall`, `ToolResult`,
|
||||
`TelemetryRecord`, `StepType` enum, `TraceStep`, `Trace`
|
||||
- **Configuration** -- `JarvisConfig` dataclass hierarchy, TOML loader with
|
||||
overlay semantics, hardware auto-detection, `generate_default_toml()` for
|
||||
`jarvis init`
|
||||
- **Event bus** -- Synchronous pub/sub `EventBus` with `EventType` enum for
|
||||
inter-primitive communication
|
||||
- **CLI skeleton** -- Click-based `jarvis` command group with `--version`,
|
||||
`--help`, and `init` subcommand
|
||||
@@ -0,0 +1,415 @@
|
||||
# Contributing Guide
|
||||
|
||||
This guide covers how to set up a development environment, run tests, and
|
||||
contribute code to OpenJarvis.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|---|---|---|
|
||||
| Python | 3.10+ | Required |
|
||||
| [uv](https://docs.astral.sh/uv/) | Latest | Package manager |
|
||||
| Node.js | 22+ | Only needed for ClaudeCodeAgent and WhatsApp channel |
|
||||
|
||||
### Clone and Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
This installs the package in editable mode along with all development
|
||||
dependencies (pytest, ruff, respx, pytest-asyncio, pytest-cov).
|
||||
|
||||
!!! tip "Optional extras"
|
||||
Install additional extras for specific backends you want to work on:
|
||||
|
||||
```bash
|
||||
# Memory backends
|
||||
uv sync --extra dev --extra memory-faiss --extra memory-colbert --extra memory-bm25
|
||||
|
||||
# Cloud inference
|
||||
uv sync --extra dev --extra inference-cloud --extra inference-google
|
||||
|
||||
# API server
|
||||
uv sync --extra dev --extra server
|
||||
|
||||
# Documentation
|
||||
uv sync --extra dev --extra docs
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
uv run jarvis --version # Should print 0.1.0
|
||||
uv run jarvis --help # Show all subcommands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
OpenJarvis uses [pytest](https://docs.pytest.org/) with approximately 1,000+
|
||||
tests organized by module.
|
||||
|
||||
### Full Test Suite
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
### Run a Specific Test File
|
||||
|
||||
```bash
|
||||
uv run pytest tests/core/test_registry.py -v
|
||||
uv run pytest tests/engine/test_ollama.py -v
|
||||
uv run pytest tests/memory/test_sqlite.py -v
|
||||
```
|
||||
|
||||
### Run a Specific Test
|
||||
|
||||
```bash
|
||||
uv run pytest tests/core/test_registry.py::test_register_and_get -v
|
||||
```
|
||||
|
||||
### Run Tests by Module
|
||||
|
||||
```bash
|
||||
uv run pytest tests/agents/ -v # All agent tests
|
||||
uv run pytest tests/tools/ -v # All tool tests
|
||||
uv run pytest tests/learning/ -v # All learning tests
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ --cov=openjarvis --cov-report=html
|
||||
```
|
||||
|
||||
### Test Markers
|
||||
|
||||
Tests that require specific hardware or running services are gated behind
|
||||
pytest markers. By default, these tests are collected but will skip
|
||||
gracefully if the requirement is not met.
|
||||
|
||||
| Marker | Description | Example |
|
||||
|---|---|---|
|
||||
| `live` | Requires a running inference engine (Ollama, vLLM, etc.) | `@pytest.mark.live` |
|
||||
| `cloud` | Requires cloud API keys (`OPENAI_API_KEY`, etc.) | `@pytest.mark.cloud` |
|
||||
| `nvidia` | Requires an NVIDIA GPU | `@pytest.mark.nvidia` |
|
||||
| `amd` | Requires an AMD GPU with ROCm | `@pytest.mark.amd` |
|
||||
| `apple` | Requires Apple Silicon | `@pytest.mark.apple` |
|
||||
| `slow` | Long-running test | `@pytest.mark.slow` |
|
||||
|
||||
Run only tests matching a specific marker:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ -m live -v # Only live engine tests
|
||||
uv run pytest tests/ -m "not slow" -v # Skip slow tests
|
||||
uv run pytest tests/ -m "not cloud" -v # Skip cloud tests
|
||||
```
|
||||
|
||||
!!! info "Registry isolation in tests"
|
||||
The test `conftest.py` includes an `autouse` fixture that clears all
|
||||
registries and resets the event bus before every test. This ensures
|
||||
complete isolation between tests. Modules that need their registrations
|
||||
to survive clearing use the `ensure_registered()` pattern described
|
||||
below.
|
||||
|
||||
---
|
||||
|
||||
## Linting
|
||||
|
||||
OpenJarvis uses [Ruff](https://docs.astral.sh/ruff/) for linting, configured
|
||||
in `pyproject.toml`:
|
||||
|
||||
```bash
|
||||
uv run ruff check src/ tests/
|
||||
```
|
||||
|
||||
The Ruff configuration targets Python 3.10 and enables the following rule sets:
|
||||
|
||||
- **E** -- pycodestyle errors
|
||||
- **F** -- Pyflakes
|
||||
- **I** -- isort (import ordering)
|
||||
- **W** -- pycodestyle warnings
|
||||
|
||||
Fix auto-fixable issues:
|
||||
|
||||
```bash
|
||||
uv run ruff check src/ tests/ --fix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building Documentation
|
||||
|
||||
The documentation site uses [MkDocs Material](https://squidfunnel.com/mkdocs-material/).
|
||||
|
||||
```bash
|
||||
# Install docs dependencies
|
||||
uv sync --extra docs
|
||||
|
||||
# Serve locally with hot reload
|
||||
uv run mkdocs serve --dev-addr 127.0.0.1:8001
|
||||
|
||||
# Build static site
|
||||
uv run mkdocs build
|
||||
```
|
||||
|
||||
The site configuration lives in `mkdocs.yml`. API reference pages use
|
||||
[mkdocstrings](https://mkdocstrings.github.io/) to auto-generate from
|
||||
docstrings with the NumPy docstring style.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
The source code is organized under `src/openjarvis/`:
|
||||
|
||||
```
|
||||
src/openjarvis/
|
||||
__init__.py # Package root, __version__
|
||||
sdk.py # Jarvis class — high-level Python SDK
|
||||
|
||||
core/ # Shared infrastructure
|
||||
config.py # JarvisConfig, hardware detection, TOML loader
|
||||
events.py # EventBus pub/sub system
|
||||
registry.py # RegistryBase[T] and all typed registries
|
||||
types.py # Message, ModelSpec, ToolResult, Trace, etc.
|
||||
|
||||
intelligence/ # Model management and query routing
|
||||
model_catalog.py # BUILTIN_MODELS, register/merge helpers
|
||||
router.py # HeuristicRouter, build_routing_context
|
||||
|
||||
engine/ # Inference engine backends
|
||||
_stubs.py # InferenceEngine ABC
|
||||
_base.py # EngineConnectionError, messages_to_dicts
|
||||
_discovery.py # discover_engines, discover_models, get_engine
|
||||
_openai_compat.py # OpenAI-compatible wrapper
|
||||
ollama.py # OllamaEngine
|
||||
openai_compat_engines.py # Data-driven registration (vLLM, SGLang, llama.cpp, MLX, LM Studio)
|
||||
cloud.py # CloudEngine (OpenAI/Anthropic/Google)
|
||||
|
||||
agents/ # Agent implementations
|
||||
_stubs.py # BaseAgent ABC, ToolUsingAgent, AgentContext, AgentResult
|
||||
simple.py # SimpleAgent — single-turn, no tools
|
||||
orchestrator.py # OrchestratorAgent — multi-turn tool calling (function_calling + structured)
|
||||
native_react.py # NativeReActAgent — Thought-Action-Observation loop
|
||||
native_openhands.py # NativeOpenHandsAgent — CodeAct-style code execution
|
||||
rlm.py # RLMAgent — recursive LM with persistent REPL
|
||||
openhands.py # OpenHandsAgent — wraps real openhands-sdk
|
||||
react.py # Backward-compat shim (re-exports NativeReActAgent)
|
||||
claude_code.py # ClaudeCodeAgent — Claude Agent SDK via Node.js subprocess
|
||||
claude_code_runner/ # Bundled Node.js runner for the Claude Agent SDK
|
||||
|
||||
memory/ # Memory / retrieval backends
|
||||
_stubs.py # MemoryBackend ABC, RetrievalResult
|
||||
sqlite.py # SQLiteMemory — FTS5 default backend
|
||||
faiss_backend.py # FAISS vector backend
|
||||
colbert_backend.py # ColBERTv2 backend
|
||||
bm25.py # BM25 backend
|
||||
hybrid.py # Hybrid (RRF fusion) backend
|
||||
chunking.py # ChunkConfig, chunk_text
|
||||
context.py # ContextConfig, inject_context
|
||||
ingest.py # ingest_path, read_document
|
||||
|
||||
tools/ # Tool system
|
||||
_stubs.py # BaseTool ABC, ToolSpec, ToolExecutor
|
||||
calculator.py # CalculatorTool — safe AST math
|
||||
think.py # ThinkTool — reasoning scratchpad
|
||||
retrieval.py # RetrievalTool — memory search
|
||||
llm_tool.py # LLMTool — sub-model calls
|
||||
file_read.py # FileReadTool — safe file reading
|
||||
web_search.py # WebSearchTool
|
||||
code_interpreter.py # CodeInterpreterTool
|
||||
|
||||
learning/ # Router policies and reward functions
|
||||
_stubs.py # RouterPolicy ABC, RewardFunction ABC
|
||||
heuristic_policy.py # Wire HeuristicRouter to registry
|
||||
trace_policy.py # TraceDrivenPolicy — learns from traces
|
||||
grpo_policy.py # GRPORouterPolicy — RL training stub
|
||||
heuristic_reward.py # HeuristicRewardFunction
|
||||
|
||||
traces/ # Full interaction recording
|
||||
store.py # TraceStore — SQLite persistence
|
||||
collector.py # TraceCollector — wraps agents
|
||||
analyzer.py # TraceAnalyzer — aggregated queries
|
||||
|
||||
telemetry/ # Inference telemetry
|
||||
store.py # TelemetryStore — SQLite persistence
|
||||
aggregator.py # TelemetryAggregator — per-model/engine stats
|
||||
wrapper.py # instrumented_generate() wrapper
|
||||
|
||||
bench/ # Benchmarking framework
|
||||
_stubs.py # BaseBenchmark ABC, BenchmarkSuite
|
||||
latency.py # LatencyBenchmark
|
||||
throughput.py # ThroughputBenchmark
|
||||
|
||||
server/ # OpenAI-compatible API server
|
||||
app.py # FastAPI application factory
|
||||
routes.py # /v1/chat/completions, /v1/models, /health
|
||||
|
||||
mcp/ # MCP (Model Context Protocol) layer
|
||||
|
||||
cli/ # Click CLI commands
|
||||
__init__.py # main group
|
||||
ask.py # jarvis ask
|
||||
init_cmd.py # jarvis init
|
||||
model.py # jarvis model list/info
|
||||
memory_cmd.py # jarvis memory index/search/stats
|
||||
telemetry_cmd.py # jarvis telemetry stats/export/clear
|
||||
bench_cmd.py # jarvis bench run
|
||||
serve.py # jarvis serve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### File Naming
|
||||
|
||||
| Pattern | Purpose | Examples |
|
||||
|---|---|---|
|
||||
| `_stubs.py` | ABC definitions and dataclasses | `engine/_stubs.py`, `agents/_stubs.py`, `tools/_stubs.py` |
|
||||
| `_discovery.py` | Auto-detection and probing logic | `engine/_discovery.py` |
|
||||
| `_base.py` | Shared utilities and re-exports | `engine/_base.py` |
|
||||
| `*_cmd.py` | CLI command modules | `init_cmd.py`, `memory_cmd.py`, `bench_cmd.py` |
|
||||
|
||||
### Registry Pattern
|
||||
|
||||
All extensible components use the decorator-based registry pattern. New
|
||||
implementations are added by decorating a class -- no factory modifications
|
||||
needed:
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
|
||||
@EngineRegistry.register("my_engine")
|
||||
class MyEngine(InferenceEngine):
|
||||
...
|
||||
```
|
||||
|
||||
Available registries:
|
||||
|
||||
| Registry | Stores | Key examples |
|
||||
|---|---|---|
|
||||
| `ModelRegistry` | `ModelSpec` objects | `"qwen3:8b"`, `"llama3.1:70b"` |
|
||||
| `EngineRegistry` | `InferenceEngine` classes | `"ollama"`, `"vllm"`, `"llamacpp"` |
|
||||
| `MemoryRegistry` | `MemoryBackend` classes | `"sqlite"`, `"faiss"`, `"bm25"` |
|
||||
| `AgentRegistry` | `BaseAgent` classes | `"simple"`, `"orchestrator"` |
|
||||
| `ToolRegistry` | `BaseTool` classes | `"calculator"`, `"think"`, `"retrieval"` |
|
||||
| `RouterPolicyRegistry` | `RouterPolicy` classes | `"heuristic"`, `"learned"` |
|
||||
| `BenchmarkRegistry` | `BaseBenchmark` classes | `"latency"`, `"throughput"` |
|
||||
|
||||
### Optional Dependencies
|
||||
|
||||
Backends that depend on optional packages use the `try/except ImportError`
|
||||
pattern to fail gracefully when deps are not installed:
|
||||
|
||||
```python
|
||||
# In __init__.py — import to trigger registration
|
||||
try:
|
||||
import openjarvis.memory.faiss_backend # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
```
|
||||
|
||||
This ensures the package always loads, even if `faiss-cpu` or other optional
|
||||
dependencies are not installed.
|
||||
|
||||
### The `ensure_registered()` Pattern
|
||||
|
||||
Benchmark and learning modules use lazy registration so that their entries
|
||||
survive registry clearing in tests:
|
||||
|
||||
```python
|
||||
def ensure_registered() -> None:
|
||||
"""Register the latency benchmark if not already present."""
|
||||
if not BenchmarkRegistry.contains("latency"):
|
||||
BenchmarkRegistry.register_value("latency", LatencyBenchmark)
|
||||
```
|
||||
|
||||
This pattern checks `contains()` before registering, making it safe to call
|
||||
multiple times without raising a duplicate-key error.
|
||||
|
||||
### Dataclass Conventions
|
||||
|
||||
- Use `slots=True` on all dataclasses for memory efficiency:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class BenchmarkResult:
|
||||
benchmark_name: str
|
||||
model: str
|
||||
...
|
||||
```
|
||||
|
||||
### Type Hints
|
||||
|
||||
- All function signatures must have type annotations
|
||||
- Use `from __future__ import annotations` at the top of every module
|
||||
- Use `Optional[X]` for nullable types
|
||||
- Use `Sequence` for read-only collections, `List` for mutable ones
|
||||
|
||||
### Import Style
|
||||
|
||||
- Absolute imports only (`from openjarvis.core.registry import ...`)
|
||||
- Sort imports with `ruff` (isort rules enabled)
|
||||
- Place `from __future__ import annotations` as the first import
|
||||
|
||||
---
|
||||
|
||||
## PR Guidelines
|
||||
|
||||
### Before Submitting
|
||||
|
||||
1. **Run the full test suite** and verify no regressions:
|
||||
```bash
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
2. **Run the linter** and fix all issues:
|
||||
```bash
|
||||
uv run ruff check src/ tests/
|
||||
```
|
||||
|
||||
3. **Add tests** for new functionality. Place them in the corresponding
|
||||
`tests/` subdirectory (e.g., new engine tests go in `tests/engine/`).
|
||||
|
||||
4. **Follow the registry pattern** for any new extensible component.
|
||||
|
||||
### Commit Messages
|
||||
|
||||
- Use the imperative mood (e.g., "Add FAISS memory backend")
|
||||
- Keep the first line under 72 characters
|
||||
- Reference relevant issues or PRs
|
||||
|
||||
### What Makes a Good PR
|
||||
|
||||
- **Focused**: One feature, fix, or refactor per PR
|
||||
- **Tested**: Include unit tests that cover the new code paths
|
||||
- **Documented**: Update docstrings and documentation pages if adding
|
||||
public API
|
||||
- **Backwards compatible**: Avoid breaking existing interfaces without
|
||||
discussion
|
||||
|
||||
### Adding a New Primitive Component
|
||||
|
||||
When adding a new engine, memory backend, agent, tool, benchmark, or router
|
||||
policy:
|
||||
|
||||
1. Implement the corresponding ABC
|
||||
2. Register with the appropriate `@XRegistry.register("key")` decorator
|
||||
3. Add an import in the module's `__init__.py` (with `try/except ImportError`
|
||||
if the component has optional deps)
|
||||
4. Add tests in the matching `tests/` subdirectory
|
||||
5. Add an entry in `pyproject.toml` under `[project.optional-dependencies]`
|
||||
if the component requires new packages
|
||||
|
||||
See the [Extending OpenJarvis](extending.md) guide for complete examples.
|
||||
@@ -0,0 +1,915 @@
|
||||
# Extending OpenJarvis
|
||||
|
||||
OpenJarvis is designed to be extended through its registry pattern. Every
|
||||
major subsystem defines an abstract base class (ABC) and uses a typed registry
|
||||
for runtime discovery. To add a new component, implement the ABC, decorate
|
||||
it with the registry, and import it in the module's `__init__.py`.
|
||||
|
||||
This guide provides complete, working code examples for each extension point.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Inference Engine
|
||||
|
||||
Inference engines connect OpenJarvis to an LLM runtime. All engines implement
|
||||
the `InferenceEngine` ABC defined in `engine/_stubs.py`.
|
||||
|
||||
### Step 1: Create the Engine Module
|
||||
|
||||
Create `src/openjarvis/engine/my_engine.py`:
|
||||
|
||||
```python
|
||||
"""My custom inference engine backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
from openjarvis.core.types import Message
|
||||
from openjarvis.engine._base import (
|
||||
EngineConnectionError,
|
||||
InferenceEngine,
|
||||
messages_to_dicts,
|
||||
)
|
||||
|
||||
|
||||
@EngineRegistry.register("my_engine") # (1)!
|
||||
class MyEngine(InferenceEngine):
|
||||
"""Custom inference engine backend."""
|
||||
|
||||
engine_id = "my_engine" # (2)!
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "http://localhost:9000",
|
||||
*,
|
||||
timeout: float = 120.0,
|
||||
) -> None:
|
||||
self._host = host.rstrip("/")
|
||||
self._client = httpx.Client(base_url=self._host, timeout=timeout)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Synchronous completion."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages_to_dicts(messages), # (3)!
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
# Pass tools if provided
|
||||
tools = kwargs.get("tools")
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
try:
|
||||
resp = self._client.post("/v1/chat/completions", json=payload)
|
||||
resp.raise_for_status()
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as exc:
|
||||
raise EngineConnectionError(
|
||||
f"Engine not reachable at {self._host}"
|
||||
) from exc
|
||||
|
||||
data = resp.json()
|
||||
choice = data.get("choices", [{}])[0]
|
||||
message = choice.get("message", {})
|
||||
usage = data.get("usage", {})
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"content": message.get("content", ""),
|
||||
"usage": {
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
"model": data.get("model", model),
|
||||
"finish_reason": choice.get("finish_reason", "stop"),
|
||||
}
|
||||
|
||||
# Extract tool calls if present
|
||||
raw_tool_calls = message.get("tool_calls", [])
|
||||
if raw_tool_calls:
|
||||
result["tool_calls"] = [
|
||||
{
|
||||
"id": tc.get("id", f"call_{i}"),
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"]["arguments"],
|
||||
}
|
||||
for i, tc in enumerate(raw_tool_calls)
|
||||
]
|
||||
return result
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield token strings as they are generated."""
|
||||
# Implement SSE or WebSocket streaming for your engine
|
||||
result = self.generate(
|
||||
messages, model=model, temperature=temperature,
|
||||
max_tokens=max_tokens, **kwargs,
|
||||
)
|
||||
yield result.get("content", "")
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""Return identifiers of models available on this engine."""
|
||||
try:
|
||||
resp = self._client.get("/v1/models")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [m["id"] for m in data.get("data", [])]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def health(self) -> bool:
|
||||
"""Return True when the engine is reachable and healthy."""
|
||||
try:
|
||||
resp = self._client.get("/health", timeout=2.0)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
1. The `@EngineRegistry.register("my_engine")` decorator makes this engine
|
||||
discoverable by key at runtime.
|
||||
2. The `engine_id` class attribute is used in telemetry and benchmark results.
|
||||
3. `messages_to_dicts()` converts `Message` objects to OpenAI-format dicts.
|
||||
|
||||
### Step 2: Register in `__init__.py`
|
||||
|
||||
Add your engine import to `src/openjarvis/engine/__init__.py`:
|
||||
|
||||
```python
|
||||
import openjarvis.engine.my_engine # noqa: F401
|
||||
```
|
||||
|
||||
If your engine requires optional dependencies, wrap the import:
|
||||
|
||||
```python
|
||||
try:
|
||||
import openjarvis.engine.my_engine # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
```
|
||||
|
||||
### Step 3: Add Optional Dependencies
|
||||
|
||||
If your engine needs extra packages, add them to `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
inference-myengine = [
|
||||
"my-engine-sdk>=1.0",
|
||||
]
|
||||
```
|
||||
|
||||
### Required ABC Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `generate` | `(messages, *, model, temperature, max_tokens, **kwargs)` | `Dict[str, Any]` | Synchronous completion with `content` and `usage` keys |
|
||||
| `stream` | `(messages, *, model, temperature, max_tokens, **kwargs)` | `AsyncIterator[str]` | Yields token strings as they are generated |
|
||||
| `list_models` | `()` | `List[str]` | Model identifiers available on this engine |
|
||||
| `health` | `()` | `bool` | `True` when the engine is reachable |
|
||||
|
||||
The `generate` return dict must include at minimum:
|
||||
|
||||
```python
|
||||
{
|
||||
"content": "The response text",
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
"model": "model-name",
|
||||
"finish_reason": "stop", # or "tool_calls"
|
||||
}
|
||||
```
|
||||
|
||||
!!! tip "Tool call support"
|
||||
If your engine supports tool/function calling, include a `"tool_calls"`
|
||||
key in the return dict. Each tool call should have `id`, `name`, and
|
||||
`arguments` (JSON string) keys.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Memory Backend
|
||||
|
||||
Memory backends provide persistent, searchable storage. All backends implement
|
||||
the `MemoryBackend` ABC defined in `tools/storage/_stubs.py` (previously `memory/_stubs.py`).
|
||||
|
||||
### Complete Example
|
||||
|
||||
Create `src/openjarvis/tools/storage/my_backend.py`:
|
||||
|
||||
```python
|
||||
"""Custom memory backend example."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
||||
|
||||
|
||||
@MemoryRegistry.register("my_backend")
|
||||
class MyMemoryBackend(MemoryBackend):
|
||||
"""Custom memory backend implementation."""
|
||||
|
||||
backend_id = "my_backend"
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
# Initialize your storage (database, index, etc.)
|
||||
self._store: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def store(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
source: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Persist content and return a unique document id."""
|
||||
import uuid
|
||||
|
||||
doc_id = uuid.uuid4().hex
|
||||
self._store[doc_id] = {
|
||||
"content": content,
|
||||
"source": source,
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
return doc_id
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
**kwargs: Any,
|
||||
) -> List[RetrievalResult]:
|
||||
"""Search for query and return the top-k results."""
|
||||
results: List[RetrievalResult] = []
|
||||
for doc_id, doc in self._store.items():
|
||||
# Implement your search/ranking logic here
|
||||
if query.lower() in doc["content"].lower():
|
||||
results.append(RetrievalResult(
|
||||
content=doc["content"],
|
||||
score=1.0,
|
||||
source=doc["source"],
|
||||
metadata=doc["metadata"],
|
||||
))
|
||||
return results[:top_k]
|
||||
|
||||
def delete(self, doc_id: str) -> bool:
|
||||
"""Delete a document by id. Return True if it existed."""
|
||||
return self._store.pop(doc_id, None) is not None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all stored documents."""
|
||||
self._store.clear()
|
||||
```
|
||||
|
||||
### Register in `__init__.py`
|
||||
|
||||
Add to `src/openjarvis/tools/storage/__init__.py`:
|
||||
|
||||
```python
|
||||
try:
|
||||
import openjarvis.tools.storage.my_backend # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
```
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old `from openjarvis.memory._stubs import MemoryBackend` import path still works via backward-compatibility shims, but new code should use `openjarvis.tools.storage._stubs`.
|
||||
|
||||
### Required ABC Methods
|
||||
|
||||
| Method | Signature | Returns | Description |
|
||||
|---|---|---|---|
|
||||
| `store` | `(content, *, source, metadata)` | `str` | Persist content, return document ID |
|
||||
| `retrieve` | `(query, *, top_k, **kwargs)` | `List[RetrievalResult]` | Search and return ranked results |
|
||||
| `delete` | `(doc_id)` | `bool` | Delete by ID, return whether it existed |
|
||||
| `clear` | `()` | `None` | Remove all stored documents |
|
||||
|
||||
The `RetrievalResult` dataclass has these fields:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class RetrievalResult:
|
||||
content: str # The retrieved text
|
||||
score: float = 0.0 # Relevance score
|
||||
source: str = "" # Source identifier
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Agent
|
||||
|
||||
Agents implement the logic for handling queries, calling tools, and managing
|
||||
multi-turn interactions. There are two paths depending on whether your agent
|
||||
uses tools:
|
||||
|
||||
- **Path A: Non-tool agent** -- Extend `BaseAgent` directly
|
||||
- **Path B: Tool-using agent** -- Extend `ToolUsingAgent` (which sets `accepts_tools = True` and provides a `ToolExecutor`)
|
||||
|
||||
### Path A: Non-tool Agent (extending BaseAgent)
|
||||
|
||||
Create `src/openjarvis/agents/my_agent.py`:
|
||||
|
||||
```python
|
||||
"""Custom agent implementation — single-turn, no tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
|
||||
|
||||
@AgentRegistry.register("my_agent")
|
||||
class MyAgent(BaseAgent):
|
||||
"""Custom agent with specialized behavior."""
|
||||
|
||||
agent_id = "my_agent"
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
"""Execute the agent on input and return an AgentResult."""
|
||||
# Use BaseAgent helpers instead of manual event bus code
|
||||
self._emit_turn_start(input)
|
||||
|
||||
# Build messages from context + user input (with optional system prompt)
|
||||
messages = self._build_messages(
|
||||
input, context,
|
||||
system_prompt="You are a helpful assistant with specialized knowledge.",
|
||||
)
|
||||
|
||||
# Call engine.generate() with stored defaults (model, temperature, max_tokens)
|
||||
result = self._generate(messages)
|
||||
content = self._strip_think_tags(result.get("content", ""))
|
||||
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(content=content, turns=1)
|
||||
```
|
||||
|
||||
!!! tip "BaseAgent helpers"
|
||||
`BaseAgent` provides these concrete helpers so you don't need to manually
|
||||
manage the event bus or engine calls:
|
||||
|
||||
| Helper | Purpose |
|
||||
|--------|---------|
|
||||
| `_emit_turn_start(input)` | Publish `AGENT_TURN_START` |
|
||||
| `_emit_turn_end(**data)` | Publish `AGENT_TURN_END` |
|
||||
| `_build_messages(input, context, *, system_prompt)` | Assemble message list |
|
||||
| `_generate(messages, **kwargs)` | Call engine with stored defaults |
|
||||
| `_strip_think_tags(text)` | Remove `<think>` blocks |
|
||||
| `_max_turns_result(tool_results, turns, content)` | Standard max-turns result |
|
||||
|
||||
### Path B: Tool-using Agent (extending ToolUsingAgent)
|
||||
|
||||
Create `src/openjarvis/agents/my_tool_agent.py`:
|
||||
|
||||
```python
|
||||
"""Custom tool-using agent with a multi-turn loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
from openjarvis.tools._stubs import BaseTool
|
||||
|
||||
|
||||
@AgentRegistry.register("my_tool_agent")
|
||||
class MyToolAgent(ToolUsingAgent):
|
||||
"""Custom agent with tool-calling loop."""
|
||||
|
||||
agent_id = "my_tool_agent"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
tools: Optional[List[BaseTool]] = None,
|
||||
bus: Optional[EventBus] = None,
|
||||
max_turns: int = 10,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
engine, model, tools=tools, bus=bus,
|
||||
max_turns=max_turns, temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: Optional[AgentContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
self._emit_turn_start(input)
|
||||
|
||||
messages = self._build_messages(input, context)
|
||||
tools_spec = self._executor.get_openai_tools()
|
||||
all_tool_results: list[ToolResult] = []
|
||||
turns = 0
|
||||
|
||||
for _ in range(self._max_turns):
|
||||
turns += 1
|
||||
result = self._generate(messages, tools=tools_spec)
|
||||
content = result.get("content", "")
|
||||
tool_calls = result.get("tool_calls", [])
|
||||
|
||||
if not tool_calls:
|
||||
self._emit_turn_end(turns=turns)
|
||||
return AgentResult(
|
||||
content=content,
|
||||
tool_results=all_tool_results,
|
||||
turns=turns,
|
||||
)
|
||||
|
||||
# Execute each tool call
|
||||
for tc in tool_calls:
|
||||
call = ToolCall(
|
||||
id=tc.get("id", f"call_{turns}"),
|
||||
name=tc["name"],
|
||||
arguments=tc["arguments"],
|
||||
)
|
||||
tr = self._executor.execute(call)
|
||||
all_tool_results.append(tr)
|
||||
|
||||
# Max turns exceeded — use the standard helper
|
||||
return self._max_turns_result(all_tool_results, turns)
|
||||
```
|
||||
|
||||
!!! info "What ToolUsingAgent adds"
|
||||
`ToolUsingAgent` extends `BaseAgent` with:
|
||||
|
||||
- **`accepts_tools = True`** — enables `--tools` in CLI and `tools=` in SDK
|
||||
- **`self._executor`** — a `ToolExecutor` initialized from the provided tools
|
||||
- **`self._tools`** — the raw list of `BaseTool` instances
|
||||
- **`self._max_turns`** — configurable loop iteration limit (default: 10)
|
||||
|
||||
### Register in `__init__.py`
|
||||
|
||||
Add to `src/openjarvis/agents/__init__.py`:
|
||||
|
||||
```python
|
||||
try:
|
||||
import openjarvis.agents.my_agent # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
```
|
||||
|
||||
### Key Types
|
||||
|
||||
=== "AgentContext"
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class AgentContext:
|
||||
conversation: Conversation = field(default_factory=Conversation)
|
||||
tools: List[str] = field(default_factory=list)
|
||||
memory_results: List[Any] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
=== "AgentResult"
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class AgentResult:
|
||||
content: str
|
||||
tool_results: List[ToolResult] = field(default_factory=list)
|
||||
turns: int = 0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
Tools are callable capabilities that agents can invoke during multi-turn
|
||||
reasoning. All tools implement the `BaseTool` ABC from `tools/_stubs.py`.
|
||||
|
||||
### Complete Example
|
||||
|
||||
Create `src/openjarvis/tools/my_tool.py`:
|
||||
|
||||
```python
|
||||
"""Custom tool implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
|
||||
@ToolRegistry.register("my_tool")
|
||||
class MyTool(BaseTool):
|
||||
"""A custom tool that does something useful."""
|
||||
|
||||
tool_id = "my_tool"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
"""Return the tool specification."""
|
||||
return ToolSpec(
|
||||
name="my_tool",
|
||||
description="Does something useful with the provided input.",
|
||||
parameters={ # (1)!
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The input to process",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return",
|
||||
"default": 5,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
category="utility",
|
||||
cost_estimate=0.001, # Estimated cost in USD per call
|
||||
latency_estimate=0.5, # Estimated latency in seconds
|
||||
requires_confirmation=False,
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
"""Execute the tool with the given parameters."""
|
||||
query = params.get("query", "")
|
||||
max_results = params.get("max_results", 5)
|
||||
|
||||
if not query:
|
||||
return ToolResult(
|
||||
tool_name="my_tool",
|
||||
content="No query provided.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
# Your tool logic here
|
||||
result_text = f"Processed '{query}' (max_results={max_results})"
|
||||
|
||||
return ToolResult(
|
||||
tool_name="my_tool",
|
||||
content=result_text,
|
||||
success=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name="my_tool",
|
||||
content=f"Error: {exc}",
|
||||
success=False,
|
||||
)
|
||||
```
|
||||
|
||||
1. The `parameters` dict follows the [JSON Schema](https://json-schema.org/)
|
||||
format used by OpenAI function calling. The `ToolExecutor` will parse
|
||||
incoming JSON arguments and pass them as keyword arguments to `execute()`.
|
||||
|
||||
### Register in `__init__.py`
|
||||
|
||||
Add to `src/openjarvis/tools/__init__.py`:
|
||||
|
||||
```python
|
||||
try:
|
||||
import openjarvis.tools.my_tool # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
```
|
||||
|
||||
### How Tools Are Invoked
|
||||
|
||||
The `ToolExecutor` handles the dispatch loop:
|
||||
|
||||
1. The agent's LLM generates a `tool_calls` response with tool name and
|
||||
JSON arguments
|
||||
2. `ToolExecutor.execute()` parses the JSON arguments
|
||||
3. The matching tool's `execute(**params)` is called
|
||||
4. The `ToolResult` is returned to the agent for the next turn
|
||||
|
||||
```python
|
||||
from openjarvis.tools._stubs import ToolExecutor
|
||||
|
||||
executor = ToolExecutor(
|
||||
tools=[MyTool()],
|
||||
bus=event_bus, # Optional — enables TOOL_CALL_START/END events
|
||||
)
|
||||
|
||||
# Dispatch a tool call
|
||||
from openjarvis.core.types import ToolCall
|
||||
|
||||
call = ToolCall(id="call_1", name="my_tool", arguments='{"query": "test"}')
|
||||
result = executor.execute(call)
|
||||
```
|
||||
|
||||
The `to_openai_function()` method converts a tool's spec to OpenAI function
|
||||
calling format, which is sent to the LLM alongside the conversation:
|
||||
|
||||
```python
|
||||
tool = MyTool()
|
||||
openai_format = tool.to_openai_function()
|
||||
# {
|
||||
# "type": "function",
|
||||
# "function": {
|
||||
# "name": "my_tool",
|
||||
# "description": "Does something useful...",
|
||||
# "parameters": { ... }
|
||||
# }
|
||||
# }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Benchmark
|
||||
|
||||
Benchmarks measure engine performance. All benchmarks implement the
|
||||
`BaseBenchmark` ABC from `bench/_stubs.py` and use the `ensure_registered()`
|
||||
pattern for lazy registration.
|
||||
|
||||
### Complete Example
|
||||
|
||||
Create `src/openjarvis/bench/my_benchmark.py`:
|
||||
|
||||
```python
|
||||
"""Custom benchmark — measures time to first token."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from openjarvis.bench._stubs import BaseBenchmark, BenchmarkResult
|
||||
from openjarvis.core.registry import BenchmarkRegistry
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
|
||||
|
||||
class TTFTBenchmark(BaseBenchmark):
|
||||
"""Measures time-to-first-token across multiple samples."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "ttft"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Measures time-to-first-token latency"
|
||||
|
||||
def run(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
num_samples: int = 10,
|
||||
) -> BenchmarkResult:
|
||||
ttft_values: list[float] = []
|
||||
errors = 0
|
||||
|
||||
for _ in range(num_samples):
|
||||
messages = [Message(role=Role.USER, content="Hello")]
|
||||
t0 = time.time()
|
||||
try:
|
||||
engine.generate(messages, model=model)
|
||||
ttft_values.append(time.time() - t0)
|
||||
except Exception:
|
||||
errors += 1
|
||||
|
||||
if not ttft_values:
|
||||
return BenchmarkResult(
|
||||
benchmark_name=self.name,
|
||||
model=model,
|
||||
engine=engine.engine_id,
|
||||
metrics={},
|
||||
samples=num_samples,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return BenchmarkResult(
|
||||
benchmark_name=self.name,
|
||||
model=model,
|
||||
engine=engine.engine_id,
|
||||
metrics={
|
||||
"mean_ttft": sum(ttft_values) / len(ttft_values),
|
||||
"min_ttft": min(ttft_values),
|
||||
"max_ttft": max(ttft_values),
|
||||
},
|
||||
samples=num_samples,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def ensure_registered() -> None: # (1)!
|
||||
"""Register the TTFT benchmark if not already present."""
|
||||
if not BenchmarkRegistry.contains("ttft"):
|
||||
BenchmarkRegistry.register_value("ttft", TTFTBenchmark)
|
||||
```
|
||||
|
||||
1. The `ensure_registered()` function uses `contains()` before
|
||||
`register_value()` so it can be called multiple times safely. This is
|
||||
required because tests clear all registries between runs.
|
||||
|
||||
### Register in `__init__.py`
|
||||
|
||||
Update `src/openjarvis/bench/__init__.py` to call `ensure_registered()`:
|
||||
|
||||
```python
|
||||
from openjarvis.bench.my_benchmark import ensure_registered as _reg_ttft
|
||||
_reg_ttft()
|
||||
```
|
||||
|
||||
### BenchmarkResult Fields
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class BenchmarkResult:
|
||||
benchmark_name: str # e.g. "latency", "throughput"
|
||||
model: str # Model identifier
|
||||
engine: str # Engine identifier
|
||||
metrics: Dict[str, float] = ... # Measured values
|
||||
metadata: Dict[str, Any] = ... # Extra info
|
||||
samples: int = 0 # Number of samples run
|
||||
errors: int = 0 # Number of failed samples
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Router Policy
|
||||
|
||||
Router policies determine which model handles a given query. All policies
|
||||
implement the `RouterPolicy` ABC from `learning/_stubs.py`. The
|
||||
`RoutingContext` dataclass is defined in `core/types.py`.
|
||||
|
||||
### Complete Example
|
||||
|
||||
Create `src/openjarvis/learning/my_policy.py`:
|
||||
|
||||
```python
|
||||
"""Custom router policy — selects model based on query length."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from openjarvis.core.registry import RouterPolicyRegistry
|
||||
from openjarvis.core.types import RoutingContext
|
||||
from openjarvis.learning._stubs import RouterPolicy
|
||||
|
||||
|
||||
class QueryLengthPolicy(RouterPolicy):
|
||||
"""Routes queries to models based on query length.
|
||||
|
||||
Short queries go to a fast, small model. Long or complex queries
|
||||
go to a larger, more capable model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
available_models: Optional[List[str]] = None,
|
||||
*,
|
||||
default_model: str = "",
|
||||
fallback_model: str = "",
|
||||
short_threshold: int = 100,
|
||||
long_threshold: int = 500,
|
||||
) -> None:
|
||||
self._available = available_models or []
|
||||
self._default = default_model
|
||||
self._fallback = fallback_model
|
||||
self._short_threshold = short_threshold
|
||||
self._long_threshold = long_threshold
|
||||
|
||||
def select_model(self, context: RoutingContext) -> str:
|
||||
"""Return the model registry key best suited for this context."""
|
||||
available = self._available
|
||||
|
||||
if not available:
|
||||
return self._default or self._fallback or ""
|
||||
|
||||
if context.query_length < self._short_threshold:
|
||||
# Prefer the first (presumably smallest) available model
|
||||
return available[0]
|
||||
elif context.query_length > self._long_threshold:
|
||||
# Prefer the last (presumably largest) available model
|
||||
return available[-1]
|
||||
|
||||
# Default to configured model
|
||||
if self._default and self._default in available:
|
||||
return self._default
|
||||
return available[0]
|
||||
|
||||
|
||||
def ensure_registered() -> None:
|
||||
"""Register QueryLengthPolicy if not already present."""
|
||||
if not RouterPolicyRegistry.contains("query_length"):
|
||||
RouterPolicyRegistry.register_value("query_length", QueryLengthPolicy)
|
||||
|
||||
|
||||
ensure_registered()
|
||||
```
|
||||
|
||||
### Register in `__init__.py`
|
||||
|
||||
Update `src/openjarvis/learning/__init__.py`:
|
||||
|
||||
```python
|
||||
from openjarvis.learning.my_policy import ensure_registered as _reg_ql
|
||||
_reg_ql()
|
||||
```
|
||||
|
||||
### Using Your Policy
|
||||
|
||||
Once registered, your policy can be selected via the config file or CLI:
|
||||
|
||||
=== "Config (TOML)"
|
||||
|
||||
```toml
|
||||
[learning.routing]
|
||||
policy = "query_length"
|
||||
```
|
||||
|
||||
=== "CLI"
|
||||
|
||||
```bash
|
||||
uv run jarvis ask --router query_length "Hello"
|
||||
```
|
||||
|
||||
### The RoutingContext
|
||||
|
||||
The `RoutingContext` dataclass provides all the information a router needs:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class RoutingContext:
|
||||
query: str = ""
|
||||
query_length: int = 0
|
||||
has_code: bool = False
|
||||
has_math: bool = False
|
||||
language: str = "en"
|
||||
urgency: float = 0.5 # 0 = low priority, 1 = real-time
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
The `build_routing_context()` helper in `learning/router.py` populates
|
||||
this from a raw query string, detecting code and math patterns automatically.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Component | ABC | Registry | Key location |
|
||||
|---|---|---|---|
|
||||
| Inference Engine | `InferenceEngine` | `EngineRegistry` | `engine/_stubs.py` |
|
||||
| Memory Backend | `MemoryBackend` | `MemoryRegistry` | `tools/storage/_stubs.py` |
|
||||
| Agent | `BaseAgent` | `AgentRegistry` | `agents/_stubs.py` |
|
||||
| Tool | `BaseTool` | `ToolRegistry` | `tools/_stubs.py` |
|
||||
| Benchmark | `BaseBenchmark` | `BenchmarkRegistry` | `bench/_stubs.py` |
|
||||
| Router Policy | `RouterPolicy` | `RouterPolicyRegistry` | `learning/_stubs.py` |
|
||||
| Learning Policy | `LearningPolicy` | `LearningRegistry` | `learning/_stubs.py` |
|
||||
|
||||
The general pattern for all extension points:
|
||||
|
||||
1. Implement the ABC in a new module
|
||||
2. Decorate the class with `@XRegistry.register("key")` or use
|
||||
`ensure_registered()` for lazy registration
|
||||
3. Import the module in the package's `__init__.py` (with `try/except
|
||||
ImportError` if optional deps are involved)
|
||||
4. Add tests in `tests/<module>/`
|
||||
5. Add optional dependencies to `pyproject.toml` if needed
|
||||
@@ -0,0 +1,86 @@
|
||||
# Roadmap
|
||||
|
||||
OpenJarvis development follows a phased approach, with each version adding
|
||||
a major primitive or cross-cutting capability to the framework.
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
| Version | Phase | Status | Delivers |
|
||||
|---|---|---|---|
|
||||
| **v0.1** | Phase 0 -- Scaffolding | :material-check-circle:{ .green } Complete | Project scaffolding, registry system (`RegistryBase[T]`), core types (`Message`, `ModelSpec`, `Conversation`, `ToolResult`), configuration loader with hardware detection, Click CLI skeleton |
|
||||
| **v0.2** | Phase 1 -- Intelligence + Inference | :material-check-circle:{ .green } Complete | Intelligence primitive (model catalog, heuristic router), inference engines (Ollama, vLLM, llama.cpp), engine discovery and health probing, `jarvis ask` command working end-to-end |
|
||||
| **v0.3** | Phase 2 -- Memory | :material-check-circle:{ .green } Complete | Memory backends (SQLite/FTS5, FAISS, ColBERTv2, BM25, Hybrid/RRF), document chunking and ingestion pipeline, context injection with source attribution, `jarvis memory` commands |
|
||||
| **v0.4** | Phase 3 -- Agents + Tools + Server | :material-check-circle:{ .green } Complete | Agent system (SimpleAgent, OrchestratorAgent), tool system (Calculator, Think, Retrieval, LLM, FileRead), ToolExecutor dispatch engine, OpenAI-compatible API server (`jarvis serve`) |
|
||||
| **v0.5** | Phase 4 -- Learning + Telemetry | :material-check-circle:{ .green } Complete | Learning system (HeuristicRouter policy, TraceDrivenPolicy, GRPO stub), reward functions, telemetry aggregation (per-model/engine stats, export), `--router` CLI flag, `jarvis telemetry` commands |
|
||||
| **v1.0** | Phase 5 -- SDK + Production | :material-check-circle:{ .green } Complete | Python SDK (`Jarvis` class, `MemoryHandle`), multi-platform channel system (Telegram, Discord, Slack, WhatsApp, etc.), benchmarking framework (latency, throughput), Docker deployment (CPU + GPU), MkDocs documentation site |
|
||||
| **v1.1** | Phase 6 -- Traces + Learning | :material-check-circle:{ .green } Complete | Trace system (`TraceStore`, `TraceCollector`, `TraceAnalyzer`), trace-driven learning, MCP integration layer |
|
||||
| **v1.5** | Phase 10 -- Agent Restructuring | :material-check-circle:{ .green } Complete | BaseAgent helpers, ToolUsingAgent intermediate base, NativeReActAgent, NativeOpenHandsAgent, RLMAgent, OpenHandsAgent (SDK), `accepts_tools` introspection, backward-compat shims, CustomAgent removed |
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
OpenJarvis v1.5 (Phase 10) is complete. The framework provides:
|
||||
|
||||
- **Four core abstractions** -- Intelligence, Engine, Agentic Logic, Memory -- each with an ABC interface and registry-based discovery
|
||||
- **Five inference engines** -- Ollama, vLLM, llama.cpp, SGLang, Cloud (OpenAI/Anthropic/Google)
|
||||
- **Five memory backends** -- SQLite/FTS5, FAISS, ColBERTv2, BM25, Hybrid (RRF fusion)
|
||||
- **Seven agent types** -- Simple, Orchestrator, NativeReAct, NativeOpenHands, RLM, Operative, MonitorOperative
|
||||
- **Seven built-in tools** -- Calculator, Think, Retrieval, LLM, FileRead, WebSearch, CodeInterpreter
|
||||
- **Python SDK** -- `Jarvis` class for programmatic use
|
||||
- **OpenAI-compatible API server** -- `POST /v1/chat/completions`, `GET /v1/models`
|
||||
- **Benchmarking framework** -- Latency and throughput measurements
|
||||
- **Telemetry and traces** -- SQLite-backed recording and aggregation
|
||||
- **Docker deployment** -- CPU and GPU images with docker-compose
|
||||
|
||||
Phase 10 (Agent Restructuring) is complete. The agent hierarchy has been
|
||||
refactored with `BaseAgent` helpers, `ToolUsingAgent` intermediate base, and
|
||||
four new agent types (NativeReActAgent, NativeOpenHandsAgent, RLMAgent,
|
||||
OpenHandsAgent SDK).
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 Details
|
||||
|
||||
Phase 10 refactored the agent hierarchy for composability and extensibility:
|
||||
|
||||
### BaseAgent Helpers
|
||||
|
||||
- **`_emit_turn_start` / `_emit_turn_end`** -- Event bus integration without boilerplate
|
||||
- **`_build_messages`** -- System prompt + context + input assembly
|
||||
- **`_generate`** -- Engine call with stored defaults
|
||||
- **`_max_turns_result`** -- Standard max-turns-exceeded result
|
||||
- **`_strip_think_tags`** -- Remove `<think>` blocks from model output
|
||||
|
||||
### ToolUsingAgent Intermediate Base
|
||||
|
||||
- Sets `accepts_tools = True` for CLI/SDK introspection
|
||||
- Initializes `ToolExecutor` from provided tools
|
||||
- Configurable `max_turns` loop limit
|
||||
|
||||
### New Agent Types
|
||||
|
||||
- **NativeReActAgent** (`native_react`, alias `react`) -- Thought-Action-Observation loop
|
||||
- **NativeOpenHandsAgent** (`native_openhands`) -- CodeAct-style code execution with URL pre-fetching
|
||||
- **RLMAgent** (`rlm`) -- Recursive LM with persistent REPL and sub-LM calls
|
||||
- **OpenHandsAgent** (`openhands`) -- Thin wrapper for real `openhands-sdk`
|
||||
|
||||
---
|
||||
|
||||
## Future Directions
|
||||
|
||||
Beyond Phase 10, areas of ongoing exploration include:
|
||||
|
||||
- **GRPO training** -- Reinforcement learning from trace data to train the
|
||||
routing policy, moving beyond heuristics and simple statistics
|
||||
- **Streaming telemetry** -- Real-time performance dashboards and alerting
|
||||
- **Multi-model orchestration** -- Coordinating multiple models within a
|
||||
single query pipeline (e.g., small model for classification, large model
|
||||
for generation)
|
||||
- **Federated memory** -- Memory backends that synchronize across devices
|
||||
- **Plugin ecosystem** -- Community-contributed engines, tools, and agents
|
||||
distributed as Python packages
|
||||
- **Energy-aware routing** -- Using power consumption data from telemetry to
|
||||
optimize for energy efficiency alongside latency and quality
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
title: Downloads
|
||||
description: Download the OpenJarvis desktop app, browser app, CLI, or Python SDK
|
||||
---
|
||||
|
||||
# Downloads
|
||||
|
||||
OpenJarvis runs entirely on your hardware. Choose the interface that fits your workflow.
|
||||
|
||||
---
|
||||
|
||||
## Desktop App
|
||||
|
||||
The desktop app is a native window for the OpenJarvis chat UI. All inference and backend
|
||||
processing happens on your local machine — the app connects to the backend you start locally.
|
||||
|
||||
!!! info "Backend required"
|
||||
Start the backend before opening the desktop app. The quickstart script handles everything:
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git && cd OpenJarvis
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
### Download
|
||||
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) | M1/M2/M3/M4 Macs |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) | Windows 10+ |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) | Ubuntu, Debian |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) | Fedora, RHEL |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) | Any distro |
|
||||
|
||||
!!! tip "All releases"
|
||||
Browse all versions on the [GitHub Releases](https://github.com/open-jarvis/OpenJarvis/releases) page.
|
||||
|
||||
### macOS: "app is damaged" fix
|
||||
|
||||
macOS Gatekeeper quarantines apps downloaded from the internet that aren't notarized
|
||||
by Apple. If you see **"OpenJarvis is damaged and can't be opened"**, run this in
|
||||
Terminal to clear the quarantine flag:
|
||||
|
||||
```bash
|
||||
xattr -cr /Applications/OpenJarvis.app
|
||||
```
|
||||
|
||||
Then open the app normally. If you installed from the DMG but haven't moved it to
|
||||
`/Applications` yet, point the command at wherever the `.app` bundle is:
|
||||
|
||||
```bash
|
||||
xattr -cr ~/Downloads/OpenJarvis.app
|
||||
```
|
||||
|
||||
!!! note
|
||||
This is standard for open-source macOS apps distributed outside the App Store.
|
||||
The command removes the quarantine extended attribute — it does not modify the app.
|
||||
|
||||
### What's included
|
||||
|
||||
The desktop app provides:
|
||||
|
||||
- **Full chat UI** — same interface as the browser app, in a native window
|
||||
- **Energy monitoring** — real-time power consumption tracking
|
||||
- **Telemetry dashboard** — token throughput, latency, and cost comparison vs. cloud models
|
||||
- **System tray** — quick access without keeping a terminal open
|
||||
|
||||
The backend (Ollama, Python API server, inference) runs separately on your machine.
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis/desktop
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
The built installer will be in `desktop/src-tauri/target/release/bundle/`.
|
||||
|
||||
---
|
||||
|
||||
## Browser App
|
||||
|
||||
Run the full chat UI in your browser. Everything stays local — the backend runs on
|
||||
your machine and the frontend connects via `localhost`.
|
||||
|
||||
### One-command setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
The script handles everything:
|
||||
|
||||
1. Checks for Python 3.10+ and Node.js 18+
|
||||
2. Installs Ollama if not present and pulls a starter model
|
||||
3. Installs Python and frontend dependencies
|
||||
4. Starts the backend API server and frontend dev server
|
||||
5. Opens `http://localhost:5173` in your browser
|
||||
|
||||
### Manual setup
|
||||
|
||||
If you prefer to run each step yourself:
|
||||
|
||||
=== "Step 1: Clone and install"
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
|
||||
=== "Step 2: Start Ollama"
|
||||
|
||||
```bash
|
||||
# Install from https://ollama.com if not already installed
|
||||
ollama serve &
|
||||
ollama pull qwen3:0.6b
|
||||
```
|
||||
|
||||
=== "Step 3: Start backend"
|
||||
|
||||
```bash
|
||||
uv run jarvis serve --port 8000
|
||||
```
|
||||
|
||||
=== "Step 4: Start frontend"
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then open [http://localhost:5173](http://localhost:5173).
|
||||
|
||||
### What you get
|
||||
|
||||
- **Chat interface** — markdown rendering, streaming responses, conversation history
|
||||
- **Tool use** — calculator, web search, code interpreter, file I/O
|
||||
- **System panel** — live telemetry, energy monitoring, cost comparison vs. cloud models
|
||||
- **Dashboard** — energy graphs, trace debugging, cost breakdown
|
||||
- **Settings** — model selection, agent configuration, theme toggle
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The command-line interface is the fastest way to interact with OpenJarvis
|
||||
programmatically. Every feature is accessible from the terminal.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
jarvis --version
|
||||
# jarvis, version 0.1.0
|
||||
```
|
||||
|
||||
### First commands
|
||||
|
||||
```bash
|
||||
# Ask a question
|
||||
jarvis ask "What is the capital of France?"
|
||||
|
||||
# Use an agent with tools
|
||||
jarvis ask --agent orchestrator --tools calculator "What is 137 * 42?"
|
||||
|
||||
# Start the API server
|
||||
jarvis serve --port 8000
|
||||
|
||||
# Run diagnostics
|
||||
jarvis doctor
|
||||
|
||||
# List available models
|
||||
jarvis model list
|
||||
|
||||
# Interactive chat
|
||||
jarvis chat
|
||||
```
|
||||
|
||||
!!! info "Inference backend required"
|
||||
The CLI requires a running inference backend (e.g., Ollama). See the
|
||||
[Installation guide](getting-started/installation.md#setting-up-an-inference-backend)
|
||||
for setup instructions.
|
||||
|
||||
---
|
||||
|
||||
## Python SDK
|
||||
|
||||
For programmatic access, the `Jarvis` class provides a high-level sync API.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Quick example
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
print(j.ask("Explain quicksort in two sentences."))
|
||||
j.close()
|
||||
```
|
||||
|
||||
### With agents and tools
|
||||
|
||||
```python
|
||||
result = j.ask_full(
|
||||
"What is the square root of 144?",
|
||||
agent="orchestrator",
|
||||
tools=["calculator", "think"],
|
||||
)
|
||||
print(result["content"]) # "12"
|
||||
print(result["tool_results"]) # tool invocations
|
||||
print(result["turns"]) # number of agent turns
|
||||
```
|
||||
|
||||
### Composition layer
|
||||
|
||||
For full control, use the `SystemBuilder`:
|
||||
|
||||
```python
|
||||
from openjarvis import SystemBuilder
|
||||
|
||||
system = (
|
||||
SystemBuilder()
|
||||
.engine("ollama")
|
||||
.model("qwen3:8b")
|
||||
.agent("orchestrator")
|
||||
.tools(["calculator", "web_search", "file_read"])
|
||||
.enable_telemetry()
|
||||
.enable_traces()
|
||||
.build()
|
||||
)
|
||||
|
||||
result = system.ask("Summarize the latest AI news.")
|
||||
system.close()
|
||||
```
|
||||
|
||||
See the [Python SDK guide](user-guide/python-sdk.md) for the full API reference.
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Generate the code reference pages."""
|
||||
from pathlib import Path
|
||||
|
||||
import mkdocs_gen_files
|
||||
|
||||
nav = mkdocs_gen_files.Nav()
|
||||
src = Path("src")
|
||||
|
||||
for path in sorted(src.rglob("*.py")):
|
||||
module_path = path.relative_to(src).with_suffix("")
|
||||
doc_path = path.relative_to(src).with_suffix(".md")
|
||||
full_doc_path = Path("api-reference", doc_path)
|
||||
|
||||
parts = tuple(module_path.parts)
|
||||
if parts[-1] == "__init__":
|
||||
parts = parts[:-1]
|
||||
doc_path = doc_path.with_name("index.md")
|
||||
full_doc_path = full_doc_path.with_name("index.md")
|
||||
elif parts[-1].startswith("_"):
|
||||
continue
|
||||
|
||||
nav[parts] = doc_path.as_posix()
|
||||
|
||||
with mkdocs_gen_files.open(full_doc_path, "w") as fd:
|
||||
identifier = ".".join(parts)
|
||||
fd.write(f"::: {identifier}")
|
||||
|
||||
mkdocs_gen_files.set_edit_path(full_doc_path, path)
|
||||
|
||||
with mkdocs_gen_files.open("api-reference/SUMMARY.md", "w") as nav_file:
|
||||
nav_file.writelines(nav.build_literate_nav())
|
||||
@@ -0,0 +1,983 @@
|
||||
---
|
||||
title: Configuration
|
||||
description: Complete reference for OpenJarvis configuration
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
OpenJarvis uses a TOML configuration file to control engine selection, model identity, memory backends, agent behavior, and more. This page is the complete reference for every configuration option, organized by primitive.
|
||||
|
||||
## Config File Location
|
||||
|
||||
The configuration file lives at:
|
||||
|
||||
```
|
||||
~/.openjarvis/config.toml
|
||||
```
|
||||
|
||||
OpenJarvis creates the `~/.openjarvis/` directory and populates it with a default config when you run `jarvis init`.
|
||||
|
||||
## Generating Configuration
|
||||
|
||||
### First-Time Setup
|
||||
|
||||
```bash
|
||||
jarvis init
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
1. Runs hardware auto-detection (GPU vendor/model/VRAM, CPU brand/cores, RAM)
|
||||
2. Selects the recommended engine based on your hardware
|
||||
3. Writes `~/.openjarvis/config.toml` with sensible defaults
|
||||
|
||||
### Regenerating Configuration
|
||||
|
||||
To overwrite an existing config:
|
||||
|
||||
```bash
|
||||
jarvis init --force
|
||||
```
|
||||
|
||||
!!! warning
|
||||
`--force` overwrites your existing config file. Back up your config first if you have custom settings.
|
||||
|
||||
## Configuration Sections
|
||||
|
||||
The config file is organized into TOML sections corresponding to the five primitives. Every field has a default value, so you only need to specify values you want to change.
|
||||
|
||||
---
|
||||
|
||||
### `[engine]` — Inference Engine
|
||||
|
||||
Controls which inference engine is used and how each engine is reached. Engine settings are now **nested** under per-engine sub-sections instead of flat fields.
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[engine.ollama]
|
||||
host = "http://localhost:11434"
|
||||
|
||||
[engine.vllm]
|
||||
host = "http://localhost:8000"
|
||||
|
||||
[engine.sglang]
|
||||
host = "http://localhost:30000"
|
||||
|
||||
# [engine.llamacpp]
|
||||
# host = "http://localhost:8080"
|
||||
# binary_path = ""
|
||||
```
|
||||
|
||||
**`[engine]` top-level:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `default` | string | Auto-detected | Default engine backend. One of: `ollama`, `vllm`, `llamacpp`, `sglang`, `cloud`. Set automatically by `jarvis init` based on hardware detection. |
|
||||
|
||||
**`[engine.ollama]`:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `host` | string | `http://localhost:11434` | Base URL for the Ollama API server. |
|
||||
|
||||
**`[engine.vllm]`:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `host` | string | `http://localhost:8000` | Base URL for the vLLM OpenAI-compatible server. |
|
||||
|
||||
**`[engine.sglang]`:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `host` | string | `http://localhost:30000` | Base URL for the SGLang server. |
|
||||
|
||||
**`[engine.llamacpp]`:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `host` | string | `http://localhost:8080` | Base URL for the llama.cpp HTTP server (`llama-server`). |
|
||||
| `binary_path` | string | `""` | Path to the llama.cpp binary, if not on `$PATH`. |
|
||||
|
||||
!!! tip "Engine fallback"
|
||||
If the configured default engine is unreachable, OpenJarvis automatically probes all registered engines and falls back to any healthy one.
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old flat field names (`ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, `sglang_host`) are still accepted as backward-compatible properties. New configurations should use the nested sub-section format.
|
||||
|
||||
---
|
||||
|
||||
### `[intelligence]` — Model Identity and Generation Defaults
|
||||
|
||||
Controls which model is used, its weight paths, quantization, and the default sampling parameters for generation. Generation parameters such as `temperature` and `max_tokens` now live here rather than under `[agent]`.
|
||||
|
||||
```toml
|
||||
[intelligence]
|
||||
default_model = ""
|
||||
fallback_model = ""
|
||||
# model_path = ""
|
||||
# checkpoint_path = ""
|
||||
# quantization = "none"
|
||||
# preferred_engine = ""
|
||||
# provider = ""
|
||||
temperature = 0.7
|
||||
max_tokens = 1024
|
||||
# top_p = 0.9
|
||||
# top_k = 40
|
||||
# repetition_penalty = 1.0
|
||||
# stop_sequences = ""
|
||||
```
|
||||
|
||||
**Model identity fields:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `default_model` | string | `""` | Preferred model identifier (e.g., `qwen3:8b`). When empty, the router policy selects the model dynamically. |
|
||||
| `fallback_model` | string | `""` | Model to use if the default is unavailable. |
|
||||
| `model_path` | string | `""` | Path or HuggingFace repo ID for local weights (e.g., `"./models/qwen3-8b.gguf"` or `"Qwen/Qwen3-8B"`). |
|
||||
| `checkpoint_path` | string | `""` | Path to a fine-tuned checkpoint or LoRA adapter directory. |
|
||||
| `quantization` | string | `"none"` | Quantization format. Accepted values: `none`, `fp8`, `int8`, `int4`, `gguf_q4`, `gguf_q8`. |
|
||||
| `preferred_engine` | string | `""` | Override engine for this model (e.g., `"vllm"`). Takes priority over `engine.default`. |
|
||||
| `provider` | string | `""` | Model provider hint: `local`, `openai`, `anthropic`, `google`. Used by the Cloud engine to route API calls. |
|
||||
|
||||
**Generation default fields** (overridable per-call):
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `temperature` | float | `0.7` | Sampling temperature. Lower values produce more deterministic output. |
|
||||
| `max_tokens` | int | `1024` | Maximum number of tokens to generate per call. |
|
||||
| `top_p` | float | `0.9` | Nucleus sampling probability mass. |
|
||||
| `top_k` | int | `40` | Top-k sampling: only consider the top-k tokens at each step. |
|
||||
| `repetition_penalty` | float | `1.0` | Penalize repeated tokens. Values > 1 reduce repetition. |
|
||||
| `stop_sequences` | string | `""` | Comma-separated stop strings. Generation halts when any stop string is produced. |
|
||||
|
||||
When both `default_model` and `fallback_model` are empty, OpenJarvis uses the configured router policy (see `[learning]`) to select a model from those available on the active engine.
|
||||
|
||||
### Engine Selection Priority
|
||||
|
||||
When resolving which engine to use for a model, `SystemBuilder`, `sdk.py`, and `cli/ask.py` check fields in this order:
|
||||
|
||||
```
|
||||
1. Explicit --engine CLI flag or engine_key= SDK parameter
|
||||
2. config.intelligence.preferred_engine
|
||||
3. config.engine.default
|
||||
4. First healthy engine discovered at runtime
|
||||
```
|
||||
|
||||
This lets you pin a specific model to a specific engine without changing the global engine default:
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[intelligence]
|
||||
default_model = "llama3.2:3b"
|
||||
model_path = "./models/llama-3.2-3b.Q4_K_M.gguf"
|
||||
quantization = "gguf_q4"
|
||||
preferred_engine = "llamacpp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `[agent]` — Agent Behavior
|
||||
|
||||
Controls the default agent, turn limits, tool selection, system prompt, and memory context injection.
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
default_agent = "simple"
|
||||
max_turns = 10
|
||||
# tools = ""
|
||||
# objective = ""
|
||||
# system_prompt = ""
|
||||
# system_prompt_path = ""
|
||||
context_from_memory = true
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `default_agent` | string | `"simple"` | Default agent to use. Available: `simple`, `orchestrator`, `react`, `operative`, `monitor_operative`. |
|
||||
| `max_turns` | int | `10` | Maximum number of tool-calling turns for the orchestrator agent before it must produce a final answer. |
|
||||
| `tools` | string | `""` | Comma-separated list of tools to enable by default (e.g., `"calculator,think"`). |
|
||||
| `objective` | string | `""` | Concise purpose string for routing, learning, and documentation. |
|
||||
| `system_prompt` | string | `""` | Inline system prompt. Takes precedence over `system_prompt_path` when set. |
|
||||
| `system_prompt_path` | string | `""` | Path to a system prompt file (`.txt` or `.md`). |
|
||||
| `context_from_memory` | bool | `true` | Whether to automatically inject relevant memory context into queries. |
|
||||
|
||||
!!! note "Generation parameters moved"
|
||||
`temperature` and `max_tokens` have moved from `[agent]` to `[intelligence]`. Old configs with these fields under `[agent]` are automatically migrated to `[intelligence]` at load time.
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old field name `default_tools` is still accepted as a backward-compatible property for `tools`. New configurations should use `tools`.
|
||||
|
||||
!!! info "Context injection"
|
||||
When `context_from_memory = true` and documents have been indexed, every query automatically searches memory for relevant chunks and prepends them as system context. This gives the model access to your indexed knowledge base without any extra steps. Disable with `--no-context` on the CLI or `context=False` in the SDK.
|
||||
|
||||
---
|
||||
|
||||
### `[learning]` — Learning Policies
|
||||
|
||||
Controls whether the learning system is enabled and configures per-primitive policies through nested sub-sections.
|
||||
|
||||
```toml
|
||||
[learning]
|
||||
enabled = false
|
||||
update_interval = 100
|
||||
# auto_update = false
|
||||
|
||||
[learning.routing]
|
||||
policy = "heuristic"
|
||||
# min_samples = 5
|
||||
|
||||
# [learning.intelligence]
|
||||
# policy = "none"
|
||||
|
||||
# [learning.agent]
|
||||
# policy = "none"
|
||||
|
||||
# [learning.metrics]
|
||||
# accuracy_weight = 0.6
|
||||
# latency_weight = 0.2
|
||||
# cost_weight = 0.1
|
||||
# efficiency_weight = 0.1
|
||||
```
|
||||
|
||||
**`[learning]` top-level:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Whether the learning system is active. |
|
||||
| `update_interval` | int | `100` | Number of traces between automatic policy updates. |
|
||||
| `auto_update` | bool | `false` | Whether to trigger policy updates automatically when the interval is reached. |
|
||||
|
||||
**`[learning.routing]` — Router policy:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `policy` | string | `"heuristic"` | Router policy for model selection. Available: `heuristic`, `learned` (trace-driven), `sft` (supervised fine-tuning), `grpo` (RL stub). |
|
||||
| `min_samples` | int | `5` | Minimum number of traces required before trusting a learned routing decision. |
|
||||
|
||||
**`[learning.intelligence]` — Intelligence learning policy:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `policy` | string | `"none"` | Intelligence learning policy. Available: `none`, `sft`. Use `sft` to learn model routing from accumulated traces. |
|
||||
|
||||
**`[learning.agent]` — Agent learning policy:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `policy` | string | `"none"` | Agent learning policy. Available: `none`, `agent_advisor`, `icl_updater`. |
|
||||
| `max_icl_examples` | int | `20` | Maximum number of in-context examples to maintain in the ICL example library. |
|
||||
| `advisor_confidence_threshold` | float | `0.7` | Minimum confidence score for the advisor to recommend a strategy change. |
|
||||
|
||||
**`[learning.metrics]` — Reward / optimization metric weights:**
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `accuracy_weight` | float | `0.6` | Weight for outcome accuracy in the composite reward score. |
|
||||
| `latency_weight` | float | `0.2` | Weight for inference latency in the composite reward score. |
|
||||
| `cost_weight` | float | `0.1` | Weight for per-call cost in the composite reward score. |
|
||||
| `efficiency_weight` | float | `0.1` | Weight for token efficiency in the composite reward score. |
|
||||
|
||||
**Router policies:**
|
||||
|
||||
| Policy | Description |
|
||||
|--------|-------------|
|
||||
| `heuristic` | Rule-based selection using 6 priority rules. Considers model availability, parameter count, context length, and query characteristics. Default. |
|
||||
| `learned` | Trace-driven policy that learns from past interaction outcomes stored in the trace system. |
|
||||
| `sft` | Supervised fine-tuning policy that learns routing from labeled trace data. |
|
||||
| `grpo` | Group Relative Policy Optimization stub for future RL-based routing. |
|
||||
|
||||
**Agent policies:**
|
||||
|
||||
| Policy | Description |
|
||||
|--------|-------------|
|
||||
| `agent_advisor` | Advises on agent strategy (tool sets, turn limits) based on trace patterns. |
|
||||
| `icl_updater` | In-context learning updater — discovers reusable ICL examples and multi-tool skill sequences from traces. |
|
||||
|
||||
You can also override the router policy per-query via the CLI:
|
||||
|
||||
```bash
|
||||
jarvis ask --router heuristic "Hello"
|
||||
```
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old flat field names `default_policy`, `intelligence_policy`, `agent_policy`, and the comma-separated `reward_weights` string are still accepted as backward-compatible properties. New configurations should use the nested sub-section format. The `tools_policy` field has been removed; use `learning.agent.policy = "icl_updater"` instead.
|
||||
|
||||
---
|
||||
|
||||
### `[tools.storage]` — Storage Backend
|
||||
|
||||
Controls the storage backend used for document memory and context injection. The `context_injection` field has moved to `agent.context_from_memory`.
|
||||
|
||||
```toml
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
db_path = "~/.openjarvis/memory.db"
|
||||
context_top_k = 5
|
||||
context_min_score = 0.1
|
||||
context_max_tokens = 2048
|
||||
chunk_size = 512
|
||||
chunk_overlap = 64
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `default_backend` | string | `"sqlite"` | Storage backend. Available: `sqlite` (FTS5), `faiss`, `colbert`, `bm25`, `hybrid`. |
|
||||
| `db_path` | string | `~/.openjarvis/memory.db` | Path to the SQLite memory database. Used by the `sqlite` backend. |
|
||||
| `context_top_k` | int | `5` | Number of top memory results to inject as context. |
|
||||
| `context_min_score` | float | `0.1` | Minimum relevance score for a memory result to be included in context. |
|
||||
| `context_max_tokens` | int | `2048` | Maximum number of tokens to use for injected context. |
|
||||
| `chunk_size` | int | `512` | Size of document chunks (in tokens) when indexing documents. |
|
||||
| `chunk_overlap` | int | `64` | Overlap between adjacent chunks (in tokens) when indexing. |
|
||||
|
||||
**Memory backends:**
|
||||
|
||||
| Backend | Extra Required | Description |
|
||||
|---------|---------------|-------------|
|
||||
| `sqlite` | None | SQLite with FTS5 full-text search. Zero dependencies. Default. |
|
||||
| `faiss` | `memory-faiss` | Facebook AI Similarity Search with sentence-transformer embeddings. |
|
||||
| `colbert` | `memory-colbert` | ColBERTv2 late-interaction retrieval. Requires PyTorch. |
|
||||
| `bm25` | `memory-bm25` | BM25 sparse retrieval via `rank-bm25`. |
|
||||
| `hybrid` | Depends on sub-backends | Reciprocal Rank Fusion combining multiple backends. |
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The `[memory]` TOML section is still supported and maps to `[tools.storage]`. New configurations should use `[tools.storage]`. The `context_injection` field under `[memory]` or `[tools.storage]` is automatically migrated to `agent.context_from_memory` at load time.
|
||||
|
||||
---
|
||||
|
||||
### `[tools.mcp]` — MCP (Model Context Protocol)
|
||||
|
||||
Controls the MCP server and external MCP tool provider integration. The MCP adapter supports protocol version 2025-11-25.
|
||||
|
||||
```toml
|
||||
[tools.mcp]
|
||||
enabled = true
|
||||
# servers = "" # JSON list of external MCP server configs
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `true` | Whether to enable the MCP adapter for exposing and consuming tools via MCP. |
|
||||
| `servers` | string | `""` | JSON-encoded list of external MCP server configuration objects. |
|
||||
|
||||
---
|
||||
|
||||
### `[server]` — API Server
|
||||
|
||||
Controls the OpenAI-compatible API server started by `jarvis serve`.
|
||||
|
||||
```toml
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8000
|
||||
agent = "orchestrator"
|
||||
model = ""
|
||||
workers = 1
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `host` | string | `"0.0.0.0"` | Bind address for the server. Use `"127.0.0.1"` to restrict to localhost. |
|
||||
| `port` | int | `8000` | Port number for the server. |
|
||||
| `agent` | string | `"orchestrator"` | Agent to use for chat completion requests. |
|
||||
| `model` | string | `""` | Default model for the server. When empty, uses `intelligence.default_model` or the first available model. |
|
||||
| `workers` | int | `1` | Number of uvicorn worker processes. |
|
||||
|
||||
CLI options override config values:
|
||||
|
||||
```bash
|
||||
jarvis serve --host 127.0.0.1 --port 9000 --model qwen3:8b --agent simple
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `[telemetry]` — Telemetry Persistence
|
||||
|
||||
Controls whether inference telemetry is recorded and where it is stored.
|
||||
|
||||
```toml
|
||||
[telemetry]
|
||||
enabled = true
|
||||
db_path = "~/.openjarvis/telemetry.db"
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `true` | Whether to record telemetry for each inference call. Records timing, token counts, model, engine, and cost. |
|
||||
| `db_path` | string | `~/.openjarvis/telemetry.db` | Path to the SQLite telemetry database. |
|
||||
|
||||
!!! info "Telemetry is local-only"
|
||||
All telemetry data is stored locally in a SQLite database. No data is ever sent to external services.
|
||||
|
||||
---
|
||||
|
||||
### `[traces]` — Trace Recording
|
||||
|
||||
Controls the trace system that records full interaction sequences for the learning system.
|
||||
|
||||
```toml
|
||||
[traces]
|
||||
enabled = false
|
||||
db_path = "~/.openjarvis/traces.db"
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Whether to record traces for each agent interaction. |
|
||||
| `db_path` | string | `~/.openjarvis/traces.db` | Path to the SQLite trace database. |
|
||||
|
||||
---
|
||||
|
||||
### `[channel]` — Channel Messaging
|
||||
|
||||
Controls the channel messaging bridge for multi-platform communication. Each supported platform has its own nested sub-section.
|
||||
|
||||
```toml
|
||||
[channel]
|
||||
enabled = false
|
||||
default_channel = ""
|
||||
default_agent = "simple"
|
||||
|
||||
# [channel.telegram]
|
||||
# bot_token = ""
|
||||
|
||||
# [channel.discord]
|
||||
# bot_token = ""
|
||||
|
||||
# [channel.slack]
|
||||
# bot_token = ""
|
||||
# app_token = ""
|
||||
|
||||
# [channel.webhook]
|
||||
# url = ""
|
||||
# secret = ""
|
||||
# method = "POST"
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Whether to enable channel messaging support. |
|
||||
| `default_channel` | string | `""` | Default channel to use when not specified. |
|
||||
| `default_agent` | string | `"simple"` | Default agent for handling channel messages. |
|
||||
|
||||
---
|
||||
|
||||
### `[security]` — Security Guardrails
|
||||
|
||||
Controls the security scanning pipeline for input/output content.
|
||||
|
||||
```toml
|
||||
[security]
|
||||
enabled = true
|
||||
mode = "warn"
|
||||
scan_input = true
|
||||
scan_output = true
|
||||
secret_scanner = true
|
||||
pii_scanner = true
|
||||
enforce_tool_confirmation = true
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `true` | Whether to enable security guardrails. |
|
||||
| `mode` | string | `"warn"` | Action on findings: `"warn"` (log only), `"redact"` (replace sensitive content), or `"block"` (raise error). |
|
||||
| `scan_input` | bool | `true` | Whether to scan user input messages. |
|
||||
| `scan_output` | bool | `true` | Whether to scan model output. |
|
||||
| `secret_scanner` | bool | `true` | Enable secret detection (API keys, tokens, passwords). |
|
||||
| `pii_scanner` | bool | `true` | Enable PII detection (emails, SSNs, credit cards). |
|
||||
| `enforce_tool_confirmation` | bool | `true` | Require confirmation before executing tools. |
|
||||
|
||||
!!! tip "Choosing a security mode"
|
||||
Use `"warn"` during development to see what would be flagged without disrupting output.
|
||||
Use `"redact"` in production to automatically sanitize sensitive content.
|
||||
Use `"block"` for strict environments where any sensitive data should halt generation.
|
||||
|
||||
---
|
||||
|
||||
## Hardware Auto-Detection
|
||||
|
||||
When you run `jarvis init`, OpenJarvis probes your system to detect available hardware. The detection runs in this order:
|
||||
|
||||
### GPU Detection
|
||||
|
||||
1. **NVIDIA GPU** — Checks for `nvidia-smi` on `$PATH`. If found, queries GPU name, VRAM (in MB), and GPU count via:
|
||||
|
||||
```
|
||||
nvidia-smi --query-gpu=name,memory.total,count --format=csv,noheader,nounits
|
||||
```
|
||||
|
||||
2. **AMD GPU** — Checks for `rocm-smi` on `$PATH`. If found, queries the product name via:
|
||||
|
||||
```
|
||||
rocm-smi --showproductname
|
||||
```
|
||||
|
||||
3. **Apple Silicon** — On macOS only. Runs `system_profiler SPDisplaysDataType` and looks for "Apple" in the chipset model line.
|
||||
|
||||
If none of these detect a GPU, the system is treated as CPU-only.
|
||||
|
||||
### CPU and RAM Detection
|
||||
|
||||
- **CPU brand**: Reads from `sysctl -n machdep.cpu.brand_string` on macOS, or parses `model name` from `/proc/cpuinfo` on Linux.
|
||||
- **CPU count**: Uses Python's `os.cpu_count()`.
|
||||
- **RAM**: Reads from `sysctl -n hw.memsize` on macOS, or parses `MemTotal` from `/proc/meminfo` on Linux.
|
||||
|
||||
### Detected Hardware Dataclass
|
||||
|
||||
The detection result is stored as a `HardwareInfo` dataclass:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class HardwareInfo:
|
||||
platform: str # "linux", "darwin", "windows"
|
||||
cpu_brand: str # e.g., "AMD EPYC 7763"
|
||||
cpu_count: int # e.g., 128
|
||||
ram_gb: float # e.g., 512.0
|
||||
gpu: GpuInfo | None
|
||||
|
||||
@dataclass
|
||||
class GpuInfo:
|
||||
vendor: str # "nvidia", "amd", "apple"
|
||||
name: str # e.g., "NVIDIA A100-SXM4-80GB"
|
||||
vram_gb: float # e.g., 80.0
|
||||
compute_capability: str # (NVIDIA only)
|
||||
count: int # e.g., 8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Engine Recommendation Logic
|
||||
|
||||
Based on the detected hardware, `recommend_engine()` selects the optimal default engine:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[detect_hardware] --> B{GPU detected?}
|
||||
B -->|No| C[llamacpp]
|
||||
B -->|Yes| D{GPU vendor?}
|
||||
D -->|Apple| E[ollama]
|
||||
D -->|NVIDIA| F{Datacenter GPU?}
|
||||
D -->|AMD| G[vllm]
|
||||
F -->|Yes: A100, H100, H200, L40, A10, A30| H[vllm]
|
||||
F -->|No: consumer GPU| I[ollama]
|
||||
```
|
||||
|
||||
| Hardware | Recommended Engine | Reason |
|
||||
|----------|--------------------|--------|
|
||||
| No GPU | `llamacpp` | Efficient CPU inference with GGUF quantized models |
|
||||
| Apple Silicon | `ollama` | Native Metal acceleration, easy model management |
|
||||
| NVIDIA consumer GPU (RTX 3090, 4090, etc.) | `ollama` | Simple setup, good performance for single-user |
|
||||
| NVIDIA datacenter GPU (A100, H100, H200, L40, A10, A30) | `vllm` | High-throughput batched serving, continuous batching |
|
||||
| AMD GPU | `vllm` | ROCm support via vLLM |
|
||||
|
||||
---
|
||||
|
||||
## Example Configurations
|
||||
|
||||
### Apple Silicon Mac
|
||||
|
||||
```toml
|
||||
# ~/.openjarvis/config.toml
|
||||
# Apple Silicon MacBook Pro (M3 Max, 128 GB unified memory)
|
||||
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[engine.ollama]
|
||||
host = "http://localhost:11434"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3:8b"
|
||||
fallback_model = "llama3.2:3b"
|
||||
temperature = 0.7
|
||||
max_tokens = 1024
|
||||
|
||||
[agent]
|
||||
default_agent = "simple"
|
||||
max_turns = 10
|
||||
context_from_memory = true
|
||||
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
agent = "orchestrator"
|
||||
|
||||
[learning]
|
||||
enabled = false
|
||||
|
||||
[learning.routing]
|
||||
policy = "heuristic"
|
||||
|
||||
[telemetry]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
### NVIDIA Datacenter (Multi-GPU)
|
||||
|
||||
```toml
|
||||
# ~/.openjarvis/config.toml
|
||||
# 8x NVIDIA A100 80GB server
|
||||
|
||||
[engine]
|
||||
default = "vllm"
|
||||
|
||||
[engine.vllm]
|
||||
host = "http://localhost:8000"
|
||||
|
||||
[engine.ollama]
|
||||
host = "http://localhost:11434"
|
||||
|
||||
[intelligence]
|
||||
default_model = "Qwen/Qwen2.5-72B-Instruct"
|
||||
fallback_model = "Qwen/Qwen2.5-7B-Instruct"
|
||||
temperature = 0.5
|
||||
max_tokens = 4096
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
max_turns = 15
|
||||
tools = "calculator,think,retrieval"
|
||||
context_from_memory = true
|
||||
|
||||
[tools.storage]
|
||||
default_backend = "faiss"
|
||||
context_top_k = 10
|
||||
context_min_score = 0.05
|
||||
context_max_tokens = 4096
|
||||
chunk_size = 1024
|
||||
chunk_overlap = 128
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8000
|
||||
agent = "orchestrator"
|
||||
model = "Qwen/Qwen2.5-72B-Instruct"
|
||||
workers = 1
|
||||
|
||||
[learning]
|
||||
enabled = false
|
||||
|
||||
[learning.routing]
|
||||
policy = "heuristic"
|
||||
|
||||
[telemetry]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
### CPU-Only (No GPU)
|
||||
|
||||
```toml
|
||||
# ~/.openjarvis/config.toml
|
||||
# CPU-only machine
|
||||
|
||||
[engine]
|
||||
default = "llamacpp"
|
||||
|
||||
[engine.llamacpp]
|
||||
host = "http://localhost:8080"
|
||||
|
||||
[intelligence]
|
||||
default_model = ""
|
||||
fallback_model = ""
|
||||
temperature = 0.7
|
||||
max_tokens = 512
|
||||
|
||||
[agent]
|
||||
default_agent = "simple"
|
||||
max_turns = 5
|
||||
context_from_memory = true
|
||||
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
context_top_k = 3
|
||||
context_max_tokens = 1024
|
||||
chunk_size = 256
|
||||
chunk_overlap = 32
|
||||
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
|
||||
[learning]
|
||||
enabled = false
|
||||
|
||||
[learning.routing]
|
||||
policy = "heuristic"
|
||||
|
||||
[telemetry]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
### Trace-Driven Learning Enabled
|
||||
|
||||
```toml
|
||||
# ~/.openjarvis/config.toml
|
||||
# Research setup with trace-driven learning active
|
||||
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[engine.ollama]
|
||||
host = "http://localhost:11434"
|
||||
|
||||
[intelligence]
|
||||
default_model = "qwen3:8b"
|
||||
temperature = 0.7
|
||||
max_tokens = 1024
|
||||
|
||||
[agent]
|
||||
default_agent = "orchestrator"
|
||||
max_turns = 10
|
||||
context_from_memory = true
|
||||
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
|
||||
[learning]
|
||||
enabled = true
|
||||
update_interval = 50
|
||||
auto_update = true
|
||||
|
||||
[learning.routing]
|
||||
policy = "learned"
|
||||
min_samples = 10
|
||||
|
||||
[learning.intelligence]
|
||||
policy = "sft"
|
||||
|
||||
[learning.agent]
|
||||
policy = "agent_advisor"
|
||||
advisor_confidence_threshold = 0.8
|
||||
|
||||
[learning.metrics]
|
||||
accuracy_weight = 0.6
|
||||
latency_weight = 0.2
|
||||
cost_weight = 0.1
|
||||
efficiency_weight = 0.1
|
||||
|
||||
[traces]
|
||||
enabled = true
|
||||
|
||||
[telemetry]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you have an existing `~/.openjarvis/config.toml` from a previous version, here is what changed and how to update it.
|
||||
|
||||
### Engine: Nested Sub-Sections
|
||||
|
||||
=== "Old Format"
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
ollama_host = "http://localhost:11434"
|
||||
vllm_host = "http://localhost:8000"
|
||||
llamacpp_path = "/usr/local/bin/llama-server"
|
||||
```
|
||||
|
||||
=== "New Format"
|
||||
|
||||
```toml
|
||||
[engine]
|
||||
default = "ollama"
|
||||
|
||||
[engine.ollama]
|
||||
host = "http://localhost:11434"
|
||||
|
||||
[engine.vllm]
|
||||
host = "http://localhost:8000"
|
||||
|
||||
[engine.llamacpp]
|
||||
binary_path = "/usr/local/bin/llama-server"
|
||||
```
|
||||
|
||||
!!! note
|
||||
The old flat names still work as backward-compatible properties. You only need to update your config if you want to use the new fields (e.g., `binary_path`).
|
||||
|
||||
### Intelligence: Generation Parameters
|
||||
|
||||
=== "Old Format"
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
temperature = 0.7
|
||||
max_tokens = 1024
|
||||
```
|
||||
|
||||
=== "New Format"
|
||||
|
||||
```toml
|
||||
[intelligence]
|
||||
temperature = 0.7
|
||||
max_tokens = 1024
|
||||
```
|
||||
|
||||
!!! note
|
||||
Old configs with `temperature` or `max_tokens` under `[agent]` are automatically migrated to `[intelligence]` at load time. No manual update is required, but updating is recommended for clarity.
|
||||
|
||||
### Agent: Renamed and Added Fields
|
||||
|
||||
=== "Old Format"
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
default_tools = "calculator,think"
|
||||
```
|
||||
|
||||
=== "New Format"
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
tools = "calculator,think"
|
||||
```
|
||||
|
||||
The `default_tools` name still works via a backward-compatible property.
|
||||
|
||||
### Memory: Context Injection Moved
|
||||
|
||||
=== "Old Format"
|
||||
|
||||
```toml
|
||||
[memory]
|
||||
context_injection = true
|
||||
default_backend = "sqlite"
|
||||
```
|
||||
|
||||
=== "New Format"
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
context_from_memory = true
|
||||
|
||||
[tools.storage]
|
||||
default_backend = "sqlite"
|
||||
```
|
||||
|
||||
!!! note
|
||||
`context_injection` under `[memory]` or `[tools.storage]` is automatically migrated to `agent.context_from_memory` at load time.
|
||||
|
||||
### Learning: Nested Sub-Sections
|
||||
|
||||
=== "Old Format"
|
||||
|
||||
```toml
|
||||
[learning]
|
||||
default_policy = "heuristic"
|
||||
intelligence_policy = "sft"
|
||||
agent_policy = "agent_advisor"
|
||||
tools_policy = "icl_updater"
|
||||
reward_weights = "accuracy=0.6,latency=0.2,cost=0.1,efficiency=0.1"
|
||||
update_interval = 100
|
||||
```
|
||||
|
||||
=== "New Format"
|
||||
|
||||
```toml
|
||||
[learning]
|
||||
enabled = true
|
||||
update_interval = 100
|
||||
|
||||
[learning.routing]
|
||||
policy = "heuristic"
|
||||
|
||||
[learning.intelligence]
|
||||
policy = "sft"
|
||||
|
||||
[learning.agent]
|
||||
policy = "agent_advisor"
|
||||
|
||||
[learning.metrics]
|
||||
accuracy_weight = 0.6
|
||||
latency_weight = 0.2
|
||||
cost_weight = 0.1
|
||||
efficiency_weight = 0.1
|
||||
```
|
||||
|
||||
!!! note
|
||||
The flat field names `default_policy`, `intelligence_policy`, `agent_policy`, and `reward_weights` are still accepted as backward-compatible properties. The `tools_policy` field has been removed; use `learning.agent.policy = "icl_updater"` instead.
|
||||
|
||||
---
|
||||
|
||||
## Programmatic Configuration
|
||||
|
||||
You can configure OpenJarvis entirely from Python without a TOML file:
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
from openjarvis.core.config import (
|
||||
AgentConfig,
|
||||
EngineConfig,
|
||||
IntelligenceConfig,
|
||||
JarvisConfig,
|
||||
LearningConfig,
|
||||
OllamaEngineConfig,
|
||||
StorageConfig,
|
||||
ToolsConfig,
|
||||
)
|
||||
|
||||
config = JarvisConfig(
|
||||
engine=EngineConfig(
|
||||
default="ollama",
|
||||
ollama=OllamaEngineConfig(host="http://my-server:11434"),
|
||||
),
|
||||
intelligence=IntelligenceConfig(
|
||||
default_model="qwen3:8b",
|
||||
temperature=0.7,
|
||||
max_tokens=2048,
|
||||
),
|
||||
agent=AgentConfig(
|
||||
default_agent="orchestrator",
|
||||
max_turns=15,
|
||||
context_from_memory=True,
|
||||
),
|
||||
tools=ToolsConfig(
|
||||
storage=StorageConfig(
|
||||
default_backend="sqlite",
|
||||
context_top_k=10,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
j = Jarvis(config=config)
|
||||
response = j.ask("Hello")
|
||||
j.close()
|
||||
```
|
||||
|
||||
Or load from a custom path:
|
||||
|
||||
```python
|
||||
j = Jarvis(config_path="/path/to/my-config.toml")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
OpenJarvis respects the following environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `OPENAI_API_KEY` | API key for OpenAI cloud inference. Required for the `cloud` engine with OpenAI models. |
|
||||
| `ANTHROPIC_API_KEY` | API key for Anthropic cloud inference. Required for the `cloud` engine with Claude models. |
|
||||
| `GOOGLE_API_KEY` | API key for Google Gemini inference. Required for the `google` engine. |
|
||||
| `TAVILY_API_KEY` | API key for the Tavily web search tool. Required for the `web_search` tool. |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Quick Start](quickstart.md) — Run your first query
|
||||
- [CLI Reference](../user-guide/cli.md) — Full reference for all CLI commands
|
||||
- [Architecture Overview](../architecture/overview.md) — Understand how the pieces fit together
|
||||
- [Intelligence Primitive](../architecture/intelligence.md) — Model identity and generation defaults
|
||||
- [Learning & Traces](../architecture/learning.md) — Router policies and the trace-driven feedback loop
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: Installation
|
||||
description: Get OpenJarvis running — browser app, desktop app, CLI, or Python SDK
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
OpenJarvis runs entirely on your hardware. Choose the interface that fits your workflow.
|
||||
|
||||
---
|
||||
|
||||
## Browser App
|
||||
|
||||
Run the full chat UI in your browser. Everything stays local — the backend runs on
|
||||
your machine and the frontend connects via `localhost`.
|
||||
|
||||
### One-command setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
The script handles everything:
|
||||
|
||||
1. Checks for Python 3.10+ and Node.js 18+
|
||||
2. Installs Ollama if not present and pulls a starter model
|
||||
3. Installs Python and frontend dependencies
|
||||
4. Starts the backend API server and frontend dev server
|
||||
5. Opens `http://localhost:5173` in your browser
|
||||
|
||||
### Manual setup
|
||||
|
||||
If you prefer to run each step yourself:
|
||||
|
||||
=== "Step 1: Clone and install"
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync --extra server
|
||||
cd frontend && npm install && cd ..
|
||||
```
|
||||
|
||||
=== "Step 2: Start Ollama"
|
||||
|
||||
```bash
|
||||
# Install from https://ollama.com if not already installed
|
||||
ollama serve &
|
||||
ollama pull qwen3:0.6b
|
||||
```
|
||||
|
||||
=== "Step 3: Start backend"
|
||||
|
||||
```bash
|
||||
uv run jarvis serve --port 8000
|
||||
```
|
||||
|
||||
=== "Step 4: Start frontend"
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then open [http://localhost:5173](http://localhost:5173).
|
||||
|
||||
---
|
||||
|
||||
## Desktop App
|
||||
|
||||
The desktop app is a native window for the OpenJarvis chat UI. All inference and backend
|
||||
processing happens on your local machine — the app connects to the backend you start locally.
|
||||
|
||||
### Setup
|
||||
|
||||
**Step 1.** Start the backend (same as Browser App):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
**Step 2.** Download and open the desktop app:
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) |
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
!!! warning "macOS: \"app is damaged\""
|
||||
If macOS says the app is damaged, clear the Gatekeeper quarantine flag:
|
||||
```bash
|
||||
xattr -cr /Applications/OpenJarvis.app
|
||||
```
|
||||
This is normal for open-source apps distributed outside the App Store.
|
||||
|
||||
!!! tip "All releases"
|
||||
Browse all versions on the [GitHub Releases](https://github.com/open-jarvis/OpenJarvis/releases) page.
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis/desktop
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
The built installer will be in `desktop/src-tauri/target/release/bundle/`.
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The command-line interface is the fastest way to interact with OpenJarvis
|
||||
programmatically. Every feature is accessible from the terminal.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
jarvis --version
|
||||
# jarvis, version 0.1.0
|
||||
```
|
||||
|
||||
### First commands
|
||||
|
||||
```bash
|
||||
jarvis ask "What is the capital of France?"
|
||||
|
||||
jarvis ask --agent orchestrator --tools calculator "What is 137 * 42?"
|
||||
|
||||
jarvis serve --port 8000
|
||||
|
||||
jarvis doctor
|
||||
|
||||
jarvis model list
|
||||
|
||||
jarvis chat
|
||||
```
|
||||
|
||||
!!! info "Inference backend required"
|
||||
The CLI requires a running inference backend (e.g., Ollama). See
|
||||
[Setting up an inference backend](#setting-up-an-inference-backend) below.
|
||||
|
||||
---
|
||||
|
||||
## Python SDK
|
||||
|
||||
For programmatic access, the `Jarvis` class provides a high-level sync API.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Quick example
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
print(j.ask("Explain quicksort in two sentences."))
|
||||
j.close()
|
||||
```
|
||||
|
||||
### With agents and tools
|
||||
|
||||
```python
|
||||
result = j.ask_full(
|
||||
"What is the square root of 144?",
|
||||
agent="orchestrator",
|
||||
tools=["calculator", "think"],
|
||||
)
|
||||
print(result["content"]) # "12"
|
||||
print(result["tool_results"]) # tool invocations
|
||||
print(result["turns"]) # number of agent turns
|
||||
```
|
||||
|
||||
### Composition layer
|
||||
|
||||
For full control, use the `SystemBuilder`:
|
||||
|
||||
```python
|
||||
from openjarvis import SystemBuilder
|
||||
|
||||
system = (
|
||||
SystemBuilder()
|
||||
.engine("ollama")
|
||||
.model("qwen3:8b")
|
||||
.agent("orchestrator")
|
||||
.tools(["calculator", "web_search", "file_read"])
|
||||
.enable_telemetry()
|
||||
.enable_traces()
|
||||
.build()
|
||||
)
|
||||
|
||||
result = system.ask("Summarize the latest AI news.")
|
||||
system.close()
|
||||
```
|
||||
|
||||
See the [Python SDK guide](../user-guide/python-sdk.md) for the full API reference.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version | Notes |
|
||||
|-------------|---------|-------|
|
||||
| Python | 3.10+ | Required |
|
||||
| Inference backend | Any | At least one of Ollama, vLLM, llama.cpp, SGLang, or a cloud API |
|
||||
| Node.js | 18+ | Required for the browser UI; 22+ for the WhatsApp Baileys channel bridge |
|
||||
|
||||
## Optional Extras
|
||||
|
||||
OpenJarvis uses optional extras to keep the base installation lightweight.
|
||||
|
||||
### Inference Backends
|
||||
|
||||
| Extra | Install Command | Description |
|
||||
|-------|----------------|-------------|
|
||||
| `inference-cloud` | `uv sync --extra inference-cloud` | OpenAI and Anthropic APIs |
|
||||
| `inference-google` | `uv sync --extra inference-google` | Google Gemini API |
|
||||
|
||||
!!! note "Ollama, vLLM, and llama.cpp are HTTP-based"
|
||||
These engines have no additional Python dependencies — OpenJarvis communicates over HTTP. You still need the engine software running on your machine.
|
||||
|
||||
### Memory Backends
|
||||
|
||||
| Extra | Install Command | Description |
|
||||
|-------|----------------|-------------|
|
||||
| `memory-faiss` | `uv sync --extra memory-faiss` | FAISS vector store |
|
||||
| `memory-colbert` | `uv sync --extra memory-colbert` | ColBERTv2 late-interaction retrieval |
|
||||
| `memory-bm25` | `uv sync --extra memory-bm25` | BM25 sparse retrieval |
|
||||
|
||||
!!! tip "SQLite memory is always available"
|
||||
The default SQLite/FTS5 memory backend requires no additional dependencies.
|
||||
|
||||
### Server & Other
|
||||
|
||||
| Extra | Install Command | Description |
|
||||
|-------|----------------|-------------|
|
||||
| `server` | `uv sync --extra server` | OpenAI-compatible API server (`jarvis serve`) |
|
||||
| `dev` | `uv sync --extra dev` | Development and testing tools |
|
||||
| `docs` | `uv sync --extra docs` | Documentation build tools |
|
||||
|
||||
Combine extras:
|
||||
|
||||
```bash
|
||||
uv sync --extra server --extra memory-faiss --extra inference-cloud
|
||||
```
|
||||
|
||||
## Setting Up an Inference Backend
|
||||
|
||||
OpenJarvis requires at least one inference backend. Choose the one that matches your hardware.
|
||||
|
||||
### Ollama (Recommended)
|
||||
|
||||
The easiest way to get started. Handles model downloading and serving automatically.
|
||||
|
||||
1. Install from [ollama.com](https://ollama.com)
|
||||
2. Start the server and pull a model:
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
ollama pull qwen3:0.6b
|
||||
```
|
||||
|
||||
3. Verify: `jarvis model list`
|
||||
|
||||
!!! tip "Best for: Apple Silicon Macs, consumer NVIDIA GPUs, CPU-only systems"
|
||||
|
||||
### vLLM
|
||||
|
||||
High-throughput serving optimized for datacenter GPUs.
|
||||
|
||||
1. Install following the [official guide](https://docs.vllm.ai)
|
||||
2. Start: `vllm serve Qwen/Qwen2.5-7B-Instruct`
|
||||
3. Auto-detected at `http://localhost:8000`
|
||||
|
||||
!!! tip "Best for: NVIDIA datacenter GPUs (A100, H100), AMD GPUs"
|
||||
|
||||
### llama.cpp
|
||||
|
||||
Efficient CPU and GPU inference with GGUF quantized models.
|
||||
|
||||
1. Build from [github.com/ggerganov/llama.cpp](https://github.com/ggerganov/llama.cpp)
|
||||
2. Start: `llama-server -m /path/to/model.gguf --port 8080`
|
||||
3. Auto-detected at `http://localhost:8080`
|
||||
|
||||
### Cloud APIs
|
||||
|
||||
```bash
|
||||
uv sync --extra inference-cloud --extra inference-google
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Quick Start](quickstart.md) — Run your first query
|
||||
- [Configuration](configuration.md) — Customize engine hosts, model routing, memory, and more
|
||||
@@ -0,0 +1,502 @@
|
||||
---
|
||||
title: Quick Start
|
||||
description: Get up and running with OpenJarvis in minutes
|
||||
---
|
||||
|
||||
# Quick Start
|
||||
|
||||
## What You Can Build
|
||||
|
||||
OpenJarvis is a modular AI assistant framework. Here's what developers build with it:
|
||||
|
||||
=== "Chat with Any Model"
|
||||
|
||||
```bash
|
||||
jarvis ask "Explain quantum entanglement" -m qwen3:8b
|
||||
```
|
||||
|
||||
=== "Agent + Tools"
|
||||
|
||||
```bash
|
||||
jarvis ask --agent orchestrator --tools calculator,web_search "What is the GDP of France in USD?"
|
||||
```
|
||||
|
||||
=== "Index Docs & Ask"
|
||||
|
||||
```bash
|
||||
jarvis memory index ./docs/
|
||||
jarvis ask "How do I configure the engine?"
|
||||
```
|
||||
|
||||
=== "5-Line Python SDK"
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
with Jarvis() as j:
|
||||
print(j.ask("Hello!"))
|
||||
```
|
||||
|
||||
=== "API Server"
|
||||
|
||||
```bash
|
||||
jarvis serve --port 8000
|
||||
# Now use any OpenAI-compatible client
|
||||
```
|
||||
|
||||
For complete copy-paste patterns, see [Code Snippets](snippets.md).
|
||||
|
||||
This guide walks through the core workflows of OpenJarvis: the browser app, CLI, Python SDK, agents with tools, memory, benchmarks, and the API server.
|
||||
|
||||
!!! info "Prerequisites"
|
||||
Make sure you have [installed OpenJarvis](installation.md) and have at least one inference backend running (e.g., `ollama serve`).
|
||||
|
||||
## Browser App
|
||||
|
||||
The quickest way to experience OpenJarvis is the full chat UI running in your browser:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
|
||||
You get a ChatGPT-like interface with streaming responses, tool use, energy monitoring, and a telemetry dashboard — all running locally on your hardware.
|
||||
|
||||
To stop all services, press ++ctrl+c++ in the terminal.
|
||||
|
||||
!!! tip "Environment variable"
|
||||
Set `OPENJARVIS_MODEL` to change the default model: `OPENJARVIS_MODEL=deepseek-r1:14b ./scripts/quickstart.sh`
|
||||
|
||||
## Initialize Configuration
|
||||
|
||||
Start by detecting your hardware and generating a configuration file:
|
||||
|
||||
```bash
|
||||
jarvis init
|
||||
```
|
||||
|
||||
This runs hardware auto-detection (GPU vendor, VRAM, CPU, RAM) and writes a config file to `~/.openjarvis/config.toml` with sensible defaults for your system. It also selects the recommended inference engine.
|
||||
|
||||
```
|
||||
Detecting hardware...
|
||||
Platform : linux
|
||||
CPU : AMD EPYC 7763 (128 cores)
|
||||
RAM : 512.0 GB
|
||||
GPU : NVIDIA A100 (80.0 GB VRAM, x8)
|
||||
|
||||
Config written successfully.
|
||||
```
|
||||
|
||||
To overwrite an existing config:
|
||||
|
||||
```bash
|
||||
jarvis init --force
|
||||
```
|
||||
|
||||
See [Configuration](configuration.md) for the full config reference.
|
||||
|
||||
## Your First Question
|
||||
|
||||
### Via CLI
|
||||
|
||||
The simplest way to interact with OpenJarvis is the `ask` command:
|
||||
|
||||
```bash
|
||||
jarvis ask "What is the capital of France?"
|
||||
```
|
||||
|
||||
OpenJarvis will auto-detect a running engine, select a model using the configured router policy, and return the response.
|
||||
|
||||
#### CLI Options
|
||||
|
||||
| Option | Description | Example |
|
||||
|--------|-------------|---------|
|
||||
| `-m`, `--model` | Override model selection | `jarvis ask -m qwen3:8b "Hello"` |
|
||||
| `-e`, `--engine` | Force a specific engine | `jarvis ask -e ollama "Hello"` |
|
||||
| `-t`, `--temperature` | Sampling temperature (default: 0.7) | `jarvis ask -t 0.2 "Hello"` |
|
||||
| `--max-tokens` | Max tokens to generate (default: 1024) | `jarvis ask --max-tokens 2048 "Hello"` |
|
||||
| `--json` | Output raw JSON result | `jarvis ask --json "Hello"` |
|
||||
| `--no-stream` | Disable streaming | `jarvis ask --no-stream "Hello"` |
|
||||
| `--no-context` | Disable memory context injection | `jarvis ask --no-context "Hello"` |
|
||||
| `-a`, `--agent` | Use an agent | `jarvis ask -a orchestrator "Hello"` |
|
||||
| `--tools` | Comma-separated tools | `jarvis ask --tools calculator,think "2+2"` |
|
||||
| `--router` | Router policy for model selection | `jarvis ask --router heuristic "Hello"` |
|
||||
|
||||
### Via Python SDK
|
||||
|
||||
The `Jarvis` class provides a high-level Python interface:
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
response = j.ask("What is the capital of France?")
|
||||
print(response)
|
||||
j.close()
|
||||
```
|
||||
|
||||
For detailed results including token usage and model info:
|
||||
|
||||
```python
|
||||
result = j.ask_full("What is the capital of France?")
|
||||
print(result["content"]) # The response text
|
||||
print(result["model"]) # Model that handled the query
|
||||
print(result["engine"]) # Engine that ran inference
|
||||
print(result["usage"]) # Token usage statistics
|
||||
```
|
||||
|
||||
#### SDK Constructor Options
|
||||
|
||||
```python
|
||||
# Use default config (auto-detected hardware, ~/.openjarvis/config.toml)
|
||||
j = Jarvis()
|
||||
|
||||
# Override the model
|
||||
j = Jarvis(model="qwen3:8b")
|
||||
|
||||
# Override the engine
|
||||
j = Jarvis(engine_key="ollama")
|
||||
|
||||
# Use a custom config file
|
||||
j = Jarvis(config_path="/path/to/config.toml")
|
||||
```
|
||||
|
||||
!!! warning "Always call `close()`"
|
||||
The `Jarvis` instance holds references to telemetry stores and memory backends. Call `j.close()` when you are done to release resources.
|
||||
|
||||
## Using Agents with Tools
|
||||
|
||||
Agents add multi-turn reasoning and tool-calling capabilities. The `orchestrator` agent runs a tool-calling loop, invoking tools as needed to answer the query.
|
||||
|
||||
### Available Agents
|
||||
|
||||
| Agent | Description |
|
||||
|-------|-------------|
|
||||
| `simple` | Single-turn, no tools. Sends the query directly to the model. |
|
||||
| `orchestrator` | Multi-turn tool-calling loop. Invokes tools iteratively until it has an answer. |
|
||||
| `custom` | Template for user-defined agent logic. |
|
||||
| `operative` | Task-oriented agent with structured planning and execution. |
|
||||
|
||||
### Available Built-in Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `calculator` | Safe mathematical expression evaluation (ast-based). |
|
||||
| `think` | Reasoning scratchpad for chain-of-thought. |
|
||||
| `retrieval` | Search the memory store for relevant context. |
|
||||
| `llm` | Make sub-queries to another model. |
|
||||
| `file_read` | Read files with path validation. |
|
||||
| `web_search` | Web search via the Tavily API (requires `tools-search` extra). |
|
||||
|
||||
### CLI Example
|
||||
|
||||
```bash
|
||||
jarvis ask --agent orchestrator --tools calculator,think "What is 137 * 42?"
|
||||
```
|
||||
|
||||
### SDK Example
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
result = j.ask_full(
|
||||
"What is the square root of 144?",
|
||||
agent="orchestrator",
|
||||
tools=["calculator", "think"],
|
||||
)
|
||||
print(result["content"])
|
||||
print(result["tool_results"]) # List of tool invocations and results
|
||||
print(result["turns"]) # Number of agent turns
|
||||
j.close()
|
||||
```
|
||||
|
||||
## Memory: Indexing and Search
|
||||
|
||||
The memory system lets you index documents and inject relevant context into queries automatically.
|
||||
|
||||
### Index Documents
|
||||
|
||||
Index a file or directory. OpenJarvis chunks the content and stores it in the configured memory backend (SQLite/FTS5 by default).
|
||||
|
||||
=== "CLI"
|
||||
|
||||
```bash
|
||||
# Index a directory
|
||||
jarvis memory index ./docs/
|
||||
|
||||
# Index a single file with custom chunk size
|
||||
jarvis memory index ./paper.txt --chunk-size 256 --chunk-overlap 32
|
||||
```
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
result = j.memory.index("./docs/", chunk_size=512, chunk_overlap=64)
|
||||
print(f"Indexed {result['chunks']} chunks")
|
||||
j.close()
|
||||
```
|
||||
|
||||
### Search Memory
|
||||
|
||||
Query the memory store to find relevant chunks:
|
||||
|
||||
=== "CLI"
|
||||
|
||||
```bash
|
||||
jarvis memory search "configuration options"
|
||||
jarvis memory search -k 10 "how to deploy"
|
||||
```
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
```python
|
||||
results = j.memory.search("configuration options", top_k=5)
|
||||
for r in results:
|
||||
print(f"[{r['score']:.4f}] {r['source']}: {r['content'][:100]}")
|
||||
```
|
||||
|
||||
### Check Memory Statistics
|
||||
|
||||
=== "CLI"
|
||||
|
||||
```bash
|
||||
jarvis memory stats
|
||||
```
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
```python
|
||||
stats = j.memory.stats()
|
||||
print(f"Backend: {stats['backend']}, Documents: {stats.get('count', 'N/A')}")
|
||||
```
|
||||
|
||||
### Automatic Context Injection
|
||||
|
||||
When you have indexed documents, OpenJarvis automatically injects relevant context into your queries. The memory system searches for chunks matching your query and prepends them as system context before sending to the model.
|
||||
|
||||
To disable this behavior:
|
||||
|
||||
=== "CLI"
|
||||
|
||||
```bash
|
||||
jarvis ask --no-context "Hello"
|
||||
```
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
```python
|
||||
response = j.ask("Hello", context=False)
|
||||
```
|
||||
|
||||
Context injection is controlled by `agent.context_from_memory` in `config.toml`. The retrieval parameters (`context_top_k`, `context_min_score`, `context_max_tokens`) live under `[tools.storage]`. See [Configuration](configuration.md) for details.
|
||||
|
||||
## Model Management
|
||||
|
||||
### List Available Models
|
||||
|
||||
See all models available on running engines:
|
||||
|
||||
```bash
|
||||
jarvis model list
|
||||
```
|
||||
|
||||
This produces a table showing each model, its engine, parameter count, context length, and VRAM requirements.
|
||||
|
||||
### Get Model Details
|
||||
|
||||
```bash
|
||||
jarvis model info qwen3:8b
|
||||
```
|
||||
|
||||
### Pull a Model (Ollama)
|
||||
|
||||
```bash
|
||||
jarvis model pull qwen3:8b
|
||||
```
|
||||
|
||||
### SDK Model Listing
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
models = j.list_models()
|
||||
engines = j.list_engines()
|
||||
print(f"Models: {models}")
|
||||
print(f"Engines: {engines}")
|
||||
j.close()
|
||||
```
|
||||
|
||||
## Running Benchmarks
|
||||
|
||||
The benchmarking framework measures inference latency and throughput against your engine.
|
||||
|
||||
=== "All benchmarks"
|
||||
|
||||
```bash
|
||||
jarvis bench run
|
||||
```
|
||||
|
||||
=== "Specific benchmark"
|
||||
|
||||
```bash
|
||||
jarvis bench run -b latency
|
||||
jarvis bench run -b throughput
|
||||
```
|
||||
|
||||
=== "Custom options"
|
||||
|
||||
```bash
|
||||
# 20 samples, JSON output
|
||||
jarvis bench run -n 20 --json
|
||||
|
||||
# Specific model and engine, write to file
|
||||
jarvis bench run -m qwen3:8b -e ollama -o results.jsonl
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Running 2 benchmark(s) on ollama/qwen3:8b (10 samples)...
|
||||
|
||||
latency (10 samples, 0 errors)
|
||||
mean_ms: 245.3200
|
||||
p50_ms: 238.1000
|
||||
p95_ms: 312.4500
|
||||
min_ms: 201.2000
|
||||
max_ms: 345.6000
|
||||
|
||||
throughput (10 samples, 0 errors)
|
||||
tokens_per_second: 42.1500
|
||||
total_tokens: 4215
|
||||
total_seconds: 100.0000
|
||||
```
|
||||
|
||||
## Starting the API Server
|
||||
|
||||
OpenJarvis provides an OpenAI-compatible API server for integration with existing tools and frontends.
|
||||
|
||||
!!! note "Requires the `server` extra"
|
||||
```bash
|
||||
uv sync --extra server
|
||||
```
|
||||
|
||||
### Start the Server
|
||||
|
||||
```bash
|
||||
jarvis serve --port 8000
|
||||
```
|
||||
|
||||
With custom options:
|
||||
|
||||
```bash
|
||||
jarvis serve --host 0.0.0.0 --port 8000 --engine ollama --model qwen3:8b --agent orchestrator
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/v1/chat/completions` | `POST` | Chat completions (streaming and non-streaming) |
|
||||
| `/v1/models` | `GET` | List available models |
|
||||
| `/health` | `GET` | Health check |
|
||||
|
||||
### Use with Any OpenAI-Compatible Client
|
||||
|
||||
Once the server is running, point any OpenAI-compatible client at it:
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
|
||||
response = client.chat.completions.create(
|
||||
model="qwen3:8b",
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
print(response.choices[0].message.content)
|
||||
```
|
||||
|
||||
Or with `curl`:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "qwen3:8b",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
OpenJarvis records telemetry for every inference call (timing, tokens, cost). View aggregated statistics:
|
||||
|
||||
```bash
|
||||
jarvis telemetry stats
|
||||
```
|
||||
|
||||
Export telemetry data:
|
||||
|
||||
```bash
|
||||
jarvis telemetry export --format json
|
||||
jarvis telemetry export --format csv -o telemetry.csv
|
||||
```
|
||||
|
||||
Clear all telemetry records:
|
||||
|
||||
```bash
|
||||
jarvis telemetry clear --yes
|
||||
```
|
||||
|
||||
## Complete Working Example
|
||||
|
||||
Here is a complete end-to-end session combining multiple features:
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
# Initialize with defaults (auto-detect hardware and engine)
|
||||
j = Jarvis()
|
||||
|
||||
# 1. Index some documentation
|
||||
index_result = j.memory.index("./docs/", chunk_size=512)
|
||||
print(f"Indexed {index_result['chunks']} chunks from {index_result['path']}")
|
||||
|
||||
# 2. Search memory
|
||||
results = j.memory.search("how to configure engines")
|
||||
for r in results:
|
||||
print(f" [{r['score']:.3f}] {r['source']}")
|
||||
|
||||
# 3. Ask a question (memory context is injected automatically)
|
||||
answer = j.ask("How do I configure the Ollama engine host?")
|
||||
print(f"\nAnswer: {answer}")
|
||||
|
||||
# 4. Use an agent with tools
|
||||
calc_result = j.ask_full(
|
||||
"Calculate the compound interest on $10,000 at 5% for 10 years",
|
||||
agent="orchestrator",
|
||||
tools=["calculator", "think"],
|
||||
)
|
||||
print(f"\nCalculation: {calc_result['content']}")
|
||||
print(f"Tools used: {[t['tool_name'] for t in calc_result['tool_results']]}")
|
||||
print(f"Agent turns: {calc_result['turns']}")
|
||||
|
||||
# 5. List available models
|
||||
models = j.list_models()
|
||||
print(f"\nAvailable models: {models}")
|
||||
|
||||
# 6. Clean up
|
||||
j.close()
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration](configuration.md) — Fine-tune engine hosts, model routing, memory settings, and more
|
||||
- [CLI Reference](../user-guide/cli.md) — Full reference for all CLI commands and options
|
||||
- [Python SDK](../user-guide/python-sdk.md) — Detailed SDK documentation
|
||||
- [Architecture Overview](../architecture/overview.md) — Understand the five-primitive design
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Code Snippets
|
||||
description: Copy-paste patterns for common OpenJarvis tasks
|
||||
---
|
||||
|
||||
# Code Snippets
|
||||
|
||||
Ready-to-use patterns for the most common OpenJarvis tasks. Each snippet is self-contained and copy-pasteable.
|
||||
|
||||
## Ask a Question (3 lines)
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
with Jarvis() as j:
|
||||
print(j.ask("What is the capital of France?"))
|
||||
```
|
||||
|
||||
## Stream Tokens (4 lines)
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from openjarvis import Jarvis
|
||||
|
||||
async def main():
|
||||
with Jarvis() as j:
|
||||
async for token in j.ask_stream("Tell me a story"):
|
||||
print(token, end="", flush=True)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Agent with Tools (5 lines)
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
with Jarvis() as j:
|
||||
result = j.ask_full(
|
||||
"Search the web for the latest Python release",
|
||||
agent="orchestrator",
|
||||
tools=["web_search", "think"],
|
||||
)
|
||||
print(result["content"])
|
||||
```
|
||||
|
||||
## Memory: Index + Search (6 lines)
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
with Jarvis() as j:
|
||||
j.memory.index("./docs/", chunk_size=512)
|
||||
results = j.memory.search("deployment options")
|
||||
for r in results:
|
||||
print(f"[{r['score']:.3f}] {r['content'][:100]}")
|
||||
```
|
||||
|
||||
## Recipe TOML (4 lines)
|
||||
|
||||
Define an agent pipeline in TOML — no code required:
|
||||
|
||||
```toml
|
||||
[recipe]
|
||||
name = "research_assistant"
|
||||
agent = "orchestrator"
|
||||
tools = ["web_search", "think", "file_read"]
|
||||
prompt = "Research the given topic and write a summary."
|
||||
```
|
||||
|
||||
Run with: `jarvis compose run research_assistant "quantum computing advances"`
|
||||
|
||||
## API Server (1 command)
|
||||
|
||||
```bash
|
||||
jarvis serve --port 8000 --engine ollama --model qwen3:8b
|
||||
```
|
||||
|
||||
Any OpenAI-compatible client works against this endpoint.
|
||||
|
||||
## Docker Deployment (2 commands)
|
||||
|
||||
```bash
|
||||
docker build -t openjarvis .
|
||||
docker run -p 8000:8000 openjarvis serve --host 0.0.0.0
|
||||
```
|
||||
|
||||
## Custom Tool (10 lines)
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
@ToolRegistry.register("my_tool")
|
||||
class MyTool(BaseTool):
|
||||
tool_id = "my_tool"
|
||||
|
||||
@property
|
||||
def spec(self):
|
||||
return ToolSpec(name="my_tool", description="My custom tool",
|
||||
parameters={"type": "object", "properties": {"input": {"type": "string"}}})
|
||||
|
||||
def execute(self, **params):
|
||||
return ToolResult(tool_name="my_tool", content=f"Processed: {params.get('input', '')}", success=True)
|
||||
```
|
||||
|
||||
## Multi-Model Routing (5 lines)
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
# Router automatically selects the best model per query
|
||||
simple = j.ask("What is 2+2?") # routes to fast/cheap model
|
||||
complex = j.ask("Analyze this research paper...") # routes to capable model
|
||||
j.close()
|
||||
```
|
||||
|
||||
## Human-in-the-Loop Confirmation (6 lines)
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
with Jarvis() as j:
|
||||
result = j.ask_full(
|
||||
"Delete old log files in /tmp",
|
||||
agent="orchestrator",
|
||||
tools=["shell_exec", "file_read"],
|
||||
)
|
||||
print(f"Agent took {result['turns']} turns")
|
||||
print(result["content"])
|
||||
```
|
||||
|
||||
Tools like `shell_exec` can be configured with `requires_confirmation: true` in TOML for interactive approval.
|
||||
@@ -0,0 +1,13 @@
|
||||
*[ABC]: Abstract Base Class
|
||||
*[FTS5]: Full-Text Search version 5
|
||||
*[GRPO]: Group Relative Policy Optimization
|
||||
*[RRF]: Reciprocal Rank Fusion
|
||||
*[SSE]: Server-Sent Events
|
||||
*[VRAM]: Video Random Access Memory
|
||||
*[MoE]: Mixture of Experts
|
||||
*[GGUF]: GPT-Generated Unified Format
|
||||
*[MCP]: Model Context Protocol
|
||||
*[SDK]: Software Development Kit
|
||||
*[CLI]: Command-Line Interface
|
||||
*[API]: Application Programming Interface
|
||||
*[LLM]: Large Language Model
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
title: OpenJarvis
|
||||
description: Personal AI, On Personal Devices
|
||||
hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
# Personal AI, On Personal Devices
|
||||
|
||||
<p class="hero-tagline">
|
||||
OpenJarvis is a research framework for composable, on-device AI systems.
|
||||
Build personal AI that runs on your hardware. Cloud APIs are optional.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Why OpenJarvis?
|
||||
|
||||
Personal AI agents are exploding in popularity, but nearly all of them still route intelligence through cloud APIs. Your "personal" AI continues to depend on someone else's server. At the same time, our [Intelligence Per Watt](https://www.intelligence-per-watt.ai/) research showed that local language models already handle 88.7% of single-turn chat and reasoning queries, with intelligence efficiency improving 5.3× from 2023 to 2025. The models and hardware are increasingly ready. What has been missing is the software stack to make local-first personal AI practical.
|
||||
|
||||
OpenJarvis is that stack. It is an opinionated framework for local-first personal AI, built around three core ideas: shared primitives for building on-device agents; evaluations that treat energy, FLOPs, latency, and dollar cost as first-class constraints alongside accuracy; and a learning loop that improves models using local trace data. The goal is simple: make it possible to build personal AI agents that run locally by default, calling the cloud only when truly necessary. OpenJarvis aims to be both a research platform and a production foundation for local AI, in the spirit of PyTorch.
|
||||
|
||||
---
|
||||
|
||||
## Get Started
|
||||
|
||||
=== "Browser App"
|
||||
|
||||
Run the full chat UI locally with one script:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
This installs dependencies, starts Ollama + a local model, launches the backend
|
||||
and frontend, and opens `http://localhost:5173` in your browser.
|
||||
|
||||
=== "Desktop App"
|
||||
|
||||
The desktop app is a native window for the OpenJarvis UI.
|
||||
The backend (Ollama + inference) runs on your machine — start it first, then open the app.
|
||||
|
||||
**Step 1.** Start the backend:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
**Step 2.** Download and open the desktop app:
|
||||
|
||||
[Download for macOS](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_universal.dmg){ .md-button .md-button--primary }
|
||||
|
||||
Also available for [Windows](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe), [Linux (DEB)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb), and [Linux (RPM)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm). See the [Downloads](downloads.md) page for details.
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
!!! warning "macOS: run `xattr -cr /Applications/OpenJarvis.app` if the app shows as \"damaged\"."
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis() # auto-detect engine
|
||||
response = j.ask("Explain quicksort.")
|
||||
print(response)
|
||||
```
|
||||
|
||||
For more control, use `ask_full()` to get usage stats, model info, and tool results:
|
||||
|
||||
```python
|
||||
result = j.ask_full(
|
||||
"What is 2 + 2?",
|
||||
agent="orchestrator",
|
||||
tools=["calculator"],
|
||||
)
|
||||
print(result["content"]) # "4"
|
||||
print(result["tool_results"]) # [{tool_name: "calculator", ...}]
|
||||
```
|
||||
|
||||
=== "CLI"
|
||||
|
||||
```bash
|
||||
jarvis ask "What is the capital of France?"
|
||||
|
||||
jarvis ask --agent orchestrator --tools calculator "What is 137 * 42?"
|
||||
|
||||
jarvis serve --port 8000
|
||||
|
||||
jarvis memory index ./docs/
|
||||
jarvis memory search "configuration options"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Five Primitives
|
||||
|
||||
1. **Intelligence** — The LM: model catalog, generation defaults, quantization, preferred engine.
|
||||
2. **Agents** — The agentic harness: system prompt, tools, context, retry and exit logic. Seven agent types.
|
||||
3. **Tools** — MCP interface: web search, calculator, file I/O, code interpreter, retrieval, and any external MCP server.
|
||||
4. **Engine** — The inference runtime: Ollama, vLLM, SGLang, llama.cpp, cloud APIs. Same `InferenceEngine` ABC.
|
||||
5. **Learning** — Improvement loop: SFT weight updates, agent advisor, ICL updater. Trace-driven feedback.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- **Five Composable Primitives**
|
||||
|
||||
---
|
||||
|
||||
Intelligence, Agents, Tools, Engine, and Learning — each with a clear ABC interface and decorator-based registry.
|
||||
|
||||
- **5 Engine Backends**
|
||||
|
||||
---
|
||||
|
||||
Ollama, vLLM, SGLang, llama.cpp, and cloud (OpenAI/Anthropic/Google). Same `InferenceEngine` ABC.
|
||||
|
||||
- **Hardware-Aware**
|
||||
|
||||
---
|
||||
|
||||
Auto-detects GPU vendor, model, and VRAM. Recommends the optimal engine for your hardware.
|
||||
|
||||
- **Offline-First**
|
||||
|
||||
---
|
||||
|
||||
All core functionality works without a network connection. Cloud APIs are optional extras.
|
||||
|
||||
- **OpenAI-Compatible API**
|
||||
|
||||
---
|
||||
|
||||
`jarvis serve` starts a FastAPI server with SSE streaming. Drop-in replacement for OpenAI clients.
|
||||
|
||||
- **Trace-Driven Learning**
|
||||
|
||||
---
|
||||
|
||||
Every interaction is traced. The learning system improves models (SFT) and agents (prompt, tools, logic).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- **[Getting Started](getting-started/installation.md)**
|
||||
|
||||
---
|
||||
|
||||
Install OpenJarvis, configure your first engine, and run your first query.
|
||||
|
||||
- **[User Guide](user-guide/cli.md)**
|
||||
|
||||
---
|
||||
|
||||
CLI, Python SDK, agents, memory, tools, telemetry, and benchmarks.
|
||||
|
||||
- **[Architecture](architecture/overview.md)**
|
||||
|
||||
---
|
||||
|
||||
Five-primitive design, registry pattern, query flow, and cross-cutting learning.
|
||||
|
||||
- **[API Reference](api-reference/openjarvis/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Auto-generated reference for every module.
|
||||
|
||||
- **[Deployment](deployment/docker.md)**
|
||||
|
||||
---
|
||||
|
||||
Docker, systemd, launchd. GPU-accelerated container images.
|
||||
|
||||
- **[Development](development/contributing.md)**
|
||||
|
||||
---
|
||||
|
||||
Contributing guide, extension patterns, roadmap, and changelog.
|
||||
|
||||
</div>
|
||||
|
||||
## Sponsors
|
||||
|
||||
<p>
|
||||
<a href="https://www.laude.org/">Laude Institute</a> •
|
||||
<a href="https://datascience.stanford.edu/marlowe">Stanford Marlowe</a> •
|
||||
<a href="https://cloud.google.com/">Google Cloud Platform</a> •
|
||||
<a href="https://lambda.ai/">Lambda Labs</a> •
|
||||
<a href="https://ollama.com/">Ollama</a> •
|
||||
<a href="https://research.ibm.com/">IBM Research</a> •
|
||||
<a href="https://hai.stanford.edu/">Stanford HAI</a>
|
||||
</p>
|
||||
@@ -0,0 +1,109 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SUPABASE_URL = "https://mtbtgpwzrbostweaanpr.supabase.co";
|
||||
var SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c";
|
||||
|
||||
function escapeHtml(s) {
|
||||
var el = document.createElement("span");
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
function fmtLarge(n) {
|
||||
if (n >= 1e12) return (n / 1e12).toFixed(1) + "T";
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function loadLeaderboard() {
|
||||
var tbody = document.getElementById("leaderboard-body");
|
||||
if (!tbody) return;
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" style="text-align:center;padding:48px;opacity:0.5">' +
|
||||
"Leaderboard not configured yet.</td></tr>";
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
SUPABASE_URL +
|
||||
"/rest/v1/savings_entries?select=*&order=dollar_savings.desc&limit=100",
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: "Bearer " + SUPABASE_ANON_KEY,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then(function (rows) {
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" style="text-align:center;padding:48px;opacity:0.5">' +
|
||||
"No entries yet. Be the first to opt in!</td></tr>";
|
||||
return;
|
||||
}
|
||||
|
||||
var totalMembers = rows.length;
|
||||
var totalDollars = 0;
|
||||
var totalRequests = 0;
|
||||
var totalTokens = 0;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
totalDollars += Number(rows[i].dollar_savings || 0);
|
||||
totalRequests += Number(rows[i].total_calls || 0);
|
||||
totalTokens += Number(rows[i].total_tokens || 0);
|
||||
}
|
||||
|
||||
var elMembers = document.getElementById("stat-members");
|
||||
var elDollars = document.getElementById("stat-dollars");
|
||||
var elRequests = document.getElementById("stat-requests");
|
||||
var elTokens = document.getElementById("stat-tokens");
|
||||
|
||||
if (elMembers) elMembers.textContent = totalMembers.toLocaleString();
|
||||
if (elDollars) elDollars.textContent = "$" + totalDollars.toFixed(2);
|
||||
if (elRequests) elRequests.textContent = totalRequests.toLocaleString();
|
||||
if (elTokens) elTokens.textContent = fmtLarge(totalTokens);
|
||||
|
||||
var html = "";
|
||||
for (var j = 0; j < rows.length; j++) {
|
||||
var rank = j + 1;
|
||||
var rankClass = rank <= 3 ? " lb-rank-" + rank : "";
|
||||
var medal =
|
||||
rank === 1 ? "\uD83E\uDD47" : rank === 2 ? "\uD83E\uDD48" : rank === 3 ? "\uD83E\uDD49" : "";
|
||||
var row = rows[j];
|
||||
html +=
|
||||
"<tr>" +
|
||||
'<td><span class="lb-rank' + rankClass + '">' + (medal || rank) + "</span></td>" +
|
||||
'<td class="lb-name">' + escapeHtml(row.display_name) + "</td>" +
|
||||
'<td class="lb-number">$' + Number(row.dollar_savings || 0).toFixed(4) + "</td>" +
|
||||
'<td class="lb-number">' + Number(row.energy_wh_saved || 0).toFixed(2) + "</td>" +
|
||||
'<td class="lb-number">' + fmtLarge(Number(row.flops_saved || 0)) + "</td>" +
|
||||
'<td class="lb-number">' + Number(row.total_calls || 0).toLocaleString() + "</td>" +
|
||||
'<td class="lb-number">' + Number(row.total_tokens || 0).toLocaleString() + "</td>" +
|
||||
"</tr>";
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
})
|
||||
.catch(function (err) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" style="text-align:center;padding:48px;color:var(--md-accent-fg-color)">' +
|
||||
"Failed to load leaderboard: " +
|
||||
escapeHtml(String(err)) +
|
||||
"</td></tr>";
|
||||
});
|
||||
}
|
||||
|
||||
// Run on page load and refresh every 60s
|
||||
if (document.getElementById("leaderboard-body")) {
|
||||
loadLeaderboard();
|
||||
setInterval(loadLeaderboard, 60000);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
# Savings Leaderboard
|
||||
|
||||
See how the OpenJarvis community saves money, energy, and compute by running AI locally instead of using cloud providers.
|
||||
|
||||
!!! info "Win a Mac Mini!"
|
||||
Opt in to share your savings from the OpenJarvis browser app or desktop app for a chance to win a Mac Mini. Your data is fully anonymous — no email, no IP, no hardware info.
|
||||
|
||||
<div id="leaderboard-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin:24px 0;">
|
||||
<div class="lb-stat-card">
|
||||
<div class="lb-stat-label">Community Members</div>
|
||||
<div class="lb-stat-value" id="stat-members">—</div>
|
||||
</div>
|
||||
<div class="lb-stat-card">
|
||||
<div class="lb-stat-label">Total Saved</div>
|
||||
<div class="lb-stat-value" id="stat-dollars">—</div>
|
||||
</div>
|
||||
<div class="lb-stat-card">
|
||||
<div class="lb-stat-label">Total Requests</div>
|
||||
<div class="lb-stat-value" id="stat-requests">—</div>
|
||||
</div>
|
||||
<div class="lb-stat-card">
|
||||
<div class="lb-stat-label">Total Tokens</div>
|
||||
<div class="lb-stat-value" id="stat-tokens">—</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="leaderboard-table-wrapper">
|
||||
<table id="leaderboard-table" class="lb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:50px">#</th>
|
||||
<th>Name</th>
|
||||
<th style="text-align:right">$ Saved</th>
|
||||
<th style="text-align:right">Energy (Wh)</th>
|
||||
<th style="text-align:right">FLOPs</th>
|
||||
<th style="text-align:right">Requests</th>
|
||||
<th style="text-align:right">Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="leaderboard-body">
|
||||
<tr>
|
||||
<td colspan="7" style="text-align:center;padding:48px;opacity:0.5">
|
||||
Loading leaderboard...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/* ── Fonts ─────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--md-text-font: Georgia, "Times New Roman", serif;
|
||||
--md-code-font: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
|
||||
/* ── Color Overrides (Light) ──────────────────────────────────────── */
|
||||
[data-md-color-scheme="default"] {
|
||||
--md-primary-fg-color: #2b2b2b;
|
||||
--md-primary-fg-color--light: #444444;
|
||||
--md-primary-fg-color--dark: #1a1a1a;
|
||||
--md-accent-fg-color: #3d5a80;
|
||||
--md-accent-fg-color--transparent: rgba(61, 90, 128, 0.07);
|
||||
--md-default-bg-color: #f8f7f4;
|
||||
--md-default-fg-color: #2b2b2b;
|
||||
--md-default-fg-color--light: #666666;
|
||||
--md-typeset-a-color: #3d5a80;
|
||||
--md-code-bg-color: #f0efeb;
|
||||
}
|
||||
|
||||
/* ── Color Overrides (Dark) ───────────────────────────────────────── */
|
||||
[data-md-color-scheme="slate"] {
|
||||
--md-primary-fg-color: #1a1a1a;
|
||||
--md-primary-fg-color--light: #2b2b2b;
|
||||
--md-primary-fg-color--dark: #0f0f0f;
|
||||
--md-accent-fg-color: #8da9c4;
|
||||
--md-accent-fg-color--transparent: rgba(141, 169, 196, 0.1);
|
||||
--md-default-bg-color: #1a1a1a;
|
||||
--md-default-fg-color: #e8e6e1;
|
||||
--md-default-fg-color--light: #999999;
|
||||
--md-typeset-a-color: #8da9c4;
|
||||
--md-code-bg-color: #222222;
|
||||
}
|
||||
|
||||
/* ── Global Typography ───────────────────────────────────────────── */
|
||||
.md-typeset {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.85;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.md-typeset strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Nav and UI chrome use a clean sans-serif */
|
||||
.md-header,
|
||||
.md-tabs,
|
||||
.md-nav,
|
||||
.md-footer,
|
||||
.md-search__input,
|
||||
.md-source {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.md-nav__link,
|
||||
.md-tabs__link {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── Hero Section (DSPy-style: clean, italic emphasis) ───────────── */
|
||||
.md-typeset h1 {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--md-default-fg-color);
|
||||
}
|
||||
|
||||
.md-typeset h1 em {
|
||||
font-style: italic;
|
||||
color: var(--md-accent-fg-color);
|
||||
}
|
||||
|
||||
.md-typeset h1 .headerlink {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-tagline {
|
||||
font-size: 1.05rem;
|
||||
color: var(--md-default-fg-color--light);
|
||||
max-width: 600px;
|
||||
line-height: 1.8;
|
||||
font-weight: 300;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Prominent install command (like DSPy's pip install) */
|
||||
.install-cmd {
|
||||
display: inline-block;
|
||||
background: var(--md-code-bg-color);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-family: var(--md-code-font);
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0;
|
||||
color: var(--md-default-fg-color);
|
||||
margin: 1rem 0 2rem 0;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .install-cmd {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ── Section Headers ──────────────────────────────────────────────── */
|
||||
.md-typeset h2 {
|
||||
font-weight: 700;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.015em;
|
||||
margin-top: 3.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.md-typeset h2:first-child,
|
||||
.md-typeset hr + h2 {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-typeset h2 {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.md-typeset h3 {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.md-typeset h4 {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* ── Feature Cards (clean, minimal like OA/DSPy) ─────────────────── */
|
||||
.md-typeset .grid.cards > ol,
|
||||
.md-typeset .grid.cards > ul {
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.md-typeset .grid.cards > ol > li,
|
||||
.md-typeset .grid.cards > ul > li {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem 1.75rem;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
background: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ol > li,
|
||||
[data-md-color-scheme="slate"] .md-typeset .grid.cards > ul > li {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: #222222;
|
||||
}
|
||||
|
||||
.md-typeset .grid.cards > ol > li:hover,
|
||||
.md-typeset .grid.cards > ul > li:hover {
|
||||
border-color: var(--md-accent-fg-color);
|
||||
background: var(--md-accent-fg-color--transparent);
|
||||
}
|
||||
|
||||
.md-typeset .grid.cards > ol > li > :first-child,
|
||||
.md-typeset .grid.cards > ul > li > :first-child {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ── Code Blocks ──────────────────────────────────────────────────── */
|
||||
.md-typeset code {
|
||||
border-radius: 4px;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
|
||||
.md-typeset pre {
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-typeset pre {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.md-typeset pre > code {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ── Tabs ─────────────────────────────────────────────────────────── */
|
||||
.md-typeset .tabbed-labels > label {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── Buttons (clean, bordered like OA) ───────────────────────────── */
|
||||
.md-typeset .md-button {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.md-typeset .md-button--primary {
|
||||
background: var(--md-default-fg-color);
|
||||
border-color: var(--md-default-fg-color);
|
||||
color: var(--md-default-bg-color);
|
||||
}
|
||||
|
||||
.md-typeset .md-button--primary:hover {
|
||||
background: var(--md-accent-fg-color);
|
||||
border-color: var(--md-accent-fg-color);
|
||||
}
|
||||
|
||||
/* ── Navigation ───────────────────────────────────────────────────── */
|
||||
.md-tabs {
|
||||
background: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.md-header {
|
||||
box-shadow: none;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-header {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* ── Admonitions ──────────────────────────────────────────────────── */
|
||||
.md-typeset .admonition,
|
||||
.md-typeset details {
|
||||
border-radius: 6px;
|
||||
border-width: 1px;
|
||||
border-left-width: 4px;
|
||||
box-shadow: none;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────────────── */
|
||||
.md-footer {
|
||||
background: var(--md-primary-fg-color--dark);
|
||||
}
|
||||
|
||||
/* ── Pill Badges ─────────────────────────────────────────────────── */
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.7rem;
|
||||
border-radius: 100px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: var(--md-accent-fg-color--transparent);
|
||||
color: var(--md-accent-fg-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Divider ──────────────────────────────────────────────────────── */
|
||||
.md-typeset hr {
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-typeset hr {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ── Inline Code ─────────────────────────────────────────────────── */
|
||||
.md-typeset :not(pre) > code {
|
||||
transition: background 0.15s ease;
|
||||
font-size: 0.8em;
|
||||
padding: 0.15em 0.4em;
|
||||
}
|
||||
|
||||
/* ── Links ───────────────────────────────────────────────────────── */
|
||||
.md-typeset a {
|
||||
transition: color 0.15s ease;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.md-typeset a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Content Width ───────────────────────────────────────────────── */
|
||||
.md-content__inner {
|
||||
max-width: 48rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
/* ── Tables ──────────────────────────────────────────────────────── */
|
||||
.md-typeset table:not([class]) {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .md-typeset table:not([class]) {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.md-typeset table:not([class]) th {
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Quick Start Tabs ─────────────────────────────────────────────── */
|
||||
.md-typeset .tabbed-set {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.md-typeset .tabbed-content {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Lists (more breathing room) ──────────────────────────────────── */
|
||||
.md-typeset ol,
|
||||
.md-typeset ul {
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.md-typeset li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.md-typeset h1 {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
.hero-tagline {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.install-cmd {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Leaderboard ─────────────────────────────────────────────────── */
|
||||
.lb-stat-card {
|
||||
background: var(--md-code-bg-color, #f5f5f5);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--md-default-fg-color--lightest, #e0e0e0);
|
||||
}
|
||||
.lb-stat-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.lb-stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
.lb-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
.lb-table th {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid var(--md-default-fg-color--lightest, #e0e0e0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lb-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--md-default-fg-color--lightest, #e0e0e0);
|
||||
}
|
||||
.lb-table tbody tr:hover {
|
||||
background: var(--md-code-bg-color, #f5f5f5);
|
||||
}
|
||||
.lb-rank {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.lb-rank-1 { color: #ffd700; }
|
||||
.lb-rank-2 { color: #c0c0c0; }
|
||||
.lb-rank-3 { color: #cd7f32; }
|
||||
.lb-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.lb-number {
|
||||
text-align: right;
|
||||
font-family: var(--md-code-font-family, monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
title: Code Companion
|
||||
description: Code review, debugging, and test generation with ReAct agents
|
||||
---
|
||||
|
||||
# Code Companion
|
||||
|
||||
This tutorial walks through `examples/code_companion/` — three developer-focused scripts that use a `native_react` (ReAct) agent to automate common coding tasks: reviewing pull request diffs, investigating errors, and generating tests. Each script adapts the same core pattern to a different workflow, making it easy to extend for your own code intelligence use cases.
|
||||
|
||||
!!! tip "Prerequisites"
|
||||
- Python 3.10 or later
|
||||
- OpenJarvis installed: `uv sync --extra dev` from the repository root
|
||||
- An inference engine running — Ollama locally or a cloud API key in `.env`
|
||||
- For `reviewer.py` and `code_review.py`: a git repository with at least two branches or commits
|
||||
|
||||
## The Three Scripts
|
||||
|
||||
| Script | Purpose | Tools Used |
|
||||
|---|---|---|
|
||||
| `reviewer.py` | Review a git diff between two branches | `git_diff`, `git_log`, `file_read`, `think` |
|
||||
| `debugger.py` | Investigate an error and propose a fix | `file_read`, `shell_exec`, `think` |
|
||||
| `test_gen.py` | Generate comprehensive tests for a Python module | `file_read`, `think`, `file_write` |
|
||||
|
||||
All three use the `native_react` agent with the same SDK pattern. The difference is which tools are provided and how the prompt is structured.
|
||||
|
||||
## The ReAct Agent Loop
|
||||
|
||||
The `native_react` agent implements the Thought-Action-Observation cycle. Rather than producing a single response, it iterates until it has gathered enough information:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Thought: Receive task prompt
|
||||
Thought --> Action: Decide which tool to call
|
||||
Action --> Observation: Execute tool, receive result
|
||||
Observation --> Thought: Feed result back into context
|
||||
Thought --> FinalAnswer: Sufficient information gathered
|
||||
FinalAnswer --> [*]
|
||||
```
|
||||
|
||||
This loop lets the agent adaptively explore the codebase. For example, the reviewer might read a diff, notice a suspicious function call, then read the source of that function before making its assessment — without any of that branching logic being hardcoded in the script.
|
||||
|
||||
## Core SDK Pattern
|
||||
|
||||
All three scripts follow the same structure. Understanding this pattern lets you adapt it to any code intelligence task:
|
||||
|
||||
```python title="Core SDK pattern" hl_lines="4 5 6"
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis(model="qwen3:8b", engine_key="ollama") # (1)!
|
||||
try:
|
||||
response = j.ask(
|
||||
prompt, # (2)!
|
||||
agent="native_react", # (3)!
|
||||
tools=["git_diff", "think"], # (4)!
|
||||
)
|
||||
print(response)
|
||||
finally:
|
||||
j.close() # (5)!
|
||||
```
|
||||
|
||||
1. Both `model` and `engine_key` are optional. Omitting them uses auto-detected defaults from `~/.openjarvis/config.toml`.
|
||||
2. The prompt describes the task in detail, including what tools to use, what steps to follow, and what the output structure should look like.
|
||||
3. `"native_react"` selects the `NativeReActAgent`. The alias `"react"` also works.
|
||||
4. The tool list is passed directly. Any registered tool name is valid — run `jarvis agent info native_react` to see all available tools.
|
||||
5. Always call `j.close()` to release engine resources. A `try/finally` block ensures cleanup even if the agent raises an exception.
|
||||
|
||||
## Code Review
|
||||
|
||||
The `reviewer.py` script reviews the diff between two git refs and produces structured feedback with issues, suggestions, and an overall verdict.
|
||||
|
||||
```bash title="Terminal"
|
||||
# Review a feature branch against main (default)
|
||||
python examples/code_companion/reviewer.py --branch feature-x
|
||||
|
||||
# Review a specific commit range
|
||||
python examples/code_companion/reviewer.py --branch HEAD --base develop
|
||||
|
||||
# Use a cloud model for larger diffs
|
||||
python examples/code_companion/reviewer.py \
|
||||
--branch feature-x --model gpt-4o --engine cloud
|
||||
```
|
||||
|
||||
The agent follows a four-step process:
|
||||
|
||||
1. Call `git_diff` to see what changed between the two refs
|
||||
2. Call `git_log` to understand the commit history and intent
|
||||
3. Call `file_read` on any files that need more context
|
||||
4. Call `think` to reason about code quality, bugs, and design decisions
|
||||
|
||||
The final output is structured with four sections: **Summary**, **Issues Found**, **Suggestions**, and **Overall Assessment** (APPROVE, REQUEST CHANGES, or COMMENT).
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--branch` | `HEAD` | Branch or commit to review |
|
||||
| `--base` | `main` | Base branch to diff against |
|
||||
| `--model` | `qwen3:8b` | Model identifier |
|
||||
| `--engine` | `ollama` | Engine backend |
|
||||
|
||||
## Debug Assistant
|
||||
|
||||
The `debugger.py` script takes an error message, optionally a file path, and produces a root cause analysis with a concrete fix.
|
||||
|
||||
```bash title="Terminal"
|
||||
# Investigate a TypeError
|
||||
python examples/code_companion/debugger.py \
|
||||
--error "TypeError: NoneType has no attribute 'split'"
|
||||
|
||||
# Provide the file where the error occurred for faster analysis
|
||||
python examples/code_companion/debugger.py \
|
||||
--error "KeyError: 'user_id'" \
|
||||
--file src/app/views.py
|
||||
|
||||
# Use a cloud model for complex stack traces
|
||||
python examples/code_companion/debugger.py \
|
||||
--error "Segfault in libfoo.so" \
|
||||
--model gpt-4o --engine cloud
|
||||
```
|
||||
|
||||
The agent uses `file_read` to examine the relevant source, `shell_exec` to run diagnostic commands (grep for symbols, check imports, inspect directory contents), and `think` to reason about root causes before proposing a fix.
|
||||
|
||||
!!! note "shell_exec safety"
|
||||
The `shell_exec` tool runs commands in the current working directory. In production deployments, `ToolExecutor` enforces RBAC capability policies — ensure the `shell_exec` capability is permitted for the agent's role. See [Architecture: Security](../architecture/security.md).
|
||||
|
||||
The output has three sections: **Root Cause**, **Proposed Fix** (concrete code change), and **Prevention** (type hints, validation, tests).
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--error` | (required) | Error message or stack trace |
|
||||
| `--file` | (none) | Optional file path where the error occurred |
|
||||
| `--model` | `qwen3:8b` | Model identifier |
|
||||
| `--engine` | `ollama` | Engine backend |
|
||||
|
||||
## Test Generator
|
||||
|
||||
The `test_gen.py` script reads a Python module, reasons about its public interface, and writes a complete test file.
|
||||
|
||||
```bash title="Terminal"
|
||||
# Generate pytest tests for a module
|
||||
python examples/code_companion/test_gen.py \
|
||||
--module src/openjarvis/tools/calculator.py
|
||||
|
||||
# Use unittest and specify the output file
|
||||
python examples/code_companion/test_gen.py \
|
||||
--module src/openjarvis/tools/calculator.py \
|
||||
--framework unittest \
|
||||
--output tests/test_calculator_generated.py
|
||||
```
|
||||
|
||||
The agent reads the module with `file_read`, uses `think` to plan test cases (happy paths, edge cases, error handling, boundary conditions), reads any related base classes for context, then writes the complete test file with `file_write`.
|
||||
|
||||
!!! note "Output path default"
|
||||
If `--output` is not specified, the generated file is saved as `test_<module_name>.py` in the current working directory. The script prints the output path when done.
|
||||
|
||||
The generated tests follow these guidelines (enforced via the prompt):
|
||||
|
||||
- Every public function and method has at least one test
|
||||
- Each test has a docstring explaining what it verifies
|
||||
- Edge cases are covered: empty input, `None`, large values, invalid types
|
||||
- External dependencies are mocked with `unittest.mock`
|
||||
- The file is self-contained and runnable with `pytest` or `unittest` without modification
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--module` | (required) | Path to the Python module |
|
||||
| `--framework` | `pytest` | Test framework (`pytest` or `unittest`) |
|
||||
| `--output` | `test_<name>.py` | Output file path |
|
||||
| `--model` | `qwen3:8b` | Model identifier |
|
||||
| `--engine` | `ollama` | Engine backend |
|
||||
|
||||
## Engine Selection
|
||||
|
||||
=== "Ollama (local)"
|
||||
|
||||
```bash title="Terminal"
|
||||
ollama serve
|
||||
ollama pull qwen3:8b
|
||||
python examples/code_companion/reviewer.py --branch feature-x
|
||||
```
|
||||
|
||||
=== "Cloud API"
|
||||
|
||||
```bash title="Terminal"
|
||||
source .env # load OPENAI_API_KEY or similar
|
||||
python examples/code_companion/reviewer.py \
|
||||
--branch feature-x \
|
||||
--model gpt-4o \
|
||||
--engine cloud
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Change the tool set
|
||||
|
||||
Edit the `tools` list in any script to add or remove tools. For example, to let the reviewer also search the web for known security advisories related to dependencies it sees in the diff:
|
||||
|
||||
```python
|
||||
tools = ["git_diff", "git_log", "file_read", "think", "web_search"]
|
||||
```
|
||||
|
||||
### Adjust the prompt
|
||||
|
||||
Each script contains a `prompt` string that instructs the agent what to do and what to produce. Modify it to match your team's conventions — different review sections, specific coding standards, or a particular output format for downstream tooling.
|
||||
|
||||
### Add memory
|
||||
|
||||
For multi-session workflows (e.g., a reviewer that remembers previous assessments of the same files), add `"memory_store"` and `"memory_search"` to the tool list and update the prompt to use them:
|
||||
|
||||
```python
|
||||
tools = ["git_diff", "git_log", "file_read", "think",
|
||||
"memory_store", "memory_search"]
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Architecture: Agents](../architecture/agents.md) — `NativeReActAgent` internals and the Thought-Action-Observation loop
|
||||
- [Architecture: Tools and Memory](../architecture/memory.md) — git tools, file tools, shell tools, and the `ToolExecutor` dispatch pipeline
|
||||
- [Architecture: Security](../architecture/security.md) — RBAC capability policies for `shell_exec` and other privileged tools
|
||||
- [Tutorials: Deep Research Assistant](deep-research.md) — the same SDK pattern with the `OrchestratorAgent` and web/memory tools
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
title: Deep Research Assistant
|
||||
description: Build a multi-source research agent with memory-augmented orchestration
|
||||
---
|
||||
|
||||
# Deep Research Assistant
|
||||
|
||||
This tutorial walks through `examples/deep_research/research.py` — a standalone script that uses an orchestrator agent to research a topic, gather sources across multiple tool-calling turns, and produce a cited report. It demonstrates how to compose web search, memory, and file output into a single coherent research workflow.
|
||||
|
||||
!!! tip "Prerequisites"
|
||||
- Python 3.10 or later
|
||||
- OpenJarvis installed: run `uv sync --extra dev` from the repository root
|
||||
- An inference engine running — either Ollama locally (see below) or a cloud API key in your `.env` file
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run the research script from the repository root, passing your topic as a positional argument:
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/deep_research/research.py "quantum computing advances 2026"
|
||||
```
|
||||
|
||||
Save the report to a file:
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/deep_research/research.py "quantum computing advances 2026" \
|
||||
--output report.md
|
||||
```
|
||||
|
||||
Use a cloud model instead of a local engine:
|
||||
|
||||
```bash title="Terminal"
|
||||
source .env # load API keys
|
||||
python examples/deep_research/research.py "climate policy trends" \
|
||||
--model gpt-4o --engine cloud --max-turns 20
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The script creates a `Jarvis` instance and delegates the research task to an `OrchestratorAgent` with five tools wired in. The orchestrator iterates through multiple tool-calling turns, deciding at each step whether to search, store, think, or synthesize.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant J as Jarvis SDK
|
||||
participant O as OrchestratorAgent
|
||||
participant W as web_search
|
||||
participant T as think
|
||||
participant MS as memory_store
|
||||
participant MQ as memory_search
|
||||
participant F as file_write
|
||||
|
||||
U->>J: research.py "quantum computing"
|
||||
J->>O: ask(prompt, agent="orchestrator", tools=[...])
|
||||
loop Up to max_turns iterations
|
||||
O->>W: search("quantum computing 2026")
|
||||
W-->>O: search results
|
||||
O->>T: think(reasoning about findings)
|
||||
T-->>O: structured thoughts
|
||||
O->>MS: store(key finding)
|
||||
MS-->>O: stored
|
||||
O->>MQ: search(earlier findings)
|
||||
MQ-->>O: related context
|
||||
end
|
||||
O->>F: file_write(report.md)
|
||||
F-->>O: saved
|
||||
O-->>J: final report with citations
|
||||
J-->>U: print report
|
||||
```
|
||||
|
||||
Each turn the orchestrator decides which tool to call based on what it has learned so far. The `think` tool lets the model reason without side effects, while `memory_store` and `memory_search` provide persistent scratch space across turns — so a finding from turn 3 can still inform the synthesis in turn 12.
|
||||
|
||||
## The Script
|
||||
|
||||
```python title="examples/deep_research/research.py" hl_lines="9 10 11 12 13"
|
||||
from openjarvis import Jarvis
|
||||
|
||||
tools = ["web_search", "think", "file_write", "memory_store", "memory_search"]
|
||||
|
||||
j = Jarvis(model="qwen3:8b", engine_key="ollama") # (1)!
|
||||
try:
|
||||
response = j.ask(
|
||||
"Research the following topic in depth and produce a report:\n\nquantum computing",
|
||||
agent="orchestrator", # (2)!
|
||||
tools=tools, # (3)!
|
||||
system_prompt=..., # (4)!
|
||||
max_turns=15, # (5)!
|
||||
temperature=0.5,
|
||||
)
|
||||
finally:
|
||||
j.close()
|
||||
```
|
||||
|
||||
1. Creates a `Jarvis` instance targeting the local Ollama engine with `qwen3:8b`. Both parameters are optional — omitting them uses auto-detected defaults from `~/.openjarvis/config.toml`.
|
||||
2. Selects the `OrchestratorAgent`, which runs a multi-turn tool-calling loop rather than a single round-trip.
|
||||
3. The tool list is passed directly to the agent. All five tools are registered in the tool registry and need no further configuration.
|
||||
4. The system prompt instructs the model to cite sources and distinguish facts from emerging claims.
|
||||
5. The loop terminates after 15 tool-calling turns or when the agent decides it has enough information.
|
||||
|
||||
## Engine Configuration
|
||||
|
||||
=== "Ollama (local)"
|
||||
|
||||
Start the Ollama daemon and pull the model before running the script:
|
||||
|
||||
```bash title="Terminal"
|
||||
ollama serve
|
||||
ollama pull qwen3:8b
|
||||
python examples/deep_research/research.py "your topic here"
|
||||
```
|
||||
|
||||
No flags needed — `--engine ollama` and `--model qwen3:8b` are the defaults.
|
||||
|
||||
=== "Cloud API"
|
||||
|
||||
Set your API key in `.env`, then pass `--engine cloud` and the appropriate model identifier:
|
||||
|
||||
```bash title="Terminal"
|
||||
# .env (in the repository root, gitignored)
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
source .env
|
||||
python examples/deep_research/research.py "your topic" \
|
||||
--model gpt-4o --engine cloud
|
||||
```
|
||||
|
||||
=== "vLLM"
|
||||
|
||||
If you are running a vLLM inference server (e.g., on a multi-GPU node):
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/deep_research/research.py "your topic" \
|
||||
--model meta-llama/Meta-Llama-3-8B-Instruct \
|
||||
--engine vllm
|
||||
```
|
||||
|
||||
Make sure `VLLM_BASE_URL` is set in `.env` pointing to your vLLM server.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--model` | `qwen3:8b` | Model identifier passed to the engine |
|
||||
| `--engine` | `ollama` | Engine backend (`ollama`, `cloud`, `vllm`, `llamacpp`, `mlx`) |
|
||||
| `--max-turns` | `15` | Maximum orchestrator loop iterations |
|
||||
| `--output` | (none) | File path to save the final report; if omitted, prints to stdout |
|
||||
|
||||
## Recipe-Driven Configuration
|
||||
|
||||
The companion `research.toml` in `examples/deep_research/` expresses the same setup declaratively. You can load it programmatically with `load_recipe()` and pass the result to `SystemBuilder`:
|
||||
|
||||
```python title="Using the recipe"
|
||||
from openjarvis.recipes import load_recipe
|
||||
from openjarvis import SystemBuilder
|
||||
|
||||
recipe = load_recipe("examples/deep_research/research.toml")
|
||||
system = SystemBuilder(**recipe.to_builder_kwargs()).build()
|
||||
response = system.ask("quantum computing advances 2026")
|
||||
system.close()
|
||||
```
|
||||
|
||||
This is useful when you want to version-control the research configuration, share it with collaborators, or feed it to the `jarvis eval` runner for benchmarking.
|
||||
|
||||
## Customization
|
||||
|
||||
### Swap the agent
|
||||
|
||||
Replace `"orchestrator"` with `"native_react"` for a Thought-Action-Observation loop, or `"native_openhands"` for a CodeAct-style agent that can write and execute code:
|
||||
|
||||
```python
|
||||
response = j.ask(prompt, agent="native_react", tools=tools)
|
||||
```
|
||||
|
||||
### Add more tools
|
||||
|
||||
Append any registered tool name to the `tools` list. For example, to also query a local knowledge base:
|
||||
|
||||
```python
|
||||
tools = ["web_search", "think", "file_write",
|
||||
"memory_store", "memory_search", "knowledge_graph_query"]
|
||||
```
|
||||
|
||||
Run `jarvis agent info orchestrator` to see the full tool catalog.
|
||||
|
||||
### Adjust temperature
|
||||
|
||||
Lower values (0.2) produce more focused, factual reports. Higher values (0.7-0.8) encourage broader exploration and more creative synthesis:
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/deep_research/research.py "your topic" --max-turns 20
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Architecture: Agents](../architecture/agents.md) — agent hierarchy (`BaseAgent`, `ToolUsingAgent`, `OrchestratorAgent`) and the `accepts_tools` mechanism
|
||||
- [Architecture: Tools and Memory](../architecture/memory.md) — tool registry, MCP adapter, and the `ToolExecutor` dispatch pipeline
|
||||
- [Getting Started: Configuration](../getting-started/configuration.md) — how to configure engines and models in `~/.openjarvis/config.toml`
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Tutorials
|
||||
description: Step-by-step guides for building with OpenJarvis
|
||||
---
|
||||
|
||||
# Tutorials
|
||||
|
||||
Hands-on guides that walk through building real applications with OpenJarvis. Each tutorial includes a standalone script you can run immediately, a TOML recipe for configuration, and a detailed walkthrough of the concepts involved.
|
||||
|
||||
!!! note "Before you begin"
|
||||
All tutorials assume OpenJarvis is installed and an inference engine is running. If you have not completed setup yet, start with the [Quick Start guide](../getting-started/quickstart.md).
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-magnify:{ .lg .middle } **Deep Research Assistant**
|
||||
|
||||
---
|
||||
|
||||
Multi-source research with a memory-augmented orchestrator agent. Searches the web, stores findings across turns, cross-references sources, and produces a cited report.
|
||||
|
||||
[:octicons-arrow-right-24: Get started](deep-research.md)
|
||||
|
||||
- :material-clock-outline:{ .lg .middle } **Scheduled Personal Ops**
|
||||
|
||||
---
|
||||
|
||||
Autonomous agents on cron schedules for recurring personal tasks — morning news digests, weekly code reviews, and gym schedule checks.
|
||||
|
||||
[:octicons-arrow-right-24: Get started](scheduled-ops.md)
|
||||
|
||||
- :material-message-outline:{ .lg .middle } **Messaging Hub**
|
||||
|
||||
---
|
||||
|
||||
Smart inbox assistant that triages messages by priority, drafts context-aware replies, and produces end-of-day summaries across Slack, WhatsApp, and other channels.
|
||||
|
||||
[:octicons-arrow-right-24: Get started](messaging-hub.md)
|
||||
|
||||
- :material-code-braces:{ .lg .middle } **Code Companion**
|
||||
|
||||
---
|
||||
|
||||
Code review, debugging, and test generation using a ReAct agent that reads source files, runs commands, and reasons step by step before producing structured output.
|
||||
|
||||
[:octicons-arrow-right-24: Get started](code-companion.md)
|
||||
|
||||
</div>
|
||||
|
||||
## What You Will Learn
|
||||
|
||||
Each tutorial demonstrates a different combination of OpenJarvis primitives working together:
|
||||
|
||||
| Tutorial | Agent | Key Primitives |
|
||||
|---|---|---|
|
||||
| Deep Research | `orchestrator` | Engine, Agents, Tools (web + memory), Recipes |
|
||||
| Scheduled Ops | `orchestrator`, `native_react` | Agents, Tools, Scheduler |
|
||||
| Messaging Hub | `orchestrator` | Agents, Tools (memory), Channels |
|
||||
| Code Companion | `native_react` | Agents, Tools (git + file + shell) |
|
||||
|
||||
## Estimated Time
|
||||
|
||||
Each tutorial takes approximately 15-30 minutes to complete end-to-end, including setup and running the scripts. The TOML configuration sections and customization tips are optional reading for when you adapt the pattern to your own use case.
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
title: Messaging Hub
|
||||
description: Smart inbox with message triage and auto-replies across channels
|
||||
---
|
||||
|
||||
# Messaging Hub
|
||||
|
||||
This tutorial walks through `examples/messaging_hub/smart_inbox.py` — a script that connects OpenJarvis to messaging platforms, triages incoming messages by priority, drafts context-aware replies, and produces end-of-day summaries. It demonstrates channel integration, structured agent output, and memory-backed aggregation across multiple messages.
|
||||
|
||||
!!! tip "Prerequisites"
|
||||
- Python 3.10 or later
|
||||
- OpenJarvis installed: `uv sync --extra dev` from the repository root
|
||||
- An inference engine running (Ollama with `qwen3:8b` pulled, or cloud API keys)
|
||||
- For live channel mode: channel-specific credentials (see [Setting Up Real Channels](#setting-up-real-channels))
|
||||
|
||||
## Quick Start: Demo Mode
|
||||
|
||||
Demo mode processes five sample messages with no channel setup or credentials required. It is the fastest way to see the triage pipeline in action:
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/messaging_hub/smart_inbox.py --demo
|
||||
```
|
||||
|
||||
Expected output (abbreviated):
|
||||
|
||||
```
|
||||
Smart Inbox — Demo Mode
|
||||
Model: qwen3:8b | Engine: ollama
|
||||
============================================================
|
||||
Processing 5 messages...
|
||||
|
||||
[1/5] Classifying: URGENT: Server is down in production...
|
||||
-> URGENT
|
||||
[2/5] Classifying: Hey, just wanted to share this interest...
|
||||
-> FYI
|
||||
[3/5] Classifying: Can you review my PR #42 by end of day...
|
||||
-> ACTION_REQUIRED
|
||||
[4/5] Classifying: Meeting reminder: Team standup at 10am...
|
||||
-> FYI
|
||||
[5/5] Classifying: Buy now! Limited time offer on premium...
|
||||
-> SPAM
|
||||
|
||||
# Category Message Reply
|
||||
---------------------------------------------------------------
|
||||
1 URGENT URGENT: Server is down... On it — escalating now.
|
||||
2 FYI Hey, just wanted to share... Thanks for sharing!
|
||||
3 ACTION_REQUIRED Can you review my PR #42... Will review before EOD.
|
||||
4 FYI Meeting reminder: Team standup... N/A
|
||||
5 SPAM Buy now! Limited time offer... N/A
|
||||
|
||||
Generating end-of-day summary...
|
||||
```
|
||||
|
||||
Override the model or engine:
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/messaging_hub/smart_inbox.py --demo --model gpt-4o --engine cloud
|
||||
```
|
||||
|
||||
## How Message Classification Works
|
||||
|
||||
Each incoming message goes through a structured prompt that asks the agent to output exactly two fields — a category and a reply — in a parseable format. The script then extracts those fields and builds a triage table.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Incoming message] --> B[OrchestratorAgent]
|
||||
B --> C{think tool: internal reasoning}
|
||||
C --> D{memory_store: persist context}
|
||||
D --> E[Structured response]
|
||||
E --> F{Parse CATEGORY and REPLY}
|
||||
F -->|URGENT| G[Flag for immediate attention]
|
||||
F -->|ACTION_REQUIRED| H[Add to action list]
|
||||
F -->|FYI| I[Log for reference]
|
||||
F -->|SPAM| J[Discard]
|
||||
G --> K[Triage table]
|
||||
H --> K
|
||||
I --> K
|
||||
J --> K
|
||||
K --> L[memory_search: cross-reference]
|
||||
L --> M[End-of-day summary]
|
||||
```
|
||||
|
||||
After all messages are processed, a second orchestrator call uses `memory_search` to retrieve the stored triage log and produces a grouped summary with open action items highlighted.
|
||||
|
||||
## The Classification Prompt
|
||||
|
||||
The agent receives a structured prompt that specifies the output format exactly. This makes the response reliably parseable without a complex schema:
|
||||
|
||||
```python title="examples/messaging_hub/smart_inbox.py"
|
||||
CLASSIFICATION_PROMPT = (
|
||||
"You are a smart inbox assistant. Classify the following message into "
|
||||
"exactly one category: URGENT, ACTION_REQUIRED, FYI, or SPAM.\n"
|
||||
"Then draft a short reply if appropriate (not for SPAM).\n\n"
|
||||
"Respond in this exact format:\n"
|
||||
"CATEGORY: <category>\n"
|
||||
"REPLY: <reply or N/A>\n\n"
|
||||
"Message:\n{message}"
|
||||
)
|
||||
```
|
||||
|
||||
The `think` tool lets the agent reason internally before committing to a category, and `memory_store` persists each classification so the summary prompt can reference the full triage log.
|
||||
|
||||
## Setting Up Real Channels
|
||||
|
||||
=== "Slack"
|
||||
|
||||
1. Add the Slack MCP server to your configuration:
|
||||
|
||||
```bash title="Terminal"
|
||||
jarvis add slack
|
||||
```
|
||||
|
||||
2. Set your credentials in `.env` (gitignored):
|
||||
|
||||
```bash title=".env"
|
||||
SLACK_BOT_TOKEN=xoxb-...
|
||||
SLACK_APP_TOKEN=xapp-...
|
||||
```
|
||||
|
||||
3. Invite the bot to the target Slack channel in the Slack workspace settings.
|
||||
|
||||
4. Run the script in live channel mode:
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/messaging_hub/smart_inbox.py --channel slack
|
||||
```
|
||||
|
||||
=== "WhatsApp"
|
||||
|
||||
1. Ensure Node.js 22 or later is installed.
|
||||
|
||||
2. Configure the WhatsApp Baileys bridge. See the [channel documentation](../architecture/overview.md) for full setup steps.
|
||||
|
||||
3. Start the bridge — it will print a QR code. Scan it with the WhatsApp mobile app to authenticate.
|
||||
|
||||
4. Run the script:
|
||||
|
||||
```bash title="Terminal"
|
||||
python examples/messaging_hub/smart_inbox.py --channel whatsapp
|
||||
```
|
||||
|
||||
=== "Other Channels"
|
||||
|
||||
OpenJarvis supports LINE, Viber, Mastodon, Rocket.Chat, Zulip, XMPP, Twitch, Nostr, and more. List all available channels:
|
||||
|
||||
```bash title="Terminal"
|
||||
jarvis channel list
|
||||
jarvis channel status
|
||||
```
|
||||
|
||||
Each channel requires its own environment variables. Run `jarvis add <channel>` where available to auto-generate the configuration template.
|
||||
|
||||
!!! warning "Live channel mode"
|
||||
Live channel mode requires channel credentials and the corresponding channel subsystem to be running. Use `--demo` to verify the triage logic before connecting to a real channel.
|
||||
|
||||
## Channel Configuration via TOML
|
||||
|
||||
The `messaging.toml` recipe in `examples/messaging_hub/` captures the agent and channel defaults declaratively:
|
||||
|
||||
```toml title="examples/messaging_hub/messaging.toml"
|
||||
[channel]
|
||||
default = "slack"
|
||||
|
||||
[agent]
|
||||
type = "orchestrator"
|
||||
max_turns = 5
|
||||
temperature = 0.3
|
||||
tools = ["think", "memory_store", "memory_search"]
|
||||
```
|
||||
|
||||
You can load this recipe programmatically:
|
||||
|
||||
```python title="Loading the messaging recipe"
|
||||
from openjarvis.recipes import load_recipe
|
||||
from openjarvis import SystemBuilder
|
||||
|
||||
recipe = load_recipe("examples/messaging_hub/messaging.toml")
|
||||
system = SystemBuilder(**recipe.to_builder_kwargs()).build()
|
||||
response = system.ask(CLASSIFICATION_PROMPT.format(message=incoming_message))
|
||||
system.close()
|
||||
```
|
||||
|
||||
## Adding Custom Triage Rules
|
||||
|
||||
Extend the classification categories by editing `CLASSIFICATION_PROMPT`. For example, to add a `FOLLOW_UP` category for messages that need a response within 48 hours:
|
||||
|
||||
```python title="Custom classification prompt" hl_lines="2"
|
||||
CLASSIFICATION_PROMPT = (
|
||||
"Classify into: URGENT, ACTION_REQUIRED, FOLLOW_UP, FYI, or SPAM.\n"
|
||||
"Then draft a short reply if appropriate (not for SPAM).\n\n"
|
||||
"Respond in this exact format:\n"
|
||||
"CATEGORY: <category>\n"
|
||||
"REPLY: <reply or N/A>\n\n"
|
||||
"Message:\n{message}"
|
||||
)
|
||||
```
|
||||
|
||||
You can also add domain rules in the system prompt via `messaging.toml` — for instance, routing any message containing "P0" or "incident" directly to URGENT regardless of phrasing.
|
||||
|
||||
## Scheduling the Daily Summary
|
||||
|
||||
After processing all messages, the end-of-day summary call runs immediately in the script. For production use, schedule it independently via the OpenJarvis scheduler:
|
||||
|
||||
```bash title="Terminal"
|
||||
jarvis scheduler create "Daily inbox summary" \
|
||||
--type cron --value "0 17 * * *"
|
||||
```
|
||||
|
||||
Or use the operator recipe pattern to run a persistent triage agent on a schedule. See the operator recipes in `src/openjarvis/recipes/data/operators/` for ready-made examples.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Architecture: Agents](../architecture/agents.md) — `OrchestratorAgent` and the multi-turn tool loop
|
||||
- [Architecture: Tools and Memory](../architecture/memory.md) — `memory_store`, `memory_search`, and the storage backends
|
||||
- [Tutorials: Scheduled Personal Ops](scheduled-ops.md) — combining scripts with the cron scheduler
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
title: Scheduled Personal Ops
|
||||
description: Run autonomous agents on cron schedules for recurring personal tasks
|
||||
---
|
||||
|
||||
# Scheduled Personal Ops
|
||||
|
||||
This tutorial walks through `examples/scheduled_ops/` — three scripts that run autonomous agents on cron-like schedules to handle recurring personal tasks. Together they demonstrate how to combine the `Jarvis` SDK, the scheduler CLI, and the Python `TaskScheduler` API to build a personal operations layer that runs in the background.
|
||||
|
||||
!!! tip "Prerequisites"
|
||||
- Python 3.10 or later
|
||||
- OpenJarvis installed: `uv sync --extra dev` from the repository root
|
||||
- An inference engine running (Ollama with `qwen3:8b` pulled, or a cloud API key)
|
||||
- For full cron expression support, install `croniter`: `uv add croniter`
|
||||
|
||||
## The Three Scripts
|
||||
|
||||
| Script | Agent | Tools | Default Schedule | Purpose |
|
||||
|---|---|---|---|---|
|
||||
| `daily_digest.py` | `orchestrator` | `web_search`, `think` | Daily 9:00 AM | Search and summarize top news for chosen topics |
|
||||
| `code_review.py` | `native_react` | `git_log`, `git_diff`, `file_read`, `think` | Monday 8:00 AM | Review the past week of commits in a repository |
|
||||
| `gym_scheduler.py` | `orchestrator` | `web_search`, `think` | MWF 6:00 AM | Check gym hours and class availability |
|
||||
|
||||
Each script follows the same SDK pattern: create a `Jarvis` instance, call `j.ask()` with an agent and tools, print the result, and close the instance. The schedule is managed externally by the OpenJarvis scheduler daemon.
|
||||
|
||||
## Quick Start: Run Scripts Manually
|
||||
|
||||
Test each script without a running scheduler by invoking it directly:
|
||||
|
||||
```bash title="Terminal"
|
||||
# Morning news digest for AI and robotics
|
||||
uv run python examples/scheduled_ops/daily_digest.py --topics "AI,robotics"
|
||||
|
||||
# Code review for the current repository (last 7 days of commits)
|
||||
uv run python examples/scheduled_ops/code_review.py --repo-path .
|
||||
|
||||
# Gym schedule check
|
||||
uv run python examples/scheduled_ops/gym_scheduler.py --gym "24 Hour Fitness"
|
||||
```
|
||||
|
||||
All scripts accept `--model` and `--engine` flags:
|
||||
|
||||
```bash title="Terminal"
|
||||
uv run python examples/scheduled_ops/daily_digest.py \
|
||||
--model qwen3:8b --engine ollama --topics "AI,finance"
|
||||
```
|
||||
|
||||
## How the Scheduler Works
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[jarvis scheduler start] --> B[Scheduler Daemon]
|
||||
B --> C{Cron trigger fires}
|
||||
C -->|0 9 * * *| D[daily_digest.py]
|
||||
C -->|0 8 * * 1| E[code_review.py]
|
||||
C -->|0 6 * * 1,3,5| F[gym_scheduler.py]
|
||||
D --> G[OrchestratorAgent]
|
||||
E --> H[NativeReActAgent]
|
||||
F --> G
|
||||
G --> I[web_search + think]
|
||||
H --> J[git_diff + git_log + file_read + think]
|
||||
I --> K[Output / Channel]
|
||||
J --> K
|
||||
```
|
||||
|
||||
The scheduler daemon reads registered tasks from SQLite, fires them at the correct time, and passes the configured prompt to the agent. Each script can also be run directly — the scheduler is only needed for recurring, unattended operation.
|
||||
|
||||
## Set Up Schedules with the CLI
|
||||
|
||||
Register each script as a recurring task using `jarvis scheduler create`:
|
||||
|
||||
```bash title="Terminal"
|
||||
# Morning digest every day at 9 AM
|
||||
jarvis scheduler create "Run daily news digest" \
|
||||
--type cron --value "0 9 * * *"
|
||||
|
||||
# Weekly code review every Monday at 8 AM
|
||||
jarvis scheduler create "Run weekly code review" \
|
||||
--type cron --value "0 8 * * 1"
|
||||
|
||||
# Gym check on Monday, Wednesday, Friday at 6 AM
|
||||
jarvis scheduler create "Check gym schedule" \
|
||||
--type cron --value "0 6 * * 1,3,5"
|
||||
```
|
||||
|
||||
Then start the scheduler daemon in the foreground (or as a background service):
|
||||
|
||||
```bash title="Terminal"
|
||||
jarvis scheduler start
|
||||
```
|
||||
|
||||
List registered tasks at any time:
|
||||
|
||||
```bash title="Terminal"
|
||||
jarvis scheduler list
|
||||
```
|
||||
|
||||
!!! note "Cron expression syntax"
|
||||
OpenJarvis uses standard five-field cron syntax: `minute hour day-of-month month day-of-week`. Install `croniter` (`uv add croniter`) for full expression support including ranges and step values. Without it, basic `hour:minute` patterns still work.
|
||||
|
||||
## Configure Schedules with TOML
|
||||
|
||||
The `schedules.toml` file in `examples/scheduled_ops/` defines all three schedules declaratively. This is convenient for version-controlling your personal ops configuration or sharing it across machines:
|
||||
|
||||
```toml title="examples/scheduled_ops/schedules.toml"
|
||||
[schedules.daily_digest]
|
||||
type = "cron"
|
||||
value = "0 9 * * *"
|
||||
description = "Morning news and social media digest"
|
||||
script = "daily_digest.py"
|
||||
|
||||
[schedules.code_review]
|
||||
type = "cron"
|
||||
value = "0 8 * * 1"
|
||||
description = "Weekly code review"
|
||||
script = "code_review.py"
|
||||
|
||||
[schedules.gym_scheduler]
|
||||
type = "cron"
|
||||
value = "0 6 * * 1,3,5"
|
||||
description = "Gym hours and class check"
|
||||
script = "gym_scheduler.py"
|
||||
```
|
||||
|
||||
Point your own tooling or a custom loader at this file to register tasks in bulk.
|
||||
|
||||
## Register Tasks via the Python API
|
||||
|
||||
The `gym_scheduler.py` script includes a `--register` flag that demonstrates programmatic task registration using `TaskScheduler` directly:
|
||||
|
||||
```bash title="Terminal"
|
||||
uv run python examples/scheduled_ops/gym_scheduler.py \
|
||||
--register --gym "Planet Fitness"
|
||||
```
|
||||
|
||||
The equivalent Python code:
|
||||
|
||||
```python title="Programmatic task registration"
|
||||
from openjarvis.scheduler import TaskScheduler
|
||||
from openjarvis.scheduler.store import SchedulerStore
|
||||
|
||||
store = SchedulerStore()
|
||||
scheduler = TaskScheduler(store)
|
||||
|
||||
task = scheduler.create_task( # (1)!
|
||||
prompt="Check gym schedule for 'Planet Fitness'",
|
||||
schedule_type="cron",
|
||||
schedule_value="0 6 * * 1,3,5",
|
||||
agent="orchestrator",
|
||||
tools="web_search,think",
|
||||
)
|
||||
print(f"Task registered: {task.id}")
|
||||
print(f"Next run: {task.next_run}")
|
||||
```
|
||||
|
||||
1. `create_task()` persists the task to SQLite and computes the next trigger time. The scheduler daemon picks it up without a restart.
|
||||
|
||||
## The Daily Digest Script
|
||||
|
||||
The digest script is the simplest of the three. It builds a date-stamped prompt and passes it to an orchestrator with `web_search` and `think`:
|
||||
|
||||
```python title="examples/scheduled_ops/daily_digest.py" hl_lines="5 6 7 8"
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis() # uses defaults from ~/.openjarvis/config.toml
|
||||
response = j.ask(
|
||||
f"Today is {today}. Search and summarize the top news on: {topics}",
|
||||
agent="orchestrator",
|
||||
tools=["web_search", "think"],
|
||||
)
|
||||
j.close()
|
||||
```
|
||||
|
||||
The orchestrator searches for each topic in a separate turn, uses `think` to synthesize across topics, and returns a structured digest with bullet-point summaries and a one-paragraph outlook.
|
||||
|
||||
## Send Results to a Channel
|
||||
|
||||
To route script output to Slack or any other supported channel, pipe stdout through `jarvis channel send`:
|
||||
|
||||
```bash title="Terminal"
|
||||
uv run python examples/scheduled_ops/daily_digest.py \
|
||||
--topics "AI,finance" | jarvis channel send slack
|
||||
```
|
||||
|
||||
Or add channel output inside the script:
|
||||
|
||||
```python title="In-script channel output"
|
||||
from openjarvis.channels import ChannelRegistry
|
||||
|
||||
channel = ChannelRegistry.create("slack", webhook_url="https://hooks.slack.com/...")
|
||||
channel.send(response)
|
||||
```
|
||||
|
||||
List all available channels:
|
||||
|
||||
```bash title="Terminal"
|
||||
jarvis channel list
|
||||
```
|
||||
|
||||
!!! warning "Channel credentials"
|
||||
Live channel output requires channel-specific credentials. Run `jarvis add slack` (or the relevant provider) to set up the MCP server and credential store, then configure environment variables in your `.env` file before starting the scheduler daemon.
|
||||
|
||||
## Customization Tips
|
||||
|
||||
- **Change topics**: Pass `--topics "finance,healthcare,sports"` to `daily_digest.py` for a different digest.
|
||||
- **Review window**: Pass `--days 14` to `code_review.py` for a two-week review cycle instead of one week.
|
||||
- **Swap agents**: Replace `orchestrator` with `native_react` in any script to compare agent behavior on the same task.
|
||||
- **Add file output**: Append `"file_write"` to the `tools` list and update the prompt to save reports to disk instead of printing them.
|
||||
- **One-time tasks**: Use `--type once --value "2026-04-01T09:00:00"` with `jarvis scheduler create` for non-recurring tasks.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Architecture: Agents](../architecture/agents.md) — `OrchestratorAgent` and `NativeReActAgent` internals
|
||||
- [Architecture: Tools and Memory](../architecture/memory.md) — tool registry and `ToolExecutor`
|
||||
- [Getting Started: Configuration](../getting-started/configuration.md) — engine and model defaults
|
||||
@@ -0,0 +1,623 @@
|
||||
# Agents
|
||||
|
||||
Agents are the agentic logic layer of OpenJarvis. They determine how a query is processed -- whether it goes directly to a model, through a tool-calling loop, via ReAct reasoning, CodeAct code execution, recursive decomposition, or an external agent runtime. All agents implement the `BaseAgent` ABC and are registered via the `AgentRegistry`.
|
||||
|
||||
## Overview
|
||||
|
||||
| Agent | Registry Key | `accepts_tools` | Multi-turn | Description |
|
||||
|---------------------|-------------------|-----------------|------------|----------------------------------------------|
|
||||
| `SimpleAgent` | `simple` | No | No | Single-turn query-to-response |
|
||||
| `OrchestratorAgent` | `orchestrator` | Yes | Yes | Multi-turn tool-calling loop (function_calling + structured) |
|
||||
| `NativeReActAgent` | `native_react` | Yes | Yes | Thought-Action-Observation loop |
|
||||
| `NativeOpenHandsAgent` | `native_openhands` | Yes | Yes | CodeAct-style code execution + tool calls |
|
||||
| `RLMAgent` | `rlm` | Yes | Yes | Recursive LM with persistent REPL |
|
||||
| `OpenHandsAgent` | `openhands` | No | Yes | Wraps real openhands-sdk |
|
||||
| `ClaudeCodeAgent` | `claude_code` | No | Yes | Claude Agent SDK via Node.js subprocess |
|
||||
| `OperativeAgent` | `operative` | Yes | Yes | Persistent scheduled agent with state management |
|
||||
| `MonitorOperativeAgent` | `monitor_operative` | Yes | Yes | Long-horizon agent with 4 configurable strategy axes |
|
||||
|
||||
---
|
||||
|
||||
## BaseAgent ABC
|
||||
|
||||
All agents extend the abstract `BaseAgent` class.
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from openjarvis.agents._stubs import AgentContext, AgentResult
|
||||
|
||||
class BaseAgent(ABC):
|
||||
agent_id: str
|
||||
accepts_tools: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def run(
|
||||
self,
|
||||
input: str,
|
||||
context: AgentContext | None = None,
|
||||
**kwargs,
|
||||
) -> AgentResult:
|
||||
"""Execute the agent on the given input."""
|
||||
```
|
||||
|
||||
The `accepts_tools` class attribute controls whether an agent can receive tools via `--tools` on the CLI or `tools=` in the SDK. Agents with `accepts_tools = False` ignore tool arguments.
|
||||
|
||||
`BaseAgent` also provides concrete helper methods (`_emit_turn_start`, `_emit_turn_end`, `_build_messages`, `_generate`, `_max_turns_result`, `_strip_think_tags`) that subclasses use to avoid duplicating common logic. See the [architecture docs](../architecture/agents.md#baseagent-abc) for details.
|
||||
|
||||
**ToolUsingAgent** is an intermediate base class (extends `BaseAgent`) that sets `accepts_tools = True` and adds a `ToolExecutor` and `max_turns` loop limit. All tool-using agents extend this class.
|
||||
|
||||
### AgentContext
|
||||
|
||||
The runtime context handed to an agent on each invocation.
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------------|--------------------|------------------------------------------------|
|
||||
| `conversation` | `Conversation` | Message history (pre-filled with context if memory injection is active) |
|
||||
| `tools` | `list[str]` | Tool names available to the agent |
|
||||
| `memory_results` | `list[Any]` | Pre-fetched memory retrieval results |
|
||||
| `metadata` | `dict[str, Any]` | Arbitrary metadata for the run |
|
||||
|
||||
### AgentResult
|
||||
|
||||
The result returned after an agent completes a run.
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|--------------------|------------------------------------------------|
|
||||
| `content` | `str` | The final response text |
|
||||
| `tool_results` | `list[ToolResult]` | Results from tool executions during the run |
|
||||
| `turns` | `int` | Number of turns (inference calls) taken |
|
||||
| `metadata` | `dict[str, Any]` | Arbitrary metadata about the run |
|
||||
|
||||
---
|
||||
|
||||
## SimpleAgent
|
||||
|
||||
The `SimpleAgent` is a single-turn agent that sends the query directly to the inference engine and returns the response. It does not support tool calling.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Builds a message list from the conversation context (if provided) plus the user query.
|
||||
2. Calls the inference engine via `_generate()`.
|
||||
3. Returns the response as an `AgentResult` with `turns=1`.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------|-------------------|---------|------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine to use |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `1024` | Maximum tokens to generate |
|
||||
|
||||
**When to use:** For straightforward question-answering without tool calling or multi-turn reasoning.
|
||||
|
||||
---
|
||||
|
||||
## OrchestratorAgent
|
||||
|
||||
The `OrchestratorAgent` is a multi-turn agent that implements a tool-calling loop. It is the primary agent for queries that require computation, knowledge retrieval, or structured reasoning. Extends `ToolUsingAgent`.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Builds the initial message list from context and the user query.
|
||||
2. Sends messages with tool definitions (OpenAI function-calling format) to the engine.
|
||||
3. If the engine responds with `tool_calls`, the `ToolExecutor` dispatches each call.
|
||||
4. Tool results are appended as `TOOL` messages and the loop continues.
|
||||
5. If no `tool_calls` are returned, the response is treated as the final answer.
|
||||
6. The loop stops after `max_turns` iterations (default: 10), returning whatever content is available along with a `max_turns_exceeded` metadata flag.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------------|-------------------|---------|--------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine to use |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `tools` | `list[BaseTool]` | `[]` | Tool instances to make available |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `max_turns` | `int` | `10` | Maximum number of tool-calling turns |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `1024` | Maximum tokens to generate |
|
||||
| `mode` | `str` | `"function_calling"` | Tool-calling mode (`function_calling` or `structured`) |
|
||||
| `system_prompt` | `str` | `None` | Custom system prompt |
|
||||
|
||||
**When to use:** For queries that need calculation, memory search, sub-model calls, file reading, or multi-step reasoning.
|
||||
|
||||
!!! info "Tool-Calling Loop"
|
||||
The orchestrator follows the OpenAI function-calling convention. The engine must support returning `tool_calls` in its response for the loop to engage. If tools are provided but the engine does not return any tool calls, the agent behaves like a single-turn agent.
|
||||
|
||||
---
|
||||
|
||||
## NativeReActAgent
|
||||
|
||||
The `NativeReActAgent` implements a **Thought-Action-Observation** loop following the ReAct pattern. It prompts the LLM to produce structured output (`Thought:`, `Action:`, `Action Input:`, `Final Answer:`) and parses the response to drive tool execution. Extends `ToolUsingAgent`.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Builds a system prompt with enriched tool descriptions (names, parameter schemas, categories) via `build_tool_descriptions()`. Parsing is case-insensitive.
|
||||
2. Generates a response and parses the ReAct-structured output.
|
||||
3. If a `Final Answer:` is found, returns it.
|
||||
4. If an `Action:` is found, executes the tool and feeds the result back as an `Observation:`.
|
||||
5. Loops until a final answer is produced or `max_turns` is exceeded.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------|-------------------|---------|------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine to use |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `tools` | `list[BaseTool]` | `[]` | Tool instances to make available |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `max_turns` | `int` | `10` | Maximum number of reasoning turns |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `1024` | Maximum tokens to generate |
|
||||
|
||||
**When to use:** For queries that benefit from explicit step-by-step reasoning with tool use, where you want visibility into the agent's thought process.
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The registry alias `"react"` maps to `NativeReActAgent`. The old import `from openjarvis.agents.react import ReActAgent` also still works.
|
||||
|
||||
---
|
||||
|
||||
## NativeOpenHandsAgent
|
||||
|
||||
The `NativeOpenHandsAgent` is a CodeAct-style agent that generates and executes Python code alongside structured tool calls. It can also pre-fetch URL content from user input to provide direct context to the LLM. Extends `ToolUsingAgent`.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Builds a detailed system prompt with enriched tool descriptions (via shared `build_tool_descriptions()` builder) and code execution instructions.
|
||||
2. Pre-fetches any URLs in the user input, inlining the content directly.
|
||||
3. For each turn, generates a response and attempts to extract code blocks or tool calls.
|
||||
4. Code is executed via `code_interpreter`; tool calls are dispatched via `ToolExecutor`.
|
||||
5. If neither is found, returns the content as the final answer.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------|-------------------|---------|------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine to use |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `tools` | `list[BaseTool]` | `[]` | Tool instances to make available |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `max_turns` | `int` | `3` | Maximum number of turns |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `2048` | Maximum tokens to generate |
|
||||
|
||||
**When to use:** For queries involving URL content, code execution, or tasks where the LLM can write and run Python to solve the problem.
|
||||
|
||||
---
|
||||
|
||||
## RLMAgent
|
||||
|
||||
The `RLMAgent` implements recursive decomposition via a persistent REPL, based on the RLM paper. Context is stored as a Python variable rather than injected into the prompt, enabling processing of arbitrarily long inputs through recursive sub-LM calls. Extends `ToolUsingAgent`.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Creates a persistent REPL with `llm_query()` and `llm_batch()` callbacks.
|
||||
2. Injects context from `AgentContext` into the REPL as a variable.
|
||||
3. Generates code and executes it in the REPL.
|
||||
4. If `FINAL(value)` is called, returns the value as the final answer.
|
||||
5. If no code block is found, treats the content as a direct text answer.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|--------------------|-------------------|--------------------|-------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine to use |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `tools` | `list[BaseTool]` | `[]` | Tool instances (optional) |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `max_turns` | `int` | `10` | Maximum number of code-execute turns |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `2048` | Maximum tokens to generate |
|
||||
| `sub_model` | `str` | same as `model` | Model for sub-LM calls |
|
||||
| `sub_temperature` | `float` | `0.3` | Temperature for sub-LM calls |
|
||||
| `sub_max_tokens` | `int` | `1024` | Max tokens for sub-LM calls |
|
||||
| `max_output_chars` | `int` | `10000` | Max REPL output characters |
|
||||
| `system_prompt` | `str` | `RLM_SYSTEM_PROMPT` | Override the system prompt |
|
||||
|
||||
**When to use:** For long-context tasks that benefit from recursive decomposition, such as summarizing large documents, processing structured data, or tasks that require programmatic manipulation of context.
|
||||
|
||||
---
|
||||
|
||||
## OpenHandsAgent (SDK)
|
||||
|
||||
The `OpenHandsAgent` wraps the real `openhands-sdk` package for AI-driven software development. Extends `BaseAgent` directly (tool management is handled by the SDK internally).
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Imports `openhands.sdk` at runtime.
|
||||
2. Creates an LLM, Agent, and Conversation from the SDK.
|
||||
3. Sends the input and runs the conversation.
|
||||
4. Returns the final message content.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------|-------------------|---------------|------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine (fallback) |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `1024` | Maximum tokens to generate |
|
||||
| `workspace` | `str` | `os.getcwd()` | Working directory for the agent |
|
||||
| `api_key` | `str` | `$LLM_API_KEY`| API key for the LLM provider |
|
||||
|
||||
**When to use:** For software development tasks (debugging, code editing, test fixing) where the OpenHands SDK provides a full development agent runtime.
|
||||
|
||||
!!! warning "Optional dependency"
|
||||
Requires `openhands-sdk` (`uv sync --extra openhands`) and Python 3.12+.
|
||||
|
||||
---
|
||||
|
||||
## Using Agents
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
# Simple agent
|
||||
jarvis ask --agent simple "What is the capital of France?"
|
||||
|
||||
# Orchestrator with tools
|
||||
jarvis ask --agent orchestrator --tools calculator,think "What is sqrt(256)?"
|
||||
|
||||
# NativeReActAgent
|
||||
jarvis ask --agent native_react --tools calculator "What is 2+2?"
|
||||
|
||||
# ReAct alias (same as native_react)
|
||||
jarvis ask --agent react --tools calculator,think "Solve step by step: 15% of 340"
|
||||
|
||||
# NativeOpenHandsAgent
|
||||
jarvis ask --agent native_openhands --tools calculator,web_search "Summarize example.com"
|
||||
|
||||
# RLMAgent
|
||||
jarvis ask --agent rlm "Summarize this long document"
|
||||
|
||||
# OpenHands SDK agent
|
||||
jarvis ask --agent openhands "Fix the bug in test_utils.py"
|
||||
```
|
||||
|
||||
### Via Python SDK
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
|
||||
# Simple agent
|
||||
response = j.ask("Hello", agent="simple")
|
||||
|
||||
# Orchestrator with tools
|
||||
response = j.ask(
|
||||
"Calculate 15% of 340",
|
||||
agent="orchestrator",
|
||||
tools=["calculator"],
|
||||
)
|
||||
|
||||
# NativeReActAgent with tools
|
||||
response = j.ask(
|
||||
"What is sqrt(256)?",
|
||||
agent="native_react",
|
||||
tools=["calculator", "think"],
|
||||
)
|
||||
|
||||
# Full result with tool details
|
||||
result = j.ask_full(
|
||||
"What is the square root of 144?",
|
||||
agent="orchestrator",
|
||||
tools=["calculator", "think"],
|
||||
)
|
||||
print(result["content"])
|
||||
print(result["turns"])
|
||||
print(result["tool_results"])
|
||||
|
||||
j.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ClaudeCodeAgent
|
||||
|
||||
The `ClaudeCodeAgent` wraps the `@anthropic-ai/claude-code` SDK via a bundled Node.js subprocess bridge. Unlike the other agents, inference is handled entirely by the Claude Agent SDK -- the `engine` parameter is accepted only for `BaseAgent` interface conformance and is not used.
|
||||
|
||||
!!! warning "Requirements"
|
||||
Requires Node.js 22+ on `PATH` and an `ANTHROPIC_API_KEY` environment variable (or pass `api_key=` directly). The bundled runner is auto-installed to `~/.openjarvis/claude_code_runner/` on first use via `npm install`.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. On first call, copies the bundled `claude_code_runner/` to `~/.openjarvis/claude_code_runner/` and runs `npm install --production` if `node_modules` is missing.
|
||||
2. Builds a JSON request payload (prompt, API key, workspace, allowed tools, system prompt, session ID) and sends it to `stdin` of a `node dist/index.js` subprocess.
|
||||
3. The Node.js runner calls the Claude Agent SDK and writes sentinel-delimited JSON to `stdout`.
|
||||
4. The Python side parses the output between `---OPENJARVIS_OUTPUT_START---` and `---OPENJARVIS_OUTPUT_END---` markers, extracting content, tool results, and metadata.
|
||||
5. Returns an `AgentResult` with `turns=1`.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|------------------|-------------------|---------------------|--------------------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | Accepted for interface conformance; not used |
|
||||
| `model` | `str` | -- | Accepted for interface conformance; not used |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `temperature` | `float` | `0.7` | Accepted for interface conformance; not used |
|
||||
| `max_tokens` | `int` | `1024` | Accepted for interface conformance; not used |
|
||||
| `api_key` | `str` | `$ANTHROPIC_API_KEY`| Anthropic API key |
|
||||
| `workspace` | `str` | `os.getcwd()` | Working directory for the Claude agent |
|
||||
| `session_id` | `str` | `""` | Optional session ID for conversation continuity |
|
||||
| `allowed_tools` | `list[str]` | `None` (all) | Claude Code tool names to allow |
|
||||
| `system_prompt` | `str` | `""` | Additional system prompt for the agent |
|
||||
| `timeout` | `int` | `300` | Subprocess timeout in seconds |
|
||||
|
||||
**When to use:** For software engineering tasks where the Claude Agent SDK's built-in tools (code editing, bash execution, file operations) provide capabilities beyond what OpenJarvis tool-calling agents support.
|
||||
|
||||
```python
|
||||
from openjarvis.agents.claude_code import ClaudeCodeAgent
|
||||
|
||||
agent = ClaudeCodeAgent(
|
||||
engine=None, # not used
|
||||
model="", # not used
|
||||
workspace="/path/to/project",
|
||||
allowed_tools=["Read", "Write", "Bash"],
|
||||
timeout=120,
|
||||
)
|
||||
result = agent.run("Add type hints to all functions in utils.py")
|
||||
print(result.content)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Via CLI
|
||||
jarvis ask --agent claude_code "Refactor the tests to use pytest fixtures"
|
||||
```
|
||||
|
||||
!!! info "accepts_tools = False"
|
||||
`ClaudeCodeAgent` does not accept OpenJarvis tools via `--tools`. Tool access for the Claude agent is configured separately via the `allowed_tools` constructor parameter, which passes tool names understood by the Claude Agent SDK itself.
|
||||
|
||||
---
|
||||
|
||||
## OperativeAgent
|
||||
|
||||
The `OperativeAgent` is a persistent, scheduled autonomous agent with built-in session persistence and state recall. Designed for "Operators" -- autonomous agents that run on a schedule with automatic state management between ticks. Extends `ToolUsingAgent`.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. **Session loading** -- restores conversation history from previous ticks via the session store.
|
||||
2. **State recall** -- retrieves previous state JSON from the memory backend.
|
||||
3. **System prompt injection** -- injects the operator's protocol instructions.
|
||||
4. **Tool loop** -- standard function-calling loop (same as OrchestratorAgent).
|
||||
5. **Session save** -- persists the tick's prompt and response to the session store.
|
||||
6. **State persistence** -- auto-persists state if the agent did not explicitly store it via the `memory_store` tool.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|------------------|-------------------|---------|--------------------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine to use |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `tools` | `list[BaseTool]` | `[]` | Tool instances to make available |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `max_turns` | `int` | `20` | Maximum number of tool-calling turns |
|
||||
| `temperature` | `float` | `0.3` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `2048` | Maximum tokens to generate |
|
||||
| `system_prompt` | `str` | `None` | Custom system prompt for the operator |
|
||||
| `operator_id` | `str` | `None` | Unique ID for session and state persistence |
|
||||
| `session_store` | `Any` | `None` | Session store backend for conversation history |
|
||||
| `memory_backend` | `Any` | `None` | Memory backend for state recall and persistence |
|
||||
|
||||
**When to use:** For autonomous agents that run on a schedule (e.g., via `TaskScheduler`) and need to maintain state between invocations. The agent automatically manages session history and state persistence across ticks.
|
||||
|
||||
```python
|
||||
from openjarvis.agents.operative import OperativeAgent
|
||||
|
||||
agent = OperativeAgent(
|
||||
engine,
|
||||
model="qwen3:8b",
|
||||
tools=[...],
|
||||
operator_id="daily-report",
|
||||
session_store=session_store,
|
||||
memory_backend=memory_backend,
|
||||
system_prompt="You are a daily report agent. Gather and summarize news.",
|
||||
)
|
||||
result = agent.run("Generate today's report")
|
||||
```
|
||||
|
||||
```bash
|
||||
# Via CLI
|
||||
jarvis ask --agent operative "Check system status"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MonitorOperativeAgent
|
||||
|
||||
The `MonitorOperativeAgent` is a long-horizon agent with four configurable strategy axes for managing information across turns and sessions. It extends `ToolUsingAgent` with strategy-driven observation compression, memory extraction, retrieval, and task decomposition. It also inherits cross-session state persistence from the OperativeAgent pattern.
|
||||
|
||||
**Strategy axes:**
|
||||
|
||||
| Axis | Valid Values | Default | Description |
|
||||
|------|-------------|---------|-------------|
|
||||
| `memory_extraction` | `causality_graph`, `scratchpad`, `structured_json`, `none` | `causality_graph` | How findings are persisted to memory |
|
||||
| `observation_compression` | `summarize`, `truncate`, `none` | `summarize` | How tool outputs are compressed before being added to context |
|
||||
| `retrieval_strategy` | `hybrid_with_self_eval`, `keyword`, `semantic`, `none` | `hybrid_with_self_eval` | How prior context is recalled at the start of each run |
|
||||
| `task_decomposition` | `phased`, `monolithic`, `hierarchical` | `phased` | How complex tasks are broken down |
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Builds a system prompt with strategy configuration and tool descriptions.
|
||||
2. Recalls previous state from the memory backend.
|
||||
3. Loads session history from previous ticks.
|
||||
4. Runs a function-calling tool loop, applying the configured strategies:
|
||||
- **Observation compression**: Long tool outputs are summarized (via LLM) or truncated before being added to the message context.
|
||||
- **Memory extraction**: After each tool call, findings are extracted and stored according to the memory strategy (causal relationships, scratchpad notes, or structured JSON).
|
||||
5. Saves the session and auto-persists state.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|--------------------------|-------------------|---------------------------|--------------------------------------------------|
|
||||
| `engine` | `InferenceEngine` | -- | The inference engine to use |
|
||||
| `model` | `str` | -- | Model identifier |
|
||||
| `tools` | `list[BaseTool]` | `[]` | Tool instances to make available |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
| `max_turns` | `int` | `25` | Maximum number of tool-calling turns |
|
||||
| `temperature` | `float` | `0.3` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `4096` | Maximum tokens to generate |
|
||||
| `system_prompt` | `str` | `None` | Custom system prompt (overrides default) |
|
||||
| `memory_extraction` | `str` | `"causality_graph"` | Memory extraction strategy |
|
||||
| `observation_compression`| `str` | `"summarize"` | Observation compression strategy |
|
||||
| `retrieval_strategy` | `str` | `"hybrid_with_self_eval"` | Retrieval strategy |
|
||||
| `task_decomposition` | `str` | `"phased"` | Task decomposition strategy |
|
||||
| `operator_id` | `str` | `None` | Unique ID for session and state persistence |
|
||||
| `session_store` | `Any` | `None` | Session store backend for conversation history |
|
||||
| `memory_backend` | `Any` | `None` | Memory backend for state and finding persistence |
|
||||
|
||||
**When to use:** For long-horizon benchmark evaluation and complex multi-step tasks that benefit from configurable strategies for memory management, context compression, and task decomposition. Particularly useful for benchmarks like GAIA, FRAMES, and LifelongAgent where strategy selection impacts performance.
|
||||
|
||||
```python
|
||||
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
|
||||
|
||||
agent = MonitorOperativeAgent(
|
||||
engine,
|
||||
model="qwen3:8b",
|
||||
tools=[...],
|
||||
operator_id="research-agent",
|
||||
memory_extraction="causality_graph",
|
||||
observation_compression="summarize",
|
||||
retrieval_strategy="hybrid_with_self_eval",
|
||||
task_decomposition="phased",
|
||||
session_store=session_store,
|
||||
memory_backend=memory_backend,
|
||||
)
|
||||
result = agent.run("Investigate the root cause of the production outage")
|
||||
```
|
||||
|
||||
```bash
|
||||
# Via CLI
|
||||
jarvis ask --agent monitor_operative "Analyze the security audit findings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SandboxedAgent
|
||||
|
||||
`SandboxedAgent` is a transparent wrapper that runs **any** `BaseAgent` inside a Docker (or Podman) container. It follows the same wrapper pattern as `GuardrailsEngine` -- the inner agent's configuration is serialized and sent to the container's stdin, and the result is read back from stdout.
|
||||
|
||||
See also the [`ContainerRunner`](#containerrunner) reference below, which manages the container lifecycle.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Builds a JSON payload with the prompt, wrapped agent ID, and model.
|
||||
2. Invokes `ContainerRunner.run()`, which starts a container with `--network none` and `--rm`, writes the payload to stdin, and waits for JSON output on stdout.
|
||||
3. Mount paths are validated against a configurable allowlist before the container is started.
|
||||
4. Parses the sentinel-delimited output and returns an `AgentResult`.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|------------------------|-------------------|--------------|---------------------------------------------------|
|
||||
| `agent` | `BaseAgent` | -- | The wrapped agent to execute inside the container |
|
||||
| `runner` | `ContainerRunner` | -- | Container runner managing Docker lifecycle |
|
||||
| `engine` | `InferenceEngine` | `None` | Override engine (defaults to wrapped agent's) |
|
||||
| `model` | `str` | `""` | Override model (defaults to wrapped agent's) |
|
||||
| `workspace` | `str` | `""` | Working directory inside the container |
|
||||
| `mounts` | `list[str]` | `[]` | Host paths to bind-mount (read-only) |
|
||||
| `secrets` | `dict[str, str]` | `{}` | Injected into payload (not environment variables) |
|
||||
| `bus` | `EventBus` | `None` | Event bus for telemetry |
|
||||
|
||||
```python
|
||||
from openjarvis.sandbox import ContainerRunner, SandboxedAgent
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
|
||||
runner = ContainerRunner(
|
||||
image="openjarvis-sandbox:latest",
|
||||
timeout=60,
|
||||
mount_allowlist_path="/etc/openjarvis/mount_allowlist.json",
|
||||
)
|
||||
inner = SimpleAgent(engine, model="qwen3:8b")
|
||||
agent = SandboxedAgent(
|
||||
agent=inner,
|
||||
runner=runner,
|
||||
mounts=["/home/user/data"],
|
||||
)
|
||||
result = agent.run("Summarize the CSV files in /home/user/data")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ContainerRunner
|
||||
|
||||
`ContainerRunner` manages the Docker (or Podman) container lifecycle for sandboxed execution. It is used directly by `SandboxedAgent` but can also be used standalone.
|
||||
|
||||
**Constructor parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|------------------------|--------|------------------------------|------------------------------------------------|
|
||||
| `image` | `str` | `"openjarvis-sandbox:latest"`| Docker image to run |
|
||||
| `timeout` | `int` | `300` | Max container execution time in seconds |
|
||||
| `mount_allowlist_path` | `str` | `""` | Path to JSON mount-allowlist file |
|
||||
| `max_concurrent` | `int` | `5` | Max concurrent containers (informational) |
|
||||
| `runtime` | `str` | `"docker"` | Container runtime binary (`docker` or `podman`)|
|
||||
|
||||
**Mount allowlist format:**
|
||||
|
||||
```json title="mount_allowlist.json"
|
||||
{
|
||||
"roots": [
|
||||
{"path": "/home/user/projects", "read_only": false},
|
||||
{"path": "/data/shared", "read_only": true}
|
||||
],
|
||||
"blocked_patterns": [".ssh", ".env", "*.pem", "*.key"]
|
||||
}
|
||||
```
|
||||
|
||||
If `mount_allowlist_path` is not set, no root restriction is applied. Blocked patterns always include `.ssh`, `.env`, `*.pem`, `*.key`, credential files, and cloud config directories by default.
|
||||
|
||||
!!! warning "Docker required"
|
||||
`ContainerRunner` raises `RuntimeError` if the configured runtime (`docker` or `podman`) is not found on `PATH`.
|
||||
|
||||
---
|
||||
|
||||
## Agent Registration
|
||||
|
||||
Agents are registered via the `@AgentRegistry.register()` decorator. This makes them discoverable by name at runtime:
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
# Check if an agent is registered
|
||||
AgentRegistry.contains("orchestrator") # True
|
||||
|
||||
# Get the agent class
|
||||
agent_cls = AgentRegistry.get("orchestrator")
|
||||
|
||||
# List all registered agent keys
|
||||
AgentRegistry.keys()
|
||||
# ["simple", "orchestrator", "native_react", "react", "native_openhands",
|
||||
# "rlm", "openhands", "claude_code", "operative", "monitor_operative"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Bus Integration
|
||||
|
||||
All agents publish events on the `EventBus` when a bus is provided:
|
||||
|
||||
| Event | When |
|
||||
|-------------------------|-----------------------------------------------------|
|
||||
| `AGENT_TURN_START` | At the beginning of a run (via `_emit_turn_start`) |
|
||||
| `AGENT_TURN_END` | At the end of a run (via `_emit_turn_end`) |
|
||||
| `TOOL_CALL_START` | Before each tool execution (`ToolUsingAgent` subclasses) |
|
||||
| `TOOL_CALL_END` | After each tool execution (`ToolUsingAgent` subclasses) |
|
||||
|
||||
!!! info "Inference events"
|
||||
`INFERENCE_START` / `INFERENCE_END` events are published by the `InstrumentedEngine` wrapper, not by agents directly. This keeps telemetry opt-in and transparent to agent code.
|
||||
|
||||
These events enable the telemetry and trace systems to record detailed interaction data automatically.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Benchmarks
|
||||
|
||||
The benchmarking framework measures inference engine performance with reproducible, standardized tests. It includes built-in benchmarks for latency and throughput, a suite runner for batch execution, and support for custom benchmarks.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenJarvis ships with two benchmarks:
|
||||
|
||||
| Benchmark | Registry Key | Measures |
|
||||
|---------------|----------------|-----------------------------------------------|
|
||||
| **Latency** | `latency` | Per-call inference latency (mean, p50, p95, min, max) |
|
||||
| **Throughput**| `throughput` | Tokens per second throughput |
|
||||
|
||||
---
|
||||
|
||||
## BaseBenchmark ABC
|
||||
|
||||
All benchmarks implement the `BaseBenchmark` abstract base class.
|
||||
|
||||
```python
|
||||
from abc import ABC, abstractmethod
|
||||
from openjarvis.bench._stubs import BenchmarkResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
|
||||
class BaseBenchmark(ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Short identifier for this benchmark."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""Human-readable description of what this benchmark measures."""
|
||||
|
||||
@abstractmethod
|
||||
def run(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
num_samples: int = 10,
|
||||
) -> BenchmarkResult:
|
||||
"""Execute the benchmark and return results."""
|
||||
```
|
||||
|
||||
### BenchmarkResult
|
||||
|
||||
Each benchmark run produces a `BenchmarkResult`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------------|------------------|------------------------------------------|
|
||||
| `benchmark_name` | `str` | Name of the benchmark |
|
||||
| `model` | `str` | Model used |
|
||||
| `engine` | `str` | Engine backend used |
|
||||
| `metrics` | `dict[str, float]` | Key-value pairs of measured metrics |
|
||||
| `metadata` | `dict[str, Any]` | Additional metadata |
|
||||
| `samples` | `int` | Number of samples run |
|
||||
| `errors` | `int` | Number of errors encountered |
|
||||
|
||||
---
|
||||
|
||||
## Built-in Benchmarks
|
||||
|
||||
### Latency Benchmark
|
||||
|
||||
Measures per-call inference latency using short, fixed prompts. Each sample sends a simple prompt to the engine and measures wall-clock time.
|
||||
|
||||
**Prompts used:** The benchmark rotates through a set of short canned prompts ("Hello", "What is 2+2?", "Explain gravity in one sentence") to keep input variation consistent across runs.
|
||||
|
||||
**Metrics produced:**
|
||||
|
||||
| Metric | Description |
|
||||
|-----------------|-----------------------------------------------------|
|
||||
| `mean_latency` | Average latency across all successful samples |
|
||||
| `p50_latency` | Median latency (50th percentile) |
|
||||
| `p95_latency` | 95th percentile latency (tail performance) |
|
||||
| `min_latency` | Fastest single call |
|
||||
| `max_latency` | Slowest single call |
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
latency (10 samples, 0 errors)
|
||||
mean_latency: 0.2345
|
||||
p50_latency: 0.2100
|
||||
p95_latency: 0.3800
|
||||
min_latency: 0.1500
|
||||
max_latency: 0.4200
|
||||
```
|
||||
|
||||
### Throughput Benchmark
|
||||
|
||||
Measures inference throughput in tokens per second. Each sample sends a longer prompt ("Write a short paragraph about artificial intelligence") and measures both the time taken and the number of completion tokens generated.
|
||||
|
||||
**Metrics produced:**
|
||||
|
||||
| Metric | Description |
|
||||
|-----------------------|------------------------------------------------|
|
||||
| `tokens_per_second` | Total completion tokens / total time |
|
||||
| `total_tokens` | Total completion tokens across all samples |
|
||||
| `total_time_seconds` | Total wall-clock time across all samples |
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
throughput (10 samples, 0 errors)
|
||||
tokens_per_second: 45.6789
|
||||
total_tokens: 1250.0000
|
||||
total_time_seconds: 27.3600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interpreting Results
|
||||
|
||||
### Latency Metrics
|
||||
|
||||
- **mean_latency:** The average response time. Use this for general performance comparison.
|
||||
- **p50_latency (median):** The typical response time. Less affected by outliers than the mean.
|
||||
- **p95_latency:** The worst-case response time for 95% of requests. Critical for user experience -- if this is too high, some users will experience noticeable delays.
|
||||
- **min/max_latency:** The best and worst individual calls. A large gap between min and max indicates inconsistent performance.
|
||||
|
||||
!!! tip "What to look for"
|
||||
A healthy setup has `p95 / p50 < 2`. If the p95 is much higher than the median, investigate whether the engine is experiencing contention, thermal throttling, or memory pressure.
|
||||
|
||||
### Throughput Metrics
|
||||
|
||||
- **tokens_per_second:** The main throughput indicator. Higher is better. Typical ranges:
|
||||
- CPU-only: 5-20 tokens/second
|
||||
- Consumer GPU (RTX 3060-4090): 30-100 tokens/second
|
||||
- Data-center GPU (A100, H100): 100-500+ tokens/second
|
||||
- **total_tokens / total_time:** The raw data behind the throughput calculation. Useful for verifying that the engine is generating meaningful output (not returning empty responses).
|
||||
|
||||
---
|
||||
|
||||
## BenchmarkSuite
|
||||
|
||||
The `BenchmarkSuite` class runs a collection of benchmarks and provides aggregation and serialization utilities.
|
||||
|
||||
```python
|
||||
from openjarvis.bench._stubs import BenchmarkSuite
|
||||
from openjarvis.bench.latency import LatencyBenchmark
|
||||
from openjarvis.bench.throughput import ThroughputBenchmark
|
||||
|
||||
suite = BenchmarkSuite([LatencyBenchmark(), ThroughputBenchmark()])
|
||||
|
||||
# Run all benchmarks
|
||||
results = suite.run_all(engine, model, num_samples=20)
|
||||
|
||||
# Serialize to JSONL (one JSON object per line)
|
||||
jsonl = suite.to_jsonl(results)
|
||||
|
||||
# Get a summary dict
|
||||
summary = suite.summary(results)
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|-------------------------|--------------------|--------------------------------------------|
|
||||
| `run_all(engine, model, num_samples=10)` | `list[BenchmarkResult]` | Run all benchmarks sequentially |
|
||||
| `to_jsonl(results)` | `str` | Serialize results to JSONL format |
|
||||
| `summary(results)` | `dict[str, Any]` | Create a summary dictionary |
|
||||
|
||||
### JSONL Format
|
||||
|
||||
Each line in the JSONL output is a JSON object:
|
||||
|
||||
```json
|
||||
{"benchmark_name": "latency", "model": "qwen3:8b", "engine": "ollama", "metrics": {"mean_latency": 0.234, "p50_latency": 0.21, "p95_latency": 0.38, "min_latency": 0.15, "max_latency": 0.42}, "metadata": {}, "samples": 10, "errors": 0}
|
||||
{"benchmark_name": "throughput", "model": "qwen3:8b", "engine": "ollama", "metrics": {"tokens_per_second": 45.67, "total_tokens": 1250.0, "total_time_seconds": 27.36}, "metadata": {}, "samples": 10, "errors": 0}
|
||||
```
|
||||
|
||||
### Summary Format
|
||||
|
||||
```json
|
||||
{
|
||||
"benchmark_count": 2,
|
||||
"benchmarks": [
|
||||
{
|
||||
"name": "latency",
|
||||
"model": "qwen3:8b",
|
||||
"engine": "ollama",
|
||||
"metrics": {"mean_latency": 0.234, ...},
|
||||
"samples": 10,
|
||||
"errors": 0
|
||||
},
|
||||
{
|
||||
"name": "throughput",
|
||||
"model": "qwen3:8b",
|
||||
"engine": "ollama",
|
||||
"metrics": {"tokens_per_second": 45.67, ...},
|
||||
"samples": 10,
|
||||
"errors": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
# Run all benchmarks with default settings (10 samples)
|
||||
jarvis bench run
|
||||
|
||||
# Run with more samples for better statistical accuracy
|
||||
jarvis bench run -n 50
|
||||
|
||||
# Run only the latency benchmark
|
||||
jarvis bench run -b latency
|
||||
|
||||
# Run only the throughput benchmark with 20 samples
|
||||
jarvis bench run -b throughput -n 20
|
||||
|
||||
# Specify model and engine
|
||||
jarvis bench run -m qwen3:8b -e ollama
|
||||
|
||||
# Output JSON summary to stdout
|
||||
jarvis bench run --json
|
||||
|
||||
# Write JSONL results to a file
|
||||
jarvis bench run -o results.jsonl
|
||||
|
||||
# Combine options
|
||||
jarvis bench run -b latency -n 100 -m qwen3:8b --json -o latency.jsonl
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|----------------------------|--------|---------|------------------------------------------|
|
||||
| `-m`, `--model MODEL` | string | auto | Model to benchmark |
|
||||
| `-e`, `--engine ENGINE` | string | auto | Engine backend |
|
||||
| `-n`, `--samples N` | int | `10` | Number of samples per benchmark |
|
||||
| `-b`, `--benchmark NAME` | string | all | Specific benchmark to run (`latency` or `throughput`) |
|
||||
| `-o`, `--output PATH` | path | none | Write JSONL results to file |
|
||||
| `--json` | flag | off | Output JSON summary to stdout |
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Benchmarks
|
||||
|
||||
Create a custom benchmark by subclassing `BaseBenchmark` and registering it with the `BenchmarkRegistry`.
|
||||
|
||||
### Step 1: Implement the Benchmark
|
||||
|
||||
```python
|
||||
import time
|
||||
from openjarvis.bench._stubs import BaseBenchmark, BenchmarkResult
|
||||
from openjarvis.core.registry import BenchmarkRegistry
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
|
||||
|
||||
class ContextLengthBenchmark(BaseBenchmark):
|
||||
"""Measures how latency scales with input length."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "context_length"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Measures latency scaling with increasing input length"
|
||||
|
||||
def run(
|
||||
self,
|
||||
engine: InferenceEngine,
|
||||
model: str,
|
||||
*,
|
||||
num_samples: int = 10,
|
||||
) -> BenchmarkResult:
|
||||
latencies = {}
|
||||
errors = 0
|
||||
|
||||
for length in [100, 500, 1000, 2000]:
|
||||
prompt = "x " * length
|
||||
messages = [Message(role=Role.USER, content=prompt)]
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
engine.generate(messages, model=model)
|
||||
latencies[f"latency_{length}_tokens"] = time.time() - t0
|
||||
except Exception:
|
||||
errors += 1
|
||||
|
||||
return BenchmarkResult(
|
||||
benchmark_name=self.name,
|
||||
model=model,
|
||||
engine=engine.engine_id,
|
||||
metrics=latencies,
|
||||
samples=len(latencies),
|
||||
errors=errors,
|
||||
)
|
||||
```
|
||||
|
||||
### Step 2: Register the Benchmark
|
||||
|
||||
Use the `ensure_registered()` pattern to survive registry clearing in tests:
|
||||
|
||||
```python
|
||||
def ensure_registered() -> None:
|
||||
"""Register the benchmark if not already present."""
|
||||
if not BenchmarkRegistry.contains("context_length"):
|
||||
BenchmarkRegistry.register_value("context_length", ContextLengthBenchmark)
|
||||
```
|
||||
|
||||
Alternatively, use the decorator at class definition time:
|
||||
|
||||
```python
|
||||
@BenchmarkRegistry.register("context_length")
|
||||
class ContextLengthBenchmark(BaseBenchmark):
|
||||
...
|
||||
```
|
||||
|
||||
!!! info "The `ensure_registered()` Pattern"
|
||||
The `ensure_registered()` function is preferred over the decorator for benchmark modules because it survives registry clearing during testing. The built-in `latency` and `throughput` benchmarks both use this pattern. The benchmark CLI command calls `ensure_registered()` before looking up benchmarks.
|
||||
|
||||
### Step 3: Use Your Benchmark
|
||||
|
||||
Once registered, your benchmark is available through the CLI:
|
||||
|
||||
```bash
|
||||
jarvis bench run -b context_length
|
||||
```
|
||||
|
||||
And through the `BenchmarkSuite`:
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import BenchmarkRegistry
|
||||
|
||||
bench_cls = BenchmarkRegistry.get("context_length")
|
||||
bench = bench_cls()
|
||||
result = bench.run(engine, model, num_samples=5)
|
||||
```
|
||||
@@ -0,0 +1,470 @@
|
||||
# Channels
|
||||
|
||||
The channels module lets OpenJarvis send and receive messages through external messaging platforms. Each platform has a dedicated channel implementation that connects directly to the platform's API -- there is no intermediate gateway.
|
||||
|
||||
!!! note "Channels are disabled by default"
|
||||
The `[channel]` config section defaults to `enabled = false`. You must set `enabled = true` and configure platform-specific credentials before channel features become active.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Channel messaging is built around the `BaseChannel` ABC. Each platform (Telegram, Discord, Slack, WhatsApp, etc.) has its own implementation registered via `@ChannelRegistry.register("name")`. Channels connect directly to their platform APIs, register handlers for incoming messages, and send outgoing messages.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Your Code] -->|send| B[TelegramChannel / DiscordChannel / SlackChannel / ...]
|
||||
B -->|Platform API| C[Telegram / Discord / Slack / ...]
|
||||
C -->|incoming messages| B
|
||||
B -->|on_message handlers| D[Your Handlers]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Channels
|
||||
|
||||
| Channel | Registry Key | Platform | Pip Extra | Auth |
|
||||
|---------|-------------|----------|-----------|------|
|
||||
| `TelegramChannel` | `telegram` | Telegram Bot API | `channel-telegram` | Bot token |
|
||||
| `DiscordChannel` | `discord` | Discord Bot API | `channel-discord` | Bot token |
|
||||
| `SlackChannel` | `slack` | Slack Web API | `channel-slack` | Bot + App tokens |
|
||||
| `WhatsAppChannel` | `whatsapp` | WhatsApp Business API | — | API token |
|
||||
| `WhatsAppBaileysChannel` | `whatsapp_baileys` | WhatsApp (Baileys) | — | QR code auth |
|
||||
| `WebhookChannel` | `webhook` | Generic HTTP webhook | — | URL + optional secret |
|
||||
| `EmailChannel` | `email` | SMTP/IMAP email | — | Email credentials |
|
||||
| `SignalChannel` | `signal` | Signal Messenger | — | Signal CLI |
|
||||
| `GoogleChatChannel` | `google_chat` | Google Chat | — | Service account |
|
||||
| `IRCChannel` | `irc` | IRC | — | Server credentials |
|
||||
| `WebChatChannel` | `webchat` | Browser-based chat | — | None |
|
||||
| `TeamsChannel` | `teams` | Microsoft Teams | — | Bot credentials |
|
||||
| `MatrixChannel` | `matrix` | Matrix protocol | — | Homeserver + token |
|
||||
| `MattermostChannel` | `mattermost` | Mattermost | — | Bot token |
|
||||
| `FeishuChannel` | `feishu` | Feishu/Lark | — | App credentials |
|
||||
| `BlueBubblesChannel` | `bluebubbles` | iMessage (BlueBubbles) | — | BlueBubbles server |
|
||||
| `LineChannel` | `line` | LINE Messaging API | `channel-line` | Channel access token |
|
||||
| `ViberChannel` | `viber` | Viber Bot API | `channel-viber` | Auth token |
|
||||
| `MessengerChannel` | `messenger` | Facebook Messenger | `channel-messenger` | Page access token |
|
||||
| `RedditChannel` | `reddit` | Reddit API | `channel-reddit` | OAuth credentials |
|
||||
| `MastodonChannel` | `mastodon` | Mastodon API | `channel-mastodon` | Access token |
|
||||
| `XMPPChannel` | `xmpp` | XMPP/Jabber | `channel-xmpp` | JID + password |
|
||||
| `RocketChatChannel` | `rocketchat` | Rocket.Chat API | `channel-rocketchat` | User credentials |
|
||||
| `ZulipChannel` | `zulip` | Zulip API | `channel-zulip` | Bot email + API key |
|
||||
| `TwitchChannel` | `twitch` | Twitch IRC/API | `channel-twitch` | OAuth token |
|
||||
| `NostrChannel` | `nostr` | Nostr protocol | `channel-nostr` | Private key (nsec) |
|
||||
|
||||
---
|
||||
|
||||
## Using a Channel
|
||||
|
||||
### Connecting
|
||||
|
||||
```python title="connect.py"
|
||||
from openjarvis.channels.telegram import TelegramChannel
|
||||
|
||||
channel = TelegramChannel(
|
||||
bot_token="YOUR_BOT_TOKEN", # (1)!
|
||||
)
|
||||
channel.connect()
|
||||
|
||||
print(channel.status()) # ChannelStatus.CONNECTED
|
||||
```
|
||||
|
||||
1. Falls back to the `TELEGRAM_BOT_TOKEN` environment variable if not provided.
|
||||
|
||||
### Sending Messages
|
||||
|
||||
```python title="send_message.py"
|
||||
from openjarvis.channels.telegram import TelegramChannel
|
||||
|
||||
channel = TelegramChannel()
|
||||
channel.connect()
|
||||
|
||||
# Send to a chat by ID
|
||||
ok = channel.send(
|
||||
"123456789",
|
||||
"Analysis complete. Results are ready.",
|
||||
conversation_id="thread-abc123", # optional, for threading
|
||||
)
|
||||
|
||||
if ok:
|
||||
print("Message delivered")
|
||||
else:
|
||||
print("Delivery failed")
|
||||
|
||||
channel.disconnect()
|
||||
```
|
||||
|
||||
### Receiving Messages
|
||||
|
||||
Register handler callbacks before calling `connect()`. Each handler receives a `ChannelMessage` and can optionally return a reply string.
|
||||
|
||||
```python title="receive_messages.py"
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
from openjarvis.channels.discord_channel import DiscordChannel
|
||||
|
||||
channel = DiscordChannel()
|
||||
|
||||
|
||||
def handle_incoming(msg: ChannelMessage) -> None:
|
||||
print(f"[{msg.channel}] {msg.sender}: {msg.content}")
|
||||
print(f" conversation_id={msg.conversation_id}")
|
||||
print(f" message_id={msg.message_id}")
|
||||
|
||||
|
||||
channel.on_message(handle_incoming) # (1)!
|
||||
channel.connect() # (2)!
|
||||
|
||||
# Messages now arrive asynchronously via the background listener thread
|
||||
# Your main thread can continue doing other work
|
||||
```
|
||||
|
||||
1. Register one or more handlers. All registered handlers are called for every incoming message.
|
||||
2. `connect()` starts the background listener thread after establishing the platform connection.
|
||||
|
||||
### Listing Available Channels
|
||||
|
||||
```python title="list_channels.py"
|
||||
from openjarvis.channels.slack import SlackChannel
|
||||
|
||||
channel = SlackChannel()
|
||||
channel.connect()
|
||||
channels = channel.list_channels()
|
||||
print(channels) # ["general", "random", "dev"]
|
||||
```
|
||||
|
||||
### Disconnecting
|
||||
|
||||
```python title="disconnect.py"
|
||||
channel.disconnect()
|
||||
# Stops the listener thread and closes the platform connection
|
||||
# Status becomes ChannelStatus.DISCONNECTED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ChannelMessage Fields
|
||||
|
||||
Every incoming message is delivered to handlers as a `ChannelMessage` dataclass.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `channel` | `str` | Name of the channel the message arrived on |
|
||||
| `sender` | `str` | Identifier of the message sender |
|
||||
| `content` | `str` | Message text |
|
||||
| `message_id` | `str` | Unique message identifier (may be empty) |
|
||||
| `conversation_id` | `str` | Thread/conversation identifier (may be empty) |
|
||||
| `session_id` | `str` | Session identifier (may be empty) |
|
||||
| `metadata` | `dict[str, Any]` | Additional platform-specific metadata |
|
||||
|
||||
---
|
||||
|
||||
## Event Bus Integration
|
||||
|
||||
Pass an `EventBus` to publish channel events to the rest of the system:
|
||||
|
||||
```python title="channel_events.py"
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.channels.telegram import TelegramChannel
|
||||
|
||||
bus = EventBus()
|
||||
|
||||
|
||||
def on_received(event):
|
||||
print(f"Message received on {event.data['channel']}: {event.data['content']}")
|
||||
|
||||
|
||||
def on_sent(event):
|
||||
print(f"Message sent to {event.data['channel']}")
|
||||
|
||||
|
||||
bus.subscribe(EventType.CHANNEL_MESSAGE_RECEIVED, on_received)
|
||||
bus.subscribe(EventType.CHANNEL_MESSAGE_SENT, on_sent)
|
||||
|
||||
channel = TelegramChannel(bus=bus)
|
||||
channel.connect()
|
||||
```
|
||||
|
||||
| Event | Published When | Data Keys |
|
||||
|-------|----------------|-----------|
|
||||
| `CHANNEL_MESSAGE_RECEIVED` | A message arrives from the platform | `channel`, `sender`, `content`, `message_id` |
|
||||
| `CHANNEL_MESSAGE_SENT` | A message is successfully sent | `channel`, `content`, `conversation_id` |
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
The `jarvis channel` subcommand group provides quick access to channel operations.
|
||||
|
||||
### List Channels
|
||||
|
||||
```bash
|
||||
jarvis channel list
|
||||
```
|
||||
|
||||
### Send a Message
|
||||
|
||||
```bash
|
||||
# Send to a channel by name
|
||||
jarvis channel send telegram "Build completed successfully"
|
||||
```
|
||||
|
||||
### Show Status
|
||||
|
||||
```bash
|
||||
jarvis channel status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Server Endpoints
|
||||
|
||||
When `jarvis serve` is running, three channel endpoints are available. Channels must be configured and enabled in `[channel]` for these endpoints to return data.
|
||||
|
||||
### `GET /v1/channels`
|
||||
|
||||
Returns the list of registered channels and their status.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/channels
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": ["telegram", "discord", "slack"],
|
||||
"status": "connected"
|
||||
}
|
||||
```
|
||||
|
||||
If no channels are configured:
|
||||
```json
|
||||
{"channels": [], "message": "No channels configured"}
|
||||
```
|
||||
|
||||
### `POST /v1/channels/send`
|
||||
|
||||
Send a message to a channel.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/channels/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"channel": "telegram", "content": "Hello!", "conversation_id": "conv-1"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{"status": "sent", "channel": "telegram"}
|
||||
```
|
||||
|
||||
Required fields: `channel`, `content`. `conversation_id` is optional.
|
||||
|
||||
### `GET /v1/channels/status`
|
||||
|
||||
Returns the connection status for each configured channel.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/channels/status
|
||||
```
|
||||
|
||||
```json
|
||||
{"status": "connected"}
|
||||
```
|
||||
|
||||
Possible values: `connected`, `disconnected`, `connecting`, `error`, `not_configured`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Channel settings live in the `[channel]` section of `~/.openjarvis/config.toml`. Each platform has its own nested sub-section.
|
||||
|
||||
```toml title="~/.openjarvis/config.toml"
|
||||
[channel]
|
||||
enabled = true
|
||||
default_channel = ""
|
||||
default_agent = "simple"
|
||||
|
||||
[channel.telegram]
|
||||
bot_token = "YOUR_TELEGRAM_BOT_TOKEN"
|
||||
|
||||
[channel.discord]
|
||||
bot_token = "YOUR_DISCORD_BOT_TOKEN"
|
||||
|
||||
[channel.slack]
|
||||
bot_token = "YOUR_SLACK_BOT_TOKEN"
|
||||
app_token = "YOUR_SLACK_APP_TOKEN"
|
||||
```
|
||||
|
||||
### Configuration Reference
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `enabled` | `bool` | `false` | Enable channel messaging |
|
||||
| `default_channel` | `str` | `""` | Default channel to use when not specified |
|
||||
| `default_agent` | `str` | `simple` | Agent to use for handling inbound messages |
|
||||
|
||||
Platform-specific settings are configured in nested sub-sections (e.g., `[channel.telegram]`, `[channel.discord]`).
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
This example connects a Telegram channel, registers a handler that echoes messages back, sends a test message, and then disconnects after a short wait.
|
||||
|
||||
```python title="full_example.py"
|
||||
import time
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
from openjarvis.channels.telegram import TelegramChannel
|
||||
from openjarvis.core.events import EventBus
|
||||
|
||||
bus = EventBus()
|
||||
channel = TelegramChannel(
|
||||
bot_token="YOUR_BOT_TOKEN",
|
||||
bus=bus,
|
||||
)
|
||||
|
||||
received_messages = []
|
||||
|
||||
|
||||
def on_message(msg: ChannelMessage) -> None:
|
||||
received_messages.append(msg)
|
||||
print(f"Received from {msg.sender} on #{msg.channel}: {msg.content}")
|
||||
|
||||
|
||||
channel.on_message(on_message)
|
||||
channel.connect()
|
||||
|
||||
# List available channels
|
||||
channels = channel.list_channels()
|
||||
print(f"Available channels: {channels}")
|
||||
|
||||
# Send a message
|
||||
if channels:
|
||||
channel.send(channels[0], "Hello from OpenJarvis!")
|
||||
|
||||
# Wait for incoming messages
|
||||
time.sleep(10)
|
||||
|
||||
channel.disconnect()
|
||||
print(f"Total messages received: {len(received_messages)}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WhatsAppBaileysChannel
|
||||
|
||||
`WhatsAppBaileysChannel` is registered as `"whatsapp_baileys"` in `ChannelRegistry` and provides **bidirectional WhatsApp messaging** using the Baileys protocol. It spawns a Node.js bridge subprocess that handles QR-code authentication, incoming message forwarding, and outbound message delivery.
|
||||
|
||||
!!! warning "Node.js 22+ required"
|
||||
The Baileys bridge is a compiled Node.js application bundled inside the package. It is auto-installed to `~/.openjarvis/whatsapp_baileys_bridge/` on first `connect()` call. If `node` is not found on `PATH`, `connect()` logs an error and sets the channel to `ChannelStatus.ERROR`.
|
||||
|
||||
!!! note "WhatsApp account required"
|
||||
WhatsApp does not offer an official API for personal accounts. Baileys operates on the WhatsApp Web protocol. You must scan a QR code with your WhatsApp mobile app to authenticate on first use.
|
||||
|
||||
### Connecting
|
||||
|
||||
```python title="whatsapp_connect.py"
|
||||
from openjarvis.channels.whatsapp_baileys import WhatsAppBaileysChannel
|
||||
|
||||
channel = WhatsAppBaileysChannel(
|
||||
assistant_name="Jarvis", # (1)!
|
||||
assistant_has_own_number=False, # (2)!
|
||||
)
|
||||
channel.connect() # spawns the Node.js bridge subprocess
|
||||
```
|
||||
|
||||
1. Display name used in conversation context.
|
||||
2. Set `True` if the assistant has a dedicated WhatsApp number and should not filter its own messages.
|
||||
|
||||
On first connection, the bridge will print a QR code to the terminal. Scan it with the WhatsApp app on your phone to authenticate. Authentication state is saved to `~/.openjarvis/whatsapp_baileys_bridge/auth/` and reused on subsequent connections.
|
||||
|
||||
### Receiving Messages
|
||||
|
||||
```python title="whatsapp_receive.py"
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
from openjarvis.channels.whatsapp_baileys import WhatsAppBaileysChannel
|
||||
|
||||
channel = WhatsAppBaileysChannel()
|
||||
|
||||
|
||||
def on_message(msg: ChannelMessage) -> None:
|
||||
print(f"[{msg.sender}] {msg.content}")
|
||||
# msg.conversation_id is the WhatsApp JID (e.g. "15551234567@s.whatsapp.net")
|
||||
|
||||
|
||||
channel.on_message(on_message)
|
||||
channel.connect()
|
||||
|
||||
# Background reader thread is running; your code continues here
|
||||
```
|
||||
|
||||
### Sending Messages
|
||||
|
||||
Messages are addressed by WhatsApp **JID** (Jabber ID) -- the canonical identifier for a WhatsApp contact or group.
|
||||
|
||||
```python title="whatsapp_send.py"
|
||||
# Individual contact JID format: <country-code><number>@s.whatsapp.net
|
||||
# Group JID format: <group-id>@g.us
|
||||
|
||||
ok = channel.send(
|
||||
"15551234567@s.whatsapp.net", # JID of the recipient
|
||||
"Hello from OpenJarvis!",
|
||||
)
|
||||
|
||||
if not ok:
|
||||
print("Send failed -- check that the bridge is connected")
|
||||
```
|
||||
|
||||
### Disconnecting
|
||||
|
||||
```python title="whatsapp_disconnect.py"
|
||||
channel.disconnect()
|
||||
# Sends disconnect command to bridge, terminates subprocess, stops reader thread
|
||||
```
|
||||
|
||||
### Constructor Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|----------------------------|------------|-------------|--------------------------------------------------------|
|
||||
| `auth_dir` | `str` | `~/.openjarvis/whatsapp_baileys_bridge/auth` | Baileys auth state directory |
|
||||
| `assistant_name` | `str` | `"Jarvis"` | Display name for the assistant |
|
||||
| `assistant_has_own_number` | `bool` | `False` | Whether the assistant has a dedicated WhatsApp number |
|
||||
| `bus` | `EventBus` | `None` | Event bus for publishing channel events |
|
||||
|
||||
### Bridge Events
|
||||
|
||||
The Node.js bridge communicates with Python via JSON lines on stdio. Python interprets the following event types:
|
||||
|
||||
| Bridge event type | Effect |
|
||||
|-------------------|-------------------------------------------------------------|
|
||||
| `status` | Updates `ChannelStatus` (`connected` / `disconnected`) |
|
||||
| `qr` | Logs "QR code received -- scan to authenticate" |
|
||||
| `message` | Dispatches to all registered `on_message` handlers |
|
||||
| `error` | Logs the error and sets status to `ChannelStatus.ERROR` |
|
||||
|
||||
### Event Bus Integration
|
||||
|
||||
When a `bus` is provided, `WhatsAppBaileysChannel` publishes the same events as other channels:
|
||||
|
||||
| Event | Published When | Data Keys |
|
||||
|-------|----------------|-----------|
|
||||
| `CHANNEL_MESSAGE_RECEIVED` | An inbound WhatsApp message arrives | `channel`, `sender`, `content`, `message_id` |
|
||||
| `CHANNEL_MESSAGE_SENT` | A message is successfully sent | `channel`, `content`, `conversation_id` |
|
||||
|
||||
### Configuration
|
||||
|
||||
WhatsApp Baileys channel settings live in the `[channel.whatsapp_baileys]` subsection:
|
||||
|
||||
```toml title="~/.openjarvis/config.toml"
|
||||
[channel.whatsapp_baileys]
|
||||
auth_dir = "/home/user/.openjarvis/whatsapp_baileys_bridge/auth"
|
||||
assistant_name = "Jarvis"
|
||||
assistant_has_own_number = false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Architecture: Channels](../architecture/channels.md) -- listener loop internals and channel design
|
||||
- [API Reference: Channels](../api-reference/openjarvis/channels/index.md) -- full class and type signatures
|
||||
- [Getting Started: Configuration](../getting-started/configuration.md) -- full config reference
|
||||
- [User Guide: Agents](agents.md) -- agent system documentation
|
||||
@@ -0,0 +1,433 @@
|
||||
# CLI Reference
|
||||
|
||||
OpenJarvis provides a command-line interface through the `jarvis` command. Built on [Click](https://click.palletsprojects.com/), it offers subcommands for querying models, managing memory, running benchmarks, and serving an OpenAI-compatible API.
|
||||
|
||||
## Global Options
|
||||
|
||||
```bash
|
||||
jarvis --version # Print the OpenJarvis version
|
||||
jarvis --help # Show top-level help with all subcommands
|
||||
```
|
||||
|
||||
## `jarvis init`
|
||||
|
||||
Detect local hardware (CPU, GPU, RAM) and generate a configuration file at `~/.openjarvis/config.toml`.
|
||||
|
||||
```bash
|
||||
jarvis init # Interactive — refuses to overwrite existing config
|
||||
jarvis init --force # Overwrite existing config without prompting
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|-----------|-----------------------------------------------|
|
||||
| `--force` | Overwrite existing configuration without prompting |
|
||||
|
||||
The `init` command auto-detects:
|
||||
|
||||
- **Platform** (Linux, macOS, Windows)
|
||||
- **CPU** brand and core count
|
||||
- **RAM** in GB
|
||||
- **GPU** vendor, model, VRAM, and count (via `nvidia-smi`, `rocm-smi`, or `system_profiler`)
|
||||
|
||||
Based on the detected hardware, it recommends an appropriate inference engine and writes a pre-configured TOML file.
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Detecting hardware...
|
||||
Platform : linux
|
||||
CPU : AMD Ryzen 9 7950X (32 cores)
|
||||
RAM : 64 GB
|
||||
GPU : NVIDIA RTX 4090 (24.0 GB VRAM, x1)
|
||||
|
||||
Config written successfully.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `jarvis ask`
|
||||
|
||||
Send a query to the inference engine (directly or through an agent) and print the response.
|
||||
|
||||
```bash
|
||||
jarvis ask "What is the capital of France?"
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|-------------------------------|---------|------------|-------------------------------------------------------|
|
||||
| `-m`, `--model MODEL` | string | auto | Model to use for inference |
|
||||
| `-e`, `--engine ENGINE` | string | auto | Engine backend (ollama, vllm, llamacpp, etc.) |
|
||||
| `-t`, `--temperature TEMP` | float | `0.7` | Sampling temperature |
|
||||
| `--max-tokens N` | int | `1024` | Maximum tokens to generate |
|
||||
| `--json` | flag | off | Output raw JSON result instead of plain text |
|
||||
| `--no-stream` | flag | off | Disable streaming (synchronous mode) |
|
||||
| `--no-context` | flag | off | Disable memory context injection |
|
||||
| `-a`, `--agent AGENT` | string | none | Agent to use (`simple`, `orchestrator`) |
|
||||
| `--tools TOOLS` | string | none | Comma-separated tool names to enable |
|
||||
|
||||
### Direct Mode vs Agent Mode
|
||||
|
||||
**Direct mode** (default) sends the query straight to the inference engine:
|
||||
|
||||
```bash
|
||||
jarvis ask "Explain quantum computing"
|
||||
```
|
||||
|
||||
**Agent mode** routes the query through an agent that can use tools and manage multi-turn interactions:
|
||||
|
||||
```bash
|
||||
jarvis ask --agent orchestrator "What is 2+2?"
|
||||
jarvis ask --agent orchestrator --tools calculator,think "Calculate sqrt(144) + 3^2"
|
||||
jarvis ask --agent simple "Hello"
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```bash
|
||||
# Basic query
|
||||
jarvis ask "What is machine learning?"
|
||||
|
||||
# Specify a model
|
||||
jarvis ask -m qwen3:8b "Summarize this concept"
|
||||
|
||||
# Use the orchestrator agent with tools
|
||||
jarvis ask --agent orchestrator --tools calculator "What is 15% of 340?"
|
||||
|
||||
# Get JSON output
|
||||
jarvis ask --json "Hello"
|
||||
|
||||
# Disable memory context injection
|
||||
jarvis ask --no-context "Tell me about Python"
|
||||
|
||||
# Set maximum token generation
|
||||
jarvis ask --max-tokens 2048 "Write a detailed essay about AI"
|
||||
```
|
||||
|
||||
### JSON Output Format
|
||||
|
||||
When using `--json` in **direct mode**, the output includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "The response text...",
|
||||
"usage": {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 85,
|
||||
"total_tokens": 97
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using `--json` in **agent mode**, the output includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "The response text...",
|
||||
"turns": 3,
|
||||
"tool_results": [
|
||||
{
|
||||
"tool_name": "calculator",
|
||||
"content": "51.0",
|
||||
"success": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `jarvis model`
|
||||
|
||||
Manage and inspect language models available on running engines.
|
||||
|
||||
### `jarvis model list`
|
||||
|
||||
List all models available from running inference engines, displayed as a Rich table with model parameters, context length, and VRAM requirements.
|
||||
|
||||
```bash
|
||||
jarvis model list
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Available Models
|
||||
┌─────────┬────────────────┬────────┬─────────┬──────┐
|
||||
│ Engine │ Model │ Params │ Context │ VRAM │
|
||||
├─────────┼────────────────┼────────┼─────────┼──────┤
|
||||
│ ollama │ qwen3:8b │ 8B │ 32,768 │ 6GB │
|
||||
│ ollama │ llama3.2:3b │ 3B │ 8,192 │ 3GB │
|
||||
└─────────┴────────────────┴────────┴─────────┴──────┘
|
||||
```
|
||||
|
||||
### `jarvis model info <model>`
|
||||
|
||||
Show detailed information about a specific model.
|
||||
|
||||
```bash
|
||||
jarvis model info qwen3:8b
|
||||
```
|
||||
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
┌─ Qwen 3 8B ──────────────────────────────┐
|
||||
│ Model ID: qwen3:8b │
|
||||
│ Name: Qwen 3 8B │
|
||||
│ Parameters: 8B │
|
||||
│ Context: 32,768 │
|
||||
│ Quantization: none │
|
||||
│ Min VRAM: 6GB │
|
||||
│ Engines: ollama, vllm │
|
||||
│ Provider: Alibaba │
|
||||
│ API Key: not required │
|
||||
└───────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### `jarvis model pull <model>`
|
||||
|
||||
Download a model via Ollama. Shows a progress bar during download.
|
||||
|
||||
```bash
|
||||
jarvis model pull qwen3:8b
|
||||
```
|
||||
|
||||
!!! note
|
||||
The `pull` command requires a running Ollama instance. It connects to the Ollama API at the host configured in your `config.toml`.
|
||||
|
||||
---
|
||||
|
||||
## `jarvis memory`
|
||||
|
||||
Manage the document memory store for retrieval-augmented generation.
|
||||
|
||||
### `jarvis memory index <path>`
|
||||
|
||||
Index documents from a file or directory into the memory store.
|
||||
|
||||
```bash
|
||||
jarvis memory index ./docs/
|
||||
jarvis memory index ./notes.md
|
||||
jarvis memory index ./data/ --chunk-size 256 --chunk-overlap 32
|
||||
jarvis memory index ./docs/ --backend sqlite
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|-----------------------------|--------|---------|--------------------------------------|
|
||||
| `--backend`, `-b` | string | config | Override the default memory backend |
|
||||
| `--chunk-size` | int | `512` | Chunk size in tokens |
|
||||
| `--chunk-overlap` | int | `64` | Overlap between chunks in tokens |
|
||||
|
||||
The ingestion pipeline supports text, markdown, code files, and PDF (with `pdfplumber` installed). Binary files and hidden directories are automatically skipped.
|
||||
|
||||
### `jarvis memory search <query>`
|
||||
|
||||
Search the memory store for relevant document chunks.
|
||||
|
||||
```bash
|
||||
jarvis memory search "machine learning basics"
|
||||
jarvis memory search -k 10 "neural networks"
|
||||
jarvis memory search --backend faiss "embeddings"
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------------------|--------|---------|--------------------------------------|
|
||||
| `--top-k`, `-k` | int | `5` | Number of results to return |
|
||||
| `--backend`, `-b` | string | config | Override the default memory backend |
|
||||
|
||||
Results are displayed in a table with rank, score, source file, and a content preview.
|
||||
|
||||
### `jarvis memory stats`
|
||||
|
||||
Show memory store statistics including document count and database size.
|
||||
|
||||
```bash
|
||||
jarvis memory stats
|
||||
jarvis memory stats --backend sqlite
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------------------|--------|---------|--------------------------------------|
|
||||
| `--backend`, `-b` | string | config | Override the default memory backend |
|
||||
|
||||
---
|
||||
|
||||
## `jarvis telemetry`
|
||||
|
||||
Query and manage inference telemetry data stored in SQLite.
|
||||
|
||||
### `jarvis telemetry stats`
|
||||
|
||||
Show aggregated telemetry statistics including total calls, tokens, cost, and latency, broken down by model and engine.
|
||||
|
||||
```bash
|
||||
jarvis telemetry stats
|
||||
jarvis telemetry stats -n 5 # Show top 5 models
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|-----------------|------|---------|-------------------------------|
|
||||
| `-n`, `--top` | int | `10` | Number of top models to show |
|
||||
|
||||
### `jarvis telemetry export`
|
||||
|
||||
Export raw telemetry records in JSON or CSV format.
|
||||
|
||||
```bash
|
||||
jarvis telemetry export # JSON to stdout
|
||||
jarvis telemetry export --format csv # CSV to stdout
|
||||
jarvis telemetry export --format json -o data.json # JSON to file
|
||||
jarvis telemetry export -f csv -o metrics.csv # CSV to file
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|-----------------------|--------|----------|---------------------------------|
|
||||
| `-f`, `--format` | choice | `json` | Output format: `json` or `csv` |
|
||||
| `-o`, `--output` | path | stdout | Output file path |
|
||||
|
||||
### `jarvis telemetry clear`
|
||||
|
||||
Delete all telemetry records from the database.
|
||||
|
||||
```bash
|
||||
jarvis telemetry clear # Interactive confirmation
|
||||
jarvis telemetry clear --yes # Skip confirmation
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|----------------|------|---------|-------------------------------|
|
||||
| `-y`, `--yes` | flag | off | Skip confirmation prompt |
|
||||
|
||||
!!! warning
|
||||
This permanently deletes all stored telemetry data. Use `--yes` to skip the confirmation prompt in automated scripts.
|
||||
|
||||
---
|
||||
|
||||
## `jarvis bench`
|
||||
|
||||
Run inference benchmarks against a running engine.
|
||||
|
||||
### `jarvis bench run`
|
||||
|
||||
Execute benchmarks and report results.
|
||||
|
||||
```bash
|
||||
jarvis bench run # Run all benchmarks, 10 samples
|
||||
jarvis bench run -n 20 # 20 samples per benchmark
|
||||
jarvis bench run -b latency # Only the latency benchmark
|
||||
jarvis bench run -b throughput -n 50 --json # Throughput, 50 samples, JSON output
|
||||
jarvis bench run -o results.jsonl # Write JSONL results to file
|
||||
jarvis bench run -m qwen3:8b -e ollama # Specific model and engine
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|----------------------------|--------|---------|------------------------------------------|
|
||||
| `-m`, `--model MODEL` | string | auto | Model to benchmark |
|
||||
| `-e`, `--engine ENGINE` | string | auto | Engine backend |
|
||||
| `-n`, `--samples N` | int | `10` | Number of samples per benchmark |
|
||||
| `-b`, `--benchmark NAME` | string | all | Specific benchmark to run |
|
||||
| `-o`, `--output PATH` | path | none | Write JSONL results to file |
|
||||
| `--json` | flag | off | Output JSON summary to stdout |
|
||||
|
||||
Available benchmarks:
|
||||
|
||||
- **latency** -- Measures per-call inference latency (mean, p50, p95, min, max)
|
||||
- **throughput** -- Measures tokens-per-second throughput
|
||||
|
||||
---
|
||||
|
||||
## `jarvis channel`
|
||||
|
||||
Manage messaging channels for multi-platform communication. Channels connect directly to platform APIs (Telegram, Discord, Slack, etc.) -- no gateway required.
|
||||
|
||||
### `jarvis channel list`
|
||||
|
||||
List registered channel backends and their connection status.
|
||||
|
||||
```bash
|
||||
jarvis channel list
|
||||
```
|
||||
|
||||
### `jarvis channel send`
|
||||
|
||||
Send a message to a specific channel.
|
||||
|
||||
```bash
|
||||
jarvis channel send slack "Hello from Jarvis!"
|
||||
jarvis channel send discord "Build complete"
|
||||
```
|
||||
|
||||
| Argument | Type | Description |
|
||||
|-------------|--------|--------------------------------------|
|
||||
| `TARGET` | string | Channel name to send to |
|
||||
| `MESSAGE` | string | Message content |
|
||||
|
||||
### `jarvis channel status`
|
||||
|
||||
Show connection status for configured channels.
|
||||
|
||||
```bash
|
||||
jarvis channel status
|
||||
```
|
||||
|
||||
!!! note "Channel Dependencies"
|
||||
Each channel requires its platform-specific credentials (bot tokens, API keys) configured in the `[channel.<platform>]` section of your config. See [Configuration](../getting-started/configuration.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## `jarvis serve`
|
||||
|
||||
Start an OpenAI-compatible API server.
|
||||
|
||||
```bash
|
||||
jarvis serve # Default host/port from config
|
||||
jarvis serve --port 8000 # Custom port
|
||||
jarvis serve --host 0.0.0.0 --port 9000 # Bind to all interfaces
|
||||
jarvis serve --model qwen3:8b # Specify default model
|
||||
jarvis serve --agent orchestrator # Route requests through an agent
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------------------------|--------|---------|------------------------------------------|
|
||||
| `--host HOST` | string | config | Bind address |
|
||||
| `--port PORT` | int | config | Port number |
|
||||
| `-e`, `--engine ENGINE` | string | auto | Engine backend |
|
||||
| `-m`, `--model MODEL` | string | config | Default model for inference |
|
||||
| `-a`, `--agent AGENT` | string | none | Agent for non-streaming requests |
|
||||
|
||||
!!! note "Server Dependencies"
|
||||
The `serve` command requires the server extra:
|
||||
|
||||
```bash
|
||||
uv sync --extra server
|
||||
```
|
||||
|
||||
This installs FastAPI, uvicorn, and related dependencies.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
The server exposes the following OpenAI-compatible endpoints:
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|--------------------------|--------------------------------|
|
||||
| POST | `/v1/chat/completions` | Chat completions (streaming & non-streaming) |
|
||||
| GET | `/v1/models` | List available models |
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/v1/channels` | List available messaging channels |
|
||||
| POST | `/v1/channels/send` | Send a message to a channel |
|
||||
| GET | `/v1/channels/status` | Channel bridge connection status |
|
||||
|
||||
**Example with curl:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "qwen3:8b",
|
||||
"messages": [{"role": "user", "content": "Hello!"}]
|
||||
}'
|
||||
```
|
||||
|
||||
When an agent is configured (e.g., `--agent orchestrator`), non-streaming requests are routed through the agent with access to all registered tools. For tool-capable agents (`orchestrator`, `react`, `openhands`), all registered tools are automatically loaded and made available.
|
||||
@@ -0,0 +1,661 @@
|
||||
# Evaluations
|
||||
|
||||
The OpenJarvis evaluation framework (`openjarvis-evals`) measures model **correctness and accuracy** on academic datasets. It is a separate package from the main OpenJarvis library and is designed specifically for research workflows where you need reproducible, dataset-driven quality assessments.
|
||||
|
||||
!!! info "Evals vs. Benchmarks"
|
||||
OpenJarvis has two distinct measurement systems that complement each other:
|
||||
|
||||
| System | Package | Measures | Entry Point |
|
||||
|--------|---------|----------|-------------|
|
||||
| **Evaluations** | `openjarvis-evals` | Correctness on academic datasets (accuracy, pass rate) | `openjarvis-eval` |
|
||||
| **Benchmarks** | `openjarvis` | Engine performance (latency, throughput) | `jarvis bench` |
|
||||
|
||||
Use evaluations to answer "does this model get the right answer?" and benchmarks to answer "how fast does this model respond?". See the [Benchmarks guide](benchmarks.md) for the performance measurement system.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The evaluation framework is a standalone package in the `evals/` directory. Install it alongside OpenJarvis:
|
||||
|
||||
```bash
|
||||
uv sync --extra eval
|
||||
```
|
||||
|
||||
This installs the `openjarvis-eval` CLI entry point and all required dependencies (`datasets`, `huggingface-hub`, `tqdm`, `rich`).
|
||||
|
||||
!!! note "Python version requirement"
|
||||
Python 3.10 requires the `tomli` package for TOML config parsing. The `evals/pyproject.toml` includes this as a conditional dependency, so it is installed automatically.
|
||||
|
||||
---
|
||||
|
||||
## Datasets
|
||||
|
||||
The framework ships with **30+ datasets** covering academic reasoning, agentic tasks, retrieval, conversation quality, and practical use-case benchmarks. Datasets are grouped by category below.
|
||||
|
||||
### Use-Case Benchmarks
|
||||
|
||||
These benchmarks evaluate models on practical tasks that mirror real OpenJarvis use cases.
|
||||
|
||||
| Dataset | Key | Description |
|
||||
|---------|-----|-------------|
|
||||
| **CodingAssistant** | `coding_assistant` | Bug-fix coding assistant (test-based) |
|
||||
| **SecurityScanner** | `security_scanner` | Security vulnerability scanner |
|
||||
| **DailyDigest** | `daily_digest` | Daily briefing generation |
|
||||
| **DocQA** | `doc_qa` | Document-grounded QA with citations |
|
||||
| **BrowserAssistant** | `browser_assistant` | Web research with fact verification |
|
||||
| **EmailTriage** | `email_triage` | Email triage classification + draft |
|
||||
| **MorningBrief** | `morning_brief` | Morning briefing generation |
|
||||
| **ResearchMining** | `research_mining` | Research synthesis + accuracy |
|
||||
| **KnowledgeBase** | `knowledge_base` | Document-grounded retrieval QA |
|
||||
| **CodingTask** | `coding_task` | Function-level code generation |
|
||||
|
||||
### Academic Benchmarks
|
||||
|
||||
These benchmarks measure reasoning and knowledge on established academic datasets.
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
|---------|-----|----------|-------------|
|
||||
| **SuperGPQA** | `supergpqa` | reasoning | Graduate-level multiple-choice across scientific disciplines |
|
||||
| **GPQA** | `gpqa` | reasoning | Graduate-level MCQ (Diamond, Extended, Main variants) |
|
||||
| **MMLU-Pro** | `mmlu-pro` | reasoning | Enhanced MMLU multiple-choice |
|
||||
| **MATH-500** | `math500` | reasoning | Competition-level math problems |
|
||||
| **NaturalReasoning** | `natural-reasoning` | reasoning | Natural language reasoning |
|
||||
| **HLE** | `hle` | reasoning | Humanity's Last Exam hard challenges |
|
||||
| **SimpleQA** | `simpleqa` | chat | Short-form factual question answering |
|
||||
| **IPW** | `ipw` | chat | Intelligence Per Watt mixed benchmark |
|
||||
|
||||
### Agent Benchmarks
|
||||
|
||||
These benchmarks test multi-step agent capabilities including tool use, code generation, and long-horizon planning.
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
|---------|-----|----------|-------------|
|
||||
| **GAIA** | `gaia` | agentic | Multi-step tasks with file I/O, calculations, web lookup |
|
||||
| **SWE-bench** | `swebench` | agentic | Real-world GitHub code patches |
|
||||
| **SWEfficiency** | `swefficiency` | agentic | Software optimization tasks |
|
||||
| **TerminalBench** | `terminalbench` | agentic | Terminal-based task completion |
|
||||
| **TerminalBench Native** | `terminalbench-native` | agentic | TerminalBench with native Docker execution |
|
||||
| **LifelongAgent** | `lifelong-agent` | agentic | Sequential task learning across sessions |
|
||||
| **PaperArena** | `paperarena` | agentic | Scientific paper analysis |
|
||||
| **DeepPlanning** | `deepplanning` | agentic | Shopping constraint planning |
|
||||
| **LogHub** | `loghub` | agentic | Log anomaly detection |
|
||||
| **AMA-Bench** | `ama-bench` | agentic | Agent memory assessment |
|
||||
| **WebChoreArena** | `webchorearena` | agentic | Web chore tasks |
|
||||
| **WorkArena** | `workarena` | agentic | WorkArena++ enterprise workflows |
|
||||
|
||||
### Retrieval Benchmarks
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
|---------|-----|----------|-------------|
|
||||
| **FRAMES** | `frames` | rag | Multi-hop factual retrieval across Wikipedia articles |
|
||||
|
||||
### Conversation Benchmarks
|
||||
|
||||
| Dataset | Key | Category | Description |
|
||||
|---------|-----|----------|-------------|
|
||||
| **WildChat** | `wildchat` | chat | Real user conversation quality (pairwise LLM judge) |
|
||||
|
||||
---
|
||||
|
||||
### Dataset Details
|
||||
|
||||
**SuperGPQA** is a large-scale multiple-choice benchmark spanning graduate-level questions across scientific disciplines. Each sample has a question, a set of lettered options, and a reference answer letter.
|
||||
|
||||
**GAIA** is an agentic benchmark requiring models to complete multi-step tasks that may involve file reading, calculations, and web lookup. Questions are drawn from the 2023 GAIA challenge set.
|
||||
|
||||
**FRAMES** tests multi-hop factual retrieval. Each question requires synthesizing information across multiple Wikipedia articles, making it a strong probe of retrieval-augmented generation capability.
|
||||
|
||||
**WildChat** uses real user conversations filtered to English single-turn exchanges. The reference answer is the original assistant response from the dataset; the model under evaluation is compared against it by an LLM judge.
|
||||
|
||||
!!! tip "GAIA dataset access"
|
||||
The GAIA dataset requires a HuggingFace account and acceptance of the dataset's terms of use. The loader downloads the full dataset snapshot on first use and caches it at `~/.cache/gaia_benchmark/`. Subsequent runs use the local cache.
|
||||
|
||||
---
|
||||
|
||||
## Use-Case Eval Configs
|
||||
|
||||
The framework includes two pre-built configs for evaluating models on the five core use-case benchmarks (coding_assistant, security_scanner, daily_digest, doc_qa, browser_assistant).
|
||||
|
||||
### Cloud models
|
||||
|
||||
```bash
|
||||
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_cloud.toml
|
||||
```
|
||||
|
||||
This config evaluates **6 cloud models** (Claude Opus 4.6, Claude Haiku 4.5, Gemini 3.1 Pro, Gemini 3.1 Flash Lite, GPT-5.4, GPT-5 Mini) against all 5 use-case benchmarks with 30 samples each, producing a 6x5 = 30-run matrix. Results are written to `results/use-cases-v2-cloud/`.
|
||||
|
||||
### Local models
|
||||
|
||||
```bash
|
||||
uv run python -m openjarvis.evals --config src/openjarvis/evals/configs/use_case_v2_local.toml
|
||||
```
|
||||
|
||||
This config evaluates **5 local models** via Ollama (Qwen3.5 122B-A10B, GPT-OSS 120B, GLM4, Qwen3.5 35B-A3B, GLM-4.7-Flash) against the same 5 benchmarks, producing a 5x5 = 25-run matrix. Uses 2 workers (suitable for single-GPU setups). Results are written to `results/use-cases-v2-local/`.
|
||||
|
||||
!!! tip "Customizing use-case evals"
|
||||
Copy one of the `use_case_v2_*.toml` configs and modify the `[[models]]` entries to evaluate your own models. The five use-case benchmarks use synthetic datasets (no HuggingFace download required) and run quickly with 30 samples each.
|
||||
|
||||
---
|
||||
|
||||
## Inference Backends
|
||||
|
||||
Every evaluation run routes model calls through one of two backends:
|
||||
|
||||
| Backend | Key | Description |
|
||||
|---------|-----|-------------|
|
||||
| **jarvis-direct** | `jarvis-direct` | Engine-level inference via `SystemBuilder`. Works for local (Ollama, vLLM, llama.cpp) and cloud models. |
|
||||
| **jarvis-agent** | `jarvis-agent` | Agent-level inference with tool calling. Uses `JarvisSystem.ask()` with the specified agent and tools. |
|
||||
|
||||
Use `jarvis-direct` for most evaluations. Use `jarvis-agent` when the benchmark requires tool use — for example, GAIA tasks that reference files that must be read with `file_read`, or arithmetic tasks that benefit from `calculator`.
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### List available benchmarks and backends
|
||||
|
||||
```bash
|
||||
openjarvis-eval list
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Benchmarks:
|
||||
supergpqa [reasoning ] SuperGPQA multiple-choice
|
||||
gaia [agentic ] GAIA agentic benchmark
|
||||
frames [rag ] FRAMES multi-hop RAG
|
||||
wildchat [chat ] WildChat conversation quality
|
||||
|
||||
Backends:
|
||||
jarvis-direct Engine-level inference (local or cloud)
|
||||
jarvis-agent Agent-level inference with tool calling
|
||||
```
|
||||
|
||||
### Run a single benchmark
|
||||
|
||||
```bash
|
||||
# Evaluate qwen3:8b on SuperGPQA (engine-level, 10 samples default)
|
||||
openjarvis-eval run -b supergpqa -m qwen3:8b
|
||||
|
||||
# Evaluate GPT-4o on GAIA using the agent backend with tools
|
||||
openjarvis-eval run -b gaia -m gpt-4o --backend jarvis-agent \
|
||||
--agent orchestrator --tools calculator,file_read -n 50
|
||||
|
||||
# Run FRAMES with vLLM engine, write output to a file
|
||||
openjarvis-eval run -b frames -m llama3:70b -e vllm \
|
||||
-o results/frames_llama70b.jsonl
|
||||
|
||||
# Run WildChat with a higher temperature for chat quality
|
||||
openjarvis-eval run -b wildchat -m qwen3:8b --temperature 0.7 -n 100
|
||||
```
|
||||
|
||||
#### Full option reference
|
||||
|
||||
| Option | Short | Type | Default | Description |
|
||||
|--------|-------|------|---------|-------------|
|
||||
| `--config` | `-c` | path | — | TOML config file; when provided, `-b` and `-m` are not required |
|
||||
| `--benchmark` | `-b` | choice | required* | `supergpqa`, `gaia`, `frames`, or `wildchat` |
|
||||
| `--backend` | | choice | `jarvis-direct` | `jarvis-direct` or `jarvis-agent` |
|
||||
| `--model` | `-m` | str | required* | Model identifier (e.g., `qwen3:8b`, `gpt-4o`) |
|
||||
| `--engine` | `-e` | str | auto | Engine key (`ollama`, `vllm`, `cloud`, ...) |
|
||||
| `--agent` | | str | `orchestrator` | Agent name for `jarvis-agent` backend |
|
||||
| `--tools` | | str | `""` | Comma-separated tool names (e.g., `calculator,file_read`) |
|
||||
| `--max-samples` | `-n` | int | all | Limit the number of samples evaluated |
|
||||
| `--max-workers` | `-w` | int | `4` | Parallel evaluation workers |
|
||||
| `--judge-model` | | str | `gpt-4o` | LLM used for judge-based scoring |
|
||||
| `--output` | `-o` | path | auto-generated | Output JSONL file path |
|
||||
| `--seed` | | int | `42` | Random seed for dataset shuffling |
|
||||
| `--split` | | str | dataset default | Override the dataset split |
|
||||
| `--temperature` | | float | `0.0` | Generation temperature |
|
||||
| `--max-tokens` | | int | `2048` | Maximum output tokens |
|
||||
| `--verbose` | `-v` | flag | off | Enable debug logging |
|
||||
|
||||
*Required when `--config` is not provided.
|
||||
|
||||
### Run all benchmarks at once
|
||||
|
||||
The `run-all` command evaluates a single model against all four benchmarks sequentially and writes results to an output directory:
|
||||
|
||||
```bash
|
||||
openjarvis-eval run-all -m qwen3:8b
|
||||
|
||||
# With options
|
||||
openjarvis-eval run-all -m gpt-4o -n 100 --output-dir results/gpt4o/
|
||||
```
|
||||
|
||||
Output files are written as `{output_dir}/{benchmark}_{model-slug}.jsonl`. The model slug replaces `/` and `:` with `-`, so `qwen3:8b` becomes `qwen3-8b`.
|
||||
|
||||
### Summarize results
|
||||
|
||||
After a run, inspect a JSONL results file:
|
||||
|
||||
```bash
|
||||
openjarvis-eval summarize results/supergpqa_qwen3-8b.jsonl
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
File: results/supergpqa_qwen3-8b.jsonl
|
||||
Benchmark: supergpqa
|
||||
Model: qwen3:8b
|
||||
Total: 200
|
||||
Scored: 198
|
||||
Correct: 143
|
||||
Accuracy: 0.7222
|
||||
Errors: 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TOML Config System
|
||||
|
||||
For research workflows that compare multiple models across multiple benchmarks, use a TOML config file to define the evaluation as a **models x benchmarks matrix**. This is the recommended approach for systematic evaluations.
|
||||
|
||||
### Running from a config
|
||||
|
||||
```bash
|
||||
openjarvis-eval run --config src/openjarvis/evals/configs/full-suite.toml
|
||||
```
|
||||
|
||||
When `--config` is provided, the `-b`/`--benchmark` and `-m`/`--model` options are not required. All settings come from the config file. The CLI expands the matrix, prints a progress table, and writes results to the configured `output_dir`.
|
||||
|
||||
### Config file format
|
||||
|
||||
A config file has six sections: `[meta]`, `[defaults]`, `[judge]`, `[run]`, `[[models]]`, and `[[benchmarks]]`. Only `[[models]]` and `[[benchmarks]]` are required — all other sections are optional and fall back to built-in defaults.
|
||||
|
||||
```toml title="evals/configs/full-suite.toml"
|
||||
# Suite-level metadata (optional)
|
||||
[meta]
|
||||
name = "full-suite-v1"
|
||||
description = "Evaluate all benchmarks against production models"
|
||||
|
||||
# Default generation parameters (optional)
|
||||
[defaults]
|
||||
temperature = 0.0
|
||||
max_tokens = 2048
|
||||
|
||||
# LLM judge configuration (optional)
|
||||
[judge]
|
||||
model = "gpt-4o"
|
||||
temperature = 0.0
|
||||
max_tokens = 1024
|
||||
|
||||
# Execution settings (optional)
|
||||
[run]
|
||||
max_workers = 4
|
||||
output_dir = "results/"
|
||||
seed = 42
|
||||
|
||||
# --- Models (one [[models]] block per model) ---
|
||||
|
||||
[[models]]
|
||||
name = "qwen3:8b"
|
||||
engine = "ollama"
|
||||
temperature = 0.3 # overrides [defaults] for this model
|
||||
max_tokens = 4096
|
||||
|
||||
[[models]]
|
||||
name = "gpt-4o"
|
||||
provider = "openai" # uses cloud engine
|
||||
|
||||
[[models]]
|
||||
name = "llama3:70b"
|
||||
engine = "vllm"
|
||||
temperature = 0.1
|
||||
|
||||
# --- Benchmarks (one [[benchmarks]] block per benchmark) ---
|
||||
|
||||
[[benchmarks]]
|
||||
name = "supergpqa"
|
||||
backend = "jarvis-direct"
|
||||
max_samples = 200
|
||||
split = "train"
|
||||
|
||||
[[benchmarks]]
|
||||
name = "gaia"
|
||||
backend = "jarvis-agent"
|
||||
agent = "orchestrator"
|
||||
tools = ["file_read", "calculator"]
|
||||
max_samples = 50
|
||||
judge_model = "claude-sonnet-4-20250514" # override judge for this benchmark
|
||||
|
||||
[[benchmarks]]
|
||||
name = "frames"
|
||||
backend = "jarvis-direct"
|
||||
max_samples = 100
|
||||
|
||||
[[benchmarks]]
|
||||
name = "wildchat"
|
||||
backend = "jarvis-direct"
|
||||
max_samples = 150
|
||||
temperature = 0.7 # override temperature for this benchmark
|
||||
```
|
||||
|
||||
This config produces 3 models x 4 benchmarks = **12 evaluation runs**.
|
||||
|
||||
### Merge precedence
|
||||
|
||||
Settings are resolved with the following precedence, from highest to lowest:
|
||||
|
||||
```
|
||||
benchmark-level > model-level > [defaults] > built-in defaults
|
||||
```
|
||||
|
||||
For example, `temperature` is resolved as: use `[defaults].temperature` (0.0), then apply `[[models]].temperature` if set (0.3 for qwen3:8b), then override with `[[benchmarks]].temperature` if set (0.7 for wildchat). The WildChat run with qwen3:8b therefore runs at `temperature = 0.7`.
|
||||
|
||||
### Minimal config
|
||||
|
||||
A config requires only one `[[models]]` and one `[[benchmarks]]` entry:
|
||||
|
||||
```toml title="evals/configs/minimal.toml"
|
||||
[[models]]
|
||||
name = "qwen3:8b"
|
||||
|
||||
[[benchmarks]]
|
||||
name = "supergpqa"
|
||||
```
|
||||
|
||||
This runs SuperGPQA against qwen3:8b with all default settings. Use this as a starting point when iterating on a single model or dataset.
|
||||
|
||||
### Single-run config with full options
|
||||
|
||||
```toml title="evals/configs/single-run.toml"
|
||||
[meta]
|
||||
name = "single-run-example"
|
||||
description = "Evaluate SuperGPQA with a single model and full configuration"
|
||||
|
||||
[defaults]
|
||||
temperature = 0.0
|
||||
max_tokens = 2048
|
||||
|
||||
[judge]
|
||||
model = "gpt-4o"
|
||||
temperature = 0.0
|
||||
max_tokens = 1024
|
||||
|
||||
[run]
|
||||
max_workers = 4
|
||||
output_dir = "results/"
|
||||
seed = 42
|
||||
|
||||
[[models]]
|
||||
name = "qwen3:8b"
|
||||
engine = "ollama"
|
||||
temperature = 0.3
|
||||
max_tokens = 4096
|
||||
|
||||
[[benchmarks]]
|
||||
name = "supergpqa"
|
||||
backend = "jarvis-direct"
|
||||
max_samples = 100
|
||||
split = "train"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config Reference
|
||||
|
||||
### `[meta]`
|
||||
|
||||
Suite-level metadata. Neither field affects evaluation behavior; both are used in CLI output and summary files.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | str | `""` | Suite name shown in CLI output |
|
||||
| `description` | str | `""` | Human-readable description |
|
||||
|
||||
### `[defaults]`
|
||||
|
||||
Default generation parameters applied to every run unless overridden at the model or benchmark level.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `temperature` | float | `0.0` | Sampling temperature |
|
||||
| `max_tokens` | int | `2048` | Maximum output tokens |
|
||||
|
||||
### `[judge]`
|
||||
|
||||
Configuration for the LLM used as a judge in GAIA, FRAMES, and WildChat scoring.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `model` | str | `"gpt-4o"` | Judge model identifier |
|
||||
| `provider` | str | `None` | Provider override (e.g., `"openai"`) |
|
||||
| `temperature` | float | `0.0` | Judge sampling temperature |
|
||||
| `max_tokens` | int | `1024` | Maximum judge output tokens |
|
||||
|
||||
!!! warning "Judge model costs"
|
||||
Every sample that requires LLM-based scoring makes a separate call to the judge model. For large runs with hundreds of samples, judge costs can exceed evaluation costs. GAIA, FRAMES, and WildChat all require a judge; SuperGPQA uses an LLM to extract the answer letter, then compares it against the reference without a separate judge call.
|
||||
|
||||
### `[run]`
|
||||
|
||||
Execution settings that apply to the entire suite.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `max_workers` | int | `4` | Number of parallel evaluation threads |
|
||||
| `output_dir` | str | `"results/"` | Directory where JSONL and summary files are written |
|
||||
| `seed` | int | `42` | Random seed for dataset shuffling |
|
||||
| `telemetry` | bool | `false` | Enable GPU telemetry capture (energy, power, utilization, throughput) |
|
||||
| `gpu_metrics` | bool | `false` | Enable GPU metric polling via `pynvml` (requires `pynvml` or `nvidia-ml-py`) |
|
||||
|
||||
### `[[models]]`
|
||||
|
||||
One block per model. The `name` field is required.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | str | required | Model identifier (e.g., `"qwen3:8b"`, `"gpt-4o"`) |
|
||||
| `engine` | str | `None` | Engine key to use (`"ollama"`, `"vllm"`, `"cloud"`, ...) |
|
||||
| `provider` | str | `None` | Provider override for cloud models (e.g., `"openai"`) |
|
||||
| `temperature` | float | `None` | Override `[defaults].temperature` for this model |
|
||||
| `max_tokens` | int | `None` | Override `[defaults].max_tokens` for this model |
|
||||
| `param_count_b` | float | `0.0` | Total model parameter count in billions (for MFU/MBU computation) |
|
||||
| `active_params_b` | float | `None` | Active parameters per token in billions (for MoE models; defaults to `param_count_b`) |
|
||||
| `gpu_peak_tflops` | float | `0.0` | GPU peak FP16 TFLOPS (e.g., 312.0 for A100 SXM) |
|
||||
| `gpu_peak_bandwidth_gb_s` | float | `0.0` | GPU peak memory bandwidth in GB/s (e.g., 2039.0 for A100 SXM) |
|
||||
| `num_gpus` | int | `1` | Number of GPUs used (for tensor-parallel inference) |
|
||||
|
||||
### `[[benchmarks]]`
|
||||
|
||||
One block per benchmark. The `name` field is required.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `name` | str | required | Benchmark key: `supergpqa`, `gaia`, `frames`, or `wildchat` |
|
||||
| `backend` | str | `"jarvis-direct"` | Inference backend: `jarvis-direct` or `jarvis-agent` |
|
||||
| `max_samples` | int | `None` | Limit number of samples; `None` evaluates the full dataset |
|
||||
| `split` | str | `None` | Override the default dataset split |
|
||||
| `agent` | str | `None` | Agent name for `jarvis-agent` backend (e.g., `"orchestrator"`) |
|
||||
| `tools` | list[str] | `[]` | Tool names for `jarvis-agent` backend |
|
||||
| `judge_model` | str | `None` | Override `[judge].model` for this benchmark only |
|
||||
| `temperature` | float | `None` | Override temperature for this benchmark (highest precedence) |
|
||||
| `max_tokens` | int | `None` | Override max tokens for this benchmark (highest precedence) |
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
### JSONL results file
|
||||
|
||||
Each completed sample is appended to the output JSONL file immediately after scoring. The file path is either specified with `-o`/`--output`, or auto-generated as `{output_dir}/{benchmark}_{model-slug}.jsonl`.
|
||||
|
||||
Each line is a JSON object with the following fields:
|
||||
|
||||
```json title="results/supergpqa_qwen3-8b.jsonl (one line per sample)"
|
||||
{
|
||||
"record_id": "supergpqa-42",
|
||||
"benchmark": "supergpqa",
|
||||
"model": "qwen3:8b",
|
||||
"backend": "jarvis-direct",
|
||||
"model_answer": "The answer is C because...",
|
||||
"is_correct": true,
|
||||
"score": 1.0,
|
||||
"latency_seconds": 1.34,
|
||||
"prompt_tokens": 187,
|
||||
"completion_tokens": 12,
|
||||
"cost_usd": 0.0,
|
||||
"error": null,
|
||||
"scoring_metadata": {"reference_letter": "C", "candidate_letter": "C"},
|
||||
"ttft": 0.0,
|
||||
"energy_joules": 140792.95,
|
||||
"power_watts": 893.0,
|
||||
"gpu_utilization_pct": 47.4,
|
||||
"throughput_tok_per_sec": 36.6,
|
||||
"mfu_pct": 0.0176,
|
||||
"mbu_pct": 26.89,
|
||||
"ipw": 0.00112,
|
||||
"ipj": 0.000007
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `record_id` | str | Unique sample identifier |
|
||||
| `benchmark` | str | Benchmark name |
|
||||
| `model` | str | Model identifier |
|
||||
| `backend` | str | Backend used |
|
||||
| `model_answer` | str | Raw model output |
|
||||
| `is_correct` | bool or null | Scoring result (`null` if unscored) |
|
||||
| `score` | float or null | Numeric score (1.0 correct, 0.0 incorrect, `null` unscored) |
|
||||
| `latency_seconds` | float | Inference latency |
|
||||
| `prompt_tokens` | int | Input tokens consumed |
|
||||
| `completion_tokens` | int | Output tokens generated |
|
||||
| `cost_usd` | float | Estimated cost in USD |
|
||||
| `error` | str or null | Error message if the sample failed |
|
||||
| `scoring_metadata` | dict | Scorer-specific details (extracted letters, judge output, etc.) |
|
||||
| `ttft` | float | Time to first token in seconds (0.0 if unavailable) |
|
||||
| `energy_joules` | float | GPU energy consumed for this sample (joules) |
|
||||
| `power_watts` | float | Average GPU power draw during inference (watts) |
|
||||
| `gpu_utilization_pct` | float | Average GPU utilization percentage |
|
||||
| `throughput_tok_per_sec` | float | Output token throughput (tokens/sec) |
|
||||
| `mfu_pct` | float | Model FLOPs Utilization percentage (requires model hardware params) |
|
||||
| `mbu_pct` | float | Memory Bandwidth Utilization percentage (requires model hardware params) |
|
||||
| `ipw` | float | Intelligence Per Watt: `accuracy / power_watts` (0 if incorrect or no power data) |
|
||||
| `ipj` | float | Intelligence Per Joule: `accuracy / energy_joules` (0 if incorrect or no energy data) |
|
||||
|
||||
### Summary JSON file
|
||||
|
||||
After all samples complete, a summary file is written alongside the JSONL at `{output_path}.summary.json`:
|
||||
|
||||
```json title="results/supergpqa_qwen3-8b.jsonl.summary.json"
|
||||
{
|
||||
"benchmark": "supergpqa",
|
||||
"category": "reasoning",
|
||||
"backend": "jarvis-direct",
|
||||
"model": "qwen3:8b",
|
||||
"total_samples": 200,
|
||||
"scored_samples": 198,
|
||||
"correct": 143,
|
||||
"accuracy": 0.7222,
|
||||
"errors": 2,
|
||||
"mean_latency_seconds": 1.4821,
|
||||
"total_cost_usd": 0.0,
|
||||
"per_subject": {
|
||||
"chemistry": {"accuracy": 0.74, "total": 50.0, "scored": 50.0, "correct": 37.0},
|
||||
"mathematics": {"accuracy": 0.68, "total": 50.0, "scored": 49.0, "correct": 33.0}
|
||||
},
|
||||
"started_at": 1708789200.0,
|
||||
"ended_at": 1708789496.3,
|
||||
"accuracy_stats": {"mean": 0.72, "median": 1.0, "min": 0.0, "max": 1.0, "std": 0.45},
|
||||
"energy_stats": {"mean": 140792.95, "median": 135112.79, "min": 3926.17, "max": 1806568.12, "std": 156038.54},
|
||||
"power_stats": {"mean": 892.98, "median": 898.19, "min": 811.50, "max": 1104.90, "std": 42.65},
|
||||
"gpu_utilization_stats": {"mean": 47.41, "median": 47.45, "min": 42.38, "max": 56.23, "std": 2.72},
|
||||
"throughput_stats": {"mean": 36.55, "median": 37.22, "min": 26.22, "max": 45.03, "std": 5.00},
|
||||
"mfu_stats": {"mean": 0.0176, "median": 0.0179, "min": 0.0126, "max": 0.0216, "std": 0.0024},
|
||||
"mbu_stats": {"mean": 26.89, "median": 27.38, "min": 19.29, "max": 33.13, "std": 3.68},
|
||||
"ipw_stats": {"mean": 0.00113, "median": 0.00112, "min": 0.00100, "max": 0.00123, "std": 0.00005},
|
||||
"ipj_stats": {"mean": 0.00003, "median": 0.00001, "min": 0.000002, "max": 0.00021, "std": 0.00004},
|
||||
"total_energy_joules": 28158590.26
|
||||
}
|
||||
```
|
||||
|
||||
When `telemetry = true` and `gpu_metrics = true` are set in `[run]`, the summary includes `MetricStats` (mean, median, min, max, std) for every telemetry metric plus `total_energy_joules`. These stats are `null` when no values are available for that metric.
|
||||
|
||||
The `per_subject` breakdown groups results by the dataset's subject or category field, which varies per benchmark:
|
||||
|
||||
- **SuperGPQA**: `subfield`, `field`, or `discipline`
|
||||
- **GAIA**: difficulty level (`level_1`, `level_2`, `level_3`)
|
||||
- **FRAMES**: reasoning type(s) (e.g., `temporal`, `intersection`)
|
||||
- **WildChat**: always `"conversation"`
|
||||
|
||||
---
|
||||
|
||||
## Scoring Methods
|
||||
|
||||
Each benchmark uses a scorer tuned to its answer format.
|
||||
|
||||
### SuperGPQA: LLM-assisted MCQ extraction
|
||||
|
||||
SuperGPQA responses are free-form text that must contain one of the valid option letters (A, B, C, D, ...). The scorer uses the judge LLM to extract the final answer letter from the model's response, then compares it against the reference letter with exact string matching.
|
||||
|
||||
The judge is prompted with the original problem and the model's response and asked to return only a single letter. This handles cases where the model reasons extensively before stating its final answer.
|
||||
|
||||
```
|
||||
is_correct = extracted_letter == reference_letter
|
||||
```
|
||||
|
||||
Scoring metadata includes: `reference_letter`, `candidate_letter`, and `valid_letters`.
|
||||
|
||||
### GAIA: Normalized exact match with LLM fallback
|
||||
|
||||
GAIA answers are typically numbers, short phrases, or comma-separated lists. The scorer applies a normalization pass before comparison:
|
||||
|
||||
- **Numbers**: strips `$`, `%`, `,` and converts to float for comparison
|
||||
- **Lists**: splits on `,`/`;` and compares element-by-element (with per-element type detection)
|
||||
- **Strings**: lowercases, strips whitespace and punctuation
|
||||
|
||||
If the normalized exact match fails, the scorer falls back to the judge LLM, which returns a structured response with `extracted_final_answer`, `reasoning`, and `correct: yes/no`. The LLM fallback handles cases like unit variations, alternative phrasings, and equivalent but differently-formatted answers.
|
||||
|
||||
### FRAMES: LLM-as-judge (factual correctness)
|
||||
|
||||
FRAMES uses an LLM judge that evaluates semantic equivalence between the model's answer and the ground truth. The judge receives the question, ground truth, and predicted answer, then responds with a structured verdict:
|
||||
|
||||
```
|
||||
extracted_final_answer: <extracted answer>
|
||||
reasoning: <brief explanation>
|
||||
correct: yes / no
|
||||
```
|
||||
|
||||
The scorer parses the `correct:` line and falls back to presence of `TRUE`/`FALSE` tokens if the structured format is missing.
|
||||
|
||||
### WildChat: Pairwise LLM comparison
|
||||
|
||||
WildChat does not have a single "correct" answer — it measures chat response quality. The scorer runs a **dual pairwise comparison**:
|
||||
|
||||
1. The judge evaluates (model answer as A, reference as B) and returns a verdict token such as `[[A>>B]]`, `[[A>B]]`, `[[A=B]]`, `[[B>A]]`, or `[[B>>A]]`.
|
||||
2. The judge then evaluates (reference as A, model answer as B) and returns another verdict.
|
||||
|
||||
The model is considered to have passed (`is_correct = True`) if it wins or ties in either comparison. The dual comparison reduces positional bias in the judge.
|
||||
|
||||
The judge uses a multi-step rubric that distinguishes subjective queries (scored on correctness, helpfulness, relevance, conciseness, and creativity) from objective/technical queries (scored on correctness only).
|
||||
|
||||
!!! tip "Interpreting WildChat accuracy"
|
||||
A WildChat accuracy score of 0.50 means the model matched or beat the reference response in half of comparisons. Because the reference response comes from the original dataset (which may include responses from capable models), a score above 0.50 indicates strong chat quality for that sample set.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution
|
||||
|
||||
The `EvalRunner` processes samples concurrently using a `ThreadPoolExecutor`. Results are flushed to the JSONL file incrementally as each sample completes, so you can inspect partial results during a long run.
|
||||
|
||||
```bash
|
||||
# Use more workers for faster evaluation (if the engine supports concurrent requests)
|
||||
openjarvis-eval run -b supergpqa -m qwen3:8b -w 8 -n 500
|
||||
```
|
||||
|
||||
!!! warning "Worker count and engine load"
|
||||
Higher worker counts increase throughput only if the inference engine can handle concurrent requests. Local Ollama instances typically handle 1-2 concurrent requests. Cloud APIs (OpenAI, Anthropic) can handle higher concurrency. Set `-w` based on your engine's actual parallelism.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Benchmarks](benchmarks.md) — Measure inference engine latency and throughput
|
||||
- [Telemetry & Traces](telemetry.md) — Record and analyze inference metrics from production use
|
||||
- [Agents](agents.md) — Configure the `OrchestratorAgent` used by `jarvis-agent` backend
|
||||
- [Tools](tools.md) — Available tools for agent-backed evaluations
|
||||
- [Python SDK](python-sdk.md) — Programmatic access to OpenJarvis inference and agents
|
||||
@@ -0,0 +1,368 @@
|
||||
# Memory
|
||||
|
||||
The memory system provides persistent, searchable document storage for retrieval-augmented generation (RAG). It supports multiple retrieval backends, a configurable chunking pipeline, document ingestion from files and directories, and automatic context injection into prompts.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Documents --> Chunking Pipeline --> Memory Backend --> Context Injection --> Prompt
|
||||
(files) (split + overlap) (store + index) (retrieve + format) (to LLM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MemoryBackend ABC
|
||||
|
||||
All memory backends implement the `MemoryBackend` abstract base class.
|
||||
|
||||
```python
|
||||
class MemoryBackend(ABC):
|
||||
backend_id: str
|
||||
|
||||
def store(self, content: str, *, source: str = "", metadata: dict | None = None) -> str:
|
||||
"""Persist content and return a unique document ID."""
|
||||
|
||||
def retrieve(self, query: str, *, top_k: int = 5, **kwargs) -> list[RetrievalResult]:
|
||||
"""Search for query and return the top-k results."""
|
||||
|
||||
def delete(self, doc_id: str) -> bool:
|
||||
"""Delete a document by ID. Return True if it existed."""
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all stored documents."""
|
||||
```
|
||||
|
||||
### RetrievalResult
|
||||
|
||||
Each retrieval returns a list of `RetrievalResult` objects:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|------------------|--------------------------------------------|
|
||||
| `content` | `str` | The retrieved text chunk |
|
||||
| `score` | `float` | Relevance score (higher is better) |
|
||||
| `source` | `str` | Originating file path or identifier |
|
||||
| `metadata` | `dict[str, Any]` | Additional metadata (chunk index, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## Backends
|
||||
|
||||
### SQLite / FTS5 (Default)
|
||||
|
||||
**Registry key:** `sqlite`
|
||||
|
||||
The default backend using SQLite's built-in FTS5 full-text search extension. Zero external dependencies -- uses Python's standard `sqlite3` module.
|
||||
|
||||
- **Scoring:** BM25 ranking via FTS5 MATCH queries
|
||||
- **Persistence:** SQLite database file (default: `~/.openjarvis/memory.db`)
|
||||
- **Dependencies:** None (built into Python)
|
||||
|
||||
```python
|
||||
from openjarvis.core.registry import MemoryRegistry
|
||||
|
||||
backend = MemoryRegistry.create("sqlite", db_path="./memory.db")
|
||||
doc_id = backend.store("Hello world", source="test.txt")
|
||||
results = backend.retrieve("hello")
|
||||
backend.close()
|
||||
```
|
||||
|
||||
!!! tip "When to use SQLite/FTS5"
|
||||
Use this backend when you want zero-configuration setup, keyword-based search is sufficient, and you need persistent storage across restarts. It works well for small to medium document collections.
|
||||
|
||||
### FAISS
|
||||
|
||||
**Registry key:** `faiss`
|
||||
|
||||
Dense neural retrieval using Facebook AI Similarity Search. Embeds documents and queries into dense vectors and retrieves by cosine similarity.
|
||||
|
||||
- **Scoring:** Cosine similarity via inner-product search on L2-normalized vectors
|
||||
- **Persistence:** In-memory only (data is lost on restart)
|
||||
- **Dependencies:** `faiss-cpu` (or `faiss-gpu`), `sentence-transformers`
|
||||
|
||||
```bash
|
||||
uv sync --extra memory-faiss
|
||||
```
|
||||
|
||||
```python
|
||||
backend = MemoryRegistry.create("faiss")
|
||||
doc_id = backend.store("Neural networks are computational models")
|
||||
results = backend.retrieve("deep learning architectures")
|
||||
```
|
||||
|
||||
!!! tip "When to use FAISS"
|
||||
Use this backend when you need semantic search (finding conceptually similar content even without exact keyword matches). Best for use cases where you can re-index on each run since data is not persisted.
|
||||
|
||||
### ColBERTv2
|
||||
|
||||
**Registry key:** `colbert`
|
||||
|
||||
Late-interaction retrieval using ColBERT's token-level embeddings with MaxSim scoring. Provides the highest retrieval quality among the available backends.
|
||||
|
||||
- **Scoring:** MaxSim -- for each query token, take the maximum cosine similarity across all document tokens, then sum
|
||||
- **Persistence:** In-memory only
|
||||
- **Dependencies:** `colbert-ai`, `torch`
|
||||
|
||||
```bash
|
||||
uv sync --extra memory-colbert
|
||||
```
|
||||
|
||||
```python
|
||||
backend = MemoryRegistry.create(
|
||||
"colbert",
|
||||
checkpoint="colbert-ir/colbertv2.0",
|
||||
device="cpu",
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|--------------|----------------------------|-------------------------------------|
|
||||
| `checkpoint` | `"colbert-ir/colbertv2.0"` | ColBERT model checkpoint |
|
||||
| `device` | `"cpu"` | Computation device (`cpu` or `cuda`) |
|
||||
|
||||
!!! tip "When to use ColBERTv2"
|
||||
Use this backend when retrieval quality is the top priority and you have the compute resources for it. The checkpoint is lazily loaded on first use to avoid slow imports. Best for research and evaluation workloads.
|
||||
|
||||
### BM25
|
||||
|
||||
**Registry key:** `bm25`
|
||||
|
||||
Classic probabilistic ranking using the BM25 Okapi algorithm. In-memory implementation using the `rank_bm25` library.
|
||||
|
||||
- **Scoring:** BM25 Okapi term-frequency scoring
|
||||
- **Persistence:** In-memory only
|
||||
- **Dependencies:** `rank-bm25`
|
||||
|
||||
```bash
|
||||
uv sync --extra memory-bm25
|
||||
```
|
||||
|
||||
```python
|
||||
backend = MemoryRegistry.create("bm25")
|
||||
backend.store("Python is a programming language", source="intro.txt")
|
||||
results = backend.retrieve("programming language")
|
||||
```
|
||||
|
||||
!!! tip "When to use BM25"
|
||||
Use this backend when you want classic keyword-based retrieval without database dependencies. Useful as the sparse component in a hybrid retrieval setup.
|
||||
|
||||
### Hybrid (RRF Fusion)
|
||||
|
||||
**Registry key:** `hybrid`
|
||||
|
||||
Combines a sparse retriever and a dense retriever using Reciprocal Rank Fusion (RRF). Documents are stored in both sub-backends, and retrieval results are merged.
|
||||
|
||||
- **Scoring:** `RRF_score(d) = sum(weight_i / (k + rank_i(d)))` across both ranked lists
|
||||
- **Persistence:** Depends on sub-backends
|
||||
- **Dependencies:** Depends on sub-backends
|
||||
|
||||
```python
|
||||
from openjarvis.tools.storage.bm25 import BM25Memory
|
||||
from openjarvis.tools.storage.faiss_backend import FAISSMemory
|
||||
|
||||
sparse = BM25Memory()
|
||||
dense = FAISSMemory()
|
||||
|
||||
backend = MemoryRegistry.create(
|
||||
"hybrid",
|
||||
sparse=sparse,
|
||||
dense=dense,
|
||||
k=60,
|
||||
sparse_weight=1.0,
|
||||
dense_weight=1.0,
|
||||
)
|
||||
```
|
||||
|
||||
!!! note "Backward compatibility"
|
||||
The old `from openjarvis.memory.bm25 import BM25Memory` still works via backward-compatibility shims, but new code should use the canonical `openjarvis.tools.storage.*` imports.
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------------|---------|------------------------------------------|
|
||||
| `sparse` | -- | Sparse retrieval backend (e.g., BM25) |
|
||||
| `dense` | -- | Dense retrieval backend (e.g., FAISS) |
|
||||
| `k` | `60` | RRF constant |
|
||||
| `sparse_weight` | `1.0` | Weight for sparse retriever results |
|
||||
| `dense_weight` | `1.0` | Weight for dense retriever results |
|
||||
|
||||
The hybrid backend over-fetches (3x `top_k`) from each sub-backend before applying fusion to improve result quality.
|
||||
|
||||
!!! tip "When to use Hybrid"
|
||||
Use this backend when you want the best of both keyword matching and semantic similarity. The RRF fusion approach is robust and does not require tuning score distributions across different retrieval methods.
|
||||
|
||||
---
|
||||
|
||||
## Backend Comparison
|
||||
|
||||
| Backend | Search Type | Persistence | Dependencies | Quality | Speed |
|
||||
|-------------|-------------------|-------------|----------------------|----------|----------|
|
||||
| SQLite/FTS5 | Keyword (BM25) | Yes | None | Good | Fast |
|
||||
| FAISS | Dense (cosine) | No | faiss, transformers | Better | Fast |
|
||||
| ColBERTv2 | Late interaction | No | colbert-ai, torch | Best | Slower |
|
||||
| BM25 | Keyword (Okapi) | No | rank-bm25 | Good | Fast |
|
||||
| Hybrid | Fusion (RRF) | Mixed | Sub-backend deps | Better | Medium |
|
||||
|
||||
---
|
||||
|
||||
## Chunking Pipeline
|
||||
|
||||
Documents are split into chunks before storage using a configurable pipeline. The chunker respects paragraph boundaries when possible.
|
||||
|
||||
### ChunkConfig
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-----------------|-------|---------|------------------------------------------|
|
||||
| `chunk_size` | `int` | `512` | Target chunk size in whitespace tokens |
|
||||
| `chunk_overlap` | `int` | `64` | Overlap between consecutive chunks |
|
||||
| `min_chunk_size`| `int` | `50` | Minimum chunk size (smaller chunks are discarded) |
|
||||
|
||||
### How Chunking Works
|
||||
|
||||
1. The document is split into paragraphs (separated by double newlines).
|
||||
2. Paragraphs are accumulated until the token count exceeds `chunk_size`.
|
||||
3. The accumulated content is emitted as a chunk.
|
||||
4. The last `chunk_overlap` tokens are retained as context for the next chunk.
|
||||
5. Paragraphs exceeding `chunk_size` are split into fixed-size windows with overlap.
|
||||
|
||||
### Chunk Output
|
||||
|
||||
Each chunk is a `Chunk` object with:
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|------------------|------------------------------------------|
|
||||
| `content` | `str` | The chunk text |
|
||||
| `source` | `str` | Originating file path |
|
||||
| `offset` | `int` | Token offset within the document |
|
||||
| `index` | `int` | Sequential chunk index |
|
||||
| `metadata` | `dict[str, Any]` | Additional metadata |
|
||||
|
||||
---
|
||||
|
||||
## Document Ingestion
|
||||
|
||||
The `ingest_path()` function reads files or recursively walks directories, producing chunks ready for storage.
|
||||
|
||||
### Supported File Types
|
||||
|
||||
| Type | Extensions |
|
||||
|----------|-------------------------------------------------------------|
|
||||
| Text | `.txt` and other plain text files |
|
||||
| Markdown | `.md`, `.markdown`, `.mdx` |
|
||||
| Code | `.py`, `.js`, `.ts`, `.rs`, `.go`, `.java`, `.c`, `.cpp`, `.rb`, `.sh`, `.yaml`, `.json`, `.html`, `.css`, and more |
|
||||
| PDF | `.pdf` (requires `pdfplumber`: `uv sync --extra memory-pdf`) |
|
||||
|
||||
### Automatic Skipping
|
||||
|
||||
The ingestion pipeline automatically skips:
|
||||
|
||||
- Hidden files and directories (starting with `.`)
|
||||
- Common non-content directories: `__pycache__`, `node_modules`, `.venv`, `.git`, etc.
|
||||
- Binary files: images, audio, video, archives, compiled files
|
||||
- Files that cannot be read (permission errors, encoding issues)
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from openjarvis.tools.storage.chunking import ChunkConfig
|
||||
from openjarvis.tools.storage.ingest import ingest_path
|
||||
|
||||
# Default chunking
|
||||
chunks = ingest_path(Path("./docs/"))
|
||||
|
||||
# Custom chunking
|
||||
config = ChunkConfig(chunk_size=256, chunk_overlap=32)
|
||||
chunks = ingest_path(Path("./notes.md"), config=config)
|
||||
|
||||
print(f"Produced {len(chunks)} chunks")
|
||||
for chunk in chunks[:3]:
|
||||
print(f" [{chunk.index}] {chunk.source}: {chunk.content[:60]}...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Injection
|
||||
|
||||
When memory context injection is enabled (the default), queries are automatically augmented with relevant retrieved documents before being sent to the model. Each retrieved passage includes source attribution.
|
||||
|
||||
### ContextConfig
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---------------------|---------|---------|--------------------------------------------------|
|
||||
| `enabled` | `bool` | `True` | Whether context injection is active |
|
||||
| `top_k` | `int` | `5` | Number of results to retrieve |
|
||||
| `min_score` | `float` | `0.1` | Minimum relevance score threshold |
|
||||
| `max_context_tokens`| `int` | `2048` | Maximum total tokens in injected context |
|
||||
|
||||
### How It Works
|
||||
|
||||
1. The user's query is searched against the memory backend.
|
||||
2. Results below `min_score` are filtered out.
|
||||
3. Results are truncated to fit within `max_context_tokens`.
|
||||
4. A system message is prepended to the conversation with the formatted context:
|
||||
|
||||
```
|
||||
The following context was retrieved from the knowledge base.
|
||||
Use it to inform your response, citing sources where applicable:
|
||||
|
||||
[Source: docs/intro.md] OpenJarvis is a modular AI framework...
|
||||
|
||||
[Source: docs/config.md] Configuration is stored in TOML format...
|
||||
```
|
||||
|
||||
### Disabling Context Injection
|
||||
|
||||
=== "CLI"
|
||||
|
||||
```bash
|
||||
jarvis ask --no-context "Tell me about Python"
|
||||
```
|
||||
|
||||
=== "Python SDK"
|
||||
|
||||
```python
|
||||
response = j.ask("Tell me about Python", context=False)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
# Index a directory
|
||||
jarvis memory index ./docs/
|
||||
|
||||
# Index with custom chunking
|
||||
jarvis memory index ./notes/ --chunk-size 256 --chunk-overlap 32
|
||||
|
||||
# Search the memory store
|
||||
jarvis memory search "machine learning"
|
||||
|
||||
# Search with more results
|
||||
jarvis memory search -k 10 "neural networks"
|
||||
|
||||
# Show memory statistics
|
||||
jarvis memory stats
|
||||
```
|
||||
|
||||
## SDK Usage
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
|
||||
# Index documents
|
||||
result = j.memory.index("./docs/", chunk_size=512, chunk_overlap=64)
|
||||
print(f"Indexed {result['chunks']} chunks")
|
||||
|
||||
# Search
|
||||
results = j.memory.search("configuration", top_k=3)
|
||||
for r in results:
|
||||
print(f" [{r['score']:.4f}] {r['source']}: {r['content'][:80]}...")
|
||||
|
||||
# Statistics
|
||||
stats = j.memory.stats()
|
||||
print(f"Backend: {stats['backend']}, Documents: {stats.get('count', 'N/A')}")
|
||||
|
||||
# Clean up
|
||||
j.close()
|
||||
```
|
||||
@@ -0,0 +1,378 @@
|
||||
# Python SDK
|
||||
|
||||
The OpenJarvis Python SDK provides a high-level interface for interacting with local inference engines, managing memory, and running agent workflows. The primary entry point is the `Jarvis` class.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/open-jarvis/OpenJarvis.git
|
||||
cd OpenJarvis
|
||||
uv sync
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
response = j.ask("What is the capital of France?")
|
||||
print(response)
|
||||
j.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jarvis Class
|
||||
|
||||
### Constructor
|
||||
|
||||
```python
|
||||
Jarvis(
|
||||
*,
|
||||
config: JarvisConfig | None = None,
|
||||
config_path: str | None = None,
|
||||
engine_key: str | None = None,
|
||||
model: str | None = None,
|
||||
)
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------|------------------|---------|----------------------------------------------------------------|
|
||||
| `config` | `JarvisConfig` | `None` | Provide a pre-built configuration object |
|
||||
| `config_path` | `str` | `None` | Path to a TOML configuration file |
|
||||
| `engine_key` | `str` | `None` | Override the engine backend (`"ollama"`, `"vllm"`, etc.) |
|
||||
| `model` | `str` | `None` | Override the default model (e.g., `"qwen3:8b"`) |
|
||||
|
||||
If no `config` or `config_path` is provided, the SDK loads configuration from the default location (`~/.openjarvis/config.toml`), falling back to built-in defaults.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Default configuration — auto-detects engine
|
||||
j = Jarvis()
|
||||
|
||||
# Override the model
|
||||
j = Jarvis(model="qwen3:8b")
|
||||
|
||||
# Override the engine
|
||||
j = Jarvis(engine_key="ollama")
|
||||
|
||||
# Load from a specific config file
|
||||
j = Jarvis(config_path="/path/to/config.toml")
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|-----------|----------------|-----------------------------------|
|
||||
| `config` | `JarvisConfig` | The active configuration object |
|
||||
| `version` | `str` | The OpenJarvis version string |
|
||||
| `memory` | `MemoryHandle` | Proxy for memory operations |
|
||||
|
||||
---
|
||||
|
||||
## `ask()` Method
|
||||
|
||||
Send a query and receive a plain-text response.
|
||||
|
||||
```python
|
||||
ask(
|
||||
query: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
agent: str | None = None,
|
||||
tools: list[str] | None = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
context: bool = True,
|
||||
) -> str
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---------------|--------------|---------|------------------------------------------------------|
|
||||
| `query` | `str` | -- | The question or prompt to send |
|
||||
| `model` | `str` | `None` | Override the model for this call |
|
||||
| `agent` | `str` | `None` | Route through an agent (`"simple"`, `"orchestrator"`) |
|
||||
| `tools` | `list[str]` | `None` | Tool names to enable (requires agent mode) |
|
||||
| `temperature` | `float` | `0.7` | Sampling temperature |
|
||||
| `max_tokens` | `int` | `1024` | Maximum tokens to generate |
|
||||
| `context` | `bool` | `True` | Whether to inject memory context |
|
||||
|
||||
**Returns:** A `str` containing the model's response text.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Simple query
|
||||
response = j.ask("What is machine learning?")
|
||||
|
||||
# Override model for this call
|
||||
response = j.ask("Hello", model="llama3.2:3b")
|
||||
|
||||
# Disable memory context injection
|
||||
response = j.ask("Tell me about Python", context=False)
|
||||
|
||||
# Adjust generation parameters
|
||||
response = j.ask("Write a haiku", temperature=0.3, max_tokens=50)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `ask_full()` Method
|
||||
|
||||
Send a query and receive a detailed result dictionary with metadata.
|
||||
|
||||
```python
|
||||
ask_full(
|
||||
query: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
agent: str | None = None,
|
||||
tools: list[str] | None = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
context: bool = True,
|
||||
) -> dict[str, Any]
|
||||
```
|
||||
|
||||
The parameters are identical to `ask()`.
|
||||
|
||||
**Returns:** A dictionary with the following keys:
|
||||
|
||||
=== "Direct Mode"
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----------|--------|------------------------------------------|
|
||||
| `content` | `str` | The response text |
|
||||
| `usage` | `dict` | Token usage (`prompt_tokens`, `completion_tokens`, `total_tokens`) |
|
||||
| `model` | `str` | The model used |
|
||||
| `engine` | `str` | The engine backend used |
|
||||
|
||||
=== "Agent Mode"
|
||||
|
||||
| Key | Type | Description |
|
||||
|----------------|--------------|------------------------------------------|
|
||||
| `content` | `str` | The response text |
|
||||
| `usage` | `dict` | Token usage (may be empty in agent mode) |
|
||||
| `tool_results` | `list[dict]` | Tool execution results |
|
||||
| `turns` | `int` | Number of agent turns taken |
|
||||
| `model` | `str` | The model used |
|
||||
| `engine` | `str` | The engine backend used |
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
result = j.ask_full("What is 2+2?")
|
||||
print(result["content"]) # "4"
|
||||
print(result["model"]) # "qwen3:8b"
|
||||
print(result["engine"]) # "ollama"
|
||||
print(result["usage"]) # {"prompt_tokens": 10, ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agent Mode
|
||||
|
||||
Pass the `agent` parameter to route queries through an agent. Agents can manage multi-turn conversations and use tools.
|
||||
|
||||
```python
|
||||
# Simple agent — single turn, no tools
|
||||
response = j.ask("Hello", agent="simple")
|
||||
|
||||
# Orchestrator agent — multi-turn with tool calling
|
||||
response = j.ask(
|
||||
"What is sqrt(144) + 3^2?",
|
||||
agent="orchestrator",
|
||||
tools=["calculator", "think"],
|
||||
)
|
||||
```
|
||||
|
||||
When using agent mode with `ask_full()`, the result includes `tool_results` showing each tool invocation:
|
||||
|
||||
```python
|
||||
result = j.ask_full(
|
||||
"Calculate 15% of 340",
|
||||
agent="orchestrator",
|
||||
tools=["calculator"],
|
||||
)
|
||||
|
||||
print(result["content"]) # "15% of 340 is 51.0"
|
||||
print(result["turns"]) # 2
|
||||
print(result["tool_results"])
|
||||
# [{"tool_name": "calculator", "content": "51.0", "success": True}]
|
||||
```
|
||||
|
||||
Available agents: `simple`, `orchestrator`, `operative`, `monitor_operative`
|
||||
|
||||
Available tools: `calculator`, `think`, `retrieval`, `llm`, `file_read`
|
||||
|
||||
---
|
||||
|
||||
## MemoryHandle
|
||||
|
||||
The `Jarvis.memory` attribute provides a `MemoryHandle` for document indexing, search, and statistics. The memory backend is lazily initialized on first use.
|
||||
|
||||
### `index()`
|
||||
|
||||
Index a file or directory into the memory store.
|
||||
|
||||
```python
|
||||
index(
|
||||
path: str,
|
||||
*,
|
||||
chunk_size: int = 512,
|
||||
chunk_overlap: int = 64,
|
||||
) -> dict[str, Any]
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------------|-------|---------|---------------------------------------|
|
||||
| `path` | `str` | -- | Path to a file or directory to index |
|
||||
| `chunk_size` | `int` | `512` | Chunk size in tokens |
|
||||
| `chunk_overlap` | `int` | `64` | Overlap between chunks in tokens |
|
||||
|
||||
**Returns:** A dictionary with `chunks` (count), `doc_ids` (list), and `path`.
|
||||
|
||||
```python
|
||||
result = j.memory.index("./docs/")
|
||||
print(f"Indexed {result['chunks']} chunks")
|
||||
# Indexed 42 chunks
|
||||
|
||||
# Custom chunking parameters
|
||||
result = j.memory.index("./notes/", chunk_size=256, chunk_overlap=32)
|
||||
```
|
||||
|
||||
### `search()`
|
||||
|
||||
Search the memory store for relevant chunks.
|
||||
|
||||
```python
|
||||
search(
|
||||
query: str,
|
||||
*,
|
||||
top_k: int = 5,
|
||||
) -> list[dict[str, Any]]
|
||||
```
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|-------|---------|--------------------------------|
|
||||
| `query` | `str` | -- | The search query |
|
||||
| `top_k` | `int` | `5` | Number of results to return |
|
||||
|
||||
**Returns:** A list of dictionaries, each containing `content`, `score`, `source`, and `metadata`.
|
||||
|
||||
```python
|
||||
results = j.memory.search("neural networks")
|
||||
for r in results:
|
||||
print(f"[{r['score']:.4f}] {r['source']}: {r['content'][:80]}...")
|
||||
```
|
||||
|
||||
### `stats()`
|
||||
|
||||
Return memory backend statistics.
|
||||
|
||||
```python
|
||||
stats() -> dict[str, Any]
|
||||
```
|
||||
|
||||
**Returns:** A dictionary with `backend` (name) and `count` (document count, if available).
|
||||
|
||||
```python
|
||||
info = j.memory.stats()
|
||||
print(f"Backend: {info['backend']}, Documents: {info.get('count', 'N/A')}")
|
||||
```
|
||||
|
||||
### `close()`
|
||||
|
||||
Release the memory backend and its resources.
|
||||
|
||||
```python
|
||||
j.memory.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model and Engine Discovery
|
||||
|
||||
### `list_models()`
|
||||
|
||||
Return a list of model identifiers available on the active engine.
|
||||
|
||||
```python
|
||||
models = j.list_models()
|
||||
print(models) # ["qwen3:8b", "llama3.2:3b", ...]
|
||||
```
|
||||
|
||||
### `list_engines()`
|
||||
|
||||
Return a list of registered engine keys.
|
||||
|
||||
```python
|
||||
engines = j.list_engines()
|
||||
print(engines) # ["ollama", "vllm", "llamacpp", ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resource Management
|
||||
|
||||
### `close()`
|
||||
|
||||
Release all resources held by the `Jarvis` instance, including the memory backend, telemetry store, and engine connection.
|
||||
|
||||
```python
|
||||
j.close()
|
||||
```
|
||||
|
||||
!!! tip "Context Manager Pattern"
|
||||
While `Jarvis` does not implement `__enter__`/`__exit__` directly, you should always call `close()` when done to free database connections and other resources:
|
||||
|
||||
```python
|
||||
j = Jarvis()
|
||||
try:
|
||||
response = j.ask("Hello")
|
||||
print(response)
|
||||
finally:
|
||||
j.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
# Initialize with auto-detected engine
|
||||
j = Jarvis(model="qwen3:8b")
|
||||
|
||||
# Index documents for context-augmented responses
|
||||
result = j.memory.index("./docs/")
|
||||
print(f"Indexed {result['chunks']} chunks from {result['path']}")
|
||||
|
||||
# Simple query with memory context
|
||||
response = j.ask("What are the main features?")
|
||||
print(response)
|
||||
|
||||
# Detailed query with agent and tools
|
||||
full_result = j.ask_full(
|
||||
"Calculate the square root of 256 and add 10",
|
||||
agent="orchestrator",
|
||||
tools=["calculator"],
|
||||
)
|
||||
print(f"Answer: {full_result['content']}")
|
||||
print(f"Turns: {full_result['turns']}")
|
||||
print(f"Tools used: {[t['tool_name'] for t in full_result['tool_results']]}")
|
||||
|
||||
# Search memory directly
|
||||
results = j.memory.search("configuration")
|
||||
for r in results:
|
||||
print(f" [{r['score']:.3f}] {r['source']}")
|
||||
|
||||
# List available models
|
||||
print("Models:", j.list_models())
|
||||
|
||||
# Clean up
|
||||
j.close()
|
||||
```
|
||||
@@ -0,0 +1,240 @@
|
||||
# Task Scheduler
|
||||
|
||||
The task scheduler lets you run agent queries automatically on a schedule -- once at a future time, on a recurring interval, or via a cron expression. Scheduled tasks are persisted in SQLite so they survive process restarts, and execution is handled by a background daemon thread that polls for due tasks every 60 seconds.
|
||||
|
||||
!!! note "Optional component"
|
||||
The scheduler is a standalone module (`openjarvis.scheduler`). It is not wired into the default `Jarvis` / `JarvisSystem` startup. You enable it explicitly via `SystemBuilder` or by starting the CLI daemon with `jarvis scheduler start`.
|
||||
|
||||
---
|
||||
|
||||
## Schedule Types
|
||||
|
||||
| `schedule_type` | `schedule_value` format | Example | Meaning |
|
||||
|-----------------|------------------------------------|-----------------------------|-------------------------------------|
|
||||
| `once` | ISO 8601 UTC datetime | `"2026-03-01T09:00:00Z"` | Run once at that timestamp |
|
||||
| `interval` | Seconds as a string | `"3600"` | Run every hour, starting immediately |
|
||||
| `cron` | Standard 5-field cron expression | `"0 9 * * 1-5"` | 09:00 UTC, Monday–Friday |
|
||||
|
||||
!!! tip "Cron support"
|
||||
Full cron expression support requires `croniter` (`uv pip install croniter`). Without it, the scheduler uses a minimal built-in parser that handles simple `minute hour * * *` patterns only.
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
The `jarvis scheduler` subcommand group manages tasks and the daemon from the terminal.
|
||||
|
||||
### Start the daemon
|
||||
|
||||
```bash
|
||||
jarvis scheduler start
|
||||
```
|
||||
|
||||
Starts the background polling daemon. The daemon runs in the foreground until interrupted (++ctrl+c++). In production, run it under systemd or launchd (see [Deployment](../deployment/systemd.md)).
|
||||
|
||||
### Create a task
|
||||
|
||||
```bash
|
||||
# Run once at a specific time
|
||||
jarvis scheduler create \
|
||||
--prompt "Generate the weekly summary report" \
|
||||
--type once \
|
||||
--value "2026-03-01T09:00:00Z"
|
||||
|
||||
# Run every hour
|
||||
jarvis scheduler create \
|
||||
--prompt "Check for new emails and summarize" \
|
||||
--type interval \
|
||||
--value "3600" \
|
||||
--agent orchestrator \
|
||||
--tools retrieval,think
|
||||
|
||||
# Run on a cron schedule
|
||||
jarvis scheduler create \
|
||||
--prompt "Summarize overnight logs" \
|
||||
--type cron \
|
||||
--value "0 8 * * 1-5"
|
||||
```
|
||||
|
||||
### List tasks
|
||||
|
||||
```bash
|
||||
# All tasks
|
||||
jarvis scheduler list
|
||||
|
||||
# Active tasks only
|
||||
jarvis scheduler list --status active
|
||||
|
||||
# Paused tasks
|
||||
jarvis scheduler list --status paused
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
ID AGENT TYPE VALUE STATUS NEXT RUN
|
||||
a3f9b12c4d8e simple cron 0 8 * * 1-5 active 2026-02-26T08:00:00+00:00
|
||||
b7c2e56f1a3d orchestr interval 3600 active 2026-02-25T14:05:00+00:00
|
||||
```
|
||||
|
||||
### Pause and resume tasks
|
||||
|
||||
```bash
|
||||
# Pause a running task
|
||||
jarvis scheduler pause a3f9b12c4d8e
|
||||
|
||||
# Resume -- next_run is recomputed from the current time
|
||||
jarvis scheduler resume a3f9b12c4d8e
|
||||
```
|
||||
|
||||
### Cancel a task
|
||||
|
||||
```bash
|
||||
# Permanently cancel (status -> "cancelled", next_run cleared)
|
||||
jarvis scheduler cancel a3f9b12c4d8e
|
||||
```
|
||||
|
||||
### View run logs
|
||||
|
||||
```bash
|
||||
# Last 10 executions for a task
|
||||
jarvis scheduler logs a3f9b12c4d8e
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```
|
||||
Run 1: started=2026-02-25T08:00:01Z finished=2026-02-25T08:00:04Z success=True
|
||||
Result: Overnight logs contain 3 warnings and no errors.
|
||||
Run 2: started=2026-02-24T08:00:00Z finished=2026-02-24T08:00:05Z success=True
|
||||
Result: Logs are clean.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python API
|
||||
|
||||
```python title="scheduler_example.py"
|
||||
from openjarvis.scheduler.store import SchedulerStore
|
||||
from openjarvis.scheduler.scheduler import TaskScheduler
|
||||
|
||||
# Set up storage
|
||||
store = SchedulerStore(db_path="~/.openjarvis/scheduler.db") # (1)!
|
||||
|
||||
# Wire in a JarvisSystem for task execution
|
||||
from openjarvis import Jarvis
|
||||
jarvis = Jarvis()
|
||||
|
||||
scheduler = TaskScheduler(
|
||||
store=store,
|
||||
system=jarvis, # (2)!
|
||||
poll_interval=60, # (3)!
|
||||
)
|
||||
|
||||
# Create tasks
|
||||
daily_summary = scheduler.create_task(
|
||||
prompt="Summarize the latest news headlines",
|
||||
schedule_type="cron",
|
||||
schedule_value="0 8 * * *",
|
||||
agent="simple",
|
||||
)
|
||||
print(f"Created task {daily_summary.id}, next run: {daily_summary.next_run}")
|
||||
|
||||
# List active tasks
|
||||
for task in scheduler.list_tasks(status="active"):
|
||||
print(f" {task.id}: {task.prompt} @ {task.next_run}")
|
||||
|
||||
# Manage task state
|
||||
scheduler.pause_task(daily_summary.id)
|
||||
scheduler.resume_task(daily_summary.id) # next_run recomputed from now
|
||||
scheduler.cancel_task(daily_summary.id) # permanent
|
||||
|
||||
# Start the background thread
|
||||
scheduler.start() # (4)!
|
||||
|
||||
# ... application runs ...
|
||||
|
||||
scheduler.stop()
|
||||
jarvis.close()
|
||||
```
|
||||
|
||||
1. SQLite database storing all task state and run logs.
|
||||
2. The scheduler calls `system.ask(task.prompt, agent=task.agent, tools=...)` when a task is due. Pass `system=None` for a dry-run mode that logs what it would execute without calling the agent.
|
||||
3. Seconds between polling cycles. Lower values increase responsiveness at the cost of more SQLite reads.
|
||||
4. Starts a daemon thread named `"jarvis-scheduler"`. Daemon threads exit automatically when the main process exits.
|
||||
|
||||
---
|
||||
|
||||
## ScheduledTask Fields
|
||||
|
||||
Every task is represented as a `ScheduledTask` dataclass.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|------------------|-------------------|---------------|---------------------------------------------------|
|
||||
| `id` | `str` | auto (16 hex) | Unique task identifier |
|
||||
| `prompt` | `str` | -- | Query sent to the agent on execution |
|
||||
| `schedule_type` | `str` | -- | `"cron"`, `"interval"`, or `"once"` |
|
||||
| `schedule_value` | `str` | -- | Cron expression, interval seconds, or ISO datetime|
|
||||
| `context_mode` | `str` | `"isolated"` | Execution context mode |
|
||||
| `status` | `str` | `"active"` | `"active"`, `"paused"`, `"completed"`, `"cancelled"` |
|
||||
| `next_run` | `str` or `None` | computed | ISO 8601 UTC datetime of the next execution |
|
||||
| `last_run` | `str` or `None` | `None` | ISO 8601 UTC datetime of the last execution |
|
||||
| `agent` | `str` | `"simple"` | Agent registry key to use for execution |
|
||||
| `tools` | `str` | `""` | Comma-separated tool names for the agent |
|
||||
| `metadata` | `dict` | `{}` | Arbitrary metadata for the task |
|
||||
|
||||
---
|
||||
|
||||
## Using Scheduler Tools with Agents
|
||||
|
||||
The five scheduler MCP tools (`schedule_task`, `list_scheduled_tasks`, `pause_scheduled_task`, `resume_scheduled_task`, `cancel_scheduled_task`) can be passed to any `ToolUsingAgent`, allowing an agent to schedule follow-up tasks autonomously.
|
||||
|
||||
```bash
|
||||
# Let the orchestrator schedule its own follow-up
|
||||
jarvis ask --agent orchestrator \
|
||||
--tools schedule_task,list_scheduled_tasks \
|
||||
"Research transformer architectures and schedule a daily summary at 8am"
|
||||
```
|
||||
|
||||
```python
|
||||
from openjarvis import Jarvis
|
||||
|
||||
j = Jarvis()
|
||||
response = j.ask(
|
||||
"Schedule a weekly digest of research papers every Monday at 9am",
|
||||
agent="orchestrator",
|
||||
tools=["schedule_task"],
|
||||
)
|
||||
print(response)
|
||||
```
|
||||
|
||||
See [Scheduler Tools](tools.md#scheduler-tools) for full parameter reference.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Scheduler settings live in the `[scheduler]` section of `~/.openjarvis/config.toml`.
|
||||
|
||||
```toml title="~/.openjarvis/config.toml"
|
||||
[scheduler]
|
||||
enabled = false
|
||||
db_path = "~/.openjarvis/scheduler.db"
|
||||
poll_interval = 60
|
||||
default_agent = "simple"
|
||||
```
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|------------------|--------|----------------------------------|--------------------------------------------|
|
||||
| `enabled` | `bool` | `false` | Start the scheduler daemon automatically |
|
||||
| `db_path` | `str` | `~/.openjarvis/scheduler.db` | SQLite database path |
|
||||
| `poll_interval` | `int` | `60` | Seconds between polling cycles |
|
||||
| `default_agent` | `str` | `"simple"` | Default agent for tasks that omit `agent` |
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Scheduler Tools reference](tools.md#scheduler-tools) -- MCP tool parameter details
|
||||
- [Architecture: Agentic Logic](../architecture/agents.md) -- how the scheduler integrates with agents
|
||||
- [Deployment: systemd](../deployment/systemd.md) -- running the scheduler as a system service
|
||||