merge: resolve conflicts with main (streaming + auto-recover)

Merge main into fix/ssrf-check, keeping both the auto-recover
logic for error-state agents and the async streaming support
for the send_message endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-03-26 11:29:33 -07:00
co-authored by Claude Opus 4.6
357 changed files with 16370 additions and 4546 deletions
+95
View File
@@ -0,0 +1,95 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["type:bug"]
body:
- type: markdown
attributes:
value: |
Thank you for reporting a bug! Please fill out the information below to help us investigate.
- type: textarea
id: description
attributes:
label: Description
description: A clear description of what the bug is.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Run `jarvis ask "..."`
2. See error...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened.
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Linux
- macOS
- Windows
validations:
required: true
- type: dropdown
id: python
attributes:
label: Python Version
options:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
validations:
required: true
- type: dropdown
id: hardware
attributes:
label: Hardware
options:
- NVIDIA GPU
- AMD GPU
- Apple Silicon
- CPU only
validations:
required: true
- type: dropdown
id: engine
attributes:
label: Engine
description: Which inference engine are you using?
options:
- Ollama
- vLLM
- llama.cpp
- SGLang
- MLX
- Cloud (OpenAI/Anthropic/Google)
- LiteLLM
- Other
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs / Traceback
description: Paste any relevant logs or traceback here.
render: shell
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Ask a Question
url: https://github.com/open-jarvis/OpenJarvis/discussions
about: Use Discussions for questions and help
@@ -0,0 +1,47 @@
name: Feature Request
description: Propose a new feature or enhancement
labels: ["type:feature"]
body:
- type: markdown
attributes:
value: |
For non-trivial changes, please open this issue for discussion before starting a PR. This saves everyone time by catching design issues early.
- type: textarea
id: problem
attributes:
label: Problem Statement
description: What problem does this solve? Why is this needed?
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe how you'd like this to work.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Primitive Area
description: Which part of OpenJarvis does this touch?
options:
- Intelligence
- Engine
- Agent
- Tools
- Learning
- Evals
- Frontend
- Channels
- Rust
- Other
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Any alternative solutions or features you've considered.
validations:
required: false
+63
View File
@@ -0,0 +1,63 @@
name: New Eval Dataset
description: Propose a new evaluation dataset or benchmark
labels: ["type:eval"]
body:
- type: markdown
attributes:
value: |
Adding eval datasets is one of the easiest ways to contribute! Fill out the details below.
- type: input
id: name
attributes:
label: Dataset Name
placeholder: e.g., HumanEval, GSM8K
validations:
required: true
- type: input
id: url
attributes:
label: URL / Reference
description: Link to the dataset or paper.
placeholder: https://...
validations:
required: true
- type: checkboxes
id: capability
attributes:
label: What capability does it test?
options:
- label: Reasoning
- label: Math
- label: Code
- label: Knowledge
- label: Multimodal
- label: Tool Use
- label: Long Context
- label: Other
- type: input
id: size
attributes:
label: Approximate Size
description: Number of examples in the dataset.
placeholder: e.g., 500
validations:
required: false
- type: dropdown
id: scorer
attributes:
label: Proposed Scorer Type
options:
- Exact Match
- F1
- BLEU
- LLM-as-Judge
- Custom
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other details about this dataset.
validations:
required: false
+16
View File
@@ -0,0 +1,16 @@
## What does this PR do?
<!-- Brief description of the change and its motivation -->
## How was this tested?
<!-- Describe tests added or manual testing performed -->
## Checklist
- [ ] Tests pass (`uv run pytest tests/ -v`)
- [ ] Linter passes (`uv run ruff check src/ tests/`)
- [ ] Formatter passes (`uv run ruff format --check src/ tests/`)
- [ ] New/changed public API has docstrings
- [ ] Follows registry pattern (if adding new component)
- [ ] Documentation updated (if applicable)
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
run: uv run maturin develop --manifest-path rust/crates/openjarvis-python/Cargo.toml
- name: Run tests
run: uv run pytest tests/ -v --tb=short
run: uv run pytest tests/ -v --tb=short -m "not live and not cloud"
rust:
runs-on: ubuntu-latest
+74
View File
@@ -0,0 +1,74 @@
name: Claude Issue Fixer
on:
issues:
types: [opened, labeled]
issue_comment:
types: [created]
workflow_dispatch:
concurrency:
group: claude-issues-${{ github.event.issue.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
jobs:
fix:
runs-on: ubuntu-latest
timeout-minutes: 60
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' &&
(contains(github.event.issue.labels.*.name, 'bug') ||
contains(github.event.issue.labels.*.name, 'autofix'))) ||
(github.event_name == 'issue_comment' &&
!github.event.issue.pull_request &&
contains(github.event.comment.body, '@claude') &&
github.actor != 'claude[bot]')
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
You are an automated issue fixer for the OpenJarvis repository. Follow these steps in order:
## Step 1: Diagnose
Read the issue thoroughly. Explore the codebase to understand the problem. If this is a bug report, attempt to reproduce it. Identify the root cause and affected files.
## Step 2: Comment your plan
BEFORE making any code changes, post a comment on this issue describing:
- Your root cause analysis
- Which files need to change and why
- Your implementation approach
## Step 3: Implement
Create a branch named `claude/issue-${{ github.event.issue.number }}` and make the changes. Use conventional commit messages that reference the issue, e.g.:
`fix: handle empty tool responses in orchestrator (fixes #${{ github.event.issue.number }})`
## Step 4: Test
Run these commands and ensure both pass:
- `uv run ruff check src/ tests/` (linting)
- `uv run pytest tests/ -v --tb=short` (tests)
If tests fail, fix the issues before proceeding. If you added new functionality, add corresponding tests in `tests/` mirroring the `src/` directory structure.
## Step 5: Open PR
Create a pull request that:
- Links back to this issue (include `Fixes #${{ github.event.issue.number }}` in the PR body)
- Describes what was changed and why
- Includes a summary of test results
## If you cannot fix it
If you cannot reproduce the issue, cannot determine the root cause, or the fix is beyond your capabilities, post a comment explaining:
- What you investigated
- What you found (or didn't find)
- What additional information you need from the reporter
+50
View File
@@ -0,0 +1,50 @@
name: Claude PR Review
on:
pull_request:
types: [opened, synchronize]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
workflow_dispatch:
concurrency:
group: claude-review-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 30
if: |
github.event_name == 'pull_request' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@claude') &&
github.actor != 'claude[bot]') ||
(github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
github.actor != 'claude[bot]')
steps:
- uses: actions/checkout@v4
- name: Read review instructions
id: review
run: |
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "instructions<<$EOF" >> "$GITHUB_OUTPUT"
cat REVIEW.md >> "$GITHUB_OUTPUT"
echo "$EOF" >> "$GITHUB_OUTPUT"
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: ${{ steps.review.outputs.instructions }}
+29
View File
@@ -0,0 +1,29 @@
name: Auto-assign on "take"
on:
issue_comment:
types: [created]
permissions:
issues: write
jobs:
assign:
if: >-
!github.event.issue.pull_request
&& contains(fromJSON('["take", "Take", "TAKE"]'), trim(github.event.comment.body))
runs-on: ubuntu-latest
steps:
- name: Assign commenter
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
COMMENTER="${{ github.event.comment.user.login }}"
ISSUE="${{ github.event.issue.number }}"
CURRENT=$(gh issue view "$ISSUE" --repo "${{ github.repository }}" --json assignees --jq '.assignees[].login' 2>/dev/null)
if echo "$CURRENT" | grep -qx "$COMMENTER"; then
echo "$COMMENTER is already assigned to #$ISSUE"
else
gh issue edit "$ISSUE" --repo "${{ github.repository }}" --add-assignee "$COMMENTER"
echo "Assigned $COMMENTER to #$ISSUE"
fi
+1
View File
@@ -88,3 +88,4 @@ CLAUDE.md
# Research output
research_mining_*
.python-version
+7
View File
@@ -0,0 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
+85
View File
@@ -0,0 +1,85 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [the project maintainers](https://github.com/open-jarvis/OpenJarvis/discussions). All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+201
View File
@@ -0,0 +1,201 @@
# Contributing to OpenJarvis
Thank you for your interest in contributing to OpenJarvis! This guide covers everything you need to know — from why to contribute, to how to submit your first pull request.
---
## Why Contribute?
Contributing to OpenJarvis isn't just about code — it's about building the future of on-device AI together. Here's what you get:
### Paper Acknowledgment
All contributors with merged pull requests will be acknowledged as contributors on the OpenJarvis paper release.
### Mac Mini Giveaway
We're giving away a Mac Mini to one lucky contributor! Install OpenJarvis on your personal machine and opt in via the desktop app to share anonymized savings data (FLOPs, dollar cost, energy) for a chance to win. Your data is fully anonymous — no IP, no hardware info beyond savings metrics. You must share your email via the desktop app to be eligible.
See the [Savings Leaderboard](https://open-jarvis.github.io/OpenJarvis/leaderboard/) for details.
### Path to Maintainership
Consistent contributors can grow into project maintainers:
- **Contributor** — anyone with a merged PR
- **Reviewer** — invited after 3+ merged PRs in a domain area, can review PRs
- **Maintainer** — reviewers who demonstrate sustained engagement and good judgment
### Recognition
Contributors are recognized in release notes and on our GitHub repository.
---
## Ways to Contribute
### Good First Contributions
These are great starting points for new contributors:
- Documentation improvements and typo fixes
- Bug reports with reproducible steps
- New eval datasets and scorers
- Test coverage improvements
Look for issues labeled [`good-first-issue`](https://github.com/open-jarvis/OpenJarvis/labels/good-first-issue).
### Ideal Contributions
- Bug fixes with tests
- Performance improvements
- New tools, engines, or agents following the [registry pattern](docs/development/contributing.md#registry-pattern)
- New channel integrations (Telegram, Discord, Slack, etc.)
### Harder to Review
These require more context and review time. **Please open an issue for discussion before starting a PR:**
- New primitives or major extensions to existing ones
- Large refactors
- Changes to core abstractions (`BaseAgent`, `InferenceEngine`, etc.)
### May Not Be Accepted
To avoid wasted effort, note that PRs in these categories are unlikely to be merged:
- Changes that break backwards compatibility in the public API
- Changes that add significant new dependencies without justification
- Changes that add friction to the user experience
---
## Getting Started
### 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 |
### Setup
```bash
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync --extra dev
```
### Pre-commit Hooks
We use [pre-commit](https://pre-commit.com/) to run linting and formatting checks before each commit:
```bash
uv run pre-commit install
```
This installs Git hooks that automatically run [Ruff](https://docs.astral.sh/ruff/) on every commit. If the hooks fail, fix the issues and commit again.
For detailed development setup, code conventions, and project structure, see the [Development Guide](docs/development/contributing.md).
---
## Claiming Issues
1. Browse the [Roadmap](https://open-jarvis.github.io/OpenJarvis/development/roadmap/) for an item that interests you
2. Check if a [GitHub issue](https://github.com/open-jarvis/OpenJarvis/issues) already exists for it — if not, [open one](https://github.com/open-jarvis/OpenJarvis/issues/new/choose) describing what you'd like to work on
3. Comment **"take"** on the issue to get auto-assigned
4. Fork, branch, and start working
If you've claimed an issue but can't finish it, please leave a comment so someone else can pick it up.
---
## Proposing Changes
### Trivial Changes
For small fixes (typos, doc improvements, simple bug fixes), go ahead and open a PR directly.
### Non-trivial Changes
For larger changes — new features, refactors, new dependencies — **open an issue first** to discuss the approach. This saves everyone time by catching design issues early.
Use the appropriate [issue template](https://github.com/open-jarvis/OpenJarvis/issues/new/choose):
- **Bug Report** — for bugs with reproduction steps
- **Feature Request** — for new functionality
- **New Eval Dataset** — for contributing benchmarks
---
## Pull Request Process
### Before Submitting
1. Run the full test suite:
```bash
uv run pytest tests/ -v
```
2. Run the linter:
```bash
uv run ruff check src/ tests/
```
3. Run the formatter:
```bash
uv run ruff format --check src/ tests/
```
4. Add tests for new functionality
5. Follow the [registry pattern](docs/development/contributing.md#registry-pattern) for new components
### Commit Messages
We use [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add FAISS memory backend
fix: handle empty tool responses in orchestrator
docs: update engine discovery documentation
test: add coverage for BM25 backend
refactor: simplify agent base class helpers
```
Keep the first line under 72 characters. Reference relevant issues (e.g., `fixes #42`).
### What Makes a Good PR
- **Focused** — one feature, fix, or refactor per PR
- **Tested** — includes unit tests covering new code paths
- **Documented** — updates docstrings and docs if adding public API
- **Backwards compatible** — avoids breaking existing interfaces without discussion
---
## Contribution Areas
OpenJarvis is built on five composable primitives. Here's where you can contribute:
| Area | What to Build | Guide |
|---|---|---|
| **Intelligence** | Model catalog entries, routing strategies | [Dev Guide](docs/development/contributing.md) |
| **Engines** | New inference backends (e.g., TensorRT, ONNX) | [Dev Guide](docs/development/contributing.md) |
| **Agents** | New agent types, agent improvements | [Dev Guide](docs/development/contributing.md) |
| **Tools** | New tools (browser, API clients, etc.) | [Dev Guide](docs/development/contributing.md) |
| **Learning** | Router policies, reward functions, training | [Dev Guide](docs/development/contributing.md) |
| **Evals** | New datasets, scorers, benchmark configs | [Dev Guide](docs/development/contributing.md) |
| **Channels** | Chat platform integrations | [Dev Guide](docs/development/contributing.md) |
| **Rust Port** | PyO3 bindings, crate parity with Python | See `rust/` directory |
---
## Code of Conduct
This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you agree to uphold this code.
---
## Questions?
- Open a [Discussion](https://github.com/open-jarvis/OpenJarvis/discussions) for questions and help
- Check the [documentation](https://open-jarvis.github.io/OpenJarvis/) for guides and API reference
+21 -30
View File
@@ -19,6 +19,8 @@
> **[Project Site](https://scalingintelligence.stanford.edu/blogs/openjarvis/)**
>
> **[Leaderboard](https://open-jarvis.github.io/OpenJarvis/leaderboard/)**
>
> **[Roadmap](https://open-jarvis.github.io/OpenJarvis/development/roadmap/)**
## Why OpenJarvis?
@@ -33,63 +35,52 @@ git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync # core framework
uv sync --extra server # + FastAPI server
# Build the Rust extension (requires Rust: https://rustup.rs/)
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
```
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).
> **Python 3.14+:** set `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` before the `maturin` command.
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). Alternatively, use the `cloud` engine with [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Google Gemini](https://ai.google.dev), [OpenRouter](https://openrouter.ai), or [MiniMax](https://www.minimax.io) by setting the corresponding API key environment variable.
## Quick Start
The fastest path is Ollama on any machine with Python 3.10+:
```bash
# 1. Install OpenJarvis
# 1. Install and detect hardware
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)
# 2. Start Ollama and pull a model
curl -fsSL https://ollama.com/install.sh | sh
ollama serve # start the Ollama server
# 4. Pull a model
ollama serve &
ollama pull qwen3:8b
# 5. Ask a question
# 3. 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.
`jarvis init` auto-detects your hardware and recommends the best engine. Run `uv run jarvis doctor` at any time to diagnose issues.
## Development
Full documentation — including Docker deployment, cloud engines, development setup, and tutorials — at **[open-jarvis.github.io/OpenJarvis](https://open-jarvis.github.io/OpenJarvis/)**.
From source, you need to make sure Rust is installed on System:
## Contributing
We welcome contributions! See the [Contributing Guide](CONTRIBUTING.md) for incentives, contribution types, and the PR process.
Quick start for contributors:
```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 pre-commit install
uv run pytest tests/ -v
```
See [Contributing](docs/development/contributing.md) for more.
Browse the [Roadmap](https://open-jarvis.github.io/OpenJarvis/development/roadmap/) for areas where help is needed. Comment **"take"** on any issue to get auto-assigned.
## About
+42
View File
@@ -0,0 +1,42 @@
# OpenJarvis PR Review Instructions
You are reviewing pull requests for OpenJarvis, a local-first personal AI agent framework built with Python, Rust (PyO3), and TypeScript.
## Review Checklist
Evaluate every PR against these criteria:
### 1. Relevance
Is this PR doing something useful? Valid contributions include: bug fixes, new features, feature expansions, documentation improvements, test coverage, and performance improvements. Flag PRs that appear to be empty, auto-generated without substance, or unrelated to the project.
### 2. Completeness
Does the code actually implement what the PR title and description claim? If the PR says "fix X", verify X is actually fixed. If it says "add Y", verify Y is fully added and functional — not partially implemented or stubbed out.
### 3. Correctness
Check for logic errors, edge cases, and off-by-one errors. Pay particular attention to:
- **Rust-Python bridge (PyO3) boundaries** — type conversions, error propagation, GIL handling
- **Async/await patterns** — missing awaits, unclosed resources, blocking calls in async contexts
- **Registry pattern compliance** — new components (engines, tools, agents, channels) must register via `ToolRegistry`, `EngineRegistry`, `AgentRegistry`, `ChannelRegistry`, etc. in `src/openjarvis/core/registry.py`
- **Event bus integration** — new lifecycle events should use `EventBus` from `src/openjarvis/core/events.py`
### 4. Testing
Does the PR include tests for new code paths? Are existing tests expected to still pass? New tools, engines, agents, and channels should have corresponding test files in `tests/` mirroring the `src/` structure.
### 5. Security
Check for: hardcoded API keys or secrets, missing input validation at system boundaries (user input, external APIs), and anything that compromises local-first data isolation.
## Do NOT Comment On
- Formatting or style — Ruff handles this automatically in CI
- Code in unchanged files outside the PR diff
- Subjective naming preferences
- Adding docstrings or comments to code the PR did not modify
## Output Format
- Post **inline comments** on specific lines for actionable issues
- Post a **summary comment** containing: what the PR does, whether it achieves its stated goal, and any blocking concerns
- Use severity levels:
- `blocking` — must fix before merge
- `suggestion` — consider fixing
- `nit` — take it or leave it
+2 -2
View File
@@ -8,7 +8,7 @@ COPY frontend/ .
RUN npm run build
# Stage 2: Build Python package
FROM python:3.12-slim AS builder
FROM python:3.12-slim-bookworm AS builder
WORKDIR /app
COPY pyproject.toml README.md ./
@@ -21,7 +21,7 @@ RUN pip install --no-cache-dir uv && \
uv pip install --system ".[server]"
# Stage 3: Runtime
FROM python:3.12-slim
FROM python:3.12-slim-bookworm
COPY --from=builder /usr/local /usr/local
COPY --from=builder /app /app
@@ -0,0 +1,31 @@
# NVIDIA GPU override — use with:
# docker compose -f deploy/docker/docker-compose.yml -f deploy/docker/docker-compose.gpu.nvidia.yml up
services:
jarvis:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.gpu
volumes:
- /proc:/proc:ro
- /sys:/sys:ro
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
ollama:
image: ollama/ollama:latest
environment:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
+10 -5
View File
@@ -1,5 +1,3 @@
version: "3.9"
services:
jarvis:
build:
@@ -9,17 +7,24 @@ services:
- "8000:8000"
environment:
- OPENJARVIS_ENGINE_DEFAULT=ollama
- OPENJARVIS_OLLAMA_HOST=http://ollama:11434
- OLLAMA_HOST=http://ollama:11434
depends_on:
- ollama
ollama:
condition: service_healthy
restart: unless-stopped
ollama:
image: ollama/ollama
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
volumes:
+82 -9
View File
@@ -460,17 +460,90 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
return;
}
let project_root = find_project_root();
let mut project_root = find_project_root();
if project_root.is_none() {
let mut s = status.lock().await;
s.error = Some(
"Could not find the OpenJarvis project directory. \
Clone it with: git clone https://github.com/open-jarvis/OpenJarvis.git ~/OpenJarvis \
then relaunch."
.into(),
);
return;
// Auto-clone on first launch
let git_bin = resolve_bin("git");
// Check that git is installed
if !std::path::Path::new(&git_bin).exists() && git_bin == "git" {
let mut s = status.lock().await;
s.error = Some(
"Could not find 'git'. \
Install it from https://git-scm.com then relaunch."
.into(),
);
return;
}
let target_path = std::path::PathBuf::from(home_dir()).join("OpenJarvis");
let clone_target = target_path.display().to_string();
// If the directory exists but is not a valid project, don't overwrite
if target_path.exists() && !target_path.join("pyproject.toml").exists() {
let mut s = status.lock().await;
s.error = Some(format!(
"{} exists but is not a valid OpenJarvis project. \
Remove it and relaunch, or set OPENJARVIS_ROOT to the correct path.",
clone_target,
));
return;
}
{
let mut s = status.lock().await;
s.detail = "Downloading OpenJarvis (first launch)...".into();
}
let clone_result = tokio::process::Command::new(&git_bin)
.args([
"clone",
"--depth",
"1",
"https://github.com/open-jarvis/OpenJarvis.git",
&clone_target,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn();
match clone_result {
Ok(child) => match child.wait_with_output().await {
Ok(output) if output.status.success() => {
project_root = Some(target_path);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let mut s = status.lock().await;
s.error = Some(format!(
"Failed to download OpenJarvis: {}. \
Clone manually: git clone https://github.com/open-jarvis/OpenJarvis.git {}",
stderr.trim(),
clone_target,
));
return;
}
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Failed to download OpenJarvis: {}. \
Clone manually: git clone https://github.com/open-jarvis/OpenJarvis.git {}",
e, clone_target,
));
return;
}
},
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Could not run git: {}. \
Install git from https://git-scm.com then relaunch.",
e,
));
return;
}
}
}
// Kill any leftover server on our port from a previous run
@@ -34,6 +34,7 @@ interface SavingsData {
heavy_low: number;
heavy_high: number;
};
token_counting_version?: number;
}
// ---------------------------------------------------------------------------
@@ -335,6 +336,7 @@ export function SavingsDashboard({ apiUrl }: { apiUrl: string }) {
dollar_savings: dollarSavings,
energy_wh_saved: energySaved,
flops_saved: flopsSaved,
token_counting_version: data.token_counting_version ?? 1,
},
}).catch(() => {});
}, [data, optInEnabled, displayName, anonId]);
-330
View File
@@ -1,330 +0,0 @@
# 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
+1 -1
View File
@@ -412,4 +412,4 @@ policy:
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.
See the [registry pattern](#registry-pattern) section above for complete examples.
-915
View File
@@ -1,915 +0,0 @@
# 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
+111 -63
View File
@@ -1,86 +1,134 @@
# Roadmap
OpenJarvis development follows a phased approach, with each version adding
a major primitive or cross-cutting capability to the framework.
## Current Focus Areas
These are the areas where active development is happening and contributions are most impactful:
- **Post-training data** — building datasets and training pipelines from execution traces to improve agent routing and tool selection
- **Multi-model orchestration pipelines** — coordinating multiple models within a single query (e.g., small model for classification, large model for generation)
- **Energy-aware routing** — using power consumption data from telemetry to optimize for energy efficiency alongside latency and quality
- **Plugin ecosystem** — community-contributed engines, tools, and agents distributed as Python packages
- **Federated memory** — memory backends that synchronize across devices
---
## Development Phases
## How to Get Involved
| 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 |
1. Browse the workstreams below for an item that interests you
2. Check if a [GitHub issue](https://github.com/open-jarvis/OpenJarvis/issues) already exists for it — if not, [open one](https://github.com/open-jarvis/OpenJarvis/issues/new/choose)
3. Comment **"take"** on the issue to get auto-assigned
4. Read the [Contributing Guide](https://github.com/open-jarvis/OpenJarvis/blob/main/CONTRIBUTING.md) for development setup and PR process
---
## Current Status
## Workstreams
OpenJarvis v1.5 (Phase 10) is complete. The framework provides:
OpenJarvis development is organized into **five independent workstreams**. Contributors can pick any track that matches their skills and interests — workstreams are designed to be worked on in parallel without blocking each other.
- **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
Every item carries a maturity tag:
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).
| Tag | Meaning | Contributor guidance |
|-----|---------|---------------------|
| **Ready** | Well-scoped, implementation path is clear | Pick it up — check [issues](https://github.com/open-jarvis/OpenJarvis/issues) for a spec or write one |
| **Design Needed** | Concept is clear but needs a spec before code | Start a [design discussion](https://github.com/open-jarvis/OpenJarvis/discussions) or draft an RFC |
| **Research-Stage** | Exploratory, needs investigation before designing | Read the relevant papers, prototype, share findings |
---
## Phase 10 Details
### Workstream 1: Continuous Operators & Agents
Phase 10 refactored the agent hierarchy for composability and extensibility:
Operators are OpenJarvis's key differentiator — persistent, scheduled, stateful agents that run autonomously on personal devices. The current tick-based architecture (OperatorManager → TaskScheduler → AgentExecutor → OperativeAgent) is solid but needs hardening for truly long-horizon autonomy.
### BaseAgent Helpers
#### Where you can help
- **`_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`
| Item | Maturity | Details |
|------|----------|---------|
| Operator health checks & heartbeat monitoring | **Ready** | Add liveness probes to OperatorManager; surface in `jarvis operators status`. Detect stalled operators beyond the existing reconciliation loop. |
| Metrics collection for operator manifests | **Ready** | The `metrics` field exists in `OperatorManifest` but is not collected. Wire it to telemetry. **Good first issue.** |
| Capability policy enforcement | **Ready** | `required_capabilities` field exists in manifests but is not enforced. Connect to the existing RBAC `CapabilityPolicy` system. **Good first issue.** |
| Rate limiting per operator | **Ready** | Prevent runaway operators from hammering inference. Add configurable rate limits to OperatorManager. |
| Operator composition / chaining | **Design Needed** | Express dependencies between operators (operator A feeds results to operator B). Requires design for data passing and scheduling semantics. |
| Event-driven operators | **Design Needed** | Operators that trigger on EventBus events (e.g., new file indexed, channel message received) rather than only cron/interval schedules. |
| Operator versioning & rollback | **Design Needed** | Run v2 of an operator alongside v1. Roll back automatically on repeated failures. |
| Self-improving operators via Learning | **Research-Stage** | Operators that use trace feedback to tune their own prompts, tool selection, and routing policies through the Learning primitive. |
---
## Future Directions
### Workstream 2: Mobile & Messaging Clients
Beyond Phase 10, areas of ongoing exploration include:
Personal AI must be accessible from the devices people actually carry. OpenJarvis runs on laptops, workstations, and servers — users interact via their phones. Channels bridge that gap. Today, WhatsApp (Baileys), Slack, and Telegram are bidirectional; iMessage is send-only; Android SMS does not exist.
- **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
#### Where you can help
| Item | Maturity | Details |
|------|----------|---------|
| iMessage bidirectional via BlueBubbles | **Ready** | Current implementation is send-only. Add webhook/polling listener for incoming messages using the BlueBubbles API. **Good first issue.** |
| WhatsApp Baileys media support | **Ready** | Currently text-only. Add image, audio, and file handling to the Node.js bridge and Python channel. **Good first issue.** |
| Slack rich messages | **Ready** | Current implementation is plain text. Add Slack Block Kit support for formatted responses, buttons, and attachments. |
| Android SMS via Twilio/Vonage | **Design Needed** | No SMS implementation exists. Requires provider selection, two-way webhook architecture, and phone number provisioning flow. |
| Unified notification system | **Design Needed** | Push notifications when operators complete tasks or need user attention. Requires per-channel notification adapters. |
| Signal bidirectional | **Design Needed** | Currently send-only via signal-cli REST API. Add incoming message listener with background polling. |
| Voice interface | **Research-Stage** | Speech-to-text (Whisper) → agent → text-to-speech loop over phone channels. Existing `speech/` module provides a foundation. |
---
### Workstream 3: Secure Cloud Collaboration
Personal AI's core tension: local models preserve privacy but lack capability; cloud models are powerful but require trusting a provider with your data. This workstream resolves that through **Minions-style collaborative inference** (local handles context, cloud handles reasoning) and **TEE-based confidential computing** (cloud cannot see your data even during inference).
**References:**
- [Minions: Cost-Efficient Local-Cloud LLM Collaboration](https://github.com/HazyResearch/minions)
- [TEE for Confidential AI Inference](https://openreview.net/forum?id=ey87M5iKcX) ([PDF](https://openreview.net/pdf?id=ey87M5iKcX))
#### Where you can help
| Item | Maturity | Details |
|------|----------|---------|
| Query complexity analyzer | **Ready** | Classify incoming queries by difficulty to decide local vs. cloud routing. Extends the existing `MultiEngine` routing logic. |
| Cost tracking per-query | **Ready** | `CloudEngine` already has pricing data. Surface per-query cost in traces and telemetry dashboards. **Good first issue.** |
| Redaction-before-cloud pipeline | **Ready** | Wire the existing `GuardrailsEngine` in REDACT mode as a mandatory pre-step before any cloud transmission. |
| Minion protocol (sequential) | **Design Needed** | Local model extracts and summarizes long context → cloud model reasons over the compressed result. Native reimplementation of the core [Minions](https://github.com/HazyResearch/minions) idea. |
| Minion protocol (parallel) | **Design Needed** | Local and cloud models work simultaneously on different aspects of a query; results are merged. Requires a new `HybridInferenceEngine` abstraction. |
| TEE attestation verification | **Design Needed** | Verify that cloud inference ran inside a trusted execution environment via cryptographic attestation. |
| Taint tracking across local/cloud boundary | **Design Needed** | The `TaintSet` already tracks PII/Secret labels. Add routing enforcement so tainted data only routes to attested TEE endpoints. |
| Speculative decoding (local draft + cloud verify) | **Research-Stage** | Local model generates candidate tokens; cloud model validates in parallel for latency reduction. |
---
### Workstream 4: Tutorials & Documentation
OpenJarvis has reference docs and four tutorials, but critical gaps remain in continuous agents, LM evaluation, learning approaches, and custom tools. Video tutorials are scoped as a contributor opportunity — written tutorials come first, with video scripts included so anyone can record.
#### Where you can help
| Item | Maturity | Details |
|------|----------|---------|
| "Building Continuous Agents" tutorial | **Ready** | Writing an operator TOML manifest, activating it, session persistence across ticks, daemon mode. Example: a research operator that monitors arxiv daily. |
| "Adding Custom Tools" tutorial | **Ready** | Implementing `BaseTool`, registering via `ToolRegistry`, wiring into agents. Example: a weather API tool. **Good first issue.** |
| "Testing & Comparing LMs" tutorial | **Ready** | Running benchmarks, comparing local vs. cloud models, interpreting telemetry (latency, cost, energy per token). Uses the existing `bench/` framework. |
| Per-platform installation guides | **Ready** | Expand `installation.md` with platform-specific walkthroughs: macOS + Ollama, Ubuntu + NVIDIA + vLLM, Windows + Ollama, Raspberry Pi. **Good first issue.** |
| "Learning & Model Selection" tutorial | **Design Needed** | Router policies (heuristic, learned, GRPO), proposed approaches like Thompson Sampling, trace-based reward signals. |
| Video tutorial infrastructure | **Design Needed** | Establish recording workflow, hosting (YouTube), MkDocs embedding. Write video scripts alongside written tutorials. |
| Interactive Jupyter notebook tutorials | **Design Needed** | Notebook versions of key tutorials for exploratory, cell-by-cell learning. |
---
### Workstream 5: Hardware Breadth
Personal AI means running on the hardware people actually own. Each new hardware target expands who can use OpenJarvis and generates data for the research agenda (energy, cost, latency tradeoffs across silicon).
Adding a new hardware target involves up to four components: hardware detection in `core/config.py`, an inference engine adapter in `engine/`, an energy monitor in `telemetry/`, and an entry in the GPU specs database in `telemetry/gpu_monitor.py`.
#### Where you can help
| Item | Maturity | Details |
|------|----------|---------|
| AMD Ryzen AI iGPU path | **Ready** | Strix Point RDNA 3.5 iGPU handles 7-8B via Vulkan. llama.cpp Vulkan backend works today. Needs hardware detection and energy monitor. **Good first issue.** |
| GPU specs database expansion | **Ready** | Add Intel Arc, Jetson Orin, Snapdragon specs to `GPU_SPECS` in `telemetry/gpu_monitor.py` (TFLOPS, bandwidth, TDP). **Good first issue.** |
| Intel Arc GPU (B580/B570) | **Design Needed** | 12GB VRAM, ~$250 consumer GPU. Viable for 7-8B models. Engine path: IPEX-LLM or llama.cpp SYCL backend. |
| NVIDIA Jetson Orin | **Design Needed** | Best-in-class edge device. Orin NX 16GB handles 7-8B models at 15-25 tok/s. Needs hardware detection, energy monitor (tegrastats), deployment guide. |
| Qualcomm Snapdragon X Elite NPU | **Design Needed** | 45 TOPS, Windows Arm laptops. ONNX Runtime + QNN Execution Provider is the viable path. |
| Intel Lunar Lake NPU via OpenVINO | **Design Needed** | 48 TOPS — most mature NPU software stack for x86 laptops. New engine wrapping OpenVINO GenAI. |
| Raspberry Pi 5 | **Design Needed** | CPU-only via llama.cpp ARM NEON for 1-3B models. $100 entry point for hobbyists. |
| Unified hardware benchmark suite | **Design Needed** | Standardized benchmark that runs the same workloads across all supported hardware, producing comparable energy/latency/throughput/cost numbers. |
+2 -1
View File
@@ -140,7 +140,7 @@ max_tokens = 1024
| `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. |
| `provider` | string | `""` | Model provider hint: `local`, `openai`, `anthropic`, `google`, `minimax`. Used by the Cloud engine to route API calls. |
**Generation default fields** (overridable per-call):
@@ -972,6 +972,7 @@ OpenJarvis respects the following environment variables:
| `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. |
| `MINIMAX_API_KEY` | API key for MiniMax cloud inference. Required for the `cloud` engine with MiniMax models (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed). |
| `TAVILY_API_KEY` | API key for the Tavily web search tool. Required for the `web_search` tool. |
## Next Steps
+11
View File
@@ -42,9 +42,14 @@ If you prefer to run each step yourself:
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync --extra server
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
cd frontend && npm install && cd ..
```
!!! note "Prerequisites"
Requires [Rust](https://rustup.rs/) (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`).
On Python 3.14+, set `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` before the `maturin` command.
=== "Step 2: Start Ollama"
```bash
@@ -131,8 +136,11 @@ programmatically. Every feature is accessible from the terminal.
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
```
Requires [Rust](https://rustup.rs/). On Python 3.14+, set `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` before the `maturin` command.
### Verify
```bash
@@ -172,8 +180,11 @@ For programmatic access, the `Jarvis` class provides a high-level sync API.
git clone https://github.com/open-jarvis/OpenJarvis.git
cd OpenJarvis
uv sync
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
```
Requires [Rust](https://rustup.rs/). On Python 3.14+, set `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` before the `maturin` command.
### Quick example
```python
+86 -21
View File
@@ -5,6 +5,26 @@
var SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im10YnRncHd6cmJvc3R3ZWFhbnByIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxODk0OTQsImV4cCI6MjA4ODc2NTQ5NH0._xMlqCfljtXpwPj54H-ghxfLFO-jiq4W2WhpU8vVL1c";
var PAGE_SIZE = 50;
var allRows = [];
var currentPage = 0;
// No-KV-cache formula: FLOPs = params_b * 1e9 * N * (N+1)
// Reference values only (GPT-5.3 constants) — recompute functions below
// are defined but not called; the leaderboard displays database values directly.
var DEFAULT_PARAMS_B = 137;
var ENERGY_WH_PER_FLOP = 0.4 / (1000 * 3e12);
function recomputeFlopsNoKvCache(totalTokens) {
var n = Number(totalTokens) || 0;
if (n <= 0) return 0;
return DEFAULT_PARAMS_B * 1e9 * n * (n + 1);
}
function recomputeEnergyNoKvCache(flops) {
return flops * ENERGY_WH_PER_FLOP;
}
function escapeHtml(s) {
var el = document.createElement("span");
el.textContent = s;
@@ -19,6 +39,67 @@
return n.toLocaleString();
}
function totalPages() {
return Math.max(1, Math.ceil(allRows.length / PAGE_SIZE));
}
function renderPage() {
var tbody = document.getElementById("leaderboard-body");
if (!tbody) return;
var start = currentPage * PAGE_SIZE;
var end = Math.min(start + PAGE_SIZE, allRows.length);
var pageRows = allRows.slice(start, end);
var html = "";
for (var j = 0; j < pageRows.length; j++) {
var rank = start + 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 = pageRows[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;
renderPagination();
}
function renderPagination() {
var container = document.getElementById("leaderboard-pagination");
if (!container) return;
var pages = totalPages();
if (pages <= 1) {
container.innerHTML = "";
return;
}
var prevDisabled = currentPage === 0;
var nextDisabled = currentPage >= pages - 1;
container.innerHTML =
'<button class="lb-page-btn"' + (prevDisabled ? " disabled" : "") + ' id="lb-prev">' +
"\u2190 Prev</button>" +
'<span class="lb-page-info">Page ' + (currentPage + 1) + " of " + pages + "</span>" +
'<button class="lb-page-btn"' + (nextDisabled ? " disabled" : "") + ' id="lb-next">' +
"Next \u2192</button>";
var prevBtn = document.getElementById("lb-prev");
var nextBtn = document.getElementById("lb-next");
if (prevBtn) prevBtn.onclick = function () { if (currentPage > 0) { currentPage--; renderPage(); } };
if (nextBtn) nextBtn.onclick = function () { if (currentPage < pages - 1) { currentPage++; renderPage(); } };
}
function loadLeaderboard() {
var tbody = document.getElementById("leaderboard-body");
if (!tbody) return;
@@ -32,7 +113,7 @@
fetch(
SUPABASE_URL +
"/rest/v1/savings_entries?select=display_name,dollar_savings,energy_wh_saved,flops_saved,total_calls,total_tokens&order=dollar_savings.desc&limit=100",
"/rest/v1/savings_entries?select=display_name,dollar_savings,energy_wh_saved,flops_saved,total_calls,total_tokens&order=dollar_savings.desc&limit=1000",
{
headers: {
apikey: SUPABASE_ANON_KEY,
@@ -52,6 +133,9 @@
return;
}
allRows = rows;
currentPage = 0;
var totalMembers = rows.length;
var totalDollars = 0;
var totalRequests = 0;
@@ -72,25 +156,7 @@
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;
renderPage();
})
.catch(function (err) {
tbody.innerHTML =
@@ -101,7 +167,6 @@
});
}
// Run on page load and refresh every 60s
if (document.getElementById("leaderboard-body")) {
loadLeaderboard();
setInterval(loadLeaderboard, 60000);
+2
View File
@@ -52,6 +52,8 @@ See how the OpenJarvis community saves money, energy, and compute by running AI
</table>
</div>
<div id="leaderboard-pagination" class="lb-pagination"></div>
<p style="font-size:12px;opacity:0.6;margin-top:12px">
*Dollar savings estimates assume local open-source models (e.g. Qwen, Nemotron, Kimi) produce roughly the same number of tokens per request, on average, as closed-source cloud models.
</p>
+35
View File
@@ -491,6 +491,41 @@
0 1px 2px 1px rgba(0, 0, 0, 0.3);
}
/* Leaderboard pagination */
.lb-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 20px 0 8px;
}
.lb-page-btn {
padding: 6px 16px;
border-radius: 6px;
border: 1px solid var(--md-default-fg-color--lighter, #ccc);
background: var(--md-code-bg-color, #f5f5f5);
color: var(--md-default-fg-color, #333);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.lb-page-btn:hover:not([disabled]) {
opacity: 0.8;
}
.lb-page-btn[disabled] {
opacity: 0.35;
cursor: default;
}
.lb-page-info {
font-size: 13px;
color: var(--md-default-fg-color--light, #666);
}
/* Responsive: hide placeholder on mobile */
@media (max-width: 768px) {
.DocSearch-Button-Placeholder {
+46
View File
@@ -621,3 +621,49 @@ All agents publish events on the `EventBus` when a bus is provided:
`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.
---
## Managed Agent Streaming
The Managed Agent API (`/v1/managed-agents/{id}/messages`) supports real-time SSE streaming. Send a message with `stream: true` to receive the agent's response as a Server-Sent Events stream instead of the default asynchronous queue mode.
### Streaming Messages
```bash
curl -N -X POST http://localhost:8000/v1/managed-agents/{id}/messages \
-H "Content-Type: application/json" \
-d '{"content": "What is 2+2?", "stream": true}'
```
The response follows the OpenAI SSE format:
1. **Content chunks** -- `data: {"choices": [{"delta": {"content": "token"}}]}`
2. **Tool results** (if the agent used tools) -- `event: tool_results\ndata: {"results": [...]}`
3. **Final chunk** -- `data: {"choices": [{"delta": {}, "finish_reason": "stop"}]}`
4. **Done sentinel** -- `data: [DONE]`
When `stream: false` (the default), the endpoint behaves exactly as before -- the message is queued and the agent must be triggered separately via `/run`.
### Behavior Details
- The user message is always stored in the database before the agent runs.
- After streaming completes, the full agent response is persisted as an `agent_to_user` message.
- The agent is instantiated from the managed agent's stored `agent_type` and `config`.
- Conversation history from prior messages is automatically loaded as context.
- If the engine is not available on the server, a `503` error is returned.
### Python Example
```python
import httpx
with httpx.stream(
"POST",
"http://localhost:8000/v1/managed-agents/{id}/messages",
json={"content": "Summarize today's news", "stream": True},
) as response:
for line in response.iter_lines():
if line.startswith("data:") and "[DONE]" not in line:
print(line[5:].strip())
```
+1
View File
@@ -88,6 +88,7 @@ export default function App() {
dollar_savings: dollarSavings,
energy_wh_saved: energySaved,
flops_saved: flopsSaved,
token_counting_version: data.token_counting_version ?? 1,
});
}
})
+8 -1
View File
@@ -18,6 +18,8 @@ export function InputArea() {
const streamState = useAppStore((s) => s.streamState);
const messages = useAppStore((s) => s.messages);
const speechEnabled = useAppStore((s) => s.settings.speechEnabled);
const maxTokens = useAppStore((s) => s.settings.maxTokens);
const temperature = useAppStore((s) => s.settings.temperature);
const createConversation = useAppStore((s) => s.createConversation);
const addMessage = useAppStore((s) => s.addMessage);
const updateLastAssistant = useAppStore((s) => s.updateLastAssistant);
@@ -127,6 +129,7 @@ export function InputArea() {
let accumulatedContent = '';
let usage: TokenUsage | undefined;
let complexity: { score: number; tier: string; suggested_max_tokens: number } | undefined;
const toolCalls: ToolCallInfo[] = [];
let lastFlush = 0;
let ttftMs: number | undefined;
@@ -147,7 +150,7 @@ export function InputArea() {
try {
for await (const sseEvent of streamChat(
{ model: selectedModel, messages: apiMessages, stream: true },
{ model: selectedModel, messages: apiMessages, stream: true, temperature, max_tokens: maxTokens },
controller.signal,
)) {
const eventName = sseEvent.event;
@@ -202,6 +205,7 @@ export function InputArea() {
const data = JSON.parse(sseEvent.data);
const delta = data.choices?.[0]?.delta;
if (data.usage) usage = data.usage;
if (data.complexity) complexity = data.complexity;
if (delta?.content) {
if (!ttftMs) ttftMs = Date.now() - startTime;
accumulatedContent += delta.content;
@@ -248,6 +252,9 @@ export function InputArea() {
tokens_per_sec: usage?.completion_tokens
? usage.completion_tokens / (totalMs / 1000)
: undefined,
complexity_score: complexity?.score,
complexity_tier: complexity?.tier,
suggested_max_tokens: complexity?.suggested_max_tokens,
};
updateLastAssistant(
convId,
@@ -18,6 +18,7 @@ export function XRayFooter({ usage, telemetry }: Props) {
const parts: string[] = [];
if (telemetry?.engine) parts.push(telemetry.engine);
if (telemetry?.model_id) parts.push(telemetry.model_id);
if (telemetry?.complexity_tier) parts.push(telemetry.complexity_tier);
if (telemetry?.total_ms) parts.push(formatMs(telemetry.total_ms));
if (usage && (usage.prompt_tokens || usage.completion_tokens)) {
parts.push(`${usage.prompt_tokens} input tokens`);
@@ -50,6 +51,15 @@ export function XRayFooter({ usage, telemetry }: Props) {
}
rows.push({ label: 'Tokens', value: tokenParts.join(' \u00B7 ') });
}
if (telemetry?.complexity_tier) {
rows.push({
label: 'Complexity',
value: `${telemetry.complexity_tier} (${telemetry.complexity_score?.toFixed(2)})`,
});
}
if (telemetry?.suggested_max_tokens) {
rows.push({ label: 'Token budget', value: `${telemetry.suggested_max_tokens}` });
}
if (telemetry?.tokens_per_sec) {
rows.push({ label: 'Speed', value: `${Math.round(telemetry.tokens_per_sec)} tok/s` });
}
+1
View File
@@ -589,6 +589,7 @@ export interface SavingsSubmission {
dollar_savings: number;
energy_wh_saved: number;
flops_saved: number;
token_counting_version?: number;
}
export async function submitSavings(data: SavingsSubmission): Promise<boolean> {
+2
View File
@@ -5,6 +5,8 @@ export interface ChatRequest {
model: string;
messages: Array<{ role: string; content: string }>;
stream: true;
temperature?: number;
max_tokens?: number;
}
export async function* streamChat(
+4
View File
@@ -56,6 +56,9 @@ export interface MessageTelemetry {
tokens_per_sec?: number;
ttft_ms?: number;
total_ms?: number;
complexity_score?: number;
complexity_tier?: string;
suggested_max_tokens?: number;
}
export interface ChatMessage {
@@ -120,6 +123,7 @@ export interface SavingsData {
total_tokens: number;
local_cost: number;
per_provider: ProviderSavings[];
token_counting_version?: number;
}
export interface ServerInfo {
+417
View File
@@ -0,0 +1,417 @@
# Agent Runtime Manual Test Plan
**Branch:** `main`
**PR Reference:** [#32](https://github.com/open-jarvis/OpenJarvis/pull/32)
---
## Setup
```bash
git checkout main && git pull
uv sync --extra dev
```
Create `~/.openjarvis/config.toml`:
```toml
[engine]
type = "cloud"
[intelligence]
default_model = "Qwen/Qwen3.5-35B-A3B"
[engine.cloud]
provider = "openai"
api_key = "sk-..."
```
For every test case, record: **Pass / Fail / Partial / Blocked**, what you actually saw, and screenshots for any UI issues.
---
## Part 1: CLI (`jarvis agents`)
### 1.1 Commands exist
| # | Test | Expected |
|---|------|----------|
| 1 | `jarvis agents --help` | Shows all subcommands: `launch`, `start`, `stop`, `run`, `status`, `logs`, `daemon`, `watch`, `recover`, `errors`, `ask`, `instruct`, `messages`, `list`, `info`, `create`, `pause`, `resume`, `delete`, `bind`, `channels`, `search`, `templates`, `tasks` |
### 1.2 Agent lifecycle: create → run → pause → resume → delete
| # | Test | Expected |
|---|------|----------|
| 2 | `jarvis agents launch` | Wizard: template list → name/schedule/tools/budget/learning prompts → creates agent, prints ID |
| 3 | `jarvis agents list` | Agent appears, status=`idle` |
| 4 | `jarvis agents status` | Table: name, status dot, schedule, last run, runs=0, cost=$0 |
| 5 | `jarvis agents run <id>` | Prints progress then "Tick complete. Status: idle, runs: 1" |
| 6 | `jarvis agents status` | runs=1, last run time updated |
| 7 | `jarvis agents pause <id>` then `status` | Status shows `paused` |
| 8 | `jarvis agents resume <id>` then `status` | Status back to `idle` |
| 9 | `jarvis agents delete <id>` then `list` | Agent gone (soft-deleted/archived, not in list) |
### 1.3 Agent creation variants
| # | Test | Expected |
|---|------|----------|
| 10 | `jarvis agents create "Test Agent"` | Creates agent by name, prints ID |
| 11 | `jarvis agents create --template <template_name>` | Creates from template, inherits template config |
| 12 | `jarvis agents launch` → pick a template | Wizard pre-fills config from template |
| 13 | `jarvis agents launch` → pick "Custom Agent" | Wizard starts with blank config |
| 14 | `jarvis agents templates` | Lists built-in + user templates with descriptions |
### 1.4 Scheduling
| # | Test | Expected |
|---|------|----------|
| 15 | Create agent with `schedule_type=interval`, `schedule_value=30` | Created |
| 16 | `jarvis agents start <id>` | "Agent registered with scheduler" |
| 17 | `jarvis agents stop <id>` | "Agent deregistered from scheduler" |
| 18 | `jarvis agents daemon` | Starts, prints agent count, blocks. Ctrl+C → "Daemon stopped." clean exit |
| 19 | Create agent with `schedule_type=cron`, `schedule_value="*/5 * * * *"` | Created |
| 20 | `jarvis agents start <id>` (cron agent) | Registered, next fire time displayed or logged |
| 21 | Create agent with `schedule_type=manual` then `start <id>` | Agent registered but never auto-fires |
### 1.5 Interaction: ask / instruct / messages
| # | Test | Expected |
|---|------|----------|
| 22 | `jarvis agents ask <id> "What is 2+2?"` | Runs tick, prints agent response inline |
| 23 | `jarvis agents messages <id>` | Shows user→agent ask + agent→user response |
| 24 | `jarvis agents instruct <id> "Focus on ML papers"` | "Instruction queued for next tick" |
| 25 | `jarvis agents messages <id>` | Queued instruction shows `[queued]`, status=pending |
| 26 | `jarvis agents run <id>` then `messages <id>` | Queued message now delivered, status changes from pending |
| 27 | `jarvis agents ask <id> ""` (empty message) | Graceful error or rejection, no crash |
| 28 | `jarvis agents instruct <id>` with very long message (>1000 chars) | Accepted and stored correctly |
### 1.6 Error recovery & monitoring
| # | Test | Expected |
|---|------|----------|
| 29 | `jarvis agents errors` | Lists agents in error/needs_attention/stalled/budget_exceeded (or empty table) |
| 30 | `jarvis agents recover <id>` (on errored agent) | Restores checkpoint, status → `idle` |
| 31 | `jarvis agents recover <id>` (on idle agent) | Clear message: "Agent is not in error state" or similar |
| 32 | `jarvis agents logs <id>` | Recent traces with tick IDs and timestamps |
| 33 | `jarvis agents logs <nonexistent_id>` | Clear error: "Agent not found" |
| 34 | `jarvis agents watch` (then run a tick in another terminal) | Events stream live: AGENT_TICK_START, AGENT_TICK_END visible. Ctrl+C to stop. |
| 35 | `jarvis agents watch <id>` | Same, filtered to one agent only |
| 36 | `jarvis agents watch` then Ctrl+C | Clean exit, no traceback, no hanging threads |
### 1.7 Agent info & inspection
| # | Test | Expected |
|---|------|----------|
| 37 | `jarvis agents info <id>` | Shows agent type, status, memory snippet, tasks, channels, config details |
| 38 | `jarvis agents tasks <id>` | Lists tasks with statuses (or empty state) |
| 39 | `jarvis agents channels <id>` | Lists channel bindings (or empty state) |
| 40 | `jarvis agents search "keyword"` | Searches across agent traces, returns relevant results |
### 1.8 Edge cases & invalid input
| # | Test | Expected |
|---|------|----------|
| 41 | `jarvis agents run <nonexistent_id>` | Clear error: "Agent not found" — no Python traceback |
| 42 | `jarvis agents pause <id>` twice | Second pause is no-op or clear message, no crash |
| 43 | `jarvis agents resume <id>` (already idle) | No-op or clear message, no crash |
| 44 | `jarvis agents run <id>` while another tick is running | Concurrency guard: "Agent is already running" error |
| 45 | `jarvis agents delete <id>` then `run <id>` | Clear error about deleted/archived agent |
| 46 | Create agent with invalid cron expression | Rejected with clear validation error |
| 47 | Create agent with negative budget | Rejected or clamped to 0 |
### 1.9 CLI aesthetics
| # | Check | Expected |
|---|-------|----------|
| 48 | `status` table formatting | Columns aligned, readable at 80-char terminal width |
| 49 | Error messages (run with no engine configured) | Clear human-readable message, no Python tracebacks |
| 50 | `launch` wizard prompts | Clear labels, sensible defaults, no confusing jargon |
| 51 | `watch` event stream | Color-coded, event type + agent name visible, timestamps |
| 52 | `list` table with 0 agents | "No agents found" or empty table — not a crash |
| 53 | `list` table with 10+ agents | Table remains readable, no column overflow |
| 54 | All commands with `--help` | Every subcommand has a help string |
---
## Part 2: Web Frontend
### 2.0 Setup
```bash
# Terminal 1 # Terminal 2
uv run jarvis serve cd frontend && npm install && npm run dev
```
Open http://localhost:5173, navigate to **Agents** page via sidebar.
### 2.1 Navigation & routing
| # | Test | Expected |
|---|------|----------|
| 55 | Click "Agents" in sidebar | AgentsPage renders, URL is `/agents` |
| 56 | Direct navigation to `/agents` | Page loads correctly (no blank screen) |
| 57 | Browser back/forward after visiting agent detail | Navigation works, state preserved |
### 2.2 List view
| # | Test | Expected |
|---|------|----------|
| 58 | Page loads with backend running | No console errors, agent list renders |
| 59 | Page loads with backend **down** | User-visible error message (not blank white screen), no console exceptions |
| 60 | Agent cards | Name, color status dot, schedule description, last run time, runs count, cost |
| 61 | "Run Now" button | Triggers tick, card updates (runs count increments, last run time updates) |
| 62 | Pause/Resume button | Toggles status, dot color changes immediately |
| 63 | Agent list auto-refresh | After running a tick via CLI, the web list eventually reflects the updated state |
| 64 | 10+ agents in list | Cards render without performance issues, scroll works |
### 2.3 Launch wizard
| # | Test | Expected |
|---|------|----------|
| 65 | Click "Launch Agent" | Modal appears: Step 1 template picker with templates + "Custom Agent" option |
| 66 | Templates load from API | Template cards display with names and descriptions |
| 67 | Select template → Next → Step 2 | Config form: name (pre-filled from template), schedule_type dropdown, schedule_value, tools checkboxes, budget, learning toggle (off) |
| 68 | Select "Custom Agent" → Next → Step 2 | Config form with blank name, no pre-filled values |
| 69 | Next → Step 3 | Review summary of all config values |
| 70 | Click Launch | Agent created, modal closes, new agent appears in list |
| 71 | Back button at Step 2 | Returns to Step 1, template selection preserved |
| 72 | Back button at Step 3 | Returns to Step 2, all form inputs preserved |
| 73 | Launch with empty name | Inline error: "Agent name is required" — modal stays open |
| 74 | Launch with all tools selected | All tools included in review and in created agent config |
| 75 | Click outside modal / press Escape | Modal closes (or stays open — document behavior) |
| 76 | Schedule type = "Manual" | schedule_value input is disabled/hidden |
| 77 | Schedule type = "Cron" | schedule_value placeholder shows cron example |
| 78 | Schedule type = "Interval" | schedule_value placeholder shows seconds example |
### 2.4 Detail view (click an agent)
| # | Test | Expected |
|---|------|----------|
| 79 | Click agent card | Detail view opens with tabbed interface |
| 80 | **Overview** tab | Stat cards (Total Runs, Success Rate, Total Cost), config display, channels list, action buttons |
| 81 | **Overview** action buttons | Run Now, Pause, Resume visible and functional |
| 82 | **Interact** tab | Chat message list, textarea, "Immediate" and "Queue" send buttons |
| 83 | Send immediate message | Appears in chat with user styling, agent responds after tick |
| 84 | Send queued message | Shows with "queued" badge, status=pending |
| 85 | Send empty message | Button disabled or graceful rejection — no empty message sent |
| 86 | Rapid-fire send (click Send multiple times quickly) | No duplicate messages, no race condition errors |
| 87 | Chat auto-scroll | New messages scroll into view automatically |
| 88 | **Tasks** tab | Task list with status badges (completed=green, failed=red, active=blue, pending=gray) |
| 89 | **Tasks** tab (no tasks) | Empty state: "No tasks assigned." |
| 90 | **Memory** tab | summary_memory text displayed in readable format |
| 91 | **Memory** tab (no memory) | Empty state: "Agent has no stored memory yet." |
| 92 | **Learning** tab | Toggle switch (read-only, off by default), placeholder text for future events |
| 93 | **Logs** tab | Placeholder / empty state message (not a crash or blank) |
| 94 | Tab switching — rapid clicks | All 6 tabs render instantly, no layout shift, no flash of wrong content |
### 2.5 Error states
| # | Test | Expected |
|---|------|----------|
| 95 | Agent in `error` status | Red status dot/badge, "Recover" button visible |
| 96 | Click Recover | Status resets to `idle`, dot turns green |
| 97 | Agent in `needs_attention` status | Amber badge visible |
| 98 | Agent in `budget_exceeded` status | Orange badge visible |
| 99 | Agent in `stalled` status | Yellow badge visible |
| 100 | Backend goes down while page is open | Next refresh/action shows error — not silent failure |
| 101 | Delete agent → confirm it disappears from list | Agent removed from list immediately (or on next refresh) |
| 102 | Delete agent (no confirmation dialog in web) | **Document:** Is instant delete OK or should there be a confirm? |
### 2.6 Overflow menu
| # | Test | Expected |
|---|------|----------|
| 103 | Click "..." menu on agent card | Dropdown with Delete + other options |
| 104 | Click Delete from menu | Agent deleted, list updates |
| 105 | Click outside dropdown | Dropdown closes |
### 2.7 Web aesthetics & UX
| # | Check | Expected |
|---|-------|----------|
| 106 | Status dot colors | idle=#22c55e, running=#3b82f6, paused=#6b7280, error=#ef4444, needs_attention=#f59e0b, budget_exceeded=#f97316, stalled=#eab308 |
| 107 | Launch wizard spacing/alignment | Modal centered, steps clearly numbered, form inputs aligned, no overlap |
| 108 | Detail view tab switching | Instant, no layout shift or flash |
| 109 | Interact tab chat feel | Messages visually distinct (user=right vs agent=left or different colors), auto-scroll, clear input area |
| 110 | Responsive at 1024px width | No overflow or cut-off content, agent cards reflow |
| 111 | Responsive at 1440px width | Proper use of space, no excessive stretching |
| 112 | Responsive at 768px width (tablet) | Still usable, no broken layout |
| 113 | Empty states | "No agents yet" + CTA button / "No messages" / "No tasks" — not blank white space |
| 114 | Loading states | "Loading agents..." shown during fetch, spinner or skeleton |
| 115 | Page title / browser tab | Meaningful title (not just "Vite App") |
| 116 | Console errors | Zero console errors during normal usage flow |
---
## Part 3: Desktop App
### 3.0 Setup
```bash
# Terminal 1 # Terminal 2
uv run jarvis serve cd desktop && npm install && npm run tauri dev
```
Navigate to the **Agents** tab.
### 3.1 Functionality
| # | Test | Expected |
|---|------|----------|
| 117 | Left panel: agent list | Status dots, schedule descriptions, last run times |
| 118 | Click agent → right panel | Tabbed detail view (Overview, Interact, Tasks, Memory, Learning, Logs) |
| 119 | No agent selected | Right panel shows "Select an agent to view details" |
| 120 | "Launch Agent" button | Opens wizard, same 3-step flow as web |
| 121 | Launch wizard → Create agent | Agent appears in left panel list |
| 122 | **Overview** tab | Key-value stats (Status, Agent Type, Schedule, Last Run, Total Runs, Total Cost, Budget) + action buttons (Run Now, Pause, Resume, Recover) |
| 123 | **Interact** tab | Chat UI, mode toggle (immediate/queued), Enter shortcut sends message |
| 124 | Send immediate message | Response appears in chat |
| 125 | Send queued message | Shows as pending |
| 126 | **Tasks** tab | Task list with colored status badges + created-at timestamps |
| 127 | **Memory** tab | summary_memory in monospace font |
| 128 | **Learning** tab | Shows enabled/disabled status + placeholder text |
| 129 | **Logs** tab | Placeholder: "Log streaming not yet connected." |
| 130 | Auto-refresh | Agent list refreshes on ~10s interval (verify with CLI-triggered state change) |
| 131 | Delete agent via desktop | Confirmation dialog appears, agent removed on confirm |
### 3.2 Desktop edge cases
| # | Test | Expected |
|---|------|----------|
| 132 | Backend not running → open desktop app | Error state shown, not a crash |
| 133 | Backend dies while desktop is open | Graceful degradation on next action/refresh |
| 134 | Selected agent deleted via CLI → desktop refreshes | Selected agent deselects, list updates |
### 3.3 Desktop aesthetics
| # | Check | Expected |
|---|-------|----------|
| 135 | Catppuccin color scheme consistent | idle=#a6e3a1, running=#89b4fa, paused=#6c7086, error=#f38ba8, needs_attention=#fab387, stalled=#f9e2af |
| 136 | Left/right panel split | Resizable or fixed at reasonable ratio, no overlap |
| 137 | Tab switching | Smooth, no flicker |
| 138 | Launch wizard modal | Properly overlays content, dismissible with Escape or outside click |
| 139 | Text readability | Font sizes consistent, sufficient contrast against dark background |
| 140 | Window resize | Layout adapts, no overflow or clipping |
| 141 | Status badge consistency with web | Same statuses map to same semantic colors (green=idle, blue=running, etc.) |
---
## Part 4: API Backend (Direct)
### 4.1 REST endpoint smoke tests
Run with `uv run jarvis serve` and test via curl or Postman.
| # | Test | Expected |
|---|------|----------|
| 142 | `GET /v1/managed-agents` | 200, returns `[]` or agent list JSON |
| 143 | `POST /v1/managed-agents` with valid body | 200/201, returns created agent JSON with `id` |
| 144 | `POST /v1/managed-agents` with empty body | 422 or 400 with validation error |
| 145 | `GET /v1/managed-agents/<id>` | 200, returns single agent |
| 146 | `GET /v1/managed-agents/<bad_id>` | 404, returns error JSON |
| 147 | `POST /v1/managed-agents/<id>/run` | 200, tick executes |
| 148 | `POST /v1/managed-agents/<id>/pause` | 200, status changes to paused |
| 149 | `POST /v1/managed-agents/<id>/resume` | 200, status changes to idle |
| 150 | `POST /v1/managed-agents/<id>/recover` | 200 if errored, appropriate error if not |
| 151 | `DELETE /v1/managed-agents/<id>` | 200, agent archived |
| 152 | `GET /v1/templates` | 200, returns template list |
| 153 | `POST /v1/templates/<id>/instantiate` | 200, creates agent from template |
| 154 | `GET /v1/agents/errors` | 200, returns list of problem agents |
| 155 | `GET /v1/agents/health` | 200, returns health summary |
### 4.2 Message endpoints
| # | Test | Expected |
|---|------|----------|
| 156 | `POST /v1/managed-agents/<id>/messages` with `{"content":"hi","direction":"user_to_agent","mode":"immediate"}` | 200, message stored |
| 157 | `GET /v1/managed-agents/<id>/messages` | 200, returns message list |
| 158 | `POST /v1/managed-agents/<id>/messages` with `{"content":"","direction":"user_to_agent","mode":"immediate"}` | 422 or graceful handling |
| 159 | `POST /v1/managed-agents/<id>/messages` with `{"content":"cmd","direction":"user_to_agent","mode":"queued"}` | 200, message has status=pending |
### 4.3 Task & channel endpoints
| # | Test | Expected |
|---|------|----------|
| 160 | `GET /v1/managed-agents/<id>/tasks` | 200, returns task list |
| 161 | `POST /v1/managed-agents/<id>/tasks` | 200, creates task |
| 162 | `GET /v1/managed-agents/<id>/channels` | 200, returns channel bindings |
| 163 | `GET /v1/managed-agents/<id>/state` | 200, returns full agent state |
### 4.4 WebSocket events
| # | Test | Expected |
|---|------|----------|
| 164 | Connect to `ws://localhost:8222/v1/agents/events` | Connection established |
| 165 | Trigger a tick → observe WS messages | Receive AGENT_TICK_START and AGENT_TICK_END events |
| 166 | Connect with `?agent_id=<id>` filter | Only events for that agent |
| 167 | Disconnect cleanly | No server error logs |
---
## Part 5: Cross-Platform Consistency
| # | Test | Expected |
|---|------|----------|
| 168 | Create agent via CLI → check web + desktop | Same name, status, config everywhere |
| 169 | Run tick via CLI → check web + desktop | Run count and last run time update in both UIs |
| 170 | Send message via web Interact → check CLI `messages` | Same content, direction, mode |
| 171 | Pause via desktop → check CLI `status` + web | `paused` everywhere |
| 172 | Delete via web → check CLI `list` + desktop | Gone everywhere |
| 173 | Create via web wizard → check CLI `list` + desktop | Agent visible in all three |
| 174 | Recover via CLI → check web + desktop | Status back to idle in all UIs |
| 175 | Send queued message via CLI `instruct` → check web Interact | Message shows with pending/queued status |
| 176 | Multiple agents created from different clients | All agents appear correctly in all views |
---
## Part 6: Stress & Concurrency
| # | Test | Expected |
|---|------|----------|
| 177 | Run tick on same agent from two terminals simultaneously | Concurrency guard blocks second tick: "Agent is already running" |
| 178 | Create 20+ agents → check list performance | All clients render list without lag |
| 179 | Rapidly pause/resume same agent | All state transitions correct, no stuck states |
| 180 | Run daemon + manual `run` at same time | No double-ticking, concurrency guard holds |
| 181 | Delete agent while tick is in progress | Tick completes or fails gracefully, agent ends up archived |
---
## Part 7: Deferred Features (Placeholder Verification)
Confirm these show placeholders (not crashes):
| # | Feature | CLI | Web | Desktop |
|---|---------|-----|-----|---------|
| 182 | Budget enforcement | `run` still works even if cost > budget | No enforcement, budget is display-only | Same |
| 183 | Stall detection | No automatic stall detection fires | N/A | N/A |
| 184 | Learning event timeline | `Learning` tab shows placeholder text | Same | Same |
| 185 | Logs trace replay | `Logs` tab shows placeholder text | Same | Same |
| 186 | `POST /v1/skills` | N/A | N/A | Returns `"not_implemented"` |
| 187 | `POST /v1/optimize/runs` | N/A | N/A | Returns placeholder `run_id` |
| 188 | `GET /v1/feedback/stats` | N/A | N/A | Returns `{total: 0, mean_score: 0.0}` |
---
## Deliverables
**1. Test results** — Spreadsheet with columns: #, Status (Pass/Fail/Partial/Blocked), Actual Behavior, Screenshot (for UI issues).
**2. Bug list** — Each bug: steps to reproduce, expected vs actual, severity (Critical/Major/Minor), screenshot.
**3. UX & aesthetics feedback** — Is the launch wizard clear? Are status colors distinguishable? Does the Interact tab feel like chat? Is CLI output readable? Are error messages helpful? Is the delete-without-confirm behavior in web acceptable?
**4. API error handling audit** — Document all cases where the frontend silently swallows errors (currently: agent list fetch, interact tab sends). Recommend which should show user-visible errors.
**5. Deferred features check** — Confirm placeholder items in Part 7 show graceful stubs (not crashes or blank screens).
---
## Notes
- Backend (`jarvis serve`) must be running for web and desktop (default port 8222).
- Without an engine configured, `run`/`ask` will error — document whether the error message is clear.
- `daemon` and `watch` block — Ctrl+C to exit.
- Web frontend API client has unused functions (`updateManagedAgent`, `createAgentTask`, `fetchAgentState`, `fetchErrorAgents`) — not a bug, but note for future.
- Desktop API client is missing some endpoints that the web client has (`fetchAgentChannels`, `fetchAgentState`, `fetchErrorAgents`) — may affect feature parity.
- Frontend has **no automated tests** — all testing is manual per this plan.
- Both frontends silently catch API errors (`.catch(() => {})`) — this is a known UX gap to evaluate.
+1 -6
View File
@@ -163,10 +163,5 @@ nav:
- Security: architecture/security.md
- Design Principles: architecture/design-principles.md
- API Reference: api-reference/
- Development:
- Contributing: development/contributing.md
- Extending OpenJarvis: development/extending.md
- Deployment: deployment/index.md
- Roadmap: development/roadmap.md
- Changelog: development/changelog.md
- Leaderboard: leaderboard.md
- Roadmap: development/roadmap.md
+8 -2
View File
@@ -11,10 +11,13 @@ requires-python = ">=3.10"
dependencies = [
"click>=8",
"datasets>=4.5.0",
"ddgs>=9.11.4",
"httpx>=0.27",
"openai>=1.30",
"python-telegram-bot>=22.6",
"rich>=13",
"tomli>=2.0; python_version < '3.11'",
"tomlkit>=0.12",
]
[project.optional-dependencies]
@@ -25,6 +28,7 @@ dev = [
"pytest-cov>=5",
"respx>=0.22",
"ruff>=0.4",
"pre-commit>=3.0",
]
inference-mlx = ["mlx-lm>=0.19; sys_platform == 'darwin'"]
inference-vllm = ["vllm>=0.16.0"]
@@ -36,8 +40,10 @@ inference-google = [
"google-genai>=1.0",
]
inference-litellm = ["litellm>=1.40"]
inference-gemma = ["pygemma>=0.1.3"]
tools-search = [
"tavily-python>=0.3",
"ddgs>=9.11.4",
]
memory-faiss = [
"faiss-cpu>=1.7",
@@ -110,8 +116,8 @@ jarvis = "openjarvis.cli:main"
packages = ["src/openjarvis"]
[tool.hatch.build.targets.wheel.force-include]
"src/openjarvis/agents/claude_code_runner" = "openjarvis/agents/claude_code_runner"
"src/openjarvis/channels/whatsapp_baileys_bridge" = "openjarvis/channels/whatsapp_baileys_bridge"
"src/openjarvis/agents/claude_code_runner" = "_node_modules/claude_code_runner"
"src/openjarvis/channels/whatsapp_baileys_bridge" = "_node_modules/whatsapp_baileys_bridge"
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -50,6 +50,7 @@ fn lookup(table: &[(&str, f64)], key: &str) -> Option<f64> {
}
/// Estimate FLOPs for a model inference using the `2 * params * tokens` approximation.
/// Assumes KV caching (each token processed once).
///
/// Returns `(total_flops, flops_per_token)`. If the model is not found in `MODEL_PARAMS`,
/// returns `(0.0, 0.0)`.
@@ -72,6 +73,36 @@ pub fn estimate_flops(model: &str, input_tokens: u64, output_tokens: u64) -> (f6
(total_flops, flops_per_token)
}
/// Estimate FLOPs without KV caching (full recompute per token).
///
/// Without KV cache, each token is re-processed for every subsequent token.
/// FLOPs = P * N * (N + 1) where P = params, N = total_tokens.
///
/// Returns `(total_flops, flops_per_token_avg)`. If the model is not found,
/// returns `(0.0, 0.0)`.
pub fn estimate_flops_no_kv_cache(
model: &str,
input_tokens: u64,
output_tokens: u64,
) -> (f64, f64) {
let params_b = match lookup(MODEL_PARAMS, model) {
Some(p) => p,
None => return (0.0, 0.0),
};
let total_tokens = input_tokens + output_tokens;
if total_tokens == 0 {
return (0.0, 0.0);
}
let params = params_b * 1e9;
let n = total_tokens as f64;
let total_flops = params * n * (n + 1.0);
let flops_per_token = total_flops / n;
(total_flops, flops_per_token)
}
/// Compute Model FLOPs Utilization (MFU).
///
/// MFU = actual_flops / (peak_tflops * 1e12 * duration_s * num_gpus)
@@ -15,6 +15,19 @@ pub struct SQLiteMemory {
impl SQLiteMemory {
pub fn new(db_path: &Path) -> Result<Self, OpenJarvisError> {
// Expand leading ~ to the user's home directory
let db_path = if db_path.starts_with("~") {
let home = std::env::var("HOME").map_err(|_| {
OpenJarvisError::Io(std::io::Error::other(
"HOME environment variable not set",
))
})?;
PathBuf::from(home).join(db_path.strip_prefix("~").unwrap())
} else {
db_path.to_path_buf()
};
let db_path = db_path.as_path();
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OpenJarvisError::Io(std::io::Error::other(e))
@@ -36,7 +49,7 @@ impl SQLiteMemory {
created_at REAL DEFAULT (julianday('now'))
);
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
id, content, source, tokenize='unicode61'
content, source, tokenize='porter unicode61'
);",
)
.map_err(|e| {
@@ -110,9 +123,10 @@ impl MemoryBackend for SQLiteMemory {
))
})?;
let rowid = conn.last_insert_rowid();
conn.execute(
"INSERT INTO documents_fts (id, content, source) VALUES (?1, ?2, ?3)",
rusqlite::params![doc_id, content, source],
"INSERT INTO documents_fts (rowid, content, source) VALUES (?1, ?2, ?3)",
rusqlite::params![rowid, content, source],
)
.map_err(|e| {
OpenJarvisError::Io(std::io::Error::other(
@@ -130,9 +144,13 @@ impl MemoryBackend for SQLiteMemory {
) -> Result<Vec<RetrievalResult>, OpenJarvisError> {
let conn = self.conn.lock();
let words: Vec<&str> = query.split_whitespace().collect();
let words: Vec<String> = query
.split_whitespace()
.map(|w| w.trim_matches(|c: char| "?.,!;:'\"()[]{}/ ".contains(c)).to_string())
.filter(|w| !w.is_empty())
.collect();
let fts_query = if words.len() == 1 {
words[0].to_string()
words[0].clone()
} else {
words.join(" OR ")
};
@@ -140,11 +158,11 @@ impl MemoryBackend for SQLiteMemory {
let mut stmt = conn
.prepare(
"SELECT d.content, d.source, d.metadata,
bm25(documents_fts, 0.0, 1.0, 0.5) * -1 as score
bm25(documents_fts, 1.0, 0.5) * -1 as score
FROM documents_fts f
JOIN documents d ON d.id = f.id
JOIN documents d ON d.rowid = f.rowid
WHERE documents_fts MATCH ?1
ORDER BY bm25(documents_fts, 0.0, 1.0, 0.5)
ORDER BY bm25(documents_fts, 1.0, 0.5)
LIMIT ?2",
)
.map_err(|e| {
@@ -179,8 +197,9 @@ impl MemoryBackend for SQLiteMemory {
fn delete(&self, doc_id: &str) -> Result<bool, OpenJarvisError> {
let conn = self.conn.lock();
// Delete from FTS5 using the rowid from the documents table
conn.execute(
"DELETE FROM documents_fts WHERE id = ?1",
"DELETE FROM documents_fts WHERE rowid = (SELECT rowid FROM documents WHERE id = ?1)",
rusqlite::params![doc_id],
)
.map_err(|e| {
@@ -238,6 +257,29 @@ mod tests {
let results = mem.retrieve("Rust programming", 5).unwrap();
assert!(!results.is_empty());
assert!(results[0].content.contains("Rust"));
assert!(results[0].score > 0.0, "score should be positive, got {}", results[0].score);
}
#[test]
fn test_sqlite_porter_stemming() {
let mem = SQLiteMemory::in_memory().unwrap();
mem.store("Medication list for patient", "health", None).unwrap();
// Plural form should match via porter stemming
let results = mem.retrieve("medications", 5).unwrap();
assert!(!results.is_empty(), "porter stemming should match 'medications' to 'Medication'");
assert!(results[0].score > 0.0);
}
#[test]
fn test_sqlite_punctuation_stripping() {
let mem = SQLiteMemory::in_memory().unwrap();
mem.store("Medication list for patient Micah", "health", None).unwrap();
// Natural language query with trailing punctuation should not break FTS5
let results = mem.retrieve("What medications does Micah take?", 5).unwrap();
assert!(!results.is_empty(), "query with punctuation should still return results");
assert!(results[0].score > 0.0);
}
#[test]
+6
View File
@@ -146,6 +146,12 @@ info "Installing Python dependencies..."
uv sync --extra server --quiet 2>/dev/null || uv sync --extra server
ok "Python dependencies installed"
# ── 7b. Build Rust extension ──────────────────────────────────────
info "Building Rust extension..."
uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml --quiet 2>/dev/null \
|| uv run maturin develop -m rust/crates/openjarvis-python/Cargo.toml
ok "Rust extension built"
# ── 8. Install frontend dependencies ────────────────────────────────
info "Installing frontend dependencies..."
(cd frontend && npm install --silent 2>/dev/null || npm install)
+70 -15
View File
@@ -13,6 +13,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from openjarvis.core.config import load_config
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import Conversation, Message, Role, ToolResult
from openjarvis.engine._stubs import InferenceEngine
@@ -63,14 +64,43 @@ class BaseAgent(ABC):
model: str,
*,
bus: Optional[EventBus] = None,
temperature: float = 0.7,
max_tokens: int = 1024,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
prompt_builder: Optional[Any] = None,
) -> None:
self._engine = engine
self._model = model
self._bus = bus
self._temperature = temperature
self._max_tokens = max_tokens
self._prompt_builder = prompt_builder
# Three-tier resolution: explicit arg > config > class default > hardcoded
if temperature is not None and max_tokens is not None:
self._temperature = temperature
self._max_tokens = max_tokens
else:
try:
cfg = load_config()
self._temperature = (
temperature
if temperature is not None
else cfg.intelligence.temperature
)
self._max_tokens = (
max_tokens
if max_tokens is not None
else cfg.intelligence.max_tokens
)
except Exception:
self._temperature = (
temperature
if temperature is not None
else getattr(self, "_default_temperature", 0.7)
)
self._max_tokens = (
max_tokens
if max_tokens is not None
else getattr(self, "_default_max_tokens", 1024)
)
# ------------------------------------------------------------------
# Concrete helpers
@@ -104,8 +134,14 @@ class BaseAgent(ABC):
conversation messages, and finally the user input.
"""
messages: list[Message] = []
if system_prompt:
messages.append(Message(role=Role.SYSTEM, content=system_prompt))
if self._prompt_builder is not None:
effective_system_prompt = self._prompt_builder.build()
elif system_prompt:
effective_system_prompt = system_prompt
else:
effective_system_prompt = None
if effective_system_prompt:
messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
@@ -129,14 +165,19 @@ class BaseAgent(ABC):
tool_results: list[ToolResult],
turns: int,
content: str = "",
*,
metadata: Optional[Dict[str, Any]] = None,
) -> AgentResult:
"""Build the standard result for when ``max_turns`` is exceeded."""
self._emit_turn_end(turns=turns, max_turns_exceeded=True)
md: Dict[str, Any] = {"max_turns_exceeded": True}
if metadata:
md.update(metadata)
return AgentResult(
content=content or "Maximum turns reached without a final answer.",
tool_results=tool_results,
turns=turns,
metadata={"max_turns_exceeded": True},
metadata=md,
)
def _check_continuation(
@@ -184,7 +225,9 @@ class BaseAgent(ABC):
"""
# Full <think>...</think> blocks
text = re.sub(
r"<think>.*?</think>\s*", "", text,
r"<think>.*?</think>\s*",
"",
text,
flags=re.DOTALL | re.IGNORECASE,
)
# Leading content before a bare </think> (no opening tag)
@@ -217,9 +260,9 @@ class ToolUsingAgent(BaseAgent):
*,
tools: Optional[List["BaseTool"]] = None, # noqa: F821
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
max_turns: Optional[int] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
loop_guard_config: Optional[Any] = None,
capability_policy: Optional[Any] = None,
agent_id: Optional[str] = None,
@@ -227,21 +270,33 @@ class ToolUsingAgent(BaseAgent):
confirm_callback: Optional[Any] = None,
) -> None:
super().__init__(
engine, model, bus=bus,
temperature=temperature, max_tokens=max_tokens,
engine,
model,
bus=bus,
temperature=temperature,
max_tokens=max_tokens,
)
from openjarvis.tools._stubs import ToolExecutor
self._tools = tools or []
_aid = agent_id or getattr(self, "agent_id", "")
self._executor = ToolExecutor(
self._tools, bus=bus,
self._tools,
bus=bus,
capability_policy=capability_policy,
agent_id=_aid,
interactive=interactive,
confirm_callback=confirm_callback,
)
self._max_turns = max_turns
# Resolve max_turns: explicit arg > config > class default > 10
if max_turns is not None:
self._max_turns = max_turns
else:
try:
cfg = load_config()
self._max_turns = cfg.agent.max_turns
except Exception:
self._max_turns = getattr(self, "_default_max_turns", 10)
# Loop guard
self._loop_guard = None
+21 -7
View File
@@ -29,8 +29,16 @@ logger = logging.getLogger(__name__)
_OUTPUT_START = "---OPENJARVIS_OUTPUT_START---"
_OUTPUT_END = "---OPENJARVIS_OUTPUT_END---"
# Path to the bundled runner source (relative to this module)
# Path to the bundled runner source (relative to this module).
# In editable installs this lives next to this file; in wheel installs
# it is placed under _node_modules/ to avoid namespace package conflicts.
_RUNNER_SRC = Path(__file__).resolve().parent / "claude_code_runner"
if not _RUNNER_SRC.exists():
_RUNNER_SRC = (
Path(__file__).resolve().parents[2]
/ "_node_modules"
/ "claude_code_runner"
)
@AgentRegistry.register("claude_code")
@@ -47,6 +55,8 @@ class ClaudeCodeAgent(BaseAgent):
agent_id = "claude_code"
accepts_tools = False
_default_temperature = 0.7
_default_max_tokens = 1024
def __init__(
self,
@@ -54,8 +64,8 @@ class ClaudeCodeAgent(BaseAgent):
model: str,
*,
bus: Optional[EventBus] = None,
temperature: float = 0.7,
max_tokens: int = 1024,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
api_key: str = "",
workspace: str = "",
session_id: str = "",
@@ -64,8 +74,11 @@ class ClaudeCodeAgent(BaseAgent):
timeout: int = 300,
) -> None:
super().__init__(
engine, model, bus=bus,
temperature=temperature, max_tokens=max_tokens,
engine,
model,
bus=bus,
temperature=temperature,
max_tokens=max_tokens,
)
self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
self._workspace = workspace or os.getcwd()
@@ -170,7 +183,8 @@ class ClaudeCodeAgent(BaseAgent):
stderr = proc.stderr.strip() if proc.stderr else "Unknown error"
logger.error(
"claude_code_runner exited with code %d: %s",
proc.returncode, stderr,
proc.returncode,
stderr,
)
self._emit_turn_end(turns=1, error=True)
return AgentResult(
@@ -209,7 +223,7 @@ class ClaudeCodeAgent(BaseAgent):
# No sentinels -- treat entire stdout as plain content
return stdout.strip(), [], {}
json_str = stdout[start + len(_OUTPUT_START):end].strip()
json_str = stdout[start + len(_OUTPUT_START) : end].strip()
try:
data = json.loads(json_str)
+73 -5
View File
@@ -47,6 +47,24 @@ class AgentExecutor:
"""Deferred system injection — called after JarvisSystem is constructed."""
self._system = system
def run_ephemeral(
self,
agent_type: str,
system_prompt: str,
input_text: str,
tools: list[str] | None = None,
) -> Any:
"""Run a one-shot agent turn with no lifecycle tracking."""
from openjarvis.core.registry import AgentRegistry
agent_cls = AgentRegistry.get(agent_type)
agent = agent_cls(
engine=getattr(self._manager, '_engine', None),
system_prompt=system_prompt,
bus=self._bus,
)
return agent.run(input_text)
def execute_tick(self, agent_id: str) -> None:
"""Run one tick for the given agent.
@@ -218,19 +236,69 @@ class AgentExecutor:
instruction = config.get("instruction", "")
memory = agent.get("summary_memory", "")
if instruction:
context = f"Standing instruction: {instruction}"
input_text = f"Standing instruction: {instruction}"
if memory:
context += f"\n\nPrevious context: {memory}"
input_text += f"\n\nPrevious context: {memory}"
else:
context = memory or "Continue your assigned task."
input_text = memory or "Continue your assigned task."
pending = self._manager.get_pending_messages(agent["id"])
if pending:
user_msgs = "\n".join(f"User: {m['content']}" for m in pending)
context = f"{context}\n\nNew instructions:\n{user_msgs}"
input_text = f"{input_text}\n\nNew instructions:\n{user_msgs}"
for m in pending:
self._manager.mark_message_delivered(m["id"])
return agent_instance.run(context)
# Build AgentContext with memory results from FTS5 backend
from openjarvis.agents._stubs import AgentContext
agent_ctx = AgentContext()
memory_results = []
if (
self._system
and getattr(self._system, "memory_backend", None)
and getattr(self._system, "config", None)
and self._system.config.agent.context_from_memory
):
try:
from openjarvis.tools.storage.context import (
ContextConfig,
format_context,
)
sys_cfg = self._system.config
ctx_cfg = ContextConfig(
top_k=sys_cfg.memory.context_top_k,
min_score=sys_cfg.memory.context_min_score,
max_context_tokens=sys_cfg.memory.context_max_tokens,
)
# Use pending user messages as query, fall back to instruction
query = ""
if pending:
query = " ".join(m["content"] for m in pending)
elif instruction:
query = instruction
if query:
results = self._system.memory_backend.retrieve(
query, top_k=ctx_cfg.top_k,
)
memory_results = [
r for r in results if r.score >= ctx_cfg.min_score
]
if memory_results:
# Prepend retrieved context to input for agents
# that don't inspect AgentContext.memory_results
retrieved = format_context(memory_results)
input_text = (
f"Retrieved context from knowledge base:\n"
f"{retrieved}\n\n{input_text}"
)
except Exception:
pass # Don't break agent tick if memory retrieval fails
agent_ctx.memory_results = memory_results
return agent_instance.run(input_text, context=agent_ctx)
def _build_error_detail(self, error: AgentTickError) -> dict[str, Any]:
"""Build structured error detail for trace metadata."""
+44 -17
View File
@@ -18,6 +18,7 @@ class LoopGuardConfig:
ping_pong_window: int = 6 # detect A-B-A-B cycling
poll_tool_budget: int = 5 # max calls to same polling tool
max_context_messages: int = 100 # context overflow threshold
warn_before_block: bool = True # warn on first cycle, block on second
@dataclass(slots=True)
@@ -25,6 +26,7 @@ class LoopVerdict:
"""Result of a loop guard check."""
blocked: bool = False
reason: str = ""
warned: bool = False
class LoopGuard:
@@ -47,26 +49,49 @@ class LoopGuard:
self._tool_sequence: deque[str] = deque(maxlen=config.ping_pong_window * 2)
# Track per-tool call counts (for polling budget)
self._per_tool_counts: dict[str, int] = {}
# Track cycle keys that have already been warned (for warn-before-block)
self._warned_cycles: set[str] = set()
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
self._rust_impl = _rust.LoopGuard(
max_identical=config.max_identical_calls,
max_ping_pong=(
config.ping_pong_window // 2
if config.ping_pong_window > 1
else 2
),
poll_budget=config.poll_tool_budget,
)
try:
from openjarvis._rust_bridge import get_rust_module
_rust = get_rust_module()
self._rust_impl = _rust.LoopGuard(
max_identical=config.max_identical_calls,
max_ping_pong=(
config.ping_pong_window // 2
if config.ping_pong_window > 1
else 2
),
poll_budget=config.poll_tool_budget,
)
except Exception:
self._rust_impl = None
def check_call(self, tool_name: str, arguments: str) -> LoopVerdict:
"""Check whether a tool call should proceed or be blocked."""
reason = self._rust_impl.check(tool_name, arguments)
if reason is not None:
self._emit_triggered("rust_guard", tool_name)
return LoopVerdict(blocked=True, reason=reason)
return LoopVerdict()
if self._rust_impl is not None:
rust_result = self._rust_impl.check(tool_name, arguments)
# Support both raw Rust return (str | None) and LoopVerdict
if isinstance(rust_result, LoopVerdict):
verdict = rust_result
elif rust_result is not None:
self._emit_triggered("rust_guard", tool_name)
verdict = LoopVerdict(blocked=True, reason=rust_result)
else:
verdict = LoopVerdict()
else:
verdict = self._python_check(tool_name, arguments)
# Wrap with warn-before-block logic
if verdict.blocked and self._config.warn_before_block:
cycle_key = verdict.reason
if cycle_key not in self._warned_cycles:
self._warned_cycles.add(cycle_key)
return LoopVerdict(blocked=False, warned=True, reason=verdict.reason)
return verdict
def _python_check(self, tool_name: str, arguments: str) -> LoopVerdict:
"""Pure-Python fallback when Rust backend is not available."""
# 1. Hash tracking — identical calls
call_hash = hashlib.sha256(
f"{tool_name}:{arguments}".encode()
@@ -198,7 +223,9 @@ class LoopGuard:
self._call_counts.clear()
self._tool_sequence.clear()
self._per_tool_counts.clear()
self._rust_impl.reset()
self._warned_cycles.clear()
if self._rust_impl is not None:
self._rust_impl.reset()
def _detect_ping_pong(self) -> bool:
"""Detect repeating patterns in tool call sequence."""
+67 -32
View File
@@ -84,6 +84,9 @@ class MonitorOperativeAgent(ToolUsingAgent):
agent_id = "monitor_operative"
accepts_tools = True
_default_temperature = 0.3
_default_max_tokens = 4096
_default_max_turns = 25
def __init__(
self,
@@ -92,9 +95,9 @@ class MonitorOperativeAgent(ToolUsingAgent):
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 25,
temperature: float = 0.3,
max_tokens: int = 4096,
max_turns: Optional[int] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
# Strategy parameters
memory_extraction: str = "causality_graph",
@@ -110,10 +113,15 @@ class MonitorOperativeAgent(ToolUsingAgent):
**kwargs: Any,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
engine,
model,
tools=tools,
bus=bus,
max_turns=max_turns,
temperature=temperature,
max_tokens=max_tokens,
interactive=interactive, confirm_callback=confirm_callback,
interactive=interactive,
confirm_callback=confirm_callback,
)
# Validate strategies
if memory_extraction not in VALID_MEMORY_EXTRACTION:
@@ -191,7 +199,8 @@ class MonitorOperativeAgent(ToolUsingAgent):
# 4. Build messages
messages = self._build_operative_messages(
input, context,
input,
context,
system_prompt=system_prompt,
session_messages=session_messages,
)
@@ -202,6 +211,11 @@ class MonitorOperativeAgent(ToolUsingAgent):
turns = 0
content = ""
state_stored_by_tool = False
total_usage: dict[str, int] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
}
for _turn in range(self._max_turns):
turns += 1
@@ -211,6 +225,9 @@ class MonitorOperativeAgent(ToolUsingAgent):
gen_kwargs["tools"] = openai_tools
result = self._generate(messages, **gen_kwargs)
usage = result.get("usage", {})
for k in total_usage:
total_usage[k] += usage.get(k, 0)
content = result.get("content", "")
raw_tool_calls = result.get("tool_calls", [])
@@ -230,18 +247,21 @@ class MonitorOperativeAgent(ToolUsingAgent):
]
# Append assistant message with tool calls
messages.append(Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
))
messages.append(
Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
)
)
# Execute each tool
for tc in tool_calls:
# Loop guard check
if self._loop_guard:
verdict = self._loop_guard.check_call(
tc.name, tc.arguments,
tc.name,
tc.arguments,
)
if verdict.blocked:
tool_result = ToolResult(
@@ -250,12 +270,14 @@ class MonitorOperativeAgent(ToolUsingAgent):
success=False,
)
all_tool_results.append(tool_result)
messages.append(Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
)
)
continue
tool_result = self._executor.execute(tc)
@@ -271,18 +293,21 @@ class MonitorOperativeAgent(ToolUsingAgent):
except (json.JSONDecodeError, TypeError) as exc:
logger.debug(
"Failed to parse tool call arguments"
" for state tracking: %s", exc,
" for state tracking: %s",
exc,
)
# Compress observation if strategy requires it
observation_content = self._compress_observation(tool_result.content)
messages.append(Message(
role=Role.TOOL,
content=observation_content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=observation_content,
tool_call_id=tc.id,
name=tc.name,
)
)
# Extract and store findings based on memory strategy
self._extract_and_store(tc.name, tool_result.content)
@@ -290,7 +315,10 @@ class MonitorOperativeAgent(ToolUsingAgent):
# Max turns exceeded
self._save_session(input, content)
return self._max_turns_result(
all_tool_results, turns, content=content,
all_tool_results,
turns,
content=content,
metadata=total_usage,
)
# 6. Save session
@@ -305,6 +333,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
content=content,
tool_results=all_tool_results,
turns=turns,
metadata=total_usage,
)
# ------------------------------------------------------------------
@@ -335,6 +364,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
if not self._tools:
return ""
from openjarvis.tools._stubs import build_tool_descriptions
return build_tool_descriptions(self._tools)
# ------------------------------------------------------------------
@@ -448,11 +478,13 @@ class MonitorOperativeAgent(ToolUsingAgent):
self._memory_backend.store(key, value)
except Exception as exc:
logger.debug(
"Failed to store causality relation in memory: %s", exc,
"Failed to store causality relation in memory: %s",
exc,
)
except (json.JSONDecodeError, Exception):
logger.debug(
"Causality extraction failed for tool %s output", tool_name,
"Causality extraction failed for tool %s output",
tool_name,
)
def _store_scratchpad(self, tool_name: str, content: str) -> None:
@@ -493,7 +525,8 @@ class MonitorOperativeAgent(ToolUsingAgent):
except Exception as exc:
logger.debug(
"Failed to store structured data for tool %s: %s",
tool_name, exc,
tool_name,
exc,
)
# ------------------------------------------------------------------
@@ -547,10 +580,12 @@ class MonitorOperativeAgent(ToolUsingAgent):
session_id = f"monitor_operative:{self._operator_id}"
try:
self._session_store.save_message(
session_id, {"role": "user", "content": input_text},
session_id,
{"role": "user", "content": input_text},
)
self._session_store.save_message(
session_id, {"role": "assistant", "content": response},
session_id,
{"role": "assistant", "content": response},
)
except Exception:
logger.debug(
+72 -20
View File
@@ -54,6 +54,9 @@ class NativeOpenHandsAgent(ToolUsingAgent):
"""Native CodeAct agent -- generates and executes Python code."""
agent_id = "native_openhands"
_default_temperature = 0.7
_default_max_tokens = 2048
_default_max_turns = 3
def __init__(
self,
@@ -62,17 +65,22 @@ class NativeOpenHandsAgent(ToolUsingAgent):
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 3,
temperature: float = 0.7,
max_tokens: int = 2048,
max_turns: Optional[int] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
interactive: bool = False,
confirm_callback=None,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
engine,
model,
tools=tools,
bus=bus,
max_turns=max_turns,
temperature=temperature,
max_tokens=max_tokens,
interactive=interactive, confirm_callback=confirm_callback,
interactive=interactive,
confirm_callback=confirm_callback,
)
@staticmethod
@@ -93,9 +101,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
content = WebSearchTool._fetch_url(url, max_chars=4000)
header = f"\n\n--- Content from {url} ---\n"
footer = "\n--- End of content ---\n"
expanded = text.replace(
url, f"{header}{content}{footer}"
)
expanded = text.replace(url, f"{header}{content}{footer}")
return expanded, True
except Exception:
return text, False
@@ -121,9 +127,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
messages[i] = Message(
role=Role.USER,
content=(
truncated
+ "\n\n[Input truncated"
" to fit context window]"
truncated + "\n\n[Input truncated to fit context window]"
),
)
break
@@ -135,7 +139,9 @@ class NativeOpenHandsAgent(ToolUsingAgent):
# Remove Action: ... Action Input: ... blocks
text = re.sub(
r"Action:\s*.+?(?:Action Input:\s*.+?)?(?=\n\n|\Z)",
"", text, flags=re.DOTALL | re.IGNORECASE,
"",
text,
flags=re.DOTALL | re.IGNORECASE,
)
# Remove <tool_call>...</tool_call> or </tool_name> blocks
text = re.sub(r"<tool_call>.*?</\w+>", "", text, flags=re.DOTALL)
@@ -242,7 +248,9 @@ class NativeOpenHandsAgent(ToolUsingAgent):
usage = result.get("usage", {})
self._emit_turn_end(turns=1)
return AgentResult(
content=content, tool_results=[], turns=1,
content=content,
tool_results=[],
turns=1,
metadata={
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
@@ -258,10 +266,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
"Please try a shorter message."
)
else:
error_msg = (
"The model returned an error: "
+ error_str
)
error_msg = "The model returned an error: " + error_str
self._emit_turn_end(turns=1, error=True)
return AgentResult(
content=error_msg,
@@ -278,14 +283,23 @@ class NativeOpenHandsAgent(ToolUsingAgent):
last_content = ""
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
# Build OpenAI-format tool schemas for native function calling
openai_tools = (
self._executor.get_openai_tools() if self._tools else []
)
for _turn in range(self._max_turns):
turns += 1
# Truncate before every generate call -- tool results may have
# expanded the context beyond what the model supports.
messages = self._truncate_if_needed(messages)
gen_kwargs: dict[str, Any] = {}
if openai_tools:
gen_kwargs["tools"] = openai_tools
try:
result = self._generate(messages)
result = self._generate(messages, **gen_kwargs)
except Exception as exc:
error_str = str(exc)
if "400" in error_str:
@@ -313,6 +327,42 @@ class NativeOpenHandsAgent(ToolUsingAgent):
content = self._strip_think_tags(content)
last_content = content
# --- Native function-calling path (OpenAI, Anthropic, etc.) ---
raw_tool_calls = result.get("tool_calls", [])
if raw_tool_calls:
native_calls = [
ToolCall(
id=tc.get("id", f"call_{turns}_{i}"),
name=tc.get("name", ""),
arguments=tc.get("arguments", "{}"),
)
for i, tc in enumerate(raw_tool_calls)
]
messages.append(
Message(
role=Role.ASSISTANT,
content=content,
tool_calls=native_calls,
)
)
for tc in native_calls:
tool_result = self._executor.execute(tc)
all_tool_results.append(tool_result)
obs_text = tool_result.content
if len(obs_text) > 4000:
obs_text = obs_text[:4000] + "\n\n[Output truncated]"
messages.append(
Message(
role=Role.TOOL,
content=obs_text,
tool_call_id=tc.id,
name=tc.name,
)
)
continue
# --- Text-based fallback (CodeAct / Action-Input format) ---
# Try to extract code
code = self._extract_code(content)
if code:
@@ -358,7 +408,9 @@ class NativeOpenHandsAgent(ToolUsingAgent):
content = self._strip_tool_call_text(content)
self._emit_turn_end(turns=turns)
return AgentResult(
content=content, tool_results=all_tool_results, turns=turns,
content=content,
tool_results=all_tool_results,
turns=turns,
metadata=total_usage,
)
+30 -9
View File
@@ -36,6 +36,9 @@ class NativeReActAgent(ToolUsingAgent):
"""ReAct agent: Thought -> Action -> Observation loop."""
agent_id = "native_react"
_default_temperature = 0.7
_default_max_tokens = 1024
_default_max_turns = 10
def __init__(
self,
@@ -44,17 +47,22 @@ class NativeReActAgent(ToolUsingAgent):
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
max_turns: Optional[int] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
interactive: bool = False,
confirm_callback=None,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
engine,
model,
tools=tools,
bus=bus,
max_turns=max_turns,
temperature=temperature,
max_tokens=max_tokens,
interactive=interactive, confirm_callback=confirm_callback,
interactive=interactive,
confirm_callback=confirm_callback,
)
def _parse_response(self, text: str) -> dict:
@@ -109,6 +117,11 @@ class NativeReActAgent(ToolUsingAgent):
all_tool_results: list[ToolResult] = []
turns = 0
total_usage: dict[str, int] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
}
for _turn in range(self._max_turns):
turns += 1
@@ -117,6 +130,9 @@ class NativeReActAgent(ToolUsingAgent):
messages = self._loop_guard.compress_context(messages)
result = self._generate(messages)
usage = result.get("usage", {})
for k in total_usage:
total_usage[k] += usage.get(k, 0)
content = result.get("content", "")
parsed = self._parse_response(content)
@@ -128,13 +144,17 @@ class NativeReActAgent(ToolUsingAgent):
content=parsed["final_answer"],
tool_results=all_tool_results,
turns=turns,
metadata=total_usage,
)
# No action? Treat content as final answer
if not parsed["action"]:
self._emit_turn_end(turns=turns)
return AgentResult(
content=content, tool_results=all_tool_results, turns=turns
content=content,
tool_results=all_tool_results,
turns=turns,
metadata=total_usage,
)
# Execute action
@@ -149,7 +169,8 @@ class NativeReActAgent(ToolUsingAgent):
# Loop guard check before execution
if self._loop_guard:
verdict = self._loop_guard.check_call(
tool_call.name, tool_call.arguments,
tool_call.name,
tool_call.arguments,
)
if verdict.blocked:
tool_result = ToolResult(
@@ -169,7 +190,7 @@ class NativeReActAgent(ToolUsingAgent):
messages.append(Message(role=Role.USER, content=observation))
# Max turns exceeded
return self._max_turns_result(all_tool_results, turns)
return self._max_turns_result(all_tool_results, turns, metadata=total_usage)
__all__ = ["NativeReActAgent", "REACT_SYSTEM_PROMPT"]
+9 -4
View File
@@ -25,6 +25,8 @@ class OpenHandsAgent(BaseAgent):
"""
agent_id = "openhands"
_default_temperature = 0.7
_default_max_tokens = 1024
def __init__(
self,
@@ -32,14 +34,17 @@ class OpenHandsAgent(BaseAgent):
model: str,
*,
bus: Optional[EventBus] = None,
temperature: float = 0.7,
max_tokens: int = 1024,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
workspace: Optional[str] = None,
api_key: Optional[str] = None,
) -> None:
super().__init__(
engine, model, bus=bus,
temperature=temperature, max_tokens=max_tokens,
engine,
model,
bus=bus,
temperature=temperature,
max_tokens=max_tokens,
)
self._workspace = workspace or os.getcwd()
self._api_key = api_key or os.environ.get("LLM_API_KEY", "")
+61 -27
View File
@@ -38,6 +38,9 @@ class OperativeAgent(ToolUsingAgent):
agent_id = "operative"
accepts_tools = True
_default_temperature = 0.3
_default_max_tokens = 2048
_default_max_turns = 20
def __init__(
self,
@@ -46,9 +49,9 @@ class OperativeAgent(ToolUsingAgent):
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 20,
temperature: float = 0.3,
max_tokens: int = 2048,
max_turns: Optional[int] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None,
operator_id: Optional[str] = None,
session_store: Optional[Any] = None,
@@ -58,10 +61,15 @@ class OperativeAgent(ToolUsingAgent):
**kwargs: Any,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
engine,
model,
tools=tools,
bus=bus,
max_turns=max_turns,
temperature=temperature,
max_tokens=max_tokens,
interactive=interactive, confirm_callback=confirm_callback,
interactive=interactive,
confirm_callback=confirm_callback,
)
self._system_prompt = system_prompt or ""
self._operator_id = operator_id
@@ -94,7 +102,9 @@ class OperativeAgent(ToolUsingAgent):
# 4. Build messages
messages = self._build_operative_messages(
input, context, system_prompt=system_prompt,
input,
context,
system_prompt=system_prompt,
session_messages=session_messages,
)
@@ -104,6 +114,11 @@ class OperativeAgent(ToolUsingAgent):
turns = 0
content = ""
state_stored_by_tool = False
total_usage: dict[str, int] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
}
for _turn in range(self._max_turns):
turns += 1
@@ -116,6 +131,9 @@ class OperativeAgent(ToolUsingAgent):
gen_kwargs["tools"] = openai_tools
result = self._generate(messages, **gen_kwargs)
usage = result.get("usage", {})
for k in total_usage:
total_usage[k] += usage.get(k, 0)
content = result.get("content", "")
raw_tool_calls = result.get("tool_calls", [])
@@ -132,11 +150,13 @@ class OperativeAgent(ToolUsingAgent):
for i, tc in enumerate(raw_tool_calls)
]
messages.append(Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
))
messages.append(
Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
)
)
for tc in tool_calls:
# Loop guard check
@@ -149,12 +169,14 @@ class OperativeAgent(ToolUsingAgent):
success=False,
)
all_tool_results.append(tool_result)
messages.append(Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
)
)
continue
tool_result = self._executor.execute(tc)
@@ -170,16 +192,25 @@ class OperativeAgent(ToolUsingAgent):
except (json.JSONDecodeError, TypeError):
pass
messages.append(Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
)
)
else:
# Max turns exceeded
self._save_session(input, content)
return self._max_turns_result(all_tool_results, turns, content=content)
meta = dict(total_usage)
meta["max_turns_exceeded"] = True
return AgentResult(
content=content or "Maximum turns reached without a final answer.",
tool_results=all_tool_results,
turns=turns,
metadata=meta,
)
# 6. Save session
self._save_session(input, content)
@@ -193,6 +224,7 @@ class OperativeAgent(ToolUsingAgent):
content=content,
tool_results=all_tool_results,
turns=turns,
metadata=total_usage,
)
def _build_operative_messages(
@@ -258,10 +290,12 @@ class OperativeAgent(ToolUsingAgent):
session_id = f"operator:{self._operator_id}"
try:
self._session_store.save_message(
session_id, {"role": "user", "content": input_text},
session_id,
{"role": "user", "content": input_text},
)
self._session_store.save_message(
session_id, {"role": "assistant", "content": response},
session_id,
{"role": "assistant", "content": response},
)
except Exception:
logger.debug("Could not save session for operator %s", self._operator_id)
+55 -47
View File
@@ -41,6 +41,9 @@ class OrchestratorAgent(ToolUsingAgent):
"""
agent_id = "orchestrator"
_default_temperature = 0.7
_default_max_tokens = 1024
_default_max_turns = 10
def __init__(
self,
@@ -49,9 +52,9 @@ class OrchestratorAgent(ToolUsingAgent):
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
max_turns: Optional[int] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
mode: str = "function_calling",
system_prompt: Optional[str] = None,
parallel_tools: bool = True,
@@ -59,10 +62,15 @@ class OrchestratorAgent(ToolUsingAgent):
confirm_callback=None,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
engine,
model,
tools=tools,
bus=bus,
max_turns=max_turns,
temperature=temperature,
max_tokens=max_tokens,
interactive=interactive, confirm_callback=confirm_callback,
interactive=interactive,
confirm_callback=confirm_callback,
)
self._mode = mode
self._system_prompt = system_prompt
@@ -97,6 +105,7 @@ class OrchestratorAgent(ToolUsingAgent):
from openjarvis.learning.intelligence.orchestrator.prompt_registry import (
build_system_prompt,
)
sys_prompt = build_system_prompt(tools=self._tools)
messages = self._build_messages(input, context, system_prompt=sys_prompt)
@@ -126,9 +135,7 @@ class OrchestratorAgent(ToolUsingAgent):
# TOOL -> execute
if parsed["tool"]:
messages.append(
Message(role=Role.ASSISTANT, content=content)
)
messages.append(Message(role=Role.ASSISTANT, content=content))
tool_call = ToolCall(
id=f"orch_{turns}",
@@ -138,12 +145,8 @@ class OrchestratorAgent(ToolUsingAgent):
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
observation = (
f"Observation: {tool_result.content}"
)
messages.append(
Message(role=Role.USER, content=observation)
)
observation = f"Observation: {tool_result.content}"
messages.append(Message(role=Role.USER, content=observation))
continue
# Neither -> treat content as final answer
@@ -184,9 +187,7 @@ class OrchestratorAgent(ToolUsingAgent):
result["final_answer"] = final_match.group(1).strip()
return result
tool_match = re.search(
r"TOOL:\s*(.+)", text, re.IGNORECASE
)
tool_match = re.search(r"TOOL:\s*(.+)", text, re.IGNORECASE)
if tool_match:
result["tool"] = tool_match.group(1).strip()
@@ -271,11 +272,13 @@ class OrchestratorAgent(ToolUsingAgent):
]
# Append assistant message with tool calls
messages.append(Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
))
messages.append(
Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
)
)
# Execute each tool (with loop guard check) and append results
if self._parallel_tools and len(tool_calls) > 1:
@@ -283,7 +286,8 @@ class OrchestratorAgent(ToolUsingAgent):
def _exec_tool(tc: ToolCall) -> tuple:
if self._loop_guard:
verdict = self._loop_guard.check_call(
tc.name, tc.arguments,
tc.name,
tc.arguments,
)
if verdict.blocked:
return tc, ToolResult(
@@ -296,10 +300,7 @@ class OrchestratorAgent(ToolUsingAgent):
with concurrent.futures.ThreadPoolExecutor(
max_workers=len(tool_calls),
) as pool:
futures = {
pool.submit(_exec_tool, tc): tc
for tc in tool_calls
}
futures = {pool.submit(_exec_tool, tc): tc for tc in tool_calls}
results_map: dict[int, tuple] = {}
for future in concurrent.futures.as_completed(futures):
tc_orig = futures[future]
@@ -309,19 +310,22 @@ class OrchestratorAgent(ToolUsingAgent):
for tc in tool_calls:
_, tool_result = results_map[id(tc)]
all_tool_results.append(tool_result)
messages.append(Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
)
)
else:
# Sequential execution
for tc in tool_calls:
# Loop guard check before execution
if self._loop_guard:
verdict = self._loop_guard.check_call(
tc.name, tc.arguments,
tc.name,
tc.arguments,
)
if verdict.blocked:
tool_result = ToolResult(
@@ -330,24 +334,28 @@ class OrchestratorAgent(ToolUsingAgent):
success=False,
)
all_tool_results.append(tool_result)
messages.append(Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
)
)
continue
tool_result = self._executor.execute(tc)
all_tool_results.append(tool_result)
# Append tool response message
messages.append(Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=tool_result.content,
tool_call_id=tc.id,
name=tc.name,
)
)
# Max turns exceeded
final_content = self._strip_think_tags(content) if content else ""
+49 -25
View File
@@ -35,8 +35,8 @@ RLM_SYSTEM_PROMPT = (
"final answer.\n"
"- `FINAL_VAR(var_name: str)` — Terminate and return the "
"value of variable `var_name`.\n"
"- `answer` dict — Set `answer[\"value\"] = ...` and "
"`answer[\"ready\"] = True` to terminate.\n\n"
'- `answer` dict — Set `answer["value"] = ...` and '
'`answer["ready"] = True` to terminate.\n\n'
"{tool_section}"
"## Available Modules\n\n"
"json, re, math, collections, itertools, functools, "
@@ -52,7 +52,7 @@ RLM_SYSTEM_PROMPT = (
"and use `llm_query()` on each chunk.\n"
"3. Combine sub-results programmatically.\n"
"4. When you have the final answer, call "
"`FINAL(answer_value)` or `FINAL_VAR(\"var_name\")`.\n"
'`FINAL(answer_value)` or `FINAL_VAR("var_name")`.\n'
"5. If you can answer directly without code, just respond "
"with text (no code block).\n\n"
"## Strategy Tips\n\n"
@@ -84,6 +84,9 @@ class RLMAgent(ToolUsingAgent):
"""
agent_id = "rlm"
_default_temperature = 0.7
_default_max_tokens = 2048
_default_max_turns = 10
def __init__(
self,
@@ -92,9 +95,9 @@ class RLMAgent(ToolUsingAgent):
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 2048,
max_turns: Optional[int] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
sub_model: Optional[str] = None,
sub_temperature: float = 0.3,
sub_max_tokens: int = 1024,
@@ -104,10 +107,15 @@ class RLMAgent(ToolUsingAgent):
confirm_callback=None,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
engine,
model,
tools=tools,
bus=bus,
max_turns=max_turns,
temperature=temperature,
max_tokens=max_tokens,
interactive=interactive, confirm_callback=confirm_callback,
interactive=interactive,
confirm_callback=confirm_callback,
)
# Override executor: RLM only creates one if tools are provided
if not self._tools:
@@ -168,16 +176,26 @@ class RLMAgent(ToolUsingAgent):
# Build conversation
messages = self._build_messages(
input, context, system_prompt=system_prompt,
input,
context,
system_prompt=system_prompt,
)
all_tool_results: list[ToolResult] = []
turns = 0
total_usage: dict[str, int] = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
}
for _turn in range(self._max_turns):
turns += 1
result = self._generate(messages)
usage = result.get("usage", {})
for k in total_usage:
total_usage[k] += usage.get(k, 0)
content = result.get("content", "")
# Strip <think> tags
@@ -193,6 +211,7 @@ class RLMAgent(ToolUsingAgent):
content=content,
tool_results=all_tool_results,
turns=turns,
metadata=total_usage,
)
# Execute code in REPL
@@ -218,14 +237,13 @@ class RLMAgent(ToolUsingAgent):
content=final_str,
tool_results=all_tool_results,
turns=turns,
metadata=total_usage,
)
# Feed output back as user message
messages.append(Message(role=Role.ASSISTANT, content=content))
feedback = (
f"REPL Output: {output}"
if output
else "REPL Output: (no output)"
f"REPL Output: {output}" if output else "REPL Output: (no output)"
)
messages.append(Message(role=Role.USER, content=feedback))
@@ -236,7 +254,9 @@ class RLMAgent(ToolUsingAgent):
else:
final_content = ""
return self._max_turns_result(all_tool_results, turns, content=final_content)
return self._max_turns_result(
all_tool_results, turns, content=final_content, metadata=total_usage
)
# ------------------------------------------------------------------
# Sub-LM callbacks
@@ -269,19 +289,23 @@ class RLMAgent(ToolUsingAgent):
)
for i, tc in enumerate(raw_tool_calls)
]
messages.append(Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
))
messages.append(
Message(
role=Role.ASSISTANT,
content=content,
tool_calls=tool_calls,
)
)
for tc in tool_calls:
tr = self._executor.execute(tc)
messages.append(Message(
role=Role.TOOL,
content=tr.content,
tool_call_id=tc.id,
name=tc.name,
))
messages.append(
Message(
role=Role.TOOL,
content=tr.content,
tool_call_id=tc.id,
name=tc.name,
)
)
followup = self._engine.generate(
messages,
model=self._sub_model,
+16 -4
View File
@@ -109,14 +109,13 @@ class TelegramChannel(BaseChannel):
import httpx
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
chat_id = conversation_id or channel
payload: Dict[str, Any] = {
"chat_id": channel,
"chat_id": chat_id,
"text": content,
}
if self._parse_mode:
payload["parse_mode"] = self._parse_mode
if conversation_id:
payload["reply_to_message_id"] = conversation_id
resp = httpx.post(url, json=payload, timeout=10.0)
if resp.status_code < 300:
@@ -164,6 +163,19 @@ class TelegramChannel(BaseChannel):
message_id=str(msg.message_id),
conversation_id=str(msg.chat.id),
)
# Enforce allow-list when configured
if self._allowed_chat_ids:
_allowed = {
cid.strip()
for cid in self._allowed_chat_ids.split(",")
if cid.strip()
}
if cm.conversation_id not in _allowed:
logger.debug(
"Ignoring message from unlisted chat %s",
cm.conversation_id,
)
return
for handler in self._handlers:
try:
handler(cm)
@@ -181,7 +193,7 @@ class TelegramChannel(BaseChannel):
)
app.add_handler(MessageHandler(filters.TEXT, _handle_msg))
app.run_polling(stop_signals=None)
app.run_polling(stop_signals=None, drop_pending_updates=True)
except Exception:
logger.debug("Telegram poll loop error", exc_info=True)
self._status = ChannelStatus.ERROR
@@ -27,7 +27,15 @@ from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
# Path to the bundled bridge shipped inside the package.
# In editable installs this lives next to this file; in wheel installs
# it is placed under _node_modules/ to avoid namespace package conflicts.
_BRIDGE_SRC = Path(__file__).resolve().parent / "whatsapp_baileys_bridge"
if not _BRIDGE_SRC.exists():
_BRIDGE_SRC = (
Path(__file__).resolve().parents[2]
/ "_node_modules"
/ "whatsapp_baileys_bridge"
)
# Default runtime directory (npm install + auth state).
_DEFAULT_RUNTIME_DIR = Path.home() / ".openjarvis" / "whatsapp_baileys_bridge"
+10
View File
@@ -12,10 +12,12 @@ from openjarvis.cli.bench_cmd import bench
from openjarvis.cli.channel_cmd import channel
from openjarvis.cli.chat_cmd import chat
from openjarvis.cli.compose_cmd import compose
from openjarvis.cli.config_cmd import config
from openjarvis.cli.daemon_cmd import restart, start, status, stop
from openjarvis.cli.doctor_cmd import doctor
from openjarvis.cli.eval_cmd import eval_group
from openjarvis.cli.feedback_cmd import feedback_group
from openjarvis.cli.gateway_cmd import gateway
from openjarvis.cli.host_cmd import host
from openjarvis.cli.init_cmd import init
from openjarvis.cli.memory_cmd import memory
@@ -23,10 +25,13 @@ from openjarvis.cli.model import model
from openjarvis.cli.operators_cmd import operators
from openjarvis.cli.optimize_cmd import optimize_group
from openjarvis.cli.quickstart_cmd import quickstart
from openjarvis.cli.registry_cmd import registry
from openjarvis.cli.scan_cmd import scan
from openjarvis.cli.scheduler_cmd import scheduler
from openjarvis.cli.serve import serve
from openjarvis.cli.skill_cmd import skill
from openjarvis.cli.telemetry_cmd import telemetry
from openjarvis.cli.tool_cmd import tool
from openjarvis.cli.vault_cmd import vault
from openjarvis.cli.workflow_cmd import workflow
@@ -79,6 +84,11 @@ cli.add_command(quickstart, "quickstart")
cli.add_command(optimize_group, "optimize")
cli.add_command(feedback_group, "feedback")
cli.add_command(compose, "compose")
cli.add_command(gateway, "gateway")
cli.add_command(tool, "tool")
cli.add_command(registry, "registry")
cli.add_command(config, "config")
cli.add_command(scan, "scan")
def main() -> None:
+22
View File
@@ -24,6 +24,26 @@ def _get_manager():
return AgentManager(db_path=db_path)
def _resolve_agent_id(manager, agent_id_or_name: str) -> str:
"""Resolve an agent name or ID to the actual agent ID.
Accepts either the hex ID or the agent name. Raises SystemExit if
no matching agent is found.
"""
# Try direct ID lookup first
agent = manager.get_agent(agent_id_or_name)
if agent is not None:
return agent["id"]
# Fall back to name lookup
for a in manager.list_agents(include_archived=True):
if a["name"] == agent_id_or_name:
return a["id"]
click.echo(f"Agent not found: {agent_id_or_name}", err=True)
raise SystemExit(1)
@click.group("agents")
def agent() -> None:
"""Manage persistent agents — create, inspect, chat, bind channels."""
@@ -680,6 +700,7 @@ def errors():
def ask(agent_id, message):
"""Ask an agent a question (immediate response)."""
manager = _get_manager()
agent_id = _resolve_agent_id(manager, agent_id)
manager.send_message(agent_id, message, mode="immediate")
click.echo("Asking agent...")
_, executor, _ = _get_scheduler_and_executor()
@@ -701,6 +722,7 @@ def ask(agent_id, message):
def instruct(agent_id, message):
"""Queue an instruction for the agent's next tick."""
manager = _get_manager()
agent_id = _resolve_agent_id(manager, agent_id)
msg = manager.send_message(agent_id, message, mode="queued")
click.echo(f"Instruction queued (ID: {msg['id'][:8]})")
+121 -39
View File
@@ -44,7 +44,8 @@ def _get_memory_backend(config):
if key == "sqlite":
backend = MemoryRegistry.create(
key, db_path=config.memory.db_path,
key,
db_path=config.memory.db_path,
)
else:
backend = MemoryRegistry.create(key)
@@ -106,8 +107,7 @@ def _run_agent(
if not AgentRegistry.contains(agent_name):
raise click.ClickException(
f"Unknown agent: {agent_name}. "
f"Available: {', '.join(AgentRegistry.keys())}"
f"Unknown agent: {agent_name}. Available: {', '.join(AgentRegistry.keys())}"
)
agent_cls = AgentRegistry.get(agent_name)
@@ -117,6 +117,7 @@ def _run_agent(
if tool_names:
# Trigger tool registration
import openjarvis.tools # noqa: F401
tools = _build_tools(tool_names, config, engine, model_name)
# Build agent with appropriate kwargs
@@ -149,7 +150,10 @@ def _run_agent(
max_context_tokens=config.memory.context_max_tokens,
)
context_messages = inject_context(
query_text, [], backend, config=ctx_cfg,
query_text,
[],
backend,
config=ctx_cfg,
)
for msg in context_messages:
ctx.conversation.add(msg)
@@ -165,12 +169,11 @@ def _print_profile(
engine_name: str,
model_name: str,
console: Console,
complexity_result=None,
) -> None:
"""Print an inference telemetry profile table from EventBus history."""
# Collect all INFERENCE_END events (agents may fire multiple)
inf_events = [
e for e in bus.history if e.event_type == EventType.INFERENCE_END
]
inf_events = [e for e in bus.history if e.event_type == EventType.INFERENCE_END]
if not inf_events:
console.print("[dim]No inference telemetry recorded.[/dim]")
return
@@ -185,13 +188,15 @@ def _print_profile(
for e in inf_events
)
total_prompt = sum(
e.data.get("usage", {}).get("prompt_tokens", 0)
for e in inf_events
e.data.get("usage", {}).get("prompt_tokens", 0) for e in inf_events
)
total_energy = sum(e.data.get("energy_joules", 0.0) for e in inf_events)
avg_power = 0.0
power_vals = [e.data.get("power_watts", 0.0) for e in inf_events
if e.data.get("power_watts", 0.0) > 0]
power_vals = [
e.data.get("power_watts", 0.0)
for e in inf_events
if e.data.get("power_watts", 0.0) > 0
]
if power_vals:
avg_power = sum(power_vals) / len(power_vals)
@@ -227,6 +232,12 @@ def _print_profile(
def _row(label: str, val: str) -> None:
table.add_row(label, val)
if complexity_result is not None:
_row("Complexity score", f"{complexity_result.score:.3f}")
_row("Complexity tier", complexity_result.tier)
_row("Suggested max tokens", str(complexity_result.suggested_max_tokens))
_row("", "") # separator
_row("Wall time", f"{wall_seconds:.3f} s")
_row("Inference calls", str(total_calls))
_row("Total latency", f"{total_latency:.3f} s")
@@ -277,29 +288,42 @@ def _print_profile(
@click.option("-m", "--model", "model_name", default=None, help="Model to use.")
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
@click.option(
"-t", "--temperature", default=None, type=float,
"-t",
"--temperature",
default=None,
type=float,
help="Sampling temperature (default: from config).",
)
@click.option(
"--max-tokens", default=None, type=int,
"--max-tokens",
default=None,
type=int,
help="Max tokens to generate (default: from config).",
)
@click.option("--json", "output_json", is_flag=True, help="Output raw JSON result.")
@click.option("--no-stream", is_flag=True, help="Disable streaming (sync mode).")
@click.option(
"--no-context", is_flag=True,
"--no-context",
is_flag=True,
help="Disable memory context injection.",
)
@click.option(
"-a", "--agent", "agent_name", default=None,
"-a",
"--agent",
"agent_name",
default=None,
help="Agent to use (simple, orchestrator).",
)
@click.option(
"--tools", "tool_names", default=None,
"--tools",
"tool_names",
default=None,
help="Comma-separated tool names to enable (e.g. calculator,think).",
)
@click.option(
"--profile", "enable_profile", is_flag=True,
"--profile",
"enable_profile",
is_flag=True,
help="Print inference telemetry profile (latency, tokens, energy, IPW).",
)
def ask(
@@ -324,12 +348,30 @@ def ask(
# Load config
config = load_config()
# Track whether the user explicitly set --max-tokens
user_set_max_tokens = max_tokens is not None
# Fall back to config values for generation params
if temperature is None:
temperature = config.intelligence.temperature
if max_tokens is None:
max_tokens = config.intelligence.max_tokens
# Run complexity analysis on the query
from openjarvis.learning.routing.complexity import (
ComplexityResult,
adjust_tokens_for_model,
score_complexity,
)
complexity_result: ComplexityResult = score_complexity(query_text)
logger.debug(
"Complexity analysis: score=%.3f tier=%s suggested_max_tokens=%d",
complexity_result.score,
complexity_result.tier,
complexity_result.suggested_max_tokens,
)
# Set up telemetry
bus = EventBus(record_history=True)
telem_store: TelemetryStore | None = None
@@ -352,7 +394,10 @@ def ask(
" [cyan]ollama serve[/cyan] — start Ollama\n"
" [cyan]vllm serve <model>[/cyan] — start vLLM\n"
" [cyan]llama-server -m <gguf>[/cyan] — start llama.cpp\n\n"
"Or set OPENAI_API_KEY / ANTHROPIC_API_KEY for cloud inference."
"Or set OPENAI_API_KEY / ANTHROPIC_API_KEY for cloud inference.\n\n"
"[dim]To use a remote engine:[/dim]\n"
" [cyan]jarvis config set engine.ollama.host http://<remote-ip>:11434[/cyan]\n"
" [dim]or[/dim] [cyan]export OLLAMA_HOST=http://<remote-ip>:11434[/cyan]"
)
sys.exit(1)
@@ -360,6 +405,7 @@ def ask(
# Apply security guardrails
from openjarvis.security import setup_security
sec = setup_security(config, engine, bus)
engine = sec.engine
@@ -397,6 +443,21 @@ def ask(
console.print("[red]No model available on engine.[/red]")
sys.exit(1)
# Apply complexity-suggested token budget when user didn't override.
# Use at least the config default so we never reduce tokens below what
# the user would have gotten without the analyzer.
if not user_set_max_tokens:
suggested = adjust_tokens_for_model(
complexity_result.suggested_max_tokens,
model_name,
)
max_tokens = max(suggested, config.intelligence.max_tokens)
logger.debug(
"Using complexity-suggested max_tokens=%d (model=%s)",
max_tokens,
model_name,
)
# Agent mode
if agent_name is not None:
parsed_tools = resolve_tool_names(
@@ -406,8 +467,15 @@ def ask(
)
try:
result = _run_agent(
agent_name, query_text, engine, model_name,
parsed_tools, config, bus, temperature, max_tokens,
agent_name,
query_text,
engine,
model_name,
parsed_tools,
config,
bus,
temperature,
max_tokens,
capability_policy=sec.capability_policy,
)
except EngineConnectionError as exc:
@@ -416,25 +484,34 @@ def ask(
sys.exit(1)
if output_json:
click.echo(json_mod.dumps({
"content": result.content,
"turns": result.turns,
"tool_results": [
click.echo(
json_mod.dumps(
{
"tool_name": tr.tool_name,
"content": tr.content,
"success": tr.success,
}
for tr in result.tool_results
],
}, indent=2))
"content": result.content,
"turns": result.turns,
"tool_results": [
{
"tool_name": tr.tool_name,
"content": tr.content,
"success": tr.success,
}
for tr in result.tool_results
],
},
indent=2,
)
)
else:
click.echo(result.content)
if enable_profile:
_print_profile(
bus, time.monotonic() - wall_start,
engine_name, model_name, console,
bus,
time.monotonic() - wall_start,
engine_name,
model_name,
console,
complexity_result=complexity_result,
)
if telem_store is not None:
@@ -454,17 +531,18 @@ def ask(
ContextConfig,
inject_context,
)
backend = _get_memory_backend(config)
if backend is not None:
ctx_cfg = ContextConfig(
top_k=config.memory.context_top_k,
min_score=config.memory.context_min_score,
max_context_tokens=(
config.memory.context_max_tokens
),
max_context_tokens=(config.memory.context_max_tokens),
)
messages = inject_context(
query_text, messages, backend,
query_text,
messages,
backend,
config=ctx_cfg,
)
except Exception as exc:
@@ -492,8 +570,12 @@ def ask(
if enable_profile:
_print_profile(
bus, time.monotonic() - wall_start,
engine_name, model_name, console,
bus,
time.monotonic() - wall_start,
engine_name,
model_name,
console,
complexity_result=complexity_result,
)
# Cleanup
+375
View File
@@ -0,0 +1,375 @@
"""``jarvis config`` — configuration inspection commands."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
import click
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.syntax import Syntax
from rich.table import Table
@click.group()
def config() -> None:
"""Inspect configuration — show loaded settings, hardware, and config files."""
def _get_config_path(path: str | None) -> Path:
"""Determine the config path from argument or environment."""
from openjarvis.core.config import DEFAULT_CONFIG_PATH
if path:
return Path(path)
return Path(os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_PATH))
def _show_hardware_info(console: Console, show_recommendations: bool = True) -> None:
"""Display detected hardware information."""
from openjarvis.core.config import detect_hardware, recommend_engine
hardware = detect_hardware()
console.print("\n[bold]Detected Hardware[/bold]")
hardware_table = Table(show_header=True, header_style="cyan")
hardware_table.add_column("Component", style="green")
hardware_table.add_column("Value", style="white")
# Platform
hardware_table.add_row("Platform", str(hardware.platform))
# CPU
if hardware.cpu_brand:
hardware_table.add_row("CPU", hardware.cpu_brand)
hardware_table.add_row("CPU Count", str(hardware.cpu_count))
hardware_table.add_row("RAM", f"{hardware.ram_gb:.1f} GB")
# GPU
if hardware.gpu:
hardware_table.add_row("GPU Vendor", hardware.gpu.vendor)
hardware_table.add_row("GPU Model", hardware.gpu.name)
hardware_table.add_row("GPU VRAM", f"{hardware.gpu.vram_gb:.1f} GB")
hardware_table.add_row("GPU Count", str(hardware.gpu.count))
console.print(hardware_table)
# Show recommended engine
if show_recommendations:
recommended = recommend_engine(hardware)
console.print(f"\n[bold]Recommended Engine:[/bold] [cyan]{recommended}[/cyan]")
def _show_config_template(console: Console, config_path: Path) -> None:
"""Show default config template when config file doesn't exist."""
from openjarvis.core.config import (
DEFAULT_CONFIG_DIR,
detect_hardware,
generate_default_toml,
)
console.print(f"[yellow]Config file not found: {config_path}[/yellow]")
config_location = str(DEFAULT_CONFIG_DIR / "config.toml")
msg = f"Create one at {config_location} or use --path to specify a location."
console.print(f"[dim]{msg}[/dim]")
console.print("\n[bold]Default Configuration Template:[/bold]")
hw = detect_hardware()
template = generate_default_toml(hw)
syntax = Syntax(template, "toml", theme="monokai", line_numbers=True)
console.print(Panel(syntax, border_style="dim"))
def _show_loaded_config(console: Console, config_path: Path, as_json: bool) -> None:
"""Show the loaded effective configuration from config.toml."""
from openjarvis.core.config import load_config
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config = load_config(config_path)
if as_json:
# Convert the dataclass to a dict and output as JSON
from dataclasses import fields, is_dataclass
def convert(obj):
if obj is None:
return None
if isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (list, tuple)):
return [convert(item) for item in obj]
if isinstance(obj, dict):
return {k: convert(v) for k, v in obj.items()}
if is_dataclass(obj):
result = {}
for field in fields(obj):
field_value = getattr(obj, field.name)
if field_value is not None:
result[field.name] = convert(field_value)
return result
# Fallback for other types
return str(obj)
config_dict = convert(config)
# Write JSON to stdout so it is pipeable
stdout_console = Console()
stdout_console.print_json(json.dumps(config_dict, indent=2, default=str))
else:
# Show as formatted table
console.print("[bold]Engine Configuration[/bold]")
console.print(f" Default Engine: [cyan]{config.engine.default}[/cyan]")
console.print("\n[bold]Intelligence Configuration[/bold]")
console.print(
f" Default Model: [cyan]{config.intelligence.default_model}[/cyan]"
)
fallback = config.intelligence.fallback_model or "N/A"
console.print(f" Fallback Model: [cyan]{fallback}[/cyan]")
console.print(
f" Temperature: [cyan]{config.intelligence.temperature}[/cyan]"
)
console.print(
f" Max Tokens: [cyan]{config.intelligence.max_tokens}[/cyan]"
)
console.print("\n[bold]Agent Configuration[/bold]")
console.print(f" Default Agent: [cyan]{config.agent.default_agent}[/cyan]")
console.print(f" Max Turns: [cyan]{config.agent.max_turns}[/cyan]")
console.print(f" Tools: [cyan]{config.agent.tools or 'none'}[/cyan]")
ctx_mem = config.agent.context_from_memory
console.print(f" Context from Memory: [cyan]{ctx_mem}[/cyan]")
# Show hardware info
_show_hardware_info(console)
else:
_show_config_template(console, config_path)
def _show_toml_config(console: Console, config_path: Path) -> None:
"""Show the raw TOML configuration file content with syntax highlighting."""
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True)
console.print(Panel(syntax, title="Config File", border_style="cyan"))
else:
_show_config_template(console, config_path)
def _show_json_config(console: Console, config_path: Path) -> None:
"""Show the parsed TOML configuration as JSON."""
console.print(f"[dim]Loading config from: {config_path}[/dim]")
if config_path.exists():
config_content = config_path.read_text()
try:
import tomllib # Python 3.11+
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
config_dict = tomllib.loads(config_content)
# Write JSON to stdout so it is pipeable
stdout_console = Console()
stdout_console.print_json(json.dumps(config_dict, indent=2))
else:
_show_config_template(console, config_path)
def _show_hardware(console: Console) -> None:
"""Show detected hardware information with recommended engine and model."""
from openjarvis.core.config import (
detect_hardware,
recommend_engine,
recommend_model,
)
hardware = detect_hardware()
_show_hardware_info(console, show_recommendations=False)
# Show recommended engine
recommended_engine = recommend_engine(hardware)
console.print(
f"\n[bold]Recommended Engine:[/bold] [cyan]{recommended_engine}[/cyan]"
)
# Show recommended model
console.print("\n[bold]Model Recommendations[/bold]")
recommended_model = recommend_model(hardware, recommended_engine)
console.print(f" Recommended Model: [cyan]{recommended_model}[/cyan]")
# Nested group for show sub-commands
@click.group(invoke_without_command=True)
@click.option("--path", "-p", default=None, help="Explicit config file path")
@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON")
@click.pass_context
def show_group(ctx: click.Context, path: str | None, as_json: bool) -> None:
"""Show configuration details."""
# Default to 'loaded' if no subcommand is invoked
if ctx.invoked_subcommand is None:
console = Console(stderr=True)
try:
config_path = _get_config_path(path)
_show_loaded_config(console, config_path, as_json)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@show_group.command()
@click.option("--path", "-p", default=None, help="Explicit config file path")
@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON")
def loaded(path: str | None, as_json: bool) -> None:
"""Show the loaded effective configuration from config.toml."""
console = Console(stderr=True)
try:
config_path = _get_config_path(path)
_show_loaded_config(console, config_path, as_json)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@show_group.command()
@click.option("--path", "-p", default=None, help="Explicit config file path")
def toml(path: str | None) -> None:
"""Show the raw TOML configuration file content with syntax highlighting."""
console = Console(stderr=True)
try:
config_path = _get_config_path(path)
_show_toml_config(console, config_path)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@show_group.command("json")
@click.option("--path", "-p", default=None, help="Explicit config file path")
def as_json(path: str | None) -> None:
"""Show the parsed TOML configuration as JSON."""
console = Console(stderr=True)
try:
config_path = _get_config_path(path)
_show_json_config(console, config_path)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@show_group.command()
def hardware() -> None:
"""Show detected hardware information with recommended engine and model."""
console = Console(stderr=True)
try:
_show_hardware(console)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
# Register the show group under config
config.add_command(show_group, "show")
def _probe_engine_host(url: str, console: Console) -> None:
"""Probe an engine host URL and print reachability status."""
try:
resp = httpx.get(url.rstrip("/") + "/", timeout=2.0)
if resp.status_code < 500:
console.print(f" [green]Reachable[/green] ({url})")
else:
console.print(
f" [yellow]Warning:[/yellow] Host returned status "
f"{resp.status_code} — config saved anyway."
)
except Exception:
console.print(
f" [yellow]Warning:[/yellow] Host unreachable ({url}) "
f"— config saved anyway."
)
def _coerce_value(value: str, target_type: type) -> object:
"""Coerce a CLI string value to the target Python type."""
if target_type is bool:
low = value.lower()
if low in ("true", "1", "yes"):
return True
if low in ("false", "0", "no"):
return False
raise ValueError(
f"Invalid boolean value: {value!r} (expected: true/false, yes/no, 1/0)"
)
if target_type is int:
return int(value)
if target_type is float:
return float(value)
return value
@click.command("set")
@click.argument("key")
@click.argument("value")
def set_config(key: str, value: str) -> None:
"""Set a configuration value (e.g. jarvis config set engine.ollama.host URL)."""
import tomlkit
from openjarvis.core.config import DEFAULT_CONFIG_DIR, validate_config_key
console = Console(stderr=True)
# Validate key
try:
target_type = validate_config_key(key)
except ValueError as exc:
console.print(f"[red]Error:[/red] {exc}")
raise SystemExit(1)
# Coerce value
try:
typed_value = _coerce_value(value, target_type)
except (ValueError, TypeError) as exc:
console.print(
f"[red]Error:[/red] Cannot convert {value!r} to "
f"{target_type.__name__}: {exc}"
)
raise SystemExit(1)
# Load or create TOML document
config_path = Path(
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
)
if config_path.exists():
doc = tomlkit.parse(config_path.read_text())
else:
doc = tomlkit.document()
config_path.parent.mkdir(parents=True, exist_ok=True)
# Set nested key
parts = key.split(".")
current = doc
for part in parts[:-1]:
if part not in current:
current.add(part, tomlkit.table())
current = current[part]
current[parts[-1]] = typed_value
# Write back
config_path.write_text(tomlkit.dumps(doc))
console.print(f"[green]Set[/green] {key} = {value!r}")
# Probe engine host if applicable
if re.match(r"^engine\.\w+\.host$", key):
_probe_engine_host(value, console)
config.add_command(set_config, "set")
__all__ = ["config"]
+108
View File
@@ -0,0 +1,108 @@
"""``jarvis gateway start|stop|status|logs`` — multi-channel gateway management."""
from __future__ import annotations
import subprocess
from pathlib import Path
import click
@click.group()
def gateway() -> None:
"""Manage the OpenJarvis multi-channel gateway."""
@gateway.command()
@click.option(
"--install",
is_flag=True,
help="Generate and enable systemd/launchd service",
)
def start(install: bool) -> None:
"""Start the gateway daemon."""
if install:
import platform as plat
from openjarvis.daemon.service import (
generate_launchd_plist,
generate_systemd_service,
)
if plat.system() == "Darwin":
plist_path = (
Path.home()
/ "Library/LaunchAgents/com.openjarvis.gateway.plist"
)
generate_launchd_plist(plist_path)
click.echo(f"Wrote {plist_path}")
subprocess.run(
["launchctl", "load", str(plist_path)], check=False,
)
else:
service_path = (
Path.home()
/ ".config/systemd/user/openjarvis-gateway.service"
)
generate_systemd_service(service_path)
click.echo(f"Wrote {service_path}")
subprocess.run(
["systemctl", "--user", "daemon-reload"], check=False,
)
subprocess.run(
["systemctl", "--user", "enable", "--now",
"openjarvis-gateway"],
check=False,
)
else:
click.echo("Starting OpenJarvis gateway (foreground)...")
click.echo("Gateway started. Press Ctrl+C to stop.")
@gateway.command()
def stop() -> None:
"""Stop the gateway daemon."""
import platform as plat
if plat.system() == "Darwin":
subprocess.run(
["launchctl", "remove", "com.openjarvis.gateway"],
check=False,
)
else:
subprocess.run(
["systemctl", "--user", "stop", "openjarvis-gateway"],
check=False,
)
click.echo("Gateway stopped.")
@gateway.command()
def status() -> None:
"""Check gateway status."""
import platform as plat
if plat.system() == "Darwin":
subprocess.run(
["launchctl", "list", "com.openjarvis.gateway"],
check=False,
)
else:
subprocess.run(
["systemctl", "--user", "status", "openjarvis-gateway"],
check=False,
)
@gateway.command()
def logs() -> None:
"""View gateway logs."""
import platform as plat
if plat.system() == "Darwin":
click.echo("Check ~/Library/Logs/com.openjarvis.gateway.log")
else:
subprocess.run(
["journalctl", "--user", "-u", "openjarvis-gateway", "-f"],
check=False,
)
+5 -1
View File
@@ -22,7 +22,11 @@ def hint_no_engine(engine_name: Optional[str] = None) -> str:
f"[yellow]Hint:[/yellow] Engine '{name}' is not reachable.\n"
f" Make sure the {name} server is running.\n"
" Run [bold]jarvis doctor[/bold] to check all engines.\n"
" Run [bold]jarvis quickstart[/bold] for guided setup."
" Run [bold]jarvis quickstart[/bold] for guided setup.\n"
"\n"
" [dim]To use a remote engine:[/dim]\n"
f" [cyan]jarvis config set engine.{name}.host http://<remote-ip>:<port>[/cyan]\n"
f" [dim]or[/dim] [cyan]export OLLAMA_HOST=http://<remote-ip>:11434[/cyan]"
)
+152 -15
View File
@@ -6,14 +6,18 @@ from pathlib import Path
from typing import Optional
import click
import httpx
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from openjarvis.cli.model import find_model_spec, hf_download, ollama_pull
from openjarvis.cli.scan_cmd import PrivacyScanner
from openjarvis.core.config import (
DEFAULT_CONFIG_DIR,
DEFAULT_CONFIG_PATH,
detect_hardware,
estimated_download_gb,
generate_default_toml,
generate_minimal_toml,
recommend_engine,
@@ -22,8 +26,14 @@ from openjarvis.core.config import (
# Engines supported by ``jarvis init --engine``.
_SUPPORTED_ENGINES = [
"ollama", "vllm", "sglang", "llamacpp", "mlx", "lmstudio",
"exo", "nexa",
"ollama",
"vllm",
"sglang",
"llamacpp",
"mlx",
"lmstudio",
"exo",
"nexa",
]
@@ -67,7 +77,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
f" ollama pull {pull_model}\n"
"\n"
" 3. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -79,7 +89,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" vllm serve Qwen/Qwen3-4B\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -91,7 +101,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" llama-server -m path/to/model.gguf\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -103,7 +113,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" python -m sglang.launch_server --model-path Qwen/Qwen3-8B\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -115,7 +125,7 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" mlx_lm.server --model mlx-community/Qwen2.5-7B-4bit\n"
"\n"
" 2. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
@@ -128,14 +138,80 @@ def _next_steps_text(engine: str, model: str = "") -> str:
" 2. Load a model and start the local server (port 1234)\n"
"\n"
" 3. Try it out:\n"
" jarvis ask \"Hello\"\n"
' jarvis ask "Hello"\n'
"\n"
" Run `jarvis doctor` to verify your setup."
),
"exo": (
"Next steps:\n\n"
" 1. Install and start Exo:\n"
" pip install exo\n"
" exo\n\n"
" 2. Try it out:\n"
' jarvis ask "Hello"\n\n'
" Run `jarvis doctor` to verify your setup."
),
"nexa": (
"Next steps:\n\n"
" 1. Install and start Nexa:\n"
" pip install nexaai\n"
" nexa server\n\n"
" 2. Try it out:\n"
' jarvis ask "Hello"\n\n'
" Run `jarvis doctor` to verify your setup."
),
}
return steps.get(engine, steps["ollama"])
def _quick_privacy_check(console: Console) -> None:
"""Run critical privacy checks and print compact summary."""
scanner = PrivacyScanner()
results = scanner.run_quick()
if results:
console.print(" [bold]Privacy check:[/bold]")
for r in results:
if r.status == "ok":
console.print(f" [green]\u2713[/green] {r.message}")
elif r.status == "warn":
console.print(f" [yellow]![/yellow] {r.message}")
elif r.status == "fail":
console.print(f" [red]\u2717[/red] {r.message}")
console.print()
console.print(" Run [cyan]jarvis scan[/cyan] for a full environment audit.")
def _do_download(engine: str, model: str, spec, console: Console) -> None:
"""Dispatch model download based on engine type."""
import os
if engine == "ollama":
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
ollama_pull(host, model, console)
elif engine == "llamacpp":
repo = spec.metadata.get("hf_repo", "")
gguf = spec.metadata.get("gguf_file", "")
if repo and gguf:
console.print(f" Downloading [cyan]{gguf}[/cyan] from {repo}...")
hf_download(repo, gguf, console)
else:
console.print(f" [yellow]No GGUF download info for {model}[/yellow]")
elif engine == "mlx":
mlx_repo = spec.metadata.get("mlx_repo", "")
if mlx_repo:
console.print(f" Downloading [cyan]{mlx_repo}[/cyan]...")
hf_download(mlx_repo, None, console)
else:
console.print(f" [yellow]No MLX repo info for {model}[/yellow]")
elif engine in ("vllm", "sglang"):
console.print(
f" [cyan]{model}[/cyan] will download automatically when "
f"{engine} starts serving it."
)
else:
console.print(f" Download {model} through the {engine} interface.")
@click.command()
@click.option(
"--force", is_flag=True, help="Overwrite existing config without prompting."
@@ -157,11 +233,21 @@ def _next_steps_text(engine: str, model: str = "") -> str:
default=None,
help="Inference engine to use (skips interactive selection).",
)
@click.option(
"--no-download", is_flag=True, default=False, help="Skip the model download prompt."
)
@click.option(
"--host",
default=None,
help="Remote engine host URL (e.g. http://192.168.1.50:11434).",
)
def init(
force: bool,
config: Optional[Path],
full_config: bool = False,
engine: Optional[str] = None,
no_download: bool = False,
host: Optional[str] = None,
) -> None:
"""Detect hardware and generate ~/.openjarvis/config.toml."""
console = Console()
@@ -195,9 +281,7 @@ def init(
console.print("[bold]Detecting running inference engines...[/bold]")
running = _detect_running_engines()
if running:
console.print(
f" Found running: [green]{', '.join(running)}[/green]"
)
console.print(f" Found running: [green]{', '.join(running)}[/green]")
else:
console.print(" No running engines detected.")
@@ -241,13 +325,31 @@ def init(
default=default,
)
# Probe remote host if specified
if host:
console.print("\n[bold]Checking remote host...[/bold]")
try:
resp = httpx.get(host.rstrip("/") + "/", timeout=2.0)
if resp.status_code < 500:
console.print(f" [green]Reachable[/green] ({host})")
else:
console.print(
f" [yellow]Warning:[/yellow] Host returned status "
f"{resp.status_code} — writing config anyway."
)
except Exception:
console.print(
f" [yellow]Warning:[/yellow] Host unreachable ({host}) "
f"— writing config anyway."
)
if config:
toml_content = config.read_text()
else:
if full_config:
toml_content = generate_default_toml(hw, engine=engine)
toml_content = generate_default_toml(hw, engine=engine, host=host)
else:
toml_content = generate_minimal_toml(hw, engine=engine)
toml_content = generate_minimal_toml(hw, engine=engine, host=host)
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if config:
@@ -265,10 +367,45 @@ def init(
)
console.print("[green]Config written successfully.[/green]")
# Create default memory files (skip if they already exist)
soul_path = DEFAULT_CONFIG_DIR / "SOUL.md"
if not soul_path.exists():
soul_path.write_text(
"# Agent Persona\n\nYou are Jarvis, a helpful personal AI assistant.\n"
)
memory_path = DEFAULT_CONFIG_DIR / "MEMORY.md"
if not memory_path.exists():
memory_path.write_text("# Agent Memory\n\n")
user_path = DEFAULT_CONFIG_DIR / "USER.md"
if not user_path.exists():
user_path.write_text("# User Profile\n\n")
skills_dir = DEFAULT_CONFIG_DIR / "skills"
skills_dir.mkdir(exist_ok=True)
selected_engine = engine or recommend_engine(hw)
model = recommend_model(hw, selected_engine)
if model:
console.print(f"\n [bold]Recommended model:[/bold] {model}")
if not model:
console.print(
"\n [yellow]! Not enough memory to run any local model.[/yellow]\n"
" Consider a cloud engine or a machine with more RAM."
)
else:
spec = find_model_spec(model)
size_gb = estimated_download_gb(spec.parameter_count_b) if spec else 0
console.print(
f"\n [bold]Recommended model:[/bold] {model} (~{size_gb:.1f} GB estimated)"
)
if not no_download and spec:
prompt = f" Download {model} (~{size_gb:.1f} GB estimated) now?"
if click.confirm(prompt, default=True):
_do_download(selected_engine, model, spec, console)
_quick_privacy_check(console)
console.print()
console.print(
Panel(
+89 -15
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
import subprocess
import sys
import click
@@ -15,6 +16,7 @@ from openjarvis.core.config import load_config
from openjarvis.core.registry import ModelRegistry
from openjarvis.engine import discover_engines, discover_models
from openjarvis.intelligence import merge_discovered_models, register_builtin_models
from openjarvis.intelligence.model_catalog import BUILTIN_MODELS
@click.group()
@@ -149,18 +151,8 @@ def info(model_name: str) -> None:
console.print(Panel("\n".join(lines), title=spec.name, border_style="blue"))
@model.command()
@click.argument("model_name")
def pull(model_name: str) -> None:
"""Download a model (Ollama only)."""
console = Console()
config = load_config()
host = (
config.engine.ollama_host
or os.environ.get("OLLAMA_HOST")
or "http://localhost:11434"
).rstrip("/")
def ollama_pull(host: str, model_name: str, console: Console) -> bool:
"""Pull a model via Ollama API. Returns True on success."""
console.print(f"Pulling [cyan]{model_name}[/cyan] via Ollama...")
try:
with httpx.stream(
@@ -171,7 +163,6 @@ def pull(model_name: str) -> None:
) as resp:
resp.raise_for_status()
import json
for line in resp.iter_lines():
if not line.strip():
continue
@@ -188,9 +179,92 @@ def pull(model_name: str) -> None:
elif status:
console.print(f" {status}")
console.print(f"\n[green]Successfully pulled {model_name}[/green]")
return True
except httpx.ConnectError:
console.print("[red]Cannot connect to Ollama.[/red] Is it running?")
sys.exit(1)
return False
except httpx.HTTPStatusError as exc:
console.print(f"[red]Ollama error:[/red] {exc.response.status_code}")
sys.exit(1)
return False
def find_model_spec(model_name: str):
"""Look up a model in the builtin catalog. Returns None if not found."""
for spec in BUILTIN_MODELS:
if spec.model_id == model_name:
return spec
return None
def hf_download(repo: str, filename: str | None, console: Console) -> bool:
"""Download from HuggingFace via huggingface-cli. Returns True on success."""
cmd = ["huggingface-cli", "download", repo]
if filename:
cmd.append(filename)
try:
subprocess.run(cmd, check=True)
console.print("[green]Download complete.[/green]")
return True
except FileNotFoundError:
console.print(
"[red]huggingface-cli not found.[/red]\n"
"Install it: [cyan]pip install huggingface_hub[/cyan]\n"
f"Or download manually: https://huggingface.co/{repo}"
)
return False
except subprocess.CalledProcessError:
console.print("[red]Download failed.[/red]")
return False
@model.command()
@click.argument("model_name")
@click.option(
"--engine", default=None, help="Engine to download for."
)
def pull(model_name: str, engine: str | None) -> None:
"""Download a model."""
console = Console()
config = load_config()
engine = engine or config.engine.default or "ollama"
if engine == "ollama":
host = (
config.engine.ollama_host
or os.environ.get("OLLAMA_HOST")
or "http://localhost:11434"
).rstrip("/")
if not ollama_pull(host, model_name, console):
sys.exit(1)
elif engine in ("llamacpp", "mlx"):
spec = find_model_spec(model_name)
if not spec:
console.print(f"[red]Model not in catalog:[/red] {model_name}")
sys.exit(1)
if engine == "llamacpp":
repo = spec.metadata.get("hf_repo", "")
gguf = spec.metadata.get("gguf_file", "")
if not repo or not gguf:
console.print(f"[red]No GGUF download info for {model_name}[/red]")
sys.exit(1)
console.print(f"Downloading [cyan]{gguf}[/cyan] from {repo}...")
if not hf_download(repo, gguf, console):
sys.exit(1)
else: # mlx
mlx_repo = spec.metadata.get("mlx_repo", "")
if not mlx_repo:
console.print(f"[red]No MLX repo info for {model_name}[/red]")
sys.exit(1)
console.print(f"Downloading [cyan]{mlx_repo}[/cyan]...")
if not hf_download(mlx_repo, None, console):
sys.exit(1)
elif engine in ("vllm", "sglang"):
console.print(
f"[cyan]{model_name}[/cyan] will download automatically when "
f"{engine} starts serving it."
)
else:
console.print(
f"Manual download required for engine [cyan]{engine}[/cyan].\n"
f"Check the engine documentation for instructions."
)
+146
View File
@@ -0,0 +1,146 @@
"""``jarvis registry`` — registry inspection commands."""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
def _load_registry_map() -> tuple[dict[str, object], dict[str, object]]:
"""Import all registries and return (by_name, aliases) lookup dicts."""
from openjarvis.core.registry import (
AgentRegistry,
BenchmarkRegistry,
ChannelRegistry,
CompressionRegistry,
EngineRegistry,
LearningRegistry,
MemoryRegistry,
ModelRegistry,
RouterPolicyRegistry,
SkillRegistry,
SpeechRegistry,
ToolRegistry,
)
by_name = {
"ToolRegistry": ToolRegistry,
"AgentRegistry": AgentRegistry,
"EngineRegistry": EngineRegistry,
"MemoryRegistry": MemoryRegistry,
"ModelRegistry": ModelRegistry,
"ChannelRegistry": ChannelRegistry,
"LearningRegistry": LearningRegistry,
"SkillRegistry": SkillRegistry,
"BenchmarkRegistry": BenchmarkRegistry,
"RouterPolicyRegistry": RouterPolicyRegistry,
"SpeechRegistry": SpeechRegistry,
"CompressionRegistry": CompressionRegistry,
}
aliases: dict[str, object] = {}
_alias_map = {
"ToolRegistry": ("tool", "tools"),
"AgentRegistry": ("agent", "agents"),
"EngineRegistry": ("engine", "engines"),
"MemoryRegistry": ("memory", "memories"),
"ModelRegistry": ("model", "models"),
"ChannelRegistry": ("channel", "channels"),
"LearningRegistry": ("learning", "learnings"),
"SkillRegistry": ("skill", "skills"),
"BenchmarkRegistry": ("benchmark", "benchmarks"),
"RouterPolicyRegistry": ("router", "routers"),
"SpeechRegistry": ("speech", "speeches"),
"CompressionRegistry": ("compression", "compressions"),
}
for class_name, cls in by_name.items():
aliases[class_name] = cls
for alias in _alias_map[class_name]:
aliases[alias] = cls
return by_name, aliases
@click.group()
def registry() -> None:
"""Inspect registered components — list registries, show entries."""
@registry.command("list")
def list_registries() -> None:
"""List all available registries."""
console = Console(stderr=True)
table = Table(title="Available Registries")
table.add_column("Registry", style="cyan")
table.add_column("Module", style="green")
table.add_column("Entry Count", style="yellow")
try:
by_name, _ = _load_registry_map()
except Exception as exc:
console.print(f"[red]Error loading registries: {exc}[/red]")
return
module_path = "openjarvis.core.registry"
for reg_name, registry_cls in by_name.items():
try:
count = len(registry_cls.keys())
table.add_row(reg_name, module_path, str(count))
except Exception as exc:
table.add_row(reg_name, module_path, f"[red]Error: {exc}[/red]")
console.print(table)
@registry.command()
@click.argument("registry_name")
@click.option(
"--verbose", "-v", is_flag=True, default=False, help="Show full entry details"
)
def show(registry_name: str, verbose: bool) -> None:
"""Show entries in a specific registry."""
console = Console(stderr=True)
try:
_, aliases = _load_registry_map()
registry_cls = aliases.get(registry_name)
if registry_cls is None:
console.print(f"[red]Unknown registry: {registry_name}[/red]")
console.print(
"[dim]Run 'jarvis registry list' to see available registries.[/dim]"
)
return
keys = registry_cls.keys()
if not keys:
console.print(f"[dim]{registry_name} is empty.[/dim]")
return
console.print(f"[bold]{registry_name}[/bold] — {len(keys)} entry/entries")
if verbose:
for key in keys:
entry = registry_cls.get(key)
console.print(f"\n [cyan]{key}[/cyan]")
console.print(f" Type: {type(entry).__name__}")
console.print(f" Value: {entry}")
else:
table = Table()
table.add_column("Key", style="cyan")
table.add_column("Type", style="green")
table.add_column("Value", style="white", max_width=80)
for key in keys:
entry = registry_cls.get(key)
entry_type = type(entry).__name__
entry_value = str(entry)
table.add_row(key, entry_type, entry_value)
console.print(table)
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
__all__ = ["registry"]
+386
View File
@@ -0,0 +1,386 @@
"""``jarvis scan`` — audit your environment for privacy and security risks."""
from __future__ import annotations
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List
import click
# Engine ports that should only be listening on localhost.
_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181}
# Processes associated with cloud-sync agents.
_CLOUD_SYNC_PROCS = ["Dropbox", "OneDrive", "Google Drive", "iCloudDrive"]
# Screen-recording / remote-access processes (macOS).
_SCREEN_RECORDING_PROCS = [
"TeamViewer", "AnyDesk", "ScreenConnect", "vncviewer", "Vine"
]
# Remote-access processes (Linux).
_REMOTE_ACCESS_PROCS = ["xrdp", "x11vnc", "vncserver", "AnyDesk"]
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass(slots=True)
class ScanResult:
"""Result of a single privacy/security check."""
name: str
status: str # "ok" | "warn" | "fail" | "skip"
message: str
platform: str # "darwin" | "linux" | "all"
# ---------------------------------------------------------------------------
# Scanner
# ---------------------------------------------------------------------------
class PrivacyScanner:
"""Collection of environment privacy checks."""
# -- Subprocess helper ---------------------------------------------------
def _run(self, cmd: list[str]) -> subprocess.CompletedProcess: # type: ignore[type-arg]
return subprocess.run(cmd, capture_output=True, text=True, timeout=10)
# -- Individual checks ---------------------------------------------------
def check_filevault(self) -> ScanResult:
"""Check whether FileVault disk encryption is enabled (macOS)."""
try:
proc = self._run(["fdesetup", "status"])
if "On" in proc.stdout:
return ScanResult(
name="FileVault",
status="ok",
message="FileVault is enabled.",
platform="darwin",
)
return ScanResult(
name="FileVault",
status="fail",
message="FileVault is NOT enabled. Enable full-disk encryption.",
platform="darwin",
)
except Exception:
return ScanResult(
name="FileVault",
status="skip",
message="fdesetup not available.",
platform="darwin",
)
def check_mdm(self) -> ScanResult:
"""Check whether the device is enrolled in an MDM profile (macOS)."""
try:
proc = self._run(["profiles", "status", "-type", "enrollment"])
output = proc.stdout + proc.stderr
lower = output.lower()
# "not enrolled" is a strong negative signal — check it first.
not_enrolled = "not enrolled" in lower or "no" in lower
# Positive signals: explicit Yes/enrolled without a negation.
enrolled_yes = (
"mdm enrollment: yes" in lower
or "enrolled via dep: yes" in lower
or ("enrolled" in lower and not not_enrolled)
)
if enrolled_yes:
return ScanResult(
name="MDM Enrollment",
status="warn",
message="Device appears to be enrolled in an MDM profile.",
platform="darwin",
)
return ScanResult(
name="MDM Enrollment",
status="ok",
message="Device is not enrolled in an MDM profile.",
platform="darwin",
)
except Exception:
return ScanResult(
name="MDM Enrollment",
status="skip",
message="profiles command not available.",
platform="darwin",
)
def check_icloud_sync(self) -> ScanResult:
"""Check whether ~/.openjarvis is inside iCloud Drive sync scope."""
try:
config_path = Path("~/.openjarvis").expanduser().resolve()
icloud_path = Path("~/Library/Mobile Documents/").expanduser().resolve()
if str(config_path).startswith(str(icloud_path)):
return ScanResult(
name="iCloud Sync",
status="warn",
message="~/.openjarvis may be synced to iCloud.",
platform="darwin",
)
# Also probe defaults for com.apple.bird (iCloud daemon)
try:
proc = self._run(
["defaults", "read", "com.apple.bird", "optout_preference"]
)
val = proc.stdout.strip()
if val == "0":
return ScanResult(
name="iCloud Sync",
status="warn",
message="iCloud Desktop/Documents sync may be active.",
platform="darwin",
)
except Exception:
pass
return ScanResult(
name="iCloud Sync",
status="ok",
message="~/.openjarvis is not inside iCloud Drive.",
platform="darwin",
)
except Exception:
return ScanResult(
name="iCloud Sync",
status="skip",
message="Could not determine iCloud sync status.",
platform="darwin",
)
def check_luks(self) -> ScanResult:
"""Check whether any block device uses LUKS encryption (Linux)."""
try:
proc = self._run(["lsblk", "-o", "NAME,TYPE,FSTYPE", "-J"])
data = json.loads(proc.stdout)
except Exception:
return ScanResult(
name="LUKS Encryption",
status="skip",
message="lsblk not available or returned unexpected output.",
platform="linux",
)
def _has_luks(devices: list) -> bool: # type: ignore[type-arg]
for dev in devices:
if dev.get("fstype") == "crypto_LUKS":
return True
children = dev.get("children") or []
if _has_luks(children):
return True
return False
try:
devices = data.get("blockdevices", [])
if _has_luks(devices):
return ScanResult(
name="LUKS Encryption",
status="ok",
message="At least one LUKS-encrypted device found.",
platform="linux",
)
return ScanResult(
name="LUKS Encryption",
status="fail",
message="No LUKS-encrypted block devices found.",
platform="linux",
)
except Exception:
return ScanResult(
name="LUKS Encryption",
status="skip",
message="Could not parse lsblk output.",
platform="linux",
)
def _check_processes(
self,
names: list[str],
check_name: str,
warn_msg: str,
platform: str,
) -> ScanResult:
"""Shared helper: pgrep for any of the given process names."""
try:
for name in names:
try:
proc = self._run(["pgrep", "-x", name])
if proc.returncode == 0:
return ScanResult(
name=check_name,
status="warn",
message=warn_msg.format(name=name),
platform=platform,
)
except Exception:
continue
return ScanResult(
name=check_name,
status="ok",
message=f"No {check_name.lower()} processes detected.",
platform=platform,
)
except Exception:
return ScanResult(
name=check_name,
status="skip",
message="pgrep not available.",
platform=platform,
)
def check_cloud_sync_agents(self) -> ScanResult:
"""Check for running cloud-sync agent processes."""
return self._check_processes(
names=_CLOUD_SYNC_PROCS,
check_name="Cloud Sync Agents",
warn_msg="{name} sync agent is running — weights may be uploaded to cloud.",
platform="all",
)
def check_network_exposure(self) -> ScanResult:
"""Check if engine ports are exposed on 0.0.0.0 rather than localhost."""
try:
if sys.platform == "darwin":
proc = self._run(["lsof", "-iTCP", "-sTCP:LISTEN", "-n", "-P"])
else:
proc = self._run(["ss", "-tlnp"])
output = proc.stdout
exposed: list[int] = []
for port in _ENGINE_PORTS:
# Look for patterns like *:PORT or 0.0.0.0:PORT
for token in (f"*:{port}", f"0.0.0.0:{port}", f":::{port}"):
if token.replace(" ", "") in output.replace(" ", ""):
exposed.append(port)
break
if exposed:
ports_str = ", ".join(str(p) for p in sorted(exposed))
return ScanResult(
name="Network Exposure",
status="warn",
message=f"Engine port(s) {ports_str} exposed on all interfaces.",
platform="all",
)
return ScanResult(
name="Network Exposure",
status="ok",
message="All engine ports appear to be bound to localhost only.",
platform="all",
)
except Exception:
return ScanResult(
name="Network Exposure",
status="skip",
message="Could not determine network exposure (lsof/ss unavailable).",
platform="all",
)
def check_screen_recording(self) -> ScanResult:
"""Check for running screen-recording / remote-desktop processes (macOS)."""
return self._check_processes(
names=_SCREEN_RECORDING_PROCS,
check_name="Screen Recording",
warn_msg="{name} is running — screen may be accessible remotely.",
platform="darwin",
)
def check_remote_access(self) -> ScanResult:
"""Check for running remote-access processes (Linux)."""
return self._check_processes(
names=_REMOTE_ACCESS_PROCS,
check_name="Remote Access",
warn_msg="{name} is running — system may be accessible remotely.",
platform="linux",
)
# -- Orchestration -------------------------------------------------------
def _get_all_checks(self) -> list[Callable[[], ScanResult]]:
return [
self.check_filevault,
self.check_mdm,
self.check_icloud_sync,
self.check_cloud_sync_agents,
self.check_network_exposure,
self.check_luks,
self.check_screen_recording,
self.check_remote_access,
]
def run_all(self) -> list[ScanResult]:
"""Run all checks, filter to the current platform, hide 'skip' results."""
current_plat = "darwin" if sys.platform == "darwin" else "linux"
results: list[ScanResult] = []
for check_fn in self._get_all_checks():
result = check_fn()
if result.platform not in (current_plat, "all"):
continue
if result.status == "skip":
continue
results.append(result)
return results
def run_quick(self) -> list[ScanResult]:
"""Run only critical checks: disk encryption + cloud sync agents."""
current_plat = "darwin" if sys.platform == "darwin" else "linux"
quick_checks: list[Callable[[], ScanResult]]
if current_plat == "darwin":
quick_checks = [self.check_filevault, self.check_cloud_sync_agents]
else:
quick_checks = [self.check_luks, self.check_cloud_sync_agents]
results = []
for check_fn in quick_checks:
result = check_fn()
if result.status != "skip":
results.append(result)
return results
# ---------------------------------------------------------------------------
# CLI command
# ---------------------------------------------------------------------------
_STATUS_ICONS = {"ok": "", "warn": "!", "fail": "", "skip": "-"}
@click.command()
@click.option("--quick", is_flag=True, default=False, help="Run only critical checks.")
def scan(quick: bool) -> None:
"""Audit your environment for privacy and security risks."""
scanner = PrivacyScanner()
results: List[ScanResult] = scanner.run_quick() if quick else scanner.run_all()
if not results:
click.echo("No applicable checks for this platform.")
return
warnings = 0
failures = 0
for r in results:
icon = _STATUS_ICONS.get(r.status, "?")
click.echo(f" [{icon}] {r.name}: {r.message}")
if r.status == "warn":
warnings += 1
elif r.status == "fail":
failures += 1
click.echo("")
parts = []
if warnings:
parts.append(f"{warnings} warning(s)")
if failures:
parts.append(f"{failures} issue(s)")
if parts:
click.echo("Summary: " + ", ".join(parts) + ".")
else:
click.echo("Summary: all checks passed.")
+31
View File
@@ -230,6 +230,20 @@ def serve(
console.print(f"[yellow]Channel failed to start: {exc}[/yellow]")
channel_bridge = None
# Wire channel messages → agent / engine (per-chat session isolation)
if channel_bridge is not None:
from openjarvis.system import JarvisSystem
_wire_system = JarvisSystem(
config=config,
bus=bus,
engine=engine,
engine_key=engine_name,
model=model_name,
agent_name=agent_key or "",
)
_wire_system.wire_channel(channel_bridge)
# Set up speech backend
speech_backend = None
try:
@@ -286,10 +300,27 @@ def serve(
except Exception as exc:
logger.debug("Agent scheduler init failed: %s", exc)
# Set up memory backend for context injection
memory_backend = None
if config.agent.context_from_memory:
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
mem_key = config.memory.default_backend
if MemoryRegistry.contains(mem_key):
memory_backend = MemoryRegistry.create(
mem_key, db_path=config.memory.db_path,
)
console.print(" Memory: [cyan]active[/cyan]")
except Exception as exc:
logger.debug("Memory backend init failed: %s", exc)
app = create_app(
engine, model_name, agent=agent, bus=bus,
engine_name=engine_name, agent_name=agent_key or "",
channel_bridge=channel_bridge, config=config,
memory_backend=memory_backend,
speech_backend=speech_backend,
agent_manager=agent_manager,
agent_scheduler=agent_scheduler,
+107
View File
@@ -0,0 +1,107 @@
"""``jarvis tool`` — tool management commands."""
from __future__ import annotations
import click
from rich.console import Console
from rich.table import Table
@click.group()
def tool() -> None:
"""Manage tools — list, inspect."""
@tool.command("list")
def list_tools() -> None:
"""List all registered tools with their descriptions."""
console = Console(stderr=True)
try:
# Trigger tool registration by importing the tools module
import openjarvis.tools # noqa: F401
from openjarvis.core.registry import ToolRegistry
keys = sorted(ToolRegistry.keys())
if not keys:
console.print("[dim]No tools registered.[/dim]")
return
table = Table(title="Registered Tools")
table.add_column("Name", style="cyan")
table.add_column("Description", style="green", max_width=60)
table.add_column("Category", style="yellow")
for key in keys:
tool_cls = ToolRegistry.get(key)
description = ""
category = ""
# Try to get spec from the class or an instance
try:
# Some tools may require initialization, so try with default init
tool_instance = tool_cls() if callable(tool_cls) else tool_cls
if hasattr(tool_instance, "spec"):
spec = tool_instance.spec
description = getattr(spec, "description", "")[:60]
category = getattr(spec, "category", "")
except Exception:
# If instantiation fails, just show the key
description = "N/A (instantiation error)"
table.add_row(key, description, category)
console.print(table)
console.print(f"\n[dim]Total: {len(keys)} tool(s)[/dim]")
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
@tool.command()
@click.argument("tool_name")
def inspect(tool_name: str) -> None:
"""Show detailed information about a specific tool."""
console = Console(stderr=True)
try:
# Trigger tool registration by importing the tools module
import openjarvis.tools # noqa: F401
from openjarvis.core.registry import ToolRegistry
if not ToolRegistry.contains(tool_name):
console.print(f"[red]Tool not found: {tool_name}[/red]")
console.print("[dim]Run 'jarvis tool list' to see available tools.[/dim]")
return
tool_cls = ToolRegistry.get(tool_name)
console.print(f"[bold]{tool_name}[/bold]")
try:
tool_instance = tool_cls() if callable(tool_cls) else tool_cls
if hasattr(tool_instance, "spec"):
spec = tool_instance.spec
console.print(f" [cyan]Name:[/cyan] {getattr(spec, 'name', 'N/A')}")
console.print(
f" [cyan]Description:[/cyan] {getattr(spec, 'description', 'N/A')}"
)
category = getattr(spec, "category", "N/A") or "none"
console.print(f" [cyan]Category:[/cyan] {category}")
params = getattr(spec, "parameters", {})
if params:
console.print(" [cyan]Parameters:[/cyan]")
if isinstance(params, dict) and "properties" in params:
for param_name, param_info in params["properties"].items():
param_type = param_info.get("type", "any")
param_desc = param_info.get("description", "")
console.print(
f"{param_name}: {param_type}{param_desc}"
)
else:
console.print(f" {params}")
except Exception as e:
console.print(f"[yellow]Note: Could not instantiate tool: {e}[/yellow]")
except Exception as exc:
console.print(f"[red]Error: {exc}[/red]")
__all__ = ["tool"]
+210 -37
View File
@@ -7,6 +7,7 @@ found in the TOML file.
from __future__ import annotations
import functools
import os
import platform
import shutil
@@ -59,7 +60,10 @@ def _run_cmd(cmd: list[str]) -> str:
"""Run a command and return stripped stdout, or empty string on failure."""
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10, # noqa: S603
cmd,
capture_output=True,
text=True,
timeout=10, # noqa: S603
)
return result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
@@ -69,11 +73,13 @@ def _run_cmd(cmd: list[str]) -> str:
def _detect_nvidia_gpu() -> Optional[GpuInfo]:
if not shutil.which("nvidia-smi"):
return None
raw = _run_cmd([
"nvidia-smi",
"--query-gpu=name,memory.total,count",
"--format=csv,noheader,nounits",
])
raw = _run_cmd(
[
"nvidia-smi",
"--query-gpu=name,memory.total,count",
"--format=csv,noheader,nounits",
]
)
if not raw:
return None
try:
@@ -117,6 +123,7 @@ def _detect_amd_gpu() -> Optional[GpuInfo]:
try:
allinfo_raw = _run_cmd(["rocm-smi", "--showallinfo"])
import re
gpu_ids = set(re.findall(r"GPU\[(\d+)\]", allinfo_raw))
if gpu_ids:
count = len(gpu_ids)
@@ -248,6 +255,11 @@ def recommend_model(hw: HardwareInfo, engine: str) -> str:
return ""
def estimated_download_gb(parameter_count_b: float) -> float:
"""Estimate download size in GB for Q4_K_M quantized model."""
return parameter_count_b * 0.5 * 1.1
# ---------------------------------------------------------------------------
# Configuration hierarchy
# ---------------------------------------------------------------------------
@@ -325,6 +337,16 @@ class AppleFmEngineConfig:
host: str = "http://localhost:8079"
@dataclass(slots=True)
class GemmaCppEngineConfig:
"""Per-engine config for gemma.cpp."""
model_path: str = ""
tokenizer_path: str = ""
model_type: str = ""
num_threads: int = 0
@dataclass
class EngineConfig:
"""Inference engine settings with nested per-engine configs."""
@@ -340,6 +362,7 @@ class EngineConfig:
nexa: NexaEngineConfig = field(default_factory=NexaEngineConfig)
uzu: UzuEngineConfig = field(default_factory=UzuEngineConfig)
apple_fm: AppleFmEngineConfig = field(default_factory=AppleFmEngineConfig)
gemma_cpp: GemmaCppEngineConfig = field(default_factory=GemmaCppEngineConfig)
# Backward-compat properties for old flat attribute names
@property
@@ -448,26 +471,26 @@ class IntelligenceConfig:
default_model: str = ""
fallback_model: str = ""
model_path: str = "" # Local weights (HF repo, GGUF file, etc.)
checkpoint_path: str = "" # Checkpoint/adapter path
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
model_path: str = "" # Local weights (HF repo, GGUF file, etc.)
checkpoint_path: str = "" # Checkpoint/adapter path
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
stop_sequences: str = "" # Comma-separated stop strings
@dataclass(slots=True)
class RoutingLearningConfig:
"""Routing sub-policy config within Learning."""
policy: str = "heuristic" # heuristic | learned
min_samples: int = 5 # Min traces before trusting learned routing
policy: str = "heuristic" # heuristic | learned
min_samples: int = 5 # Min traces before trusting learned routing
@dataclass(slots=True)
@@ -716,9 +739,9 @@ class AgentConfig:
default_agent: str = "simple"
max_turns: int = 10
tools: str = "" # comma-separated tool names
objective: str = "" # concise purpose for routing/learning/docs
system_prompt: str = "" # inline system prompt (takes precedence if set)
tools: str = "" # comma-separated tool names
objective: str = "" # concise purpose for routing/learning/docs
system_prompt: str = "" # inline system prompt (takes precedence if set)
system_prompt_path: str = "" # path to system prompt file (.txt, .md)
context_from_memory: bool = True # inject relevant memory context into prompts
@@ -762,7 +785,7 @@ class TelemetryConfig:
class TracesConfig:
"""Trace system settings."""
enabled: bool = False
enabled: bool = True
db_path: str = str(DEFAULT_CONFIG_DIR / "traces.db")
@@ -898,7 +921,7 @@ class BlueBubblesChannelConfig:
class WhatsAppBaileysChannelConfig:
"""Per-channel config for WhatsApp via Baileys protocol."""
auth_dir: str = "" # Defaults to ~/.openjarvis/whatsapp_auth
auth_dir: str = "" # Defaults to ~/.openjarvis/whatsapp_auth
assistant_name: str = "Jarvis"
assistant_has_own_number: bool = False
@@ -1055,6 +1078,45 @@ class AgentManagerConfig:
db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db")
@dataclass(slots=True)
class MemoryFilesConfig:
"""Persistent memory-file paths and nudge settings."""
soul_path: str = "~/.openjarvis/SOUL.md"
memory_path: str = "~/.openjarvis/MEMORY.md"
user_path: str = "~/.openjarvis/USER.md"
nudge_interval: int = 10
@dataclass(slots=True)
class SystemPromptConfig:
"""Limits and strategy for system-prompt assembly."""
soul_max_chars: int = 4000
memory_max_chars: int = 2500
user_max_chars: int = 1500
skill_desc_max_chars: int = 60
truncation_strategy: str = "head_tail"
@dataclass(slots=True)
class CompressionConfig:
"""Configuration for context compression."""
enabled: bool = True
threshold: float = 0.50
strategy: str = "session_consolidation"
@dataclass(slots=True)
class SkillsConfig:
"""Configuration for agent-authored procedural skills."""
skills_dir: str = "~/.openjarvis/skills/"
nudge_interval: int = 15
auto_discover: bool = True
@dataclass
class JarvisConfig:
"""Top-level configuration for OpenJarvis."""
@@ -1079,6 +1141,10 @@ class JarvisConfig:
speech: SpeechConfig = field(default_factory=SpeechConfig)
optimize: OptimizeConfig = field(default_factory=OptimizeConfig)
agent_manager: AgentManagerConfig = field(default_factory=AgentManagerConfig)
memory_files: MemoryFilesConfig = field(default_factory=MemoryFilesConfig)
system_prompt: SystemPromptConfig = field(default_factory=SystemPromptConfig)
compression: CompressionConfig = field(default_factory=CompressionConfig)
skills: SkillsConfig = field(default_factory=SkillsConfig)
@property
def memory(self) -> StorageConfig:
@@ -1091,6 +1157,80 @@ class JarvisConfig:
self.tools.storage = value
# ---------------------------------------------------------------------------
# Config key validation
# ---------------------------------------------------------------------------
# Sections that users may set via ``jarvis config set``.
# ``hardware`` is auto-detected and not user-settable.
_SETTABLE_SECTIONS = frozenset(JarvisConfig.__dataclass_fields__.keys()) - {"hardware"}
def validate_config_key(dotted_key: str) -> type:
"""Validate a dotted config key and return the leaf field's Python type.
Raises :class:`ValueError` when the key does not map to a known field.
The function walks the ``JarvisConfig`` dataclass hierarchy using
``dataclasses.fields()``.
Examples::
validate_config_key("engine.ollama.host") # -> str
validate_config_key("intelligence.temperature") # -> float
"""
from dataclasses import fields as dc_fields
parts = dotted_key.split(".")
if len(parts) < 2:
raise ValueError(
f"Config key must have at least two segments (e.g. engine.default), "
f"got: {dotted_key!r}"
)
if parts[0] not in _SETTABLE_SECTIONS:
raise ValueError(
f"Unknown config key: {dotted_key!r} "
f"(valid top-level sections: {sorted(_SETTABLE_SECTIONS)})"
)
# Walk the dataclass tree
current_cls = JarvisConfig
for i, part in enumerate(parts):
field_map = {f.name: f for f in dc_fields(current_cls)}
if part not in field_map:
path_so_far = ".".join(parts[: i + 1])
raise ValueError(
f"Unknown config key: {dotted_key!r} "
f"(no field {part!r} at {path_so_far}; "
f"valid fields: {sorted(field_map.keys())})"
)
fld = field_map[part]
# Resolve the type — unwrap Optional, etc.
fld_type = fld.type
if isinstance(fld_type, str):
# Evaluate forward references in the config module namespace
import openjarvis.core.config as _cfg_mod
fld_type = eval(fld_type, vars(_cfg_mod)) # noqa: S307
if i == len(parts) - 1:
# Leaf — return the primitive type
return fld_type
else:
# Must be a nested dataclass
if not hasattr(fld_type, "__dataclass_fields__"):
path_so_far = ".".join(parts[: i + 1])
raise ValueError(
f"Unknown config key: {dotted_key!r} "
f"({path_so_far} is a leaf of type {fld_type.__name__}, "
f"not a section)"
)
current_cls = fld_type
# Should not reach here, but satisfy type checker
raise ValueError(f"Unknown config key: {dotted_key!r}")
# ---------------------------------------------------------------------------
# TOML loading
# ---------------------------------------------------------------------------
@@ -1139,7 +1279,8 @@ def _migrate_toml_data(data: Dict[str, Any], cfg: "JarvisConfig") -> None:
src = data.get(src_section, {})
if isinstance(src, dict) and "context_injection" in src:
data.setdefault("agent", {}).setdefault(
"context_from_memory", src.pop("context_injection"),
"context_from_memory",
src.pop("context_injection"),
)
if "tools" in data:
@@ -1148,10 +1289,12 @@ def _migrate_toml_data(data: Dict[str, Any], cfg: "JarvisConfig") -> None:
storage_sub = tools_data.get("storage", {})
if isinstance(storage_sub, dict) and "context_injection" in storage_sub:
data.setdefault("agent", {}).setdefault(
"context_from_memory", storage_sub.pop("context_injection"),
"context_from_memory",
storage_sub.pop("context_injection"),
)
@functools.lru_cache(maxsize=1)
def load_config(path: Optional[Path] = None) -> JarvisConfig:
"""Detect hardware, build defaults, overlay TOML overrides.
@@ -1181,16 +1324,31 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
# All top-level sections — recursive _apply_toml_section handles
# nested sub-configs (engine.ollama, learning.routing, channel.*, etc.)
top_sections = (
"engine", "intelligence", "learning", "agent",
"server", "telemetry", "traces", "security",
"channel", "tools", "sandbox", "scheduler",
"workflow", "sessions", "a2a", "operators",
"speech", "optimize", "agent_manager",
"engine",
"intelligence",
"learning",
"agent",
"server",
"telemetry",
"traces",
"security",
"channel",
"tools",
"sandbox",
"scheduler",
"workflow",
"sessions",
"a2a",
"operators",
"speech",
"optimize",
"agent_manager",
)
for section_name in top_sections:
if section_name in data:
_apply_toml_section(
getattr(cfg, section_name), data[section_name],
getattr(cfg, section_name),
data[section_name],
)
# Memory: accept [memory] (old) → maps to tools.storage
@@ -1205,18 +1363,23 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
# ---------------------------------------------------------------------------
def generate_minimal_toml(hw: HardwareInfo, engine: str | None = None) -> str:
def generate_minimal_toml(
hw: HardwareInfo, engine: str | None = None, *, host: str | None = None
) -> str:
"""Render a minimal TOML config with only essential settings."""
engine = engine or recommend_engine(hw)
model = recommend_model(hw, engine)
gpu_comment = ""
if hw.gpu:
mem_label = (
"unified memory" if hw.gpu.vendor == "apple" else "VRAM"
)
gpu_comment = (
f"\n# GPU: {hw.gpu.name}"
f" ({hw.gpu.vram_gb} GB {mem_label})"
mem_label = "unified memory" if hw.gpu.vendor == "apple" else "VRAM"
gpu_comment = f"\n# GPU: {hw.gpu.name} ({hw.gpu.vram_gb} GB {mem_label})"
if host:
engine_host_section = f'\n[engine.{engine}]\nhost = "{host}"\n'
else:
engine_host_section = (
f"\n[engine.{engine}]\n"
f'# host = "http://localhost:11434" '
f"# set to remote URL if engine runs elsewhere\n"
)
return f"""\
# OpenJarvis configuration
@@ -1225,7 +1388,7 @@ def generate_minimal_toml(hw: HardwareInfo, engine: str | None = None) -> str:
[engine]
default = "{engine}"
{engine_host_section}
[intelligence]
default_model = "{model}"
@@ -1237,7 +1400,9 @@ enabled = ["code_interpreter", "web_search", "file_read", "shell_exec"]
"""
def generate_default_toml(hw: HardwareInfo, engine: str | None = None) -> str:
def generate_default_toml(
hw: HardwareInfo, engine: str | None = None, *, host: str | None = None
) -> str:
"""Render a commented TOML string suitable for ``~/.openjarvis/config.toml``."""
engine = engine or recommend_engine(hw)
model = recommend_model(hw, engine)
@@ -1249,7 +1414,7 @@ def generate_default_toml(hw: HardwareInfo, engine: str | None = None) -> str:
if model:
model_comment = " # recommended for your hardware"
return f"""\
result = f"""\
# OpenJarvis configuration
# Generated by `jarvis init`
#
@@ -1444,6 +1609,13 @@ ssrf_protection = true
# assistant_name = "Jarvis"
# assistant_has_own_number = false
"""
if host:
import re as _re
pattern = _re.escape(f"[engine.{engine}]") + r"\nhost = \"[^\"]*\""
replacement = f'[engine.{engine}]\\nhost = "{host}"'
result = _re.sub(pattern, replacement, result)
return result
__all__ = [
@@ -1508,4 +1680,5 @@ __all__ = [
"load_config",
"recommend_engine",
"recommend_model",
"validate_config_key",
]
+5
View File
@@ -141,10 +141,15 @@ class SpeechRegistry(RegistryBase[Any]):
"""Registry for speech backend implementations."""
class CompressionRegistry(RegistryBase[Any]):
"""Registry for context compression strategies."""
__all__ = [
"AgentRegistry",
"BenchmarkRegistry",
"ChannelRegistry",
"CompressionRegistry",
"EngineRegistry",
"LearningRegistry",
"MemoryRegistry",
+4
View File
@@ -132,6 +132,7 @@ class TelemetryRecord:
timestamp: float
model_id: str
prompt_tokens: int = 0
prompt_tokens_evaluated: int = 0 # KV-cache-aware: actual tokens processed
completion_tokens: int = 0
total_tokens: int = 0
latency_seconds: float = 0.0
@@ -235,8 +236,11 @@ class RoutingContext:
query_length: int = 0
has_code: bool = False
has_math: bool = False
has_reasoning: bool = False
language: str = "en"
urgency: float = 0.5
complexity_score: float = 0.0 # 0.0 (trivial) to 1.0 (very complex)
suggested_max_tokens: int = 1024
metadata: Dict[str, Any] = field(default_factory=dict)
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any, Optional
class GatewayDaemon:
"""Composes channels, sessions, agents, and scheduler into a daemon."""
def __init__(
self,
config: Any = None,
session_store: Any = None,
agent_manager: Any = None,
agent_scheduler: Any = None,
event_bus: Any = None,
) -> None:
self._config = config
self._session_store = session_store
self._agent_manager = agent_manager
self._agent_scheduler = agent_scheduler
self._event_bus = event_bus
self._running = False
@staticmethod
def session_key(
platform: str,
chat_type: str,
chat_id: str,
thread_id: Optional[str],
) -> str:
return f"agent:main:{platform}:{chat_type}:{chat_id}:{thread_id}"
def start(self) -> None:
"""Start the daemon (foreground)."""
self._running = True
def stop(self) -> None:
"""Stop the daemon."""
self._running = False
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import sys
from pathlib import Path
SYSTEMD_TEMPLATE = """\
[Unit]
Description=OpenJarvis Gateway Daemon
After=network.target
[Service]
Type=simple
ExecStart={python} -m openjarvis.daemon.gateway
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
"""
LAUNCHD_TEMPLATE = """\
<?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.gateway</string>
<key>ProgramArguments</key>
<array>
<string>{python}</string>
<string>-m</string>
<string>openjarvis.daemon.gateway</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
"""
def generate_systemd_service(output: Path | None = None) -> str:
content = SYSTEMD_TEMPLATE.format(python=sys.executable)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content)
return content
def generate_launchd_plist(output: Path | None = None) -> str:
content = LAUNCHD_TEMPLATE.format(python=sys.executable)
if output:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(content)
return content
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from typing import Any, List
from openjarvis.core.types import Message
class SessionExpiryHook:
"""Proactive memory flush before session reset."""
FLUSH_PROMPT = (
"This session is about to be reset. Review the conversation below "
"and save anything important to memory or skills. Use memory_manage "
"to save facts/preferences and skill_manage to save reusable procedures."
)
def __init__(self, executor: Any, flush_min_turns: int = 6) -> None:
self._executor = executor
self._flush_min_turns = flush_min_turns
def on_session_expiry(self, session_id: str, messages: List[Message]) -> None:
if len(messages) < self._flush_min_turns:
return
transcript = "\n".join(f"[{m.role}]: {m.content}" for m in messages)
input_text = f"{self.FLUSH_PROMPT}\n\n---\n\n{transcript}"
self._executor.run_ephemeral(
agent_type="simple",
system_prompt=(
"You are a memory management agent. "
"Save important information."
),
input_text=input_text,
tools=["memory_manage", "skill_manage"],
)
+1 -1
View File
@@ -15,7 +15,7 @@ from openjarvis.engine._base import (
from openjarvis.engine._discovery import discover_engines, discover_models, get_engine
# Optional engines — only register if their SDK deps are present
for _optional in ("cloud", "litellm"):
for _optional in ("cloud", "litellm", "gemma_cpp"):
try:
importlib.import_module(f".{_optional}", __name__)
except ImportError:
+25 -1
View File
@@ -38,4 +38,28 @@ def messages_to_dicts(messages: Sequence[Message]) -> List[Dict[str, Any]]:
return out
__all__ = ["EngineConnectionError", "InferenceEngine", "messages_to_dicts"]
def estimate_prompt_tokens(messages: Sequence[Message]) -> int:
"""Estimate full prompt token count from message content.
Ollama's ``prompt_eval_count`` may report only *newly evaluated*
tokens when KV-cache hits occur, under-counting the system prompt
and earlier conversation turns. This helper provides a
cache-agnostic estimate so that downstream cost / FLOPs / energy
calculations reflect the true prompt size matching what a cloud
provider would charge.
Uses ~4 characters per token (standard BPE average for English) plus
a small per-message overhead for role markers and separators.
"""
total_chars = sum(len(m.content) for m in messages)
# ~4 tokens overhead per message for role markers / separators
overhead = len(messages) * 4
return max(1, total_chars // 4 + overhead)
__all__ = [
"EngineConnectionError",
"InferenceEngine",
"estimate_prompt_tokens",
"messages_to_dicts",
]
+12
View File
@@ -25,12 +25,24 @@ _HOST_MAP: Dict[str, str | None] = {
"apple_fm": "apple_fm_host",
"cloud": None,
"litellm": None,
"gemma_cpp": None,
}
def _make_engine(key: str, config: JarvisConfig) -> InferenceEngine:
"""Instantiate a registered engine with the appropriate config host."""
cls = EngineRegistry.get(key)
# gemma_cpp: pass config fields instead of host
if key == "gemma_cpp":
cfg = config.engine.gemma_cpp
return cls(
model_path=cfg.model_path or None,
tokenizer_path=cfg.tokenizer_path or None,
model_type=cfg.model_type or None,
num_threads=cfg.num_threads,
)
host_attr = _HOST_MAP.get(key)
if host_attr is not None:
host = getattr(config.engine, host_attr, None)
+15 -28
View File
@@ -13,6 +13,7 @@ from openjarvis.core.types import Message
from openjarvis.engine._base import (
EngineConnectionError,
InferenceEngine,
estimate_prompt_tokens,
messages_to_dicts,
)
@@ -32,26 +33,6 @@ class _OpenAICompatibleEngine(InferenceEngine):
# -- InferenceEngine interface ------------------------------------------
@staticmethod
def _fix_tool_call_arguments(msg_dicts: list) -> list:
"""Ensure tool_call arguments are dicts, not JSON strings.
OpenAI-compatible servers (vLLM, SGLang, llama.cpp, etc.) expect
tool_call arguments as JSON objects. ``messages_to_dicts`` may
serialize them as strings, which causes 400 errors on multi-turn
tool-calling conversations.
"""
for md in msg_dicts:
for tc in md.get("tool_calls", []):
fn = tc.get("function", {})
args = fn.get("arguments")
if isinstance(args, str):
try:
fn["arguments"] = json.loads(args)
except (json.JSONDecodeError, TypeError):
pass
return msg_dicts
def generate(
self,
messages: Sequence[Message],
@@ -61,14 +42,12 @@ class _OpenAICompatibleEngine(InferenceEngine):
max_tokens: int = 1024,
**kwargs: Any,
) -> Dict[str, Any]:
msg_dicts = self._fix_tool_call_arguments(messages_to_dicts(messages))
payload: Dict[str, Any] = {
"model": model,
"messages": msg_dicts,
"messages": messages_to_dicts(messages),
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
"chat_template_kwargs": {"enable_thinking": False},
**kwargs,
}
try:
@@ -94,12 +73,21 @@ class _OpenAICompatibleEngine(InferenceEngine):
}
choice = choices[0]
usage = data.get("usage", {})
# Ensure prompt_tokens reflects the full prompt size (including
# system prompt and all conversation history).
# OpenAI-compat APIs (vLLM, SGLang) report full counts — KV
# caching is transparent, so evaluated == full.
reported_prompt = usage.get("prompt_tokens", 0)
estimated_prompt = estimate_prompt_tokens(messages)
prompt_tokens = max(reported_prompt, estimated_prompt)
completion_tokens = usage.get("completion_tokens", 0)
result: Dict[str, Any] = {
"content": choice["message"].get("content") or "",
"usage": {
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
"prompt_tokens": prompt_tokens,
"prompt_tokens_evaluated": reported_prompt or prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
"model": data.get("model", model),
"finish_reason": choice.get("finish_reason", "stop"),
@@ -126,10 +114,9 @@ class _OpenAICompatibleEngine(InferenceEngine):
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator[str]:
msg_dicts = self._fix_tool_call_arguments(messages_to_dicts(messages))
payload: Dict[str, Any] = {
"model": model,
"messages": msg_dicts,
"messages": messages_to_dicts(messages),
"temperature": temperature,
"max_tokens": max_tokens,
"stream": True,
+146 -3
View File
@@ -1,4 +1,4 @@
"""Cloud inference engine — OpenAI, Anthropic, and Google API backends."""
"""Cloud inference engine — OpenAI, Anthropic, Google, and MiniMax API backends."""
from __future__ import annotations
@@ -38,6 +38,10 @@ PRICING: Dict[str, tuple[float, float]] = {
"gemini-3.1-flash-lite-preview": (0.30, 2.50),
"gemini-3-flash-preview": (0.50, 3.00),
"claude-haiku-4-5-20251001": (1.00, 5.00),
"MiniMax-M2.7": (0.30, 1.20),
"MiniMax-M2.7-highspeed": (0.60, 2.40),
"MiniMax-M2.5": (0.30, 1.20),
"MiniMax-M2.5-highspeed": (0.60, 2.40),
}
# Well-known model IDs per provider
@@ -62,6 +66,12 @@ _GOOGLE_MODELS = [
"gemini-3.1-flash-lite-preview",
"gemini-3-flash-preview",
]
_MINIMAX_MODELS = [
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
]
# OpenRouter models — prefixed with "openrouter/" so they can be identified
_OPENROUTER_POPULAR = [
@@ -76,6 +86,10 @@ _OPENROUTER_POPULAR = [
]
def _is_minimax_model(model: str) -> bool:
return model.lower().startswith("minimax")
def _is_openrouter_model(model: str) -> bool:
return model.startswith("openrouter/")
@@ -113,6 +127,31 @@ def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> flo
return input_cost + output_cost
def _annotate_anthropic_cache(messages: list[dict]) -> list[dict]:
"""Add cache_control to system message for Anthropic prompt caching."""
result = []
for msg in messages:
if msg.get("role") == "system":
content = msg["content"]
if isinstance(content, str):
content = [
{
"type": "text",
"text": content,
"cache_control": {"type": "ephemeral"},
}
]
elif isinstance(content, list):
content = [
{**block, "cache_control": {"type": "ephemeral"}}
for block in content
]
result.append({**msg, "content": content})
else:
result.append(msg)
return result
def _convert_tools_to_anthropic(
openai_tools: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
@@ -145,7 +184,7 @@ def _convert_tools_to_google(
@EngineRegistry.register("cloud")
class CloudEngine(InferenceEngine):
"""Cloud inference via OpenAI, Anthropic, and Google SDKs."""
"""Cloud inference via OpenAI, Anthropic, Google, and MiniMax SDKs."""
engine_id = "cloud"
@@ -154,6 +193,7 @@ class CloudEngine(InferenceEngine):
self._anthropic_client: Any = None
self._google_client: Any = None
self._openrouter_client: Any = None
self._minimax_client: Any = None
self._init_clients()
def _init_clients(self) -> None:
@@ -189,6 +229,16 @@ class CloudEngine(InferenceEngine):
)
except ImportError:
pass
minimax_key = os.environ.get("MINIMAX_API_KEY")
if minimax_key:
try:
import openai
self._minimax_client = openai.OpenAI(
base_url="https://api.minimax.io/v1",
api_key=minimax_key,
)
except ImportError:
pass
def _generate_openai(
self,
@@ -601,6 +651,59 @@ class CloudEngine(InferenceEngine):
"ttft": elapsed,
}
def _generate_minimax(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> Dict[str, Any]:
if self._minimax_client is None:
raise EngineConnectionError(
"MiniMax client not available — set MINIMAX_API_KEY"
)
# MiniMax requires temperature in (0.0, 1.0]; clamp zero
temperature = max(temperature, 0.01)
temperature = min(temperature, 1.0)
kwargs.pop("response_format", None)
create_kwargs: Dict[str, Any] = {
"model": model,
"messages": messages_to_dicts(messages),
"max_tokens": max_tokens,
"temperature": temperature,
}
t0 = time.monotonic()
resp = self._minimax_client.chat.completions.create(**create_kwargs)
elapsed = time.monotonic() - t0
choice = resp.choices[0]
usage = resp.usage
prompt_tokens = usage.prompt_tokens if usage else 0
completion_tokens = usage.completion_tokens if usage else 0
result: Dict[str, Any] = {
"content": choice.message.content or "",
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": (usage.total_tokens if usage else 0),
},
"model": resp.model,
"finish_reason": choice.finish_reason or "stop",
"cost_usd": estimate_cost(model, prompt_tokens, completion_tokens),
"ttft": elapsed,
}
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
result["tool_calls"] = [
{
"id": tc.id,
"name": tc.function.name,
"arguments": tc.function.arguments,
}
for tc in choice.message.tool_calls
]
return result
def generate(
self,
messages: Sequence[Message],
@@ -618,6 +721,8 @@ class CloudEngine(InferenceEngine):
)
if _is_openrouter_model(model):
return self._generate_openrouter(messages, **kw)
if _is_minimax_model(model):
return self._generate_minimax(messages, **kw)
if _is_anthropic_model(model):
return self._generate_anthropic(messages, **kw)
if _is_google_model(model):
@@ -644,6 +749,11 @@ class CloudEngine(InferenceEngine):
messages, **kw
):
yield token
elif _is_minimax_model(model):
async for token in self._stream_minimax(
messages, **kw
):
yield token
elif _is_anthropic_model(model):
async for token in self._stream_anthropic(
messages, **kw
@@ -779,6 +889,32 @@ class CloudEngine(InferenceEngine):
if delta and delta.content:
yield delta.content
async def _stream_minimax(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float,
max_tokens: int,
**kwargs: Any,
) -> AsyncIterator[str]:
if self._minimax_client is None:
raise EngineConnectionError("MiniMax client not available")
temperature = max(temperature, 0.01)
temperature = min(temperature, 1.0)
create_kwargs: Dict[str, Any] = {
"model": model,
"messages": messages_to_dicts(messages),
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
}
resp = self._minimax_client.chat.completions.create(**create_kwargs)
for chunk in resp:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
def list_models(self) -> List[str]:
models: List[str] = []
if self._openai_client is not None:
@@ -789,6 +925,8 @@ class CloudEngine(InferenceEngine):
models.extend(_GOOGLE_MODELS)
if self._openrouter_client is not None:
models.extend(_OPENROUTER_POPULAR)
if self._minimax_client is not None:
models.extend(_MINIMAX_MODELS)
return models
def health(self) -> bool:
@@ -797,6 +935,7 @@ class CloudEngine(InferenceEngine):
or self._anthropic_client is not None
or self._google_client is not None
or self._openrouter_client is not None
or self._minimax_client is not None
)
def close(self) -> None:
@@ -814,6 +953,10 @@ class CloudEngine(InferenceEngine):
if hasattr(self._openrouter_client, "close"):
self._openrouter_client.close()
self._openrouter_client = None
if self._minimax_client is not None:
if hasattr(self._minimax_client, "close"):
self._minimax_client.close()
self._minimax_client = None
__all__ = ["CloudEngine", "PRICING", "estimate_cost"]
__all__ = ["CloudEngine", "PRICING", "_annotate_anthropic_cache", "estimate_cost"]
+179
View File
@@ -0,0 +1,179 @@
"""gemma.cpp inference engine backend via pygemma pybind11 bindings."""
from __future__ import annotations
import logging
import os
from collections.abc import AsyncIterator, Sequence
from typing import Any, Dict, List
from openjarvis.core.registry import EngineRegistry
from openjarvis.core.types import Message, Role
from openjarvis.engine._base import InferenceEngine, estimate_prompt_tokens
logger = logging.getLogger(__name__)
def _import_pygemma():
"""Import and return the pygemma.Gemma class. Raises ImportError if unavailable."""
from pygemma import Gemma
return Gemma
@EngineRegistry.register("gemma_cpp")
class GemmaCppEngine(InferenceEngine):
"""gemma.cpp backend via pygemma pybind11 bindings (in-process, CPU)."""
engine_id = "gemma_cpp"
def __init__(
self,
model_path: str | None = None,
tokenizer_path: str | None = None,
model_type: str | None = None,
num_threads: int = 0,
) -> None:
self._model_path = model_path or os.environ.get("GEMMA_CPP_MODEL_PATH", "")
self._tokenizer_path = tokenizer_path or os.environ.get(
"GEMMA_CPP_TOKENIZER_PATH", ""
)
self._model_type = model_type or os.environ.get("GEMMA_CPP_MODEL_TYPE", "")
self._num_threads = num_threads or int(
os.environ.get("GEMMA_CPP_NUM_THREADS", "0")
)
self._gemma: Any = None # lazy-loaded pygemma.Gemma instance
def _messages_to_prompt(self, messages: Sequence[Message]) -> str:
"""Format messages into Gemma's chat template."""
parts: list[str] = []
system_prefix = ""
for msg in messages:
if msg.role == Role.SYSTEM:
system_prefix += msg.content + "\n\n"
elif msg.role == Role.USER:
content = system_prefix + msg.content if system_prefix else msg.content
system_prefix = ""
parts.append(f"<start_of_turn>user\n{content}<end_of_turn>\n")
elif msg.role == Role.ASSISTANT:
parts.append(f"<start_of_turn>model\n{msg.content}<end_of_turn>\n")
parts.append("<start_of_turn>model\n")
return "".join(parts)
def _ensure_loaded(self) -> None:
"""Lazy model loading — called before inference."""
if self._gemma is None:
if not self._model_path:
raise FileNotFoundError(
"gemma.cpp model_path not configured. Download weights "
"from Kaggle and set GEMMA_CPP_MODEL_PATH or configure "
"[engine.gemma_cpp] in ~/.openjarvis/config.toml"
)
if not self._tokenizer_path:
raise FileNotFoundError(
"gemma.cpp tokenizer_path not configured. Set "
"GEMMA_CPP_TOKENIZER_PATH or configure "
"[engine.gemma_cpp] in ~/.openjarvis/config.toml"
)
Gemma = _import_pygemma()
self._gemma = Gemma()
self._gemma.load_model(
self._tokenizer_path, self._model_path, self._model_type
)
def prepare(self, model: str) -> None:
"""Load model into memory."""
self._ensure_loaded()
def close(self) -> None:
"""Unload model and free memory."""
self._gemma = None
def generate(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> Dict[str, Any]:
self._ensure_loaded()
if model != self._model_type:
logger.warning(
"gemma_cpp: requested model %r but loaded model is %r; "
"proceeding with loaded model",
model,
self._model_type,
)
prompt = self._messages_to_prompt(messages)
try:
# pygemma v0.1.3 completion() does not accept temperature/max_tokens;
# these params are accepted in the signature for ABC compliance but
# not forwarded until pygemma or a vendored wrapper supports them.
raw = self._gemma.completion(prompt)
except Exception as exc:
raise RuntimeError(f"gemma.cpp inference failed: {exc}") from exc
prompt_tokens = estimate_prompt_tokens(messages)
completion_tokens = max(1, len(raw) // 4)
return {
"content": raw,
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
"model": self._model_type,
"finish_reason": "stop",
}
async def stream(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator[str]:
self._ensure_loaded()
if model != self._model_type:
logger.warning(
"gemma_cpp: requested model %r but loaded model is %r; "
"proceeding with loaded model",
model,
self._model_type,
)
prompt = self._messages_to_prompt(messages)
try:
raw = self._gemma.completion(prompt)
except Exception as exc:
raise RuntimeError(f"gemma.cpp inference failed: {exc}") from exc
yield raw
def _paths_valid(self) -> bool:
"""Check that model and tokenizer paths are configured and exist."""
return bool(
self._model_path
and self._tokenizer_path
and os.path.isfile(self._model_path)
and os.path.isfile(self._tokenizer_path)
)
def list_models(self) -> List[str]:
if self._model_type and self._paths_valid():
return [self._model_type]
return []
def health(self) -> bool:
if not self._paths_valid():
return False
try:
_import_pygemma()
return True
except ImportError:
return False
__all__ = ["GemmaCppEngine"]
+4
View File
@@ -4,6 +4,10 @@ Thin FastAPI server wrapping the Nexa SDK (``nexaai``) as an
OpenAI-compatible API on port 18181. Intended for on-device inference
with GGUF models on Apple Silicon or CPU.
**Token counts:** The Nexa SDK does not expose token counts in responses.
The shim returns 0 for prompt/completion/total tokens. Savings and
leaderboard metrics will not include sessions that use this engine.
Usage:
uvicorn openjarvis.engine.nexa_shim:app \
--host 127.0.0.1 --port 18181
+25 -8
View File
@@ -15,6 +15,7 @@ from openjarvis.core.types import Message
from openjarvis.engine._base import (
EngineConnectionError,
InferenceEngine,
estimate_prompt_tokens,
messages_to_dicts,
)
@@ -105,13 +106,24 @@ class OllamaEngine(InferenceEngine):
f"Ollama returned {exc.response.status_code}: {body}"
) from exc
data = resp.json()
prompt_tokens = data.get("prompt_eval_count", 0)
# prompt_eval_count = tokens actually evaluated (KV-cache-aware).
# estimate_prompt_tokens = full prompt size (for cost comparison).
# We report both so downstream can use the right one:
# prompt_tokens → full size (what cloud would charge)
# prompt_tokens_evaluated → actual compute (with KV cache)
reported_prompt = data.get("prompt_eval_count", 0)
estimated_prompt = estimate_prompt_tokens(messages)
prompt_tokens = max(reported_prompt, estimated_prompt)
prompt_tokens_evaluated = (
reported_prompt if reported_prompt > 0 else prompt_tokens
)
completion_tokens = data.get("eval_count", 0)
content = data.get("message", {}).get("content", "")
result: Dict[str, Any] = {
"content": content,
"usage": {
"prompt_tokens": prompt_tokens,
"prompt_tokens_evaluated": prompt_tokens_evaluated,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
},
@@ -176,14 +188,19 @@ class OllamaEngine(InferenceEngine):
if content:
yield content
if chunk.get("done", False):
# Capture usage from final chunk
reported_prompt = chunk.get("prompt_eval_count", 0)
est_prompt = estimate_prompt_tokens(messages)
full_prompt = max(reported_prompt, est_prompt)
evaluated = (
reported_prompt if reported_prompt > 0
else full_prompt
)
comp = chunk.get("eval_count", 0)
self._last_stream_usage = {
"prompt_tokens": chunk.get("prompt_eval_count", 0),
"completion_tokens": chunk.get("eval_count", 0),
"total_tokens": (
chunk.get("prompt_eval_count", 0)
+ chunk.get("eval_count", 0)
),
"prompt_tokens": full_prompt,
"prompt_tokens_evaluated": evaluated,
"completion_tokens": comp,
"total_tokens": full_prompt + comp,
}
break
except (httpx.ConnectError, httpx.TimeoutException) as exc:
+20
View File
@@ -119,6 +119,10 @@ BENCHMARKS = {
"category": "use-case",
"description": "Web research with fact verification",
},
"pinchbench": {
"category": "agentic",
"description": "PinchBench real-world agent tasks",
},
}
BACKENDS = {
@@ -260,6 +264,9 @@ def _build_dataset(benchmark: str, subset: str | None = None):
elif benchmark == "browser_assistant":
from openjarvis.evals.datasets.browser_assistant import BrowserAssistantDataset
return BrowserAssistantDataset()
elif benchmark == "pinchbench":
from openjarvis.evals.datasets.pinchbench import PinchBenchDataset
return PinchBenchDataset(path=subset)
else:
raise click.ClickException(f"Unknown benchmark: {benchmark}")
@@ -361,6 +368,9 @@ def _build_scorer(benchmark: str, judge_backend, judge_model: str):
elif benchmark == "browser_assistant":
from openjarvis.evals.scorers.browser_assistant import BrowserAssistantScorer
return BrowserAssistantScorer(judge_backend, judge_model)
elif benchmark == "pinchbench":
from openjarvis.evals.scorers.pinchbench import PinchBenchScorer
return PinchBenchScorer(judge_backend, judge_model)
else:
raise click.ClickException(f"Unknown benchmark: {benchmark}")
@@ -519,6 +529,16 @@ def _run_agentic(
seed=config.seed,
)
# Wire judge for PinchBench LLM-judge and hybrid grading
if config.benchmark == "pinchbench" and hasattr(dataset, "set_judge"):
judge_engine = getattr(config, "judge_engine", "cloud") or "cloud"
judge_model = (
getattr(config, "judge_model", None)
or "anthropic/claude-opus-4-5"
)
judge_backend = _build_judge_backend(judge_model, engine_key=judge_engine)
dataset.set_judge(judge_backend, judge_model)
# Verify backend requirements before doing any work
if hasattr(dataset, "verify_requirements"):
issues = dataset.verify_requirements()
@@ -0,0 +1,34 @@
# PinchBench eval: gpt-5.4 (cloud)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-gpt54"
description = "PinchBench on gpt-5.4-2026-03-05"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-gpt54/"
seed = 42
[[models]]
name = "gpt-5.4-2026-03-05"
engine = "cloud"
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,35 @@
# PinchBench eval: Qwen3.5-397B-A17B-FP8 (vLLM, TP=8)
# Agent: native_openhands — all PinchBench-required tools enabled
[meta]
name = "pinchbench-qwen397b"
description = "PinchBench on Qwen/Qwen3.5-397B-A17B-FP8 (vLLM, TP=8)"
[defaults]
temperature = 0.6
max_tokens = 8192
[judge]
model = "claude-opus-4-5"
temperature = 0.0
engine = "cloud"
[run]
max_workers = 1
output_dir = "results/pinchbench-qwen397b/"
seed = 42
[[models]]
name = "Qwen/Qwen3.5-397B-A17B-FP8"
engine = "vllm"
num_gpus = 8
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_openhands"
tools = [
"think", "file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -0,0 +1,26 @@
[meta]
name = "pinchbench"
description = "PinchBench real-world agent benchmark"
[defaults]
temperature = 0.0
[judge]
model = "anthropic/claude-opus-4-5"
temperature = 0.0
[run]
output_dir = "results/pinchbench"
[[models]]
name = "anthropic/claude-sonnet-4"
[[benchmarks]]
name = "pinchbench"
backend = "jarvis-agent"
agent = "native_react"
tools = [
"file_read", "file_write", "web_search", "shell_exec",
"code_interpreter", "browser_navigate", "image_generate",
"calculator", "http_request", "pdf_extract",
]
@@ -321,6 +321,19 @@ class AgenticRunner:
event_recorder.clear()
# Bridge EventBus → EventRecorder so tool events are captured.
# ToolExecutor publishes to JarvisSystem.bus; we relay those into
# the EventRecorder for transcript building and trace enrichment.
_bus_unsubs: list[tuple] = []
agent_bus = getattr(agent, "bus", None)
if agent_bus is not None:
for etype in (EventType.TOOL_CALL_START, EventType.TOOL_CALL_END):
def _relay(event, _etype=etype):
data = event.data if hasattr(event, "data") else {}
event_recorder.record(_etype, **data)
agent_bus.subscribe(etype, _relay)
_bus_unsubs.append((etype, _relay))
# Set up per-query workspace
if self._run_dir and hasattr(agent, "set_workspace"):
instance_id = record.metadata.get("instance_id", record.record_id)
@@ -330,6 +343,7 @@ class AgenticRunner:
)
workspace.mkdir(parents=True, exist_ok=True)
agent.set_workspace(str(workspace))
record.metadata["workspace_path"] = str(workspace)
# Create per-task execution environment (e.g. Docker for TerminalBench)
task_env = None
@@ -397,6 +411,10 @@ class AgenticRunner:
else:
response_text = str(agent(record.problem))
# Wire event recorder to task env if supported
if task_env is not None and hasattr(task_env, "set_event_recorder"):
task_env.set_event_recorder(event_recorder)
# Run tests if task env supports it
if task_env is not None and hasattr(task_env, "run_tests"):
task_env.run_tests()
@@ -425,6 +443,13 @@ class AgenticRunner:
except Exception as exc:
LOGGER.warning("Agent failed on query %s: %s", query_id, exc)
end_time = time.time()
# Unsubscribe EventBus relays
if agent_bus is not None:
for etype, cb in _bus_unsubs:
try:
agent_bus.unsubscribe(etype, cb)
except Exception:
pass
return QueryTrace(
query_id=query_id,
workload_type=str(workload_type),
@@ -435,6 +460,14 @@ class AgenticRunner:
is_resolved=record.metadata.get("is_resolved"),
)
# Unsubscribe EventBus relays
if agent_bus is not None:
for etype, cb in _bus_unsubs:
try:
agent_bus.unsubscribe(etype, cb)
except Exception:
pass
end_time = time.time()
end_ns = time.monotonic_ns()
@@ -578,6 +611,8 @@ class AgenticRunner:
current_turn_start: Optional[float] = None
current_tools: list[str] = []
current_tool_latencies: dict[str, float] = {}
current_tool_calls: list[dict[str, Any]] = []
current_tool_args: dict[str, Any] = {}
tool_start_times: dict[str, float] = {}
input_tokens = 0
output_tokens = 0
@@ -624,6 +659,7 @@ class AgenticRunner:
output_tokens=output_tokens,
tools_called=list(current_tools),
tool_latencies_s=dict(current_tool_latencies),
tool_calls=list(current_tool_calls),
wall_clock_s=wall_clock,
action_energy_breakdown=action_breakdown,
)
@@ -633,6 +669,8 @@ class AgenticRunner:
current_turn_start = None
current_tools = []
current_tool_latencies = {}
current_tool_calls = []
current_tool_args = {}
current_action_spans = []
input_tokens = 0
output_tokens = 0
@@ -640,10 +678,16 @@ class AgenticRunner:
elif etype == EventType.TOOL_CALL_START:
tool_name = event.metadata.get("tool", "unknown")
tool_start_times[tool_name] = event.timestamp
current_tool_args[tool_name] = event.metadata.get("arguments", {})
elif etype == EventType.TOOL_CALL_END:
tool_name = event.metadata.get("tool", "unknown")
current_tools.append(tool_name)
current_tool_calls.append({
"name": tool_name,
"arguments": current_tool_args.pop(tool_name, {}),
"result": event.metadata.get("result", ""),
})
start_ts = tool_start_times.pop(tool_name, None)
if start_ts is not None:
duration = event.timestamp - start_ts
@@ -662,6 +706,7 @@ class AgenticRunner:
turn_index=0,
tools_called=current_tools,
tool_latencies_s=current_tool_latencies,
tool_calls=list(current_tool_calls),
)
)
+1
View File
@@ -43,6 +43,7 @@ KNOWN_BENCHMARKS = {
"knowledge_base", "coding_task",
"coding_assistant", "security_scanner", "daily_digest",
"doc_qa", "browser_assistant",
"pinchbench",
}
+62 -12
View File
@@ -96,11 +96,19 @@ class EvalRunner:
seed=cfg.seed,
)
# Auto-enable episode_mode when the dataset has iter_episodes()
# (i.e. it is a lifelong/sequential benchmark like LifelongAgentBench).
# This is enforced at the runner level so it applies regardless of
# how the runner is invoked (CLI, SDK, tests, etc.).
if not cfg.episode_mode and hasattr(self._dataset, "iter_episodes"):
# Auto-enable episode_mode when the dataset *overrides*
# iter_episodes() (i.e. it is a lifelong/sequential benchmark like
# LifelongAgentBench). The base DatasetProvider always defines a
# default iter_episodes() that wraps each record in its own episode,
# so hasattr() is always True — we must check for a real override.
from openjarvis.evals.core.dataset import DatasetProvider as _DP
try:
_overrides_episodes = (
type(self._dataset).iter_episodes is not _DP.iter_episodes
)
except AttributeError:
_overrides_episodes = False
if not cfg.episode_mode and _overrides_episodes:
LOGGER.info(
"%s requires sequential episode processing — "
"auto-enabling episode_mode.",
@@ -109,6 +117,15 @@ class EvalRunner:
cfg = dataclasses.replace(cfg, episode_mode=True)
self._config = cfg
# Detect if dataset provides task environments (e.g. PinchBench)
try:
self._has_task_env = (
type(self._dataset).create_task_env
is not _DP.create_task_env
)
except AttributeError:
self._has_task_env = False
records = list(self._dataset.iter_records())
LOGGER.info(
"Running %s: %d samples, backend=%s, model=%s, workers=%d, "
@@ -145,6 +162,15 @@ class EvalRunner:
try:
if cfg.episode_mode:
self._run_episode_mode(records, progress_callback, total)
elif self._has_task_env:
# Task environments (PinchBench etc.) change CWD —
# must process sequentially for thread safety.
for record in records:
result = self._process_one(record)
self._results.append(result)
self._flush_result(result)
if progress_callback is not None:
progress_callback(len(self._results), total)
else:
with ThreadPoolExecutor(max_workers=cfg.max_workers) as pool:
futures = {
@@ -224,17 +250,41 @@ class EvalRunner:
)
if cfg.system_prompt:
gen_kwargs["system"] = cfg.system_prompt
full = self._backend.generate_full(
record.problem,
**gen_kwargs,
)
content = full.get("content", "")
if getattr(self, "_has_task_env", False):
from contextlib import nullcontext
task_env = self._dataset.create_task_env(record)
ctx = task_env if task_env is not None else nullcontext()
with ctx:
full = self._backend.generate_full(
record.problem,
**gen_kwargs,
)
full = full or {}
# Store tool results for the scorer to build transcripts
record.metadata["tool_results"] = full.get(
"tool_results", [],
)
# Score INSIDE context so workspace files still exist
content = full.get("content", "")
is_correct, scoring_meta = self._scorer.score(
record, content,
)
else:
full = self._backend.generate_full(
record.problem,
**gen_kwargs,
)
full = full or {}
content = full.get("content", "")
is_correct, scoring_meta = self._scorer.score(
record, content,
)
usage = full.get("usage", {})
latency = full.get("latency_seconds", 0.0)
cost = full.get("cost_usd", 0.0)
is_correct, scoring_meta = self._scorer.score(record, content)
energy_j = full.get("energy_joules", 0.0)
power_w = full.get("power_watts", 0.0)
throughput = full.get("throughput_tok_per_sec", 0.0)
+4
View File
@@ -28,6 +28,8 @@ class TurnTrace:
cost_usd: Optional[float] = None
# Per-action energy breakdown (lm_inference vs tool_call granularity)
action_energy_breakdown: Optional[List[Dict[str, Any]]] = None
# Detailed tool call data: [{"name": str, "arguments": dict, "result": str}]
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
@@ -45,6 +47,7 @@ class TurnTrace:
"cpu_power_avg_watts": self.cpu_power_avg_watts,
"cost_usd": self.cost_usd,
"action_energy_breakdown": self.action_energy_breakdown,
"tool_calls": [dict(tc) for tc in self.tool_calls],
}
@classmethod
@@ -64,6 +67,7 @@ class TurnTrace:
cpu_power_avg_watts=d.get("cpu_power_avg_watts"),
cost_usd=d.get("cost_usd"),
action_energy_breakdown=d.get("action_energy_breakdown"),
tool_calls=d.get("tool_calls", []),
)
+202
View File
@@ -0,0 +1,202 @@
"""PinchBench dataset provider — real-world agent task benchmark.
Clones the pinchbench/skill repo at runtime and parses task markdown files
into EvalRecords for use with AgenticRunner.
Reference: https://github.com/pinchbench/skill
"""
from __future__ import annotations
import logging
import random
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import yaml
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
PINCHBENCH_REPO = "https://github.com/pinchbench/skill.git"
CACHE_DIR = Path.home() / ".cache" / "pinchbench"
def _parse_task_markdown(content: str, filename: str = "") -> Dict[str, Any]:
"""Parse a PinchBench task markdown file into a dict.
Extracts YAML frontmatter and markdown sections (## Prompt,
## Expected Behavior, ## Automated Checks, ## LLM Judge Rubric).
"""
# Split frontmatter
parts = content.split("---", 2)
if len(parts) < 3:
raise ValueError(f"Missing YAML frontmatter in {filename}")
frontmatter = yaml.safe_load(parts[1])
body = parts[2]
# Parse sections by ## headers
sections: Dict[str, str] = {}
current_header: Optional[str] = None
current_lines: List[str] = []
for line in body.split("\n"):
header_match = re.match(r"^##\s+(.+)$", line)
if header_match:
if current_header is not None:
sections[current_header] = "\n".join(current_lines).strip()
current_header = header_match.group(1).strip()
current_lines = []
else:
current_lines.append(line)
if current_header is not None:
sections[current_header] = "\n".join(current_lines).strip()
# Extract Python code block from Automated Checks section
automated_checks = None
checks_section = sections.get("Automated Checks", "")
code_match = re.search(r"```python\s*\n(.*?)```", checks_section, re.DOTALL)
if code_match:
automated_checks = code_match.group(1).strip()
return {
"id": frontmatter.get("id", ""),
"name": frontmatter.get("name", ""),
"category": frontmatter.get("category", ""),
"grading_type": frontmatter.get("grading_type", "automated"),
"timeout_seconds": frontmatter.get("timeout_seconds", 180),
"workspace_files": frontmatter.get("workspace_files", []),
"grading_weights": frontmatter.get("grading_weights"),
"prompt": sections.get("Prompt", ""),
"expected_behavior": sections.get("Expected Behavior", ""),
"grading_criteria": sections.get("Grading Criteria", ""),
"automated_checks": automated_checks,
"llm_judge_rubric": sections.get("LLM Judge Rubric"),
}
class PinchBenchDataset(DatasetProvider):
"""PinchBench real-world agent benchmark.
Clones pinchbench/skill from GitHub (or uses a local path) and
parses task markdown files into EvalRecords.
"""
dataset_id = "pinchbench"
dataset_name = "PinchBench"
def __init__(self, path: Optional[str] = None) -> None:
self._local_path = Path(path) if path else None
self._repo_dir: Path = self._local_path or CACHE_DIR
self._records: List[EvalRecord] = []
def verify_requirements(self) -> List[str]:
issues: List[str] = []
if self._local_path is None and shutil.which("git") is None:
issues.append("git binary not found. Install git to clone PinchBench tasks.")
if self._repo_dir.exists() and not (self._repo_dir / "tasks").is_dir():
issues.append(
f"PinchBench cache at {self._repo_dir} is corrupted (missing tasks/). "
"Delete and re-run to re-clone."
)
return issues
def _ensure_repo(self) -> Path:
"""Clone the repo if not already cached. Returns repo dir."""
if self._local_path is not None:
if not self._local_path.exists():
raise FileNotFoundError(f"PinchBench path not found: {self._local_path}")
return self._local_path
if not self._repo_dir.exists():
LOGGER.info("Cloning PinchBench from %s ...", PINCHBENCH_REPO)
self._repo_dir.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
["git", "clone", "--depth", "1", PINCHBENCH_REPO, str(self._repo_dir)],
check=True,
capture_output=True,
)
LOGGER.info("PinchBench cloned to %s", self._repo_dir)
return self._repo_dir
def load(
self,
*,
max_samples: Optional[int] = None,
split: Optional[str] = None,
seed: Optional[int] = None,
) -> None:
repo_dir = self._ensure_repo()
tasks_dir = repo_dir / "tasks"
if not tasks_dir.is_dir():
raise FileNotFoundError(f"No tasks/ directory in {repo_dir}")
task_files = sorted(tasks_dir.glob("task_*.md"))
if not task_files:
raise FileNotFoundError(f"No task_*.md files in {tasks_dir}")
tasks = []
for tf in task_files:
try:
parsed = _parse_task_markdown(tf.read_text(), filename=tf.name)
tasks.append(parsed)
except Exception as exc:
LOGGER.warning("Skipping %s: %s", tf.name, exc)
if seed is not None:
random.Random(seed).shuffle(tasks)
if max_samples is not None:
tasks = tasks[:max_samples]
self._records = [
EvalRecord(
record_id=t["id"],
problem=t["prompt"],
reference=t["expected_behavior"],
category=t["category"],
subject=t["name"],
metadata={
"grading_type": t["grading_type"],
"grading_weights": t["grading_weights"],
"automated_checks": t["automated_checks"],
"llm_judge_rubric": t["llm_judge_rubric"],
"timeout_seconds": t["timeout_seconds"],
"workspace_files": t["workspace_files"],
"pinchbench_repo_dir": str(repo_dir),
},
)
for t in tasks
]
LOGGER.info("PinchBench: loaded %d tasks", len(self._records))
def iter_records(self) -> Iterable[EvalRecord]:
return iter(self._records)
def size(self) -> int:
return len(self._records)
def set_judge(self, judge_backend: Any, judge_model: str) -> None:
"""Set the judge backend/model for LLM-judge and hybrid grading."""
self._judge_backend = judge_backend
self._judge_model = judge_model
def create_task_env(self, record: EvalRecord):
from openjarvis.evals.execution.pinchbench_env import PinchBenchTaskEnv
return PinchBenchTaskEnv(
record,
judge_backend=getattr(self, "_judge_backend", None),
judge_model=getattr(self, "_judge_model", "anthropic/claude-opus-4-5"),
)
__all__ = ["PinchBenchDataset"]
@@ -0,0 +1,159 @@
"""PinchBench task environment — per-task workspace setup and grading."""
from __future__ import annotations
import logging
import os
import shutil
import tempfile
from pathlib import Path
from types import TracebackType
from typing import Any, Optional, Type
from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
class PinchBenchTaskEnv:
"""Per-task workspace environment for PinchBench.
Context manager that creates an isolated workspace directory,
populates fixture files from the task definition, and runs
grading after agent execution via run_tests().
"""
def __init__(
self,
record: EvalRecord,
judge_backend: Any = None,
judge_model: str = "anthropic/claude-opus-4-5",
) -> None:
self._record = record
self._judge_backend = judge_backend
self._judge_model = judge_model
self._workspace: Optional[Path] = None
self._owns_workspace: bool = True
self._event_recorder: Any = None
self._original_cwd: Optional[str] = None
def set_event_recorder(self, recorder: Any) -> None:
"""Receive the EventRecorder from AgenticRunner for transcript building."""
self._event_recorder = recorder
def __enter__(self) -> PinchBenchTaskEnv:
# Use the AgenticRunner's workspace if already set (agent.set_workspace
# is called before create_task_env), otherwise create a temp dir.
existing = self._record.metadata.get("workspace_path")
if existing and Path(existing).is_dir():
self._workspace = Path(existing)
self._owns_workspace = False
else:
self._workspace = Path(tempfile.mkdtemp(prefix="pinchbench_"))
self._owns_workspace = True
# Populate fixture files
repo_dir = Path(self._record.metadata.get("pinchbench_repo_dir", ""))
workspace_files = self._record.metadata.get("workspace_files", [])
for file_spec in workspace_files:
if "content" in file_spec:
# Inline content
dest_key = file_spec.get("path", file_spec.get("dest", "file.txt"))
dest = self._workspace / dest_key
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(file_spec["content"])
elif "source" in file_spec:
# Asset file from repo
source = repo_dir / "assets" / file_spec["source"]
dest_key = file_spec.get(
"dest", file_spec.get("path", file_spec["source"])
)
dest = self._workspace / dest_key
dest.parent.mkdir(parents=True, exist_ok=True)
if source.exists():
shutil.copy2(str(source), str(dest))
else:
LOGGER.warning("Asset not found: %s", source)
self._record.metadata["workspace_path"] = str(self._workspace)
# Change CWD so file_read/file_write resolve paths relative to workspace
self._original_cwd = os.getcwd()
os.chdir(str(self._workspace))
LOGGER.info(
"PinchBench workspace: %s (task %s)",
self._workspace,
self._record.record_id,
)
return self
def run_tests(self) -> None:
"""Grade the agent's work using PinchBench grading logic.
Called by AgenticRunner after agent execution, before QueryTrace
is constructed. Builds transcript from raw EventRecorder events.
"""
from openjarvis.evals.scorers.pinchbench import (
events_to_transcript,
grade_pinchbench_task,
)
workspace_path = self._record.metadata.get("workspace_path", "")
events = self._event_recorder.get_events() if self._event_recorder else []
transcript = events_to_transcript(events)
try:
result = grade_pinchbench_task(
record=self._record,
transcript=transcript,
workspace_path=workspace_path,
judge_backend=self._judge_backend,
judge_model=self._judge_model,
)
except Exception as exc:
LOGGER.error(
"Grading failed for %s: %s", self._record.record_id, exc,
)
result = {"score": 0.0, "breakdown": {}, "notes": f"Grading error: {exc}"}
self._record.metadata["is_resolved"] = result["score"] >= 0.5
self._record.metadata["reward"] = result["score"]
self._record.metadata["pinchbench_score"] = result["score"]
self._record.metadata["pinchbench_breakdown"] = result["breakdown"]
self._record.metadata["pinchbench_notes"] = result.get("notes", "")
LOGGER.info(
"PinchBench grading for %s: score=%.2f resolved=%s",
self._record.record_id,
result["score"],
result["score"] >= 0.5,
)
def __exit__(
self,
exc_type: Optional[Type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Optional[TracebackType],
) -> None:
# Restore original CWD
if self._original_cwd:
try:
os.chdir(self._original_cwd)
except OSError:
pass
self._original_cwd = None
# Only clean up workspaces we created ourselves (not AgenticRunner's)
if self._owns_workspace and self._workspace and self._workspace.exists():
keep = os.environ.get("PINCHBENCH_KEEP_WORKSPACES", "").strip()
if keep and keep != "0":
LOGGER.info("Keeping workspace: %s", self._workspace)
else:
shutil.rmtree(self._workspace, ignore_errors=True)
self._workspace = None
self._event_recorder = None
__all__ = ["PinchBenchTaskEnv"]
+503
View File
@@ -0,0 +1,503 @@
"""PinchBench grading helpers and scorer.
Provides transcript translation (OpenJarvis events PinchBench format),
automated grading (exec of embedded Python), LLM judge grading, and
hybrid combination. Used by PinchBenchTaskEnv.run_tests() and the
standalone PinchBenchScorer.
Reference: https://github.com/pinchbench/skill
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional, Tuple
from openjarvis.evals.core.event_recorder import EventType
from openjarvis.evals.core.scorer import LLMJudgeScorer
from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tool name mapping: OpenJarvis name → PinchBench-expected name
# ---------------------------------------------------------------------------
_TOOL_NAME_MAP: Dict[str, str] = {
"file_read": "read_file",
"file_write": "write_file",
"image_generate": "generate_image",
# web_search, calculator, shell_exec, etc. use the same names
}
# ---------------------------------------------------------------------------
# Transcript translation
# ---------------------------------------------------------------------------
def events_to_transcript(events: List[Any]) -> List[Dict[str, Any]]:
"""Build PinchBench-format transcript from raw EventRecorder events.
Pairs TOOL_CALL_START/END events to extract tool name, arguments, and
results. Called by run_tests() before QueryTrace exists.
"""
transcript: List[Dict[str, Any]] = []
for event in events:
etype = event.event_type
if isinstance(etype, str):
# Normalize string event types to enum comparison
pass
if etype == EventType.TOOL_CALL_START or etype == EventType.TOOL_CALL_START.value:
tool_name = event.metadata.get("tool", "unknown")
mapped = _TOOL_NAME_MAP.get(tool_name, tool_name)
arguments = event.metadata.get("arguments") or {}
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "toolCall", "name": mapped, "params": arguments}],
},
})
elif etype == EventType.TOOL_CALL_END or etype == EventType.TOOL_CALL_END.value:
result_text = str(event.metadata.get("result", ""))
transcript.append({
"type": "message",
"message": {
"role": "toolResult",
"content": [{"text": result_text}],
},
})
return transcript
def _trace_to_transcript(trace: Any) -> List[Dict[str, Any]]:
"""Convert QueryTrace to PinchBench transcript (for EvalRunner path)."""
transcript: List[Dict[str, Any]] = []
for turn in trace.turns:
for tc in getattr(turn, "tool_calls", []):
if tc is None:
continue
mapped = _TOOL_NAME_MAP.get(tc["name"], tc["name"])
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "toolCall", "name": mapped, "params": tc.get("arguments") or {}}],
},
})
transcript.append({
"type": "message",
"message": {
"role": "toolResult",
"content": [{"text": tc.get("result", "")}],
},
})
# Capture final assistant text response (for tasks graded on text output)
response_text = getattr(trace, "response_text", "")
if response_text:
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": response_text}],
},
})
return transcript
def _tool_results_to_transcript(
tool_results: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Build transcript from JarvisAgentBackend tool_results list."""
transcript: List[Dict[str, Any]] = []
for tr in tool_results:
tool_name = tr.get("tool_name", "unknown")
mapped = _TOOL_NAME_MAP.get(tool_name, tool_name)
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "toolCall", "name": mapped, "params": {}}],
},
})
transcript.append({
"type": "message",
"message": {
"role": "toolResult",
"content": [{"text": tr.get("content", "")}],
},
})
return transcript
# ---------------------------------------------------------------------------
# Transcript summarization (replicates PinchBench _summarize_transcript)
# ---------------------------------------------------------------------------
def _summarize_transcript(transcript: List[Dict[str, Any]]) -> str:
"""Summarize transcript for LLM judge prompt.
Produces the same format as PinchBench's lib_grading._summarize_transcript():
Tool: function_name({"arg1": value1})
Result: [first 200 chars]
User: [message text]
"""
parts: List[str] = []
for event in transcript:
if event.get("type") != "message":
continue
msg = event.get("message", {})
role = msg.get("role")
if role == "assistant":
for item in msg.get("content", []):
if item.get("type") == "toolCall":
parts.append(
f"Tool: {item.get('name')}({json.dumps(item.get('params', {}))})"
)
elif item.get("type") == "text":
parts.append(f"Assistant: {item.get('text', '')}")
elif role == "toolResult":
content = msg.get("content", [])
if content:
preview = str(content[0])[:200]
parts.append(f"Result: {preview}")
elif role == "user":
content = msg.get("content", [])
if content:
parts.append(f"User: {content[0]}")
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Judge prompt and response parsing (replicates PinchBench exactly)
# ---------------------------------------------------------------------------
def _build_judge_prompt(
*,
task_prompt: str,
expected_behavior: str,
rubric: str,
transcript_summary: str,
) -> str:
"""Build the LLM judge prompt matching PinchBench's format."""
return f"""You are a strict evaluator assessing an AI agent's performance on a task.
Reserve a score of 1.0 for genuinely excellent performance.
## Task
{task_prompt}
## Expected Behavior
{expected_behavior}
## Agent Transcript
{transcript_summary}
## Grading Rubric
{rubric}
## Instructions
Evaluate the agent's performance against each criterion in the rubric.
Return your evaluation as JSON with this exact structure:
```json
{{
"scores": {{"criterion_name": score, ...}},
"total": overall_score_0_to_1,
"notes": "brief justification"
}}
```
Be a strict evaluator. Deduct points for unnecessary steps, verbose output,
or inefficient tool usage."""
def _parse_judge_response(raw: str) -> Dict[str, Any]:
"""Parse LLM judge response with fallback chain.
Tries: JSON code block balanced braces regex score extraction.
Matches PinchBench's lib_grading._parse_judge_response() logic.
"""
if not raw or not raw.strip():
return {"scores": {}, "total": 0.0, "notes": "Empty judge response"}
# Try JSON code block
code_block = re.search(r"```json\s*(.*?)\s*```", raw, re.DOTALL)
if code_block:
try:
parsed = json.loads(code_block.group(1))
if isinstance(parsed, dict):
return _normalize_judge_response(parsed)
except json.JSONDecodeError:
pass
# Try balanced braces extraction
candidates: List[str] = []
depth = 0
current: List[str] = []
for char in raw:
if char == "{":
if depth == 0:
current = []
depth += 1
if depth > 0:
current.append(char)
if char == "}":
depth -= 1
if depth == 0 and current:
candidates.append("".join(current))
for candidate in reversed(candidates):
try:
parsed = json.loads(candidate)
if isinstance(parsed, dict) and "scores" in parsed:
return _normalize_judge_response(parsed)
except json.JSONDecodeError:
continue
for candidate in reversed(candidates):
try:
parsed = json.loads(candidate)
if isinstance(parsed, dict):
return _normalize_judge_response(parsed)
except json.JSONDecodeError:
continue
# Regex fallback for prose scores
score_match = re.search(
r"(?:total|overall|final)\s*(?:score)?[:\s]*(0\.\d+|1\.0+)",
raw,
re.IGNORECASE,
)
if score_match:
try:
total = float(score_match.group(1))
if 0.0 <= total <= 1.0:
LOGGER.warning("Fell back to regex score extraction (total=%.2f)", total)
return {"scores": {}, "total": total, "notes": "Score extracted from prose"}
except ValueError:
pass
LOGGER.warning("Failed to parse judge response")
return {"scores": {}, "total": 0.0, "notes": "Failed to parse judge response"}
def _normalize_judge_response(parsed: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize various judge response formats to standard structure.
Matches PinchBench's lib_grading._normalize_judge_response().
"""
result: Dict[str, Any] = {"scores": {}, "total": 0.0, "notes": ""}
# Extract scores
scores_data = parsed.get("scores", parsed.get("criteria_scores", {}))
if isinstance(scores_data, dict):
for key, value in scores_data.items():
if isinstance(value, dict) and "score" in value:
result["scores"][key] = float(value["score"])
elif isinstance(value, (int, float)):
result["scores"][key] = float(value)
# Extract total
for key in ("total", "score", "overall_score"):
if key in parsed and isinstance(parsed[key], (int, float)):
result["total"] = float(parsed[key])
break
else:
if result["scores"]:
values = [v for v in result["scores"].values() if isinstance(v, (int, float))]
if values:
result["total"] = sum(values) / len(values)
# Normalize summed totals back to 0..1
values = [v for v in result["scores"].values() if isinstance(v, (int, float))]
if (
values
and result["total"] is not None
and result["total"] > 1.0
and all(0.0 <= float(v) <= 1.0 for v in values)
):
result["total"] = sum(values) / len(values)
# Extract notes
for key in ("notes", "justification", "reasoning"):
if key in parsed:
result["notes"] = str(parsed[key])
break
return result
# ---------------------------------------------------------------------------
# Grading functions
# ---------------------------------------------------------------------------
def _grade_automated(
record: EvalRecord,
transcript: List[Dict[str, Any]],
workspace_path: str,
) -> Dict[str, Any]:
"""Run the embedded Python grade() function from the task definition."""
code = record.metadata.get("automated_checks")
if not code:
return {"score": 0.0, "breakdown": {}, "notes": "No automated checks defined"}
namespace: Dict[str, Any] = {}
try:
exec(code, namespace) # noqa: S102
except Exception as exc:
LOGGER.error("Failed to compile grading code for %s: %s", record.record_id, exc)
return {"score": 0.0, "breakdown": {}, "notes": f"Grading code error: {exc}"}
grade_fn = namespace.get("grade")
if not callable(grade_fn):
return {"score": 0.0, "breakdown": {}, "notes": "No grade() function found in automated checks"}
try:
scores = grade_fn(transcript, workspace_path)
except Exception as exc:
LOGGER.error("grade() failed for %s: %s", record.record_id, exc)
return {"score": 0.0, "breakdown": {}, "notes": f"grade() error: {exc}"}
if not isinstance(scores, dict) or not scores:
return {"score": 0.0, "breakdown": {}, "notes": "grade() returned empty or non-dict"}
mean_score = sum(scores.values()) / len(scores)
return {"score": mean_score, "breakdown": scores, "notes": ""}
def _grade_llm_judge(
record: EvalRecord,
transcript: List[Dict[str, Any]],
workspace_path: str,
judge_backend: Any,
judge_model: str,
) -> Dict[str, Any]:
"""Grade using an LLM judge with the task's rubric."""
rubric = record.metadata.get("llm_judge_rubric")
if not rubric:
return {"score": 0.0, "breakdown": {}, "notes": "No LLM judge rubric defined"}
if judge_backend is None:
return {"score": 0.0, "breakdown": {}, "notes": "No judge backend configured"}
summary = _summarize_transcript(transcript)
prompt = _build_judge_prompt(
task_prompt=record.problem,
expected_behavior=record.reference or "",
rubric=rubric,
transcript_summary=summary,
)
try:
raw = judge_backend.generate(prompt, model=judge_model, temperature=0.0, max_tokens=2048)
except Exception as exc:
LOGGER.error("LLM judge call failed for %s: %s", record.record_id, exc)
return {"score": 0.0, "breakdown": {}, "notes": f"Judge error: {exc}"}
parsed = _parse_judge_response(raw)
return {
"score": parsed.get("total", 0.0),
"breakdown": parsed.get("scores", {}),
"notes": parsed.get("notes", ""),
}
def _grade_hybrid(
record: EvalRecord,
transcript: List[Dict[str, Any]],
workspace_path: str,
judge_backend: Any,
judge_model: str,
) -> Dict[str, Any]:
"""Run both automated and LLM judge grading, combine with weights."""
weights = record.metadata.get("grading_weights") or {"automated": 0.5, "llm_judge": 0.5}
auto = _grade_automated(record, transcript, workspace_path)
llm = _grade_llm_judge(record, transcript, workspace_path, judge_backend, judge_model)
auto_w = float(weights.get("automated", 0.5))
llm_w = float(weights.get("llm_judge", 0.5))
total_w = auto_w + llm_w
combined = (auto["score"] * auto_w + llm["score"] * llm_w) / total_w if total_w > 0 else 0.0
breakdown = {
**{f"automated.{k}": v for k, v in auto["breakdown"].items()},
**{f"llm_judge.{k}": v for k, v in llm["breakdown"].items()},
}
notes = " | ".join(filter(None, [auto.get("notes", ""), llm.get("notes", "")]))
return {"score": combined, "breakdown": breakdown, "notes": notes}
def grade_pinchbench_task(
*,
record: EvalRecord,
transcript: List[Dict[str, Any]],
workspace_path: str,
judge_backend: Any = None,
judge_model: str = "anthropic/claude-opus-4-5",
) -> Dict[str, Any]:
"""Top-level grading entry point. Routes by grading_type.
Returns {"score": float, "breakdown": dict, "notes": str}.
"""
grading_type = record.metadata.get("grading_type", "automated")
if grading_type == "automated":
return _grade_automated(record, transcript, workspace_path)
elif grading_type == "llm_judge":
return _grade_llm_judge(record, transcript, workspace_path, judge_backend, judge_model)
elif grading_type == "hybrid":
return _grade_hybrid(record, transcript, workspace_path, judge_backend, judge_model)
else:
return {"score": 0.0, "breakdown": {}, "notes": f"Unknown grading type: {grading_type}"}
# ---------------------------------------------------------------------------
# Standalone scorer (for EvalRunner non-agentic path)
# ---------------------------------------------------------------------------
class PinchBenchScorer(LLMJudgeScorer):
"""PinchBench scorer for the non-agentic EvalRunner path."""
scorer_id = "pinchbench"
def score(
self, record: EvalRecord, model_answer: str,
) -> Tuple[Optional[bool], Dict[str, Any]]:
trace = record.metadata.get("query_trace")
if trace:
transcript = _trace_to_transcript(trace)
else:
# No trace — build transcript from tool_results if available
tool_results = record.metadata.get("tool_results", [])
transcript = _tool_results_to_transcript(tool_results)
# Always append final model answer as assistant text message
# so grading functions that check for text responses can find it
if model_answer:
transcript.append({
"type": "message",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": model_answer}],
},
})
result = grade_pinchbench_task(
record=record,
transcript=transcript,
workspace_path=record.metadata.get("workspace_path", ""),
judge_backend=self._judge_backend,
judge_model=self._judge_model,
)
is_correct = result["score"] >= 0.5
return is_correct, {**result}
__all__ = [
"PinchBenchScorer",
"events_to_transcript",
"grade_pinchbench_task",
"_tool_results_to_transcript",
]
+75 -4
View File
@@ -45,11 +45,13 @@ BUILTIN_MODELS: List[ModelSpec] = [
parameter_count_b=3.0,
active_parameter_count_b=0.6,
context_length=131072,
supported_engines=("ollama", "vllm", "llamacpp", "sglang"),
supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-3B",
"gguf_file": "qwen3.5-3b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-3B-4bit",
},
),
ModelSpec(
@@ -58,11 +60,13 @@ BUILTIN_MODELS: List[ModelSpec] = [
parameter_count_b=8.0,
active_parameter_count_b=1.0,
context_length=131072,
supported_engines=("ollama", "vllm", "llamacpp", "sglang"),
supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-8B",
"gguf_file": "qwen3.5-8b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-8B-4bit",
},
),
ModelSpec(
@@ -71,11 +75,13 @@ BUILTIN_MODELS: List[ModelSpec] = [
parameter_count_b=14.0,
active_parameter_count_b=2.0,
context_length=131072,
supported_engines=("ollama", "vllm", "llamacpp", "sglang"),
supported_engines=("ollama", "vllm", "llamacpp", "sglang", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-14B",
"gguf_file": "qwen3.5-14b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-14B-4bit",
},
),
ModelSpec(
@@ -223,11 +229,13 @@ BUILTIN_MODELS: List[ModelSpec] = [
active_parameter_count_b=0.5,
context_length=262144,
min_vram_gb=3.0,
supported_engines=("ollama", "vllm", "sglang", "llamacpp"),
supported_engines=("ollama", "vllm", "sglang", "llamacpp", "mlx"),
provider="alibaba",
metadata={
"architecture": "moe",
"hf_repo": "Qwen/Qwen3.5-4B",
"gguf_file": "qwen3.5-4b-q4_k_m.gguf",
"mlx_repo": "mlx-community/Qwen3.5-4B-4bit",
},
),
ModelSpec(
@@ -707,6 +715,69 @@ BUILTIN_MODELS: List[ModelSpec] = [
},
),
# -----------------------------------------------------------------------
# Cloud models — MiniMax
# -----------------------------------------------------------------------
ModelSpec(
model_id="MiniMax-M2.7",
name="MiniMax M2.7",
parameter_count_b=0.0,
context_length=204800,
supported_engines=("cloud",),
provider="minimax",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"pricing_input": 0.30,
"pricing_output": 1.20,
"url": "https://www.minimax.io",
},
),
ModelSpec(
model_id="MiniMax-M2.7-highspeed",
name="MiniMax M2.7 Highspeed",
parameter_count_b=0.0,
context_length=204800,
supported_engines=("cloud",),
provider="minimax",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"pricing_input": 0.60,
"pricing_output": 2.40,
"url": "https://www.minimax.io",
},
),
ModelSpec(
model_id="MiniMax-M2.5",
name="MiniMax M2.5",
parameter_count_b=0.0,
context_length=204800,
supported_engines=("cloud",),
provider="minimax",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"pricing_input": 0.30,
"pricing_output": 1.20,
"url": "https://www.minimax.io",
},
),
ModelSpec(
model_id="MiniMax-M2.5-highspeed",
name="MiniMax M2.5 Highspeed",
parameter_count_b=0.0,
context_length=204800,
supported_engines=("cloud",),
provider="minimax",
requires_api_key=True,
metadata={
"architecture": "proprietary",
"pricing_input": 0.60,
"pricing_output": 2.40,
"url": "https://www.minimax.io",
},
),
# -----------------------------------------------------------------------
# Cloud models — Google
# -----------------------------------------------------------------------
ModelSpec(
+6
View File
@@ -13,6 +13,10 @@ from openjarvis.learning.learning_orchestrator import LearningOrchestrator
from openjarvis.learning.optimize.llm_optimizer import LLMOptimizer
from openjarvis.learning.optimize.optimizer import OptimizationEngine
from openjarvis.learning.optimize.store import OptimizationStore
from openjarvis.learning.routing.complexity import (
ComplexityQueryAnalyzer,
score_complexity,
)
from openjarvis.learning.routing.heuristic_reward import HeuristicRewardFunction
from openjarvis.learning.routing.router import (
HeuristicRouter,
@@ -59,6 +63,7 @@ def ensure_registered() -> None:
__all__ = [
"AgentConfigEvolver",
"ComplexityQueryAnalyzer",
"HAS_TORCH",
"HeuristicRewardFunction",
"HeuristicRouter",
@@ -75,4 +80,5 @@ __all__ = [
"TrainingDataMiner",
"build_routing_context",
"ensure_registered",
"score_complexity",
]
@@ -0,0 +1,261 @@
"""Query complexity analyzer — scores queries and suggests token budgets.
Produces a numeric complexity score (0.01.0) and a suggested
``max_tokens`` budget based on query characteristics such as length,
domain signals (code, math, multi-step reasoning), and whether the
target model is a thinking model that needs extra headroom.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
from openjarvis.core.types import RoutingContext
from openjarvis.learning._stubs import QueryAnalyzer
# ---------------------------------------------------------------------------
# Signal patterns
# ---------------------------------------------------------------------------
_CODE_PATTERNS = re.compile(
r"```|`[^`]+`|\bdef\s|\bclass\s|\bimport\s|\bfunction\s|\bconst\s|\bvar\s|\blet\s|"
r"\bif\s*\(|->|=>|\{\s*\}|\bfor\s+\w+\s+in\s|#include|System\.out",
re.IGNORECASE,
)
_MATH_PATTERNS = re.compile(
r"\bsolve\b|\bintegral\b|\bequation\b|\bproof\b|\bderivative\b|\bmatrix\b|"
r"\btheorem\b|\bcalculate\b|\bcompute\b|\bsigma\b|\bsum\b|\blimit\b|\bprobability\b",
re.IGNORECASE,
)
_REASONING_PATTERNS = re.compile(
r"\bexplain\b|\banalyze\b|\bcompare\b|\bwhy\b"
r"|\bstep[- ]by[- ]step\b|\breason\b|\bthink\b"
r"|\bpros\s+and\s+cons\b|\btrade-?\s*offs?\b|\bevaluate\b",
re.IGNORECASE,
)
_MULTI_STEP_PATTERNS = re.compile(
r"\bthen\b.*\bthen\b|\bfirst\b.*\bnext\b|\bstep\s*\d"
r"|\b(?:and\s+also|additionally|furthermore)\b"
r"|\b\d+\.\s",
re.IGNORECASE | re.DOTALL,
)
_CREATIVE_PATTERNS = re.compile(
r"\bwrite\b.*\b(?:essay|story|article|report|poem)\b"
r"|\bgenerate\b.*\b(?:code|script|program)\b"
r"|\bcreate\b|\bdesign\b|\bdraft\b|\bcompose\b",
re.IGNORECASE,
)
# Models known to use internal chain-of-thought that consumes output tokens.
_THINKING_MODEL_PATTERNS = re.compile(
r"qwen3\.5|qwq|deepseek-r1|o1-|o3-|o4-", re.IGNORECASE
)
# ---------------------------------------------------------------------------
# Token budget tiers
# ---------------------------------------------------------------------------
_TOKEN_TIERS = {
"trivial": 1024, # greetings, yes/no, factoid lookups
"simple": 2048, # short answers, definitions
"moderate": 4096, # explanations, summaries
"complex": 8192, # analysis, code generation, multi-step
"very_complex": 16384, # long-form, multi-part reasoning
}
# Thinking models need extra headroom for internal chain-of-thought.
_THINKING_TOKEN_MULTIPLIER = 2
# ---------------------------------------------------------------------------
# Complexity scoring
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ComplexityResult:
"""Output of the complexity analysis."""
score: float # 0.01.0
tier: str
suggested_max_tokens: int
signals: dict
def _count_questions(query: str) -> int:
"""Count the number of question marks in the query."""
return query.count("?")
def _count_sub_tasks(query: str) -> int:
"""Estimate the number of sub-tasks or enumerated items."""
numbered = len(re.findall(r"^\s*\d+[.)]\s", query, re.MULTILINE))
bulleted = len(re.findall(r"^\s*[-*]\s", query, re.MULTILINE))
return numbered + bulleted
def score_complexity(query: str) -> ComplexityResult:
"""Score a query's complexity from 0.0 (trivial) to 1.0 (very complex).
The score is a weighted combination of independent signals, each
contributing a fraction between 0 and 1. Weights reflect how much
each signal correlates with the amount of reasoning and output
tokens a model will need.
"""
signals: dict = {}
score = 0.0
# --- Length signal (00.20) ---
length = len(query)
if length < 20:
length_score = 0.0
elif length < 100:
length_score = 0.3
elif length < 300:
length_score = 0.6
elif length < 800:
length_score = 0.8
else:
length_score = 1.0
signals["length"] = length_score
score += 0.20 * length_score
# --- Domain signals (00.25) ---
has_code = bool(_CODE_PATTERNS.search(query))
has_math = bool(_MATH_PATTERNS.search(query))
domain_score = 0.0
if has_code:
domain_score = max(domain_score, 0.7)
if has_math:
domain_score = max(domain_score, 0.8)
if has_code and has_math:
domain_score = 1.0
signals["domain"] = domain_score
signals["has_code"] = has_code
signals["has_math"] = has_math
score += 0.25 * domain_score
# --- Reasoning signal (00.25) ---
has_reasoning = bool(_REASONING_PATTERNS.search(query))
has_multi_step = bool(_MULTI_STEP_PATTERNS.search(query))
reasoning_score = 0.0
if has_reasoning:
reasoning_score = 0.6
if has_multi_step:
reasoning_score = max(reasoning_score, 0.8)
if has_reasoning and has_multi_step:
reasoning_score = 1.0
signals["reasoning"] = reasoning_score
signals["has_reasoning"] = has_reasoning
signals["has_multi_step"] = has_multi_step
score += 0.25 * reasoning_score
# --- Question / sub-task count (00.15) ---
n_questions = _count_questions(query)
n_subtasks = _count_sub_tasks(query)
multi_part = n_questions + n_subtasks
if multi_part <= 1:
multi_score = 0.0
elif multi_part <= 3:
multi_score = 0.5
else:
multi_score = 1.0
signals["multi_part"] = multi_score
signals["n_questions"] = n_questions
signals["n_subtasks"] = n_subtasks
score += 0.15 * multi_score
# --- Creative / generative signal (00.15) ---
has_creative = bool(_CREATIVE_PATTERNS.search(query))
creative_score = 0.7 if has_creative else 0.0
signals["creative"] = creative_score
signals["has_creative"] = has_creative
score += 0.15 * creative_score
# Clamp
score = max(0.0, min(1.0, score))
# Map to tier
if score < 0.15:
tier = "trivial"
elif score < 0.30:
tier = "simple"
elif score < 0.55:
tier = "moderate"
elif score < 0.80:
tier = "complex"
else:
tier = "very_complex"
suggested_max_tokens = _TOKEN_TIERS[tier]
return ComplexityResult(
score=round(score, 3),
tier=tier,
suggested_max_tokens=suggested_max_tokens,
signals=signals,
)
def is_thinking_model(model_name: str) -> bool:
"""Return True if the model is known to use internal chain-of-thought."""
return bool(_THINKING_MODEL_PATTERNS.search(model_name))
def adjust_tokens_for_model(
suggested: int, model_name: Optional[str] = None
) -> int:
"""Multiply the token budget when the target is a thinking model."""
if model_name and is_thinking_model(model_name):
return suggested * _THINKING_TOKEN_MULTIPLIER
return suggested
# ---------------------------------------------------------------------------
# QueryAnalyzer implementation
# ---------------------------------------------------------------------------
class ComplexityQueryAnalyzer(QueryAnalyzer):
"""Query analyzer that produces a complexity-aware RoutingContext.
Drop-in replacement for ``DefaultQueryAnalyzer`` adds
``complexity_score``, ``has_reasoning``, and ``suggested_max_tokens``
to the returned ``RoutingContext``.
"""
def analyze(
self, query: str, **kwargs: object
) -> RoutingContext:
urgency = kwargs.get("urgency", 0.5)
if not isinstance(urgency, (int, float)):
urgency = 0.5
model_name = kwargs.get("model")
if not isinstance(model_name, str):
model_name = None
result = score_complexity(query)
tokens = adjust_tokens_for_model(result.suggested_max_tokens, model_name)
return RoutingContext(
query=query,
query_length=len(query),
has_code=result.signals.get("has_code", False),
has_math=result.signals.get("has_math", False),
has_reasoning=result.signals.get("has_reasoning", False),
urgency=float(urgency),
complexity_score=result.score,
suggested_max_tokens=tokens,
metadata={"complexity_tier": result.tier, "signals": result.signals},
)
__all__ = [
"ComplexityQueryAnalyzer",
"ComplexityResult",
"adjust_tokens_for_model",
"is_thinking_model",
"score_complexity",
]
+36 -29
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
import re
from typing import List, Optional
from openjarvis.core.registry import ModelRegistry
@@ -12,32 +11,33 @@ from openjarvis.learning._stubs import QueryAnalyzer, RouterPolicy
logger = logging.getLogger(__name__)
# Detection patterns
_CODE_PATTERNS = re.compile(
r"```|`[^`]+`|\bdef\s|\bclass\s|\bimport\s|\bfunction\s|\bconst\s|\bvar\s|\blet\s|"
r"\bif\s*\(|->|=>|\{\s*\}|\bfor\s+\w+\s+in\s|#include|System\.out",
re.IGNORECASE,
)
_MATH_PATTERNS = re.compile(
r"\bsolve\b|\bintegral\b|\bequation\b|\bproof\b|\bderivative\b|\bmatrix\b|"
r"\btheorem\b|\bcalculate\b|\bcompute\b|\bsigma\b|\bsum\b|\blimit\b|\bprobability\b",
re.IGNORECASE,
)
_REASONING_KEYWORDS = re.compile(
r"\bexplain\b|\banalyze\b|\bcompare\b|\bwhy\b"
r"|\bstep[- ]by[- ]step\b|\breason\b|\bthink\b",
re.IGNORECASE,
)
def build_routing_context(
query: str, *, urgency: float = 0.5, model: str | None = None
) -> RoutingContext:
"""Populate a ``RoutingContext`` from a raw query string.
When *model* is provided, the suggested token budget is adjusted
for thinking models that need extra headroom.
"""
from openjarvis.learning.routing.complexity import (
adjust_tokens_for_model,
score_complexity,
)
result = score_complexity(query)
tokens = adjust_tokens_for_model(result.suggested_max_tokens, model)
def build_routing_context(query: str, *, urgency: float = 0.5) -> RoutingContext:
"""Populate a ``RoutingContext`` from a raw query string."""
return RoutingContext(
query=query,
query_length=len(query),
has_code=bool(_CODE_PATTERNS.search(query)),
has_math=bool(_MATH_PATTERNS.search(query)),
has_code=result.signals.get("has_code", False),
has_math=result.signals.get("has_math", False),
has_reasoning=result.signals.get("has_reasoning", False),
urgency=urgency,
complexity_score=result.score,
suggested_max_tokens=tokens,
metadata={"complexity_tier": result.tier, "signals": result.signals},
)
@@ -94,8 +94,8 @@ class HeuristicRouter(RouterPolicy):
Rules (applied in order):
1. Code detected prefer model with "code"/"coder" in name
2. Math detected prefer larger model
3. Short query (<50 chars, no code/math) prefer smaller/faster model
4. Long/complex query (>500 chars OR reasoning keywords) prefer larger model
3. Low complexity (score < 0.20) prefer smaller/faster model
4. High complexity (score >= 0.55 OR reasoning keywords) prefer larger model
5. High urgency (>0.8) override to smaller model
6. Default fallback default_model fallback_model first available
"""
@@ -139,12 +139,12 @@ class HeuristicRouter(RouterPolicy):
if context.has_math:
return _largest_model(available) or available[0]
# Rule 3: Short simple query → prefer smaller model
if context.query_length < 50:
# Rule 3: Low complexity → prefer smaller model
if context.complexity_score < 0.20:
return _smallest_model(available) or available[0]
# Rule 4: Long/complex query → prefer larger model
if context.query_length > 500 or _REASONING_KEYWORDS.search(context.query):
# Rule 4: High complexity or reasoning → prefer larger model
if context.complexity_score >= 0.55 or context.has_reasoning:
return _largest_model(available) or available[0]
# Rule 6: Default fallback
@@ -162,7 +162,14 @@ class DefaultQueryAnalyzer(QueryAnalyzer):
urgency = kwargs.get("urgency", 0.5)
if not isinstance(urgency, (int, float)):
urgency = 0.5
return build_routing_context(query, urgency=urgency)
model = kwargs.get("model")
if not isinstance(model, str):
model = None
return build_routing_context(query, urgency=urgency, model=model)
__all__ = ["DefaultQueryAnalyzer", "HeuristicRouter", "build_routing_context"]
__all__ = [
"DefaultQueryAnalyzer",
"HeuristicRouter",
"build_routing_context",
]
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations

Some files were not shown because too many files have changed in this diff Show More