mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 10:52:15 +00:00
Add desktop distribution pipeline: rolling releases, auto-updates, code signing
- Rewrite .github/workflows/desktop.yml: 2-job pipeline (validate + build-and-release) with rolling desktop-latest pre-release on push to main and stable desktop-v* releases - Add UpdateChecker component: checks for updates on startup + every 30 min, background download with progress bar, one-click relaunch - Configure Tauri updater: endpoints pointing to desktop-latest release, pubkey placeholder - Add tauri-plugin-process for relaunch support (Cargo.toml, lib.rs, package.json) - Add macOS Entitlements.plist for notarization (network + file access, no sandbox) - Add scripts/bump-desktop-version.sh for atomic version bumps across 3 config files - Add desktop/README.md with dev setup, auto-update architecture, signing docs - Update .gitignore for desktop/node_modules, dist, target - Configure macOS minimumSystemVersion, Windows timestampUrl - Include all Phase 14-21 work: agent hardening, RBAC, taint tracking, workflows, skills, knowledge graph, sessions, A2A, MCP templates, WASM sandbox, TUI dashboard, production tools, CLI expansion, API expansion, learning productionization, Tauri desktop app, and 10 new channels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
24972e3e52
commit
a4c4081ff4
@@ -0,0 +1,160 @@
|
||||
name: Desktop Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
tags:
|
||||
- 'desktop-v*'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/desktop.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: desktop
|
||||
run: npm install
|
||||
|
||||
- name: TypeScript type-check
|
||||
working-directory: desktop
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Vite build
|
||||
working-directory: desktop
|
||||
run: npx vite build
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'desktop/src-tauri -> target'
|
||||
|
||||
- name: Cargo check
|
||||
working-directory: desktop/src-tauri
|
||||
run: cargo check
|
||||
|
||||
build-and-release:
|
||||
needs: [validate]
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: ubuntu-22.04
|
||||
args: ''
|
||||
- platform: macos-latest
|
||||
args: '--target aarch64-apple-darwin'
|
||||
- platform: macos-13
|
||||
args: '--target x86_64-apple-darwin'
|
||||
- platform: windows-latest
|
||||
args: ''
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libgtk-3-dev \
|
||||
libappindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
libxdo-dev
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'desktop/src-tauri -> target'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: desktop
|
||||
run: npm install
|
||||
|
||||
- name: Determine release info
|
||||
id: release-info
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/desktop-v* ]]; then
|
||||
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop ${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build and release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
projectPath: desktop
|
||||
tauriScript: npx tauri
|
||||
tagName: ${{ steps.release-info.outputs.tag }}
|
||||
releaseName: ${{ steps.release-info.outputs.name }}
|
||||
releaseBody: 'Desktop application built from ${{ github.sha }}'
|
||||
releaseDraft: false
|
||||
prerelease: ${{ steps.release-info.outputs.prerelease }}
|
||||
includeUpdaterJson: true
|
||||
args: ${{ matrix.args }}
|
||||
@@ -51,3 +51,8 @@ site/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
src/openjarvis/server/static/
|
||||
|
||||
# Desktop (Tauri)
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/src-tauri/target/
|
||||
|
||||
@@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Status
|
||||
|
||||
OpenJarvis is a research framework for studying on-device AI systems. Phase 13 (Install, Hosting, Cross-Hardware, Eval) complete. Five composable pillars: Intelligence, Engine, Agents, Tools (with storage + MCP), and Learning — with trace-driven learning as a cross-cutting concern. Python SDK (`Jarvis` class), composition layer (`SystemBuilder`/`JarvisSystem`), OpenClaw agent infrastructure, benchmarking framework, Docker deployment all ready. Agent hierarchy refactored: `BaseAgent` (with shared helpers) → `ToolUsingAgent` (tool-using agents), `accepts_tools` introspection, real OpenHands SDK integration. NanoClaw functionality subsumed: `ClaudeCodeAgent` (Claude Agent SDK via Node.js), `WhatsAppBaileysChannel` (Baileys protocol), `ContainerRunner`/`SandboxedAgent` (Docker sandbox), `TaskScheduler` (cron/interval/once with SQLite + MCP tools). ~2244 tests pass (37 skipped for optional deps). Eval framework with 4 benchmarks (SuperGPQA, GAIA, FRAMES, WildChat) and LLM-as-judge scoring via `gpt-5-mini-2025-08-07`. Energy measurement upgraded: `EnergyMonitor` ABC with multi-vendor support (NVIDIA hw counters, AMD amdsmi, Apple Silicon zeus-ml, CPU RAPL sysfs), batch-level energy-per-token accounting (`EnergyBatch`), steady-state detection (`SteadyStateDetector`), `EnergyBenchmark` with warmup phase. Tool system enriched with shared `build_tool_descriptions()` builder; engine tool_calls normalized across OpenAI, Anthropic, Google, and LiteLLM. Phase 13: `jarvis doctor` diagnostic command, `jarvis init` post-setup guidance, MLX engine backend (Apple Silicon → `mlx` recommendation), AMD VRAM/multi-GPU detection, PyTorch MPS device selection, PWA support for browser/desktop hosting, ROCm Docker support.
|
||||
OpenJarvis is a research framework for studying on-device AI systems. Phase 21 complete. Five composable pillars: Intelligence, Engine, Agents, Tools (with storage + MCP), and Learning — with trace-driven learning as a cross-cutting concern. ~2940 tests pass (~51 skipped for optional deps). Python SDK (`Jarvis` class), composition layer (`SystemBuilder`/`JarvisSystem`), benchmarking framework, Docker deployment, Tauri desktop app, 40+ tools, 20+ CLI commands, 40+ API endpoints all ready.
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
uv sync --extra dev # Install deps + dev tools
|
||||
uv run pytest tests/ -v # Run ~2244 tests (37 skipped if optional deps missing)
|
||||
uv run pytest tests/ -v # Run ~2997 tests (~42 skipped if optional deps missing)
|
||||
uv run ruff check src/ tests/ # Lint
|
||||
uv run jarvis --version # 1.0.0
|
||||
uv run jarvis ask "Hello" # Query via discovered engine (direct mode)
|
||||
@@ -35,19 +35,30 @@ uv run jarvis telemetry clear --yes # Delete all telemetry records
|
||||
uv run jarvis channel list # List available messaging channels
|
||||
uv run jarvis channel send slack "Hello" # Send a message to a channel
|
||||
uv run jarvis channel status # Show channel bridge connection status
|
||||
uv run jarvis scheduler create "Check weather" --type cron --value "0 9 * * *" # Schedule a task
|
||||
uv run jarvis scheduler create "Check weather" --type cron --value "0 9 * * *"
|
||||
uv run jarvis scheduler list # List scheduled tasks
|
||||
uv run jarvis scheduler pause TASK_ID # Pause a scheduled task
|
||||
uv run jarvis scheduler cancel TASK_ID # Cancel a scheduled task
|
||||
uv run jarvis scheduler logs TASK_ID # Show task run history
|
||||
uv run jarvis scheduler start # Start scheduler daemon (foreground)
|
||||
uv run jarvis bench run # Run all benchmarks against engine
|
||||
uv run jarvis bench run -n 20 --json # Run with 20 samples, JSON output
|
||||
uv run jarvis bench run -b latency -o results.jsonl # Specific benchmark to file
|
||||
uv run jarvis bench run -b energy -w 5 -n 20 --json # Energy benchmark with warmup
|
||||
uv run jarvis serve --port 8000 # OpenAI-compatible API server (requires openjarvis[server])
|
||||
uv run jarvis doctor # Run diagnostic checks (config, engines, models, deps)
|
||||
uv run jarvis doctor --json # Machine-readable diagnostics
|
||||
uv run jarvis start # Start server as background daemon
|
||||
uv run jarvis stop # Stop background daemon
|
||||
uv run jarvis restart # Restart background daemon
|
||||
uv run jarvis status # Show daemon status (PID, uptime)
|
||||
uv run jarvis chat # Interactive REPL (/quit, /clear, /model, /help, /history)
|
||||
uv run jarvis chat --agent orchestrator --tools calculator # REPL with agent
|
||||
uv run jarvis agent list # List registered agents
|
||||
uv run jarvis agent info native_react # Show agent details
|
||||
uv run jarvis workflow list # List available workflows
|
||||
uv run jarvis workflow run my_workflow # Execute a workflow
|
||||
uv run jarvis skill list # List installed skills
|
||||
uv run jarvis skill install path/to/skill.toml # Install a skill
|
||||
uv run jarvis vault set MY_KEY # Store encrypted credential
|
||||
uv run jarvis vault get MY_KEY # Retrieve credential
|
||||
uv run jarvis vault list # List stored keys
|
||||
uv run jarvis add github # Quick-add MCP server (github, slack, postgres, etc.)
|
||||
uv run jarvis --help # Show all subcommands
|
||||
uv run jarvis init --force # Detect hardware, write ~/.openjarvis/config.toml
|
||||
# Eval framework
|
||||
@@ -87,8 +98,8 @@ j.close() # Release resources
|
||||
```
|
||||
|
||||
- **Package manager:** `uv` with `hatchling` build backend
|
||||
- **Config:** `pyproject.toml` with extras for optional backends (e.g., `openjarvis[inference-vllm]`, `openjarvis[inference-mlx]`, `openjarvis[memory-colbert]`, `openjarvis[server]`, `openjarvis[openclaw]`, `openjarvis[energy-amd]`, `openjarvis[energy-apple]`, `openjarvis[energy-all]`)
|
||||
- **CLI entry point:** `jarvis` (Click-based) — subcommands: `init`, `ask`, `serve`, `model`, `memory`, `telemetry`, `bench`, `channel`, `scheduler`, `doctor`
|
||||
- **Config:** `pyproject.toml` with extras for optional backends (e.g., `openjarvis[inference-vllm]`, `openjarvis[inference-mlx]`, `openjarvis[memory-colbert]`, `openjarvis[server]`, `openjarvis[openclaw]`, `openjarvis[energy-amd]`, `openjarvis[energy-apple]`, `openjarvis[energy-all]`, `openjarvis[security-signing]`, `openjarvis[sandbox-wasm]`, `openjarvis[dashboard]`, `openjarvis[browser]`, `openjarvis[media]`, `openjarvis[pdf]`, `openjarvis[channel-line]`, `openjarvis[channel-viber]`, `openjarvis[channel-reddit]`, `openjarvis[channel-mastodon]`, `openjarvis[channel-xmpp]`, `openjarvis[channel-rocketchat]`, `openjarvis[channel-zulip]`, `openjarvis[channel-twitch]`, `openjarvis[channel-nostr]`)
|
||||
- **CLI entry point:** `jarvis` (Click-based) — subcommands: `init`, `ask`, `serve`, `start`, `stop`, `restart`, `status`, `chat`, `model`, `memory`, `telemetry`, `bench`, `channel`, `scheduler`, `doctor`, `agent`, `workflow`, `skill`, `vault`, `add`
|
||||
- **Python:** 3.10+ required
|
||||
- **Node.js:** 22+ required only for OpenClaw agent
|
||||
|
||||
@@ -98,170 +109,121 @@ OpenJarvis is a research framework for on-device AI organized around **five comp
|
||||
|
||||
### Five Pillars
|
||||
|
||||
1. **Intelligence** (`src/openjarvis/intelligence/`) — The model definition, catalog, and generation defaults. `ModelRegistry` maps model keys to `ModelSpec`. `IntelligenceConfig` holds model identity (default/fallback model, model_path, checkpoint_path, quantization, preferred_engine, provider) and generation defaults (temperature, max_tokens, top_p, top_k, repetition_penalty, stop_sequences). Model catalog (`model_catalog.py`) maintains `BUILTIN_MODELS` list with auto-discovery via `merge_discovered_models()`. Backward-compat shims in `intelligence/_stubs.py` and `intelligence/router.py` re-export from `learning/` for old import paths.
|
||||
2. **Engine** (`src/openjarvis/engine/`) — The inference runtime. Backends: vLLM, SGLang, Ollama, llama.cpp, MLX. All implement `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`, `health()`. Engines extract and pass through `tool_calls` in OpenAI format.
|
||||
3. **Agents** (`src/openjarvis/agents/`) — Pluggable logic for handling queries, making tool/API calls, managing memory. Class hierarchy: `BaseAgent` ABC (with concrete helpers: `_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`, `_strip_think_tags`) → `ToolUsingAgent` (adds `tools`, `ToolExecutor`, `max_turns`). Agents: `SimpleAgent` (single-turn, no tools, extends `BaseAgent`), `OrchestratorAgent` (multi-turn tool-calling loop, extends `ToolUsingAgent`), `NativeReActAgent` (Thought-Action-Observation loop, extends `ToolUsingAgent`, registry key `"native_react"`, alias `"react"`), `NativeOpenHandsAgent` (CodeAct-style code execution, extends `ToolUsingAgent`, registry key `"native_openhands"`), `RLMAgent` (recursive LM with code gen, extends `ToolUsingAgent`), `OpenHandsAgent` (wraps real `openhands-sdk`, extends `BaseAgent`, registry key `"openhands"`, requires `openjarvis[openhands]` + Python 3.12+), `OpenClawAgent` (HTTP/subprocess transport), `ClaudeCodeAgent` (wraps Claude Agent SDK via Node.js subprocess, registry key `"claude_code"`, requires Node.js 22+, `accepts_tools=False`), `SandboxedAgent` (transparent Docker wrapper for any agent, registry key `"sandboxed"`, `accepts_tools=False`). `accepts_tools` class attribute enables CLI/SDK to auto-detect tool-using agents. Backward-compat: `from openjarvis.agents.react import ReActAgent` still works (shim). Agents call `engine.generate()` directly — telemetry is handled by the `InstrumentedEngine` wrapper when enabled.
|
||||
1. **Intelligence** (`src/openjarvis/intelligence/`) — Model definition, catalog, and generation defaults. `ModelRegistry` maps model keys to `ModelSpec`. `IntelligenceConfig` holds model identity (default/fallback model, model_path, checkpoint_path, quantization, preferred_engine, provider) and generation defaults (temperature, max_tokens, top_p, top_k, repetition_penalty, stop_sequences). Model catalog maintains `BUILTIN_MODELS` with auto-discovery via `merge_discovered_models()`. Backward-compat shims re-export from `learning/` for old import paths.
|
||||
2. **Engine** (`src/openjarvis/engine/`) — The inference runtime. Backends: vLLM, SGLang, Ollama, llama.cpp, MLX, LM Studio. All implement `InferenceEngine` ABC with `generate()`, `stream()`, `list_models()`, `health()`. Engines extract and pass through `tool_calls` in OpenAI format.
|
||||
3. **Agents** (`src/openjarvis/agents/`) — Pluggable logic for queries, tool/API calls, memory. Hierarchy: `BaseAgent` ABC (helpers: `_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`, `_strip_think_tags`, `_check_continuation`) → `ToolUsingAgent` (adds `tools`, `ToolExecutor`, `max_turns`). Agents: `SimpleAgent` (single-turn), `OrchestratorAgent` (multi-turn tool loop), `NativeReActAgent` (Thought-Action-Observation, key `"native_react"`, alias `"react"`), `NativeOpenHandsAgent` (CodeAct, key `"native_openhands"`), `RLMAgent` (recursive LM), `OpenHandsAgent` (real `openhands-sdk`, key `"openhands"`, requires Python 3.12+), `OpenClawAgent` (HTTP/subprocess transport), `ClaudeCodeAgent` (Claude Agent SDK via Node.js, key `"claude_code"`), `SandboxedAgent` (Docker wrapper, key `"sandboxed"`). `accepts_tools` class attribute for CLI/SDK auto-detection. Agents call `engine.generate()` directly — telemetry handled by `InstrumentedEngine` wrapper.
|
||||
4. **Tools** (`src/openjarvis/tools/`) — All tools managed via MCP (Model Context Protocol).
|
||||
- **API tools**: `CalculatorTool`, `ThinkTool`, `FileReadTool`, `WebSearchTool`, `CodeInterpreterTool` — all implement `BaseTool` ABC
|
||||
- **Storage tools** (`tools/storage_tools.py`): `MemoryStoreTool`, `MemoryRetrieveTool`, `MemorySearchTool`, `MemoryIndexTool` — wrap `MemoryBackend` operations as MCP-discoverable tools
|
||||
- **LM tool** (`tools/llm_tool.py`): Sub-model calls via engine
|
||||
- **Storage backends** (`tools/storage/`): SQLite/FTS5 (default), FAISS, ColBERTv2, BM25, Hybrid (RRF fusion). All implement `MemoryBackend` ABC with `store()`, `retrieve()`, `delete()`, `clear()`. Canonical import: `from openjarvis.tools.storage.sqlite import SQLiteMemory`. Backward-compat shims in `memory/` still work.
|
||||
- **Scheduler tools** (`scheduler/tools.py`): `ScheduleTaskTool`, `ListScheduledTasksTool`, `PauseScheduledTaskTool`, `ResumeScheduledTaskTool`, `CancelScheduledTaskTool` — MCP-discoverable tools for task scheduling, injected with `_scheduler` dependency
|
||||
- **MCP adapter** (`tools/mcp_adapter.py`): `MCPToolAdapter` wraps external MCP server tools as native `BaseTool` instances. `MCPToolProvider` discovers tools from an MCP server.
|
||||
- **MCP server** (`mcp/server.py`): Exposes all built-in tools via JSON-RPC `tools/list` + `tools/call` (MCP spec 2025-11-25). Any MCP client (Claude, GPT, etc.) can discover and use OpenJarvis tools.
|
||||
5. **Learning** (`src/openjarvis/learning/`) — Structured learning system with nested per-pillar sub-policies. `LearningConfig` has nested sections: `routing` (policy selection: heuristic/learned/grpo), `intelligence` (model updates: none/sft), `agent` (agent logic: none/agent_advisor/icl_updater), `metrics` (reward weights: accuracy/latency/cost/efficiency). `RouterPolicy` ABC and `QueryAnalyzer` ABC defined in `learning/_stubs.py`. `HeuristicRouter` and `build_routing_context()` in `learning/router.py`. `LearningPolicy` ABC taxonomy: `IntelligenceLearningPolicy` (updates model routing), `AgentLearningPolicy` (updates agent logic). Implementations: `SFTRouterPolicy` (learns query→model mapping from traces; backward-compat alias `SFTPolicy`), `AgentAdvisorPolicy` (LM-guided agent restructuring), `ICLUpdaterPolicy` (in-context example + skill discovery, registered as `AgentLearningPolicy`). Router policies: `HeuristicRouter` (registered as "heuristic"), `TraceDrivenPolicy` (registered as "learned"), `GRPORouterPolicy` (stub, registered as "grpo"). `HeuristicRewardFunction` scores inference results on latency/cost/efficiency. Orchestrator training subpackage (`learning/orchestrator/`) provides SFT and GRPO pipelines for structured-mode OrchestratorAgent training.
|
||||
- **API tools**: `CalculatorTool`, `ThinkTool`, `FileReadTool`, `FileWriteTool`, `WebSearchTool`, `CodeInterpreterTool`, `LLMTool`, `ShellExecTool`, `ApplyPatchTool`, `HttpRequestTool`, `DatabaseQueryTool`, `PDFExtractTool`, `ImageGenerateTool`, `AudioTranscribeTool` — all implement `BaseTool` ABC
|
||||
- **Git tools** (`git_tool.py`): `GitStatusTool`, `GitDiffTool`, `GitCommitTool`, `GitLogTool`
|
||||
- **Browser tools** (`browser.py`): `BrowserNavigateTool`, `BrowserClickTool`, `BrowserTypeTool`, `BrowserScreenshotTool`, `BrowserExtractTool` (Playwright, optional `[browser]`)
|
||||
- **Agent tools** (`agent_tools.py`): `AgentSpawnTool`, `AgentSendTool`, `AgentListTool`, `AgentKillTool`
|
||||
- **Storage tools** (`storage_tools.py`): `MemoryStoreTool`, `MemoryRetrieveTool`, `MemorySearchTool`, `MemoryIndexTool`
|
||||
- **Storage backends** (`tools/storage/`): SQLite/FTS5 (default), FAISS, ColBERTv2, BM25, Hybrid (RRF fusion), KnowledgeGraph. All implement `MemoryBackend` ABC. Canonical import: `from openjarvis.tools.storage.sqlite import SQLiteMemory`. Backward-compat shims in `memory/` still work.
|
||||
- **Scheduler tools** (`scheduler/tools.py`): 5 MCP tools for task scheduling
|
||||
- **Knowledge graph tools** (`knowledge_tools.py`): `KGAddEntityTool`, `KGAddRelationTool`, `KGQueryTool`, `KGNeighborsTool`
|
||||
- **MCP adapter** (`mcp_adapter.py`): `MCPToolAdapter` wraps external MCP tools as native `BaseTool`; `MCPToolProvider` discovers from server
|
||||
- **MCP server** (`mcp/server.py`): Exposes all built-in tools via JSON-RPC `tools/list` + `tools/call` (MCP spec 2025-11-25)
|
||||
- **MCP templates** (`tools/templates/`): `ToolTemplate` dynamically constructs tools from TOML specs. 10 builtin templates. `discover_templates()` auto-discovers.
|
||||
- **`ToolExecutor`**: dispatch with RBAC check + taint check, `timeout_seconds` on `ToolSpec` (default 30s via `ThreadPoolExecutor`), event bus integration
|
||||
- All registered via `@ToolRegistry.register("name")` decorator
|
||||
5. **Learning** (`src/openjarvis/learning/`) — Structured learning with nested per-pillar sub-policies. `LearningConfig` sections: `routing` (heuristic/learned/grpo/bandit), `intelligence` (none/sft), `agent` (none/agent_advisor/icl_updater), `metrics` (accuracy/latency/cost/efficiency weights). Policies: `SFTRouterPolicy` (query→model from traces), `AgentAdvisorPolicy` (LM-guided), `ICLUpdaterPolicy` (in-context with example DB, versioning, rollback, quality gates), `GRPORouterPolicy` (softmax sampling, group relative advantage, per-query-class weights), `BanditRouterPolicy` (Thompson Sampling / UCB1, per-arm stats). `SkillDiscovery` mines tool subsequences from traces to auto-generate skill manifests. Router policies: `HeuristicRouter`, `TraceDrivenPolicy`. Orchestrator training subpackage provides SFT and GRPO pipelines.
|
||||
|
||||
### Cross-cutting: Traces
|
||||
### Cross-cutting Systems
|
||||
|
||||
- **Traces** (`src/openjarvis/traces/`) — Full interaction-level recording. Every agent interaction produces a `Trace` capturing the sequence of `TraceStep`s (route, retrieve, generate, tool_call, respond) with timing, inputs, outputs, and outcomes. `TraceStore` persists to SQLite. `TraceCollector` wraps any `BaseAgent` to record traces automatically. `TraceAnalyzer` provides aggregated stats for the learning system.
|
||||
- **Traces** (`src/openjarvis/traces/`) — Full interaction recording. `Trace` captures `TraceStep`s (route, retrieve, generate, tool_call, respond) with timing. `TraceStore` (SQLite), `TraceCollector` (auto-wraps agents), `TraceAnalyzer` (stats for learning).
|
||||
- **Telemetry** (`src/openjarvis/telemetry/`) — `InstrumentedEngine` wraps any engine, publishing events to SQLite via `TelemetryStore`. `TelemetryAggregator` for read-only queries. `EnergyMonitor` ABC with vendor-specific implementations: `NvidiaEnergyMonitor` (hw counters/polling), `AmdEnergyMonitor` (amdsmi), `AppleEnergyMonitor` (zeus-ml), `RaplEnergyMonitor` (sysfs). `EnergyBatch` for batch-level energy-per-token. `SteadyStateDetector` for thermal equilibrium (CV-based).
|
||||
- **Security** (`src/openjarvis/security/`) — `SecretScanner` + `PIIScanner` (implement `BaseScanner` ABC). `GuardrailsEngine` wraps engines with input/output scanning (WARN/REDACT/BLOCK modes). `AuditLogger` with Merkle hash chain (SHA-256 tamper-evidence). `CapabilityPolicy` RBAC (10 capabilities with glob matching, enforced in `ToolExecutor`). `TaintLabel`/`TaintSet` information flow control with `SINK_POLICY`. Ed25519 signing via `cryptography` (optional `[security-signing]`). `file_policy.py` for sensitive file detection. `InjectionScanner` (11 regex patterns: prompt override, identity override, code/shell injection, exfiltration, jailbreak, delimiter injection). `check_ssrf()` SSRF protection (RFC 1918, loopback, link-local, cloud metadata blocking). `RateLimiter` with `TokenBucket` (thread-safe, per-key). `run_sandboxed()` subprocess isolation (`os.setsid`, process group kill, env clearing). Security HTTP middleware (7 headers: CSP, HSTS, X-Frame-Options, etc.).
|
||||
|
||||
### Composition Layer (`src/openjarvis/system.py`)
|
||||
### Composition & Infrastructure
|
||||
|
||||
- `SystemBuilder`: Config-driven fluent builder — `.engine()`, `.model()`, `.agent()`, `.tools()`, `.telemetry()`, `.traces()`, `.build()` → `JarvisSystem`
|
||||
- `JarvisSystem`: Fully wired system with `ask()`, `close()`. Single source of truth for pillar wiring.
|
||||
- Consolidates duplicated engine/model/tool/agent resolution logic from CLI, SDK, and server.
|
||||
|
||||
### Python SDK (`src/openjarvis/sdk.py`)
|
||||
|
||||
- `Jarvis` class: High-level sync API wrapping CLI code paths
|
||||
- `MemoryHandle`: Lazy memory backend proxy on `j.memory`
|
||||
- `ask()` / `ask_full()`: Direct engine or agent mode, with router policy selection
|
||||
- Lazy engine initialization, telemetry recording, resource cleanup via `close()`
|
||||
- Also exports `JarvisSystem` and `SystemBuilder` from `system.py` for config-driven composition
|
||||
|
||||
### Tool System (`src/openjarvis/tools/`)
|
||||
|
||||
- `_stubs.py` — `ToolSpec` dataclass, `BaseTool` ABC (abstract `spec`, `execute()`), `ToolExecutor` (dispatch with event bus integration, JSON argument parsing, latency tracking)
|
||||
- Built-in API tools: `CalculatorTool` (ast-based safe eval), `ThinkTool` (reasoning scratchpad), `RetrievalTool` (memory search), `LLMTool` (sub-model calls), `FileReadTool` (safe file reading with path validation), `WebSearchTool`, `CodeInterpreterTool`
|
||||
- Storage MCP tools (`storage_tools.py`): `MemoryStoreTool`, `MemoryRetrieveTool`, `MemorySearchTool`, `MemoryIndexTool` — expose memory operations as MCP-discoverable tools
|
||||
- MCP adapter (`mcp_adapter.py`): `MCPToolAdapter` wraps external MCP tools as native `BaseTool` instances; `MCPToolProvider` discovers tools from an `MCPClient`
|
||||
- Storage backends (`tools/storage/`): `MemoryBackend` ABC, `SQLiteMemory`, `BM25Memory`, `FAISSMemory`, `ColBERTMemory`, `HybridMemory`, plus chunking, context injection, embeddings, ingest. Canonical imports from `openjarvis.tools.storage.*`; backward-compat shims in `openjarvis.memory.*`
|
||||
- All registered via `@ToolRegistry.register("name")` decorator
|
||||
|
||||
### Benchmarking Framework (`src/openjarvis/bench/`)
|
||||
|
||||
- `_stubs.py` — `BenchmarkResult` dataclass (includes `warmup_samples`, `steady_state_samples`, `steady_state_reached`, `total_energy_joules`, `energy_per_token_joules`, `energy_method`), `BaseBenchmark` ABC, `BenchmarkSuite` runner
|
||||
- `latency.py` — `LatencyBenchmark`: measures per-call latency (mean, p50, p95, min, max). Supports `warmup_samples` parameter.
|
||||
- `throughput.py` — `ThroughputBenchmark`: measures tokens/second throughput. Supports `warmup_samples` parameter.
|
||||
- `energy.py` — `EnergyBenchmark`: measures energy per token at thermal equilibrium. Uses `SteadyStateDetector` + `EnergyBatch` with optional `EnergyMonitor`. Warmup phase excluded from metrics.
|
||||
- All registered via `BenchmarkRegistry` with `ensure_registered()` pattern
|
||||
- CLI: `jarvis bench run` with options for model, engine, samples, benchmark selection, warmup (`-w`), JSON/JSONL output
|
||||
|
||||
### OpenClaw Infrastructure (`src/openjarvis/agents/openclaw*.py`)
|
||||
|
||||
- `openclaw_protocol.py` — `MessageType` enum, `ProtocolMessage` dataclass, JSON-line `serialize()`/`deserialize()`
|
||||
- `openclaw_transport.py` — `OpenClawTransport` ABC, `HttpTransport` (HTTP POST to OpenClaw server), `SubprocessTransport` (Node.js stdin/stdout)
|
||||
- `openclaw.py` — `OpenClawAgent`: transport-based agent with tool-call loop, event bus integration
|
||||
- `openclaw_plugin.py` — `ProviderPlugin` (wraps engine for OpenClaw), `MemorySearchManager` (wraps memory for OpenClaw)
|
||||
|
||||
### API Server (`src/openjarvis/server/`)
|
||||
|
||||
- OpenAI-compatible server via `jarvis serve` (FastAPI + uvicorn, optional `[server]` extra)
|
||||
- `POST /v1/chat/completions` — non-streaming through agent/engine, streaming via SSE
|
||||
- `GET /v1/models` — list available models
|
||||
- `GET /health` — health check
|
||||
- Pydantic request/response models matching OpenAI API format
|
||||
|
||||
### Trace System (`src/openjarvis/traces/`)
|
||||
|
||||
- `store.py` — `TraceStore` writes complete `Trace` objects (with `TraceStep` lists) to SQLite. Supports filtering by agent, model, outcome, time range. Subscribes to `TRACE_COMPLETE` events on EventBus.
|
||||
- `collector.py` — `TraceCollector` wraps any `BaseAgent` and records a `Trace` for every `run()`. Subscribes to EventBus events (inference, tool, memory) during agent execution, converting them to `TraceStep` objects. Automatically persists traces and publishes `TRACE_COMPLETE`.
|
||||
- `analyzer.py` — `TraceAnalyzer` read-only query layer: `summary()`, `per_route_stats()`, `per_tool_stats()`, `traces_for_query_type()`, `export_traces()`. Time-range filtering. Provides inputs for the learning system.
|
||||
- Dataclasses: `RouteStats`, `ToolStats`, `TraceSummary`
|
||||
|
||||
### Telemetry (`src/openjarvis/telemetry/`)
|
||||
|
||||
- `store.py` — `TelemetryStore` writes records to SQLite via EventBus subscription (append-only). Schema includes `energy_method`, `energy_vendor`, `batch_id`, `is_warmup`, `cpu_energy_joules`, `gpu_energy_joules`, `dram_energy_joules` columns.
|
||||
- `aggregator.py` — `TelemetryAggregator` read-only query layer: `per_model_stats()`, `per_engine_stats()`, `per_batch_stats()`, `top_models()`, `summary()`, `export_records()`, `clear()`. Time-range filtering via `since`/`until`.
|
||||
- `instrumented_engine.py` — `InstrumentedEngine` wraps any `InferenceEngine` transparently, publishing `INFERENCE_START/END` and `TELEMETRY_RECORD` events. Prefers `EnergyMonitor` over legacy `GpuMonitor` when available. Agents call `engine.generate()` normally; telemetry is opt-in via this wrapper (applied by `SystemBuilder` when `config.telemetry.enabled`).
|
||||
- `energy_monitor.py` — `EnergyMonitor` ABC: `available()`, `vendor()`, `energy_method()`, `sample()` context manager, `close()`. `EnergySample` dataclass (superset of `GpuSample`): total + per-component (CPU, GPU, DRAM, ANE) energy, vendor/device info, measurement method. `EnergyVendor` enum: NVIDIA, AMD, APPLE, CPU_RAPL. `create_energy_monitor()` factory with auto-detection order: NVIDIA > AMD > Apple > CPU RAPL.
|
||||
- `energy_nvidia.py` — `NvidiaEnergyMonitor`: hardware counters via `nvmlDeviceGetTotalEnergyConsumption()` on Volta+, trapezoidal polling fallback on pre-Volta. `energy_method` = "hw_counter" or "polling".
|
||||
- `energy_amd.py` — `AmdEnergyMonitor`: hardware counters via `amdsmi_get_energy_count()` (ROCm 6.1+). `energy_method` = "hw_counter".
|
||||
- `energy_apple.py` — `AppleEnergyMonitor`: wraps `zeus-ml[apple]` `AppleSiliconMonitor`. Per-component breakdown (CPU, GPU, DRAM, ANE). `energy_method` = "zeus".
|
||||
- `energy_rapl.py` — `RaplEnergyMonitor`: reads Intel RAPL counters from `/sys/class/powercap/intel-rapl/` (no deps). Handles counter wrap-around. `energy_method` = "rapl".
|
||||
- `batch.py` — `EnergyBatch`: wraps `EnergyMonitor.sample()` around grouped requests. `BatchMetrics` dataclass with energy-per-token/per-request accounting. `BATCH_START`/`BATCH_END` events.
|
||||
- `steady_state.py` — `SteadyStateDetector`: CV-based sliding window to detect thermal equilibrium. `SteadyStateConfig` (warmup_samples, window_size, cv_threshold). Used by `EnergyBenchmark`.
|
||||
- `wrapper.py` — Legacy `instrumented_generate()` function (still used by some CLI/SDK code paths)
|
||||
- Dataclasses: `ModelStats`, `EngineStats`, `AggregatedStats`, `EnergySample`, `BatchMetrics`, `SteadyStateResult`
|
||||
|
||||
### Security (`src/openjarvis/security/`)
|
||||
|
||||
- `_stubs.py` — `BaseScanner` ABC (abstract `scan()`, `redact()`)
|
||||
- `types.py` — `ThreatLevel`, `RedactionMode`, `ScanFinding`, `ScanResult`, `SecurityEvent`, `SecurityEventType`
|
||||
- `scanner.py` — `SecretScanner` (API keys, tokens, passwords, connection strings, private keys), `PIIScanner` (email, SSN, credit cards, phone, IP)
|
||||
- `guardrails.py` — `GuardrailsEngine` wraps any `InferenceEngine` with input/output scanning. Modes: WARN (pass-through + event), REDACT (replace sensitive content), BLOCK (raise `SecurityBlockError`). `stream()` does post-hoc scan of accumulated output.
|
||||
- `file_policy.py` — `is_sensitive_file()` checks filename against `DEFAULT_SENSITIVE_PATTERNS` (`.env`, `*.pem`, `*.key`, `credentials.*`, etc.). `filter_sensitive_paths()` filters path lists.
|
||||
- `audit.py` — `AuditLogger` persists security events to SQLite for compliance/forensics.
|
||||
|
||||
### Channels (`src/openjarvis/channels/`)
|
||||
|
||||
- `_stubs.py` — `BaseChannel` ABC (`connect()`, `disconnect()`, `send()`, `status()`, `list_channels()`, `on_message()`), `ChannelMessage` dataclass, `ChannelStatus` enum, `ChannelHandler` type
|
||||
- `openclaw_bridge.py` — `OpenClawChannelBridge`: WebSocket/HTTP bridge to OpenClaw gateway. Background listener thread, reconnect logic, handler registration, event bus integration. Registered as `"openclaw"` in `ChannelRegistry`.
|
||||
- `whatsapp_baileys.py` — `WhatsAppBaileysChannel`: Bidirectional WhatsApp messaging via Baileys protocol. Spawns Node.js bridge subprocess, JSON-line stdio protocol, QR code auth, background reader thread. Registered as `"whatsapp_baileys"` in `ChannelRegistry`. Bundled bridge in `whatsapp_baileys_bridge/`.
|
||||
|
||||
### Sandbox (`src/openjarvis/sandbox/`)
|
||||
|
||||
- `runner.py` — `ContainerRunner`: manages Docker/Podman container lifecycle for sandboxed agent execution. Sentinel-wrapped JSON I/O, timeout enforcement, mount validation, orphan cleanup. `SandboxedAgent(BaseAgent)`: transparent wrapper that runs any agent inside a container (follows `GuardrailsEngine` wrapper pattern).
|
||||
- `mount_security.py` — `MountAllowlist`, `AllowedRoot` dataclasses, `DEFAULT_BLOCKED_PATTERNS` (`.ssh`, `.gnupg`, `.env`, `*.pem`, `*.key`, etc.), `validate_mount()`, `validate_mounts()`, path traversal prevention.
|
||||
- `Dockerfile.sandbox` — Container image for sandboxed execution (Python 3.12-slim + Node.js 22).
|
||||
|
||||
### Scheduler (`src/openjarvis/scheduler/`)
|
||||
|
||||
- `scheduler.py` — `ScheduledTask` dataclass, `TaskScheduler` with cron/interval/once scheduling, background polling thread, event bus integration (`SCHEDULER_TASK_START/END`).
|
||||
- `store.py` — `SchedulerStore`: SQLite CRUD for `scheduled_tasks` and `task_run_logs` tables.
|
||||
- `tools.py` — 5 MCP tools: `ScheduleTaskTool`, `ListScheduledTasksTool`, `PauseScheduledTaskTool`, `ResumeScheduledTaskTool`, `CancelScheduledTaskTool`.
|
||||
- CLI: `jarvis scheduler create|list|pause|resume|cancel|logs|start` (Click command group in `cli/scheduler_cmd.py`).
|
||||
- **Composition Layer** (`system.py`) — `SystemBuilder` fluent builder → `JarvisSystem` with `ask()`, `close()`. Wires engine, model, agent, tools, telemetry, traces, workflow, sessions, capability policy.
|
||||
- **SDK** (`sdk.py`) — `Jarvis` class: high-level sync API with `ask()`/`ask_full()`, `MemoryHandle`, lazy init, telemetry. Also exports `JarvisSystem`/`SystemBuilder`.
|
||||
- **Benchmarks** (`bench/`) — `LatencyBenchmark`, `ThroughputBenchmark`, `EnergyBenchmark`. All registered via `BenchmarkRegistry`. CLI: `jarvis bench run`.
|
||||
- **OpenClaw** (`agents/openclaw*.py`) — `OpenClawAgent` with `HttpTransport`/`SubprocessTransport`, JSON-line protocol, `ProviderPlugin`, `MemorySearchManager`.
|
||||
- **API Server** (`server/`) — OpenAI-compatible via `jarvis serve` (FastAPI + uvicorn). Endpoints: `POST /v1/chat/completions`, `GET /v1/models`, `GET /health`, channel endpoints. SSE streaming.
|
||||
- **Channels** (`channels/`) — `BaseChannel` ABC. `OpenClawChannelBridge` (WebSocket/HTTP to OpenClaw gateway). `WhatsAppBaileysChannel` (Baileys protocol, Node.js bridge, QR auth). Phase 21 channels: `LINEChannel`, `ViberChannel`, `MessengerChannel`, `RedditChannel`, `MastodonChannel`, `XMPPChannel`, `RocketChatChannel`, `ZulipChannel`, `TwitchChannel`, `NostrChannel`. All follow `BaseChannel` ABC with env var fallbacks, `@ChannelRegistry.register()`, `EventBus` integration.
|
||||
- **Sandbox** (`sandbox/`) — `ContainerRunner` (Docker/Podman lifecycle, mount validation). `WasmRunner` (wasmtime-py, fuel/memory limits, optional `[sandbox-wasm]`). `SandboxedAgent` transparent wrapper. `create_sandbox_runner()` factory. `MountAllowlist` with path traversal prevention.
|
||||
- **Scheduler** (`scheduler/`) — `TaskScheduler` with cron/interval/once scheduling, SQLite persistence, 5 MCP tools, event bus. CLI: `jarvis scheduler create|list|pause|resume|cancel|logs|start`.
|
||||
- **Agent Hardening** (`agents/loop_guard.py`) — `LoopGuard`: SHA-256 hash tracking (identical calls), ping-pong detection (A-B-A-B patterns), poll-tool budget, context overflow recovery. `BaseAgent._check_continuation()` auto-resumes on `finish_reason=length`.
|
||||
- **Workflow Engine** (`workflow/`) — DAG-based `WorkflowGraph` (cycle detection, topological sort, parallel stages via `ThreadPoolExecutor`). `WorkflowBuilder` fluent API. `WorkflowEngine` executes against `JarvisSystem`. TOML loader. Node types: agent, tool, condition, parallel, loop, transform.
|
||||
- **Skills** (`skills/`) — `SkillManifest`/`SkillExecutor` (sequential tool steps with template rendering). Ed25519 signature verification. `SkillTool` adapter wraps skills as invocable tools. TOML loader.
|
||||
- **Knowledge Graph** (`tools/storage/knowledge_graph.py`) — `KnowledgeGraphMemory(MemoryBackend)`: SQLite entity-relation store. `add_entity()`, `add_relation()`, `neighbors()`, `query_pattern()`. Registered as `"knowledge_graph"`.
|
||||
- **Sessions** (`sessions/`) — `SessionStore` (SQLite): cross-channel persistent sessions. `SessionIdentity` canonical user across channels. `consolidate()` summarizes old messages, `decay()` removes expired.
|
||||
- **A2A Protocol** (`a2a/`) — Google Agent-to-Agent spec (JSON-RPC 2.0). `A2AServer` (tasks/send, tasks/get, tasks/cancel, `/.well-known/agent.json`). `A2AClient`. `A2AAgentTool` adapter.
|
||||
- **TUI Dashboard** (`cli/dashboard.py`) — `textual`-based terminal dashboard (optional `[dashboard]`). Panels: system status, event stream, telemetry, agent activity, sessions.
|
||||
- **Desktop App** (`desktop/`) — Tauri 2.0 native desktop application. 5 dashboard panels: EnergyDashboard (real-time power monitoring with recharts), TraceDebugger (timeline inspection with step-type color coding), LearningCurve (policy visualization, GRPO/bandit stats), MemoryBrowser (search + stats), AdminPanel (health, agents, server control). Tauri commands proxy to OpenJarvis REST API. Plugins: notification, shell, global-shortcut, autostart, updater, single-instance. CI: `.github/workflows/desktop.yml` (Linux/macOS/Windows).
|
||||
- **Vault** (`cli/vault_cmd.py`) — Fernet-encrypted credential store at `~/.openjarvis/vault.enc` with auto-generated key (`0o600` permissions).
|
||||
- **MCP Quick-Add** (`cli/add_cmd.py`) — `jarvis add <server>` with 8 templates (github, filesystem, slack, postgres, brave-search, memory, puppeteer, google-maps). Saves JSON config to `~/.openjarvis/mcp/`.
|
||||
|
||||
### Core Module (`src/openjarvis/core/`)
|
||||
|
||||
- `registry.py` — `RegistryBase[T]` generic base class adapted from IPW. Typed subclasses: `ModelRegistry`, `EngineRegistry`, `MemoryRegistry`, `AgentRegistry`, `ToolRegistry`, `RouterPolicyRegistry`, `BenchmarkRegistry`, `ChannelRegistry`, `LearningRegistry`.
|
||||
- `registry.py` — `RegistryBase[T]` generic base. Subclasses: `ModelRegistry`, `EngineRegistry`, `MemoryRegistry`, `AgentRegistry`, `ToolRegistry`, `RouterPolicyRegistry`, `BenchmarkRegistry`, `ChannelRegistry`, `LearningRegistry`, `SkillRegistry`.
|
||||
- `types.py` — `Message`, `Conversation`, `ModelSpec`, `ToolResult`, `TelemetryRecord`, `StepType`, `TraceStep`, `Trace`, `RoutingContext`.
|
||||
- `config.py` — `JarvisConfig` dataclass hierarchy with TOML loader. Config classes: `EngineConfig` (nested `OllamaEngineConfig`, `VLLMEngineConfig`, `SGLangEngineConfig`, `LlamaCppEngineConfig`, `MLXEngineConfig`), `IntelligenceConfig` (model identity + generation defaults: temperature, max_tokens, top_p, top_k, repetition_penalty, stop_sequences), `AgentConfig` (default_agent, tools, objective, system_prompt, system_prompt_path, context_from_memory), `ToolsConfig` (nests `StorageConfig` + `MCPConfig`), `LearningConfig` (nested `RoutingLearningConfig`, `IntelligenceLearningConfig`, `AgentLearningConfig`, `MetricsConfig`), `TracesConfig`, `TelemetryConfig`, `ServerConfig`, `ChannelConfig` (nests `WhatsAppBaileysChannelConfig`), `SecurityConfig`, `SandboxConfig`, `SchedulerConfig`. Backward-compat properties: `engine.ollama_host` → `engine.ollama.host`, `agent.default_tools` → `agent.tools`, `learning.default_policy` → `learning.routing.policy`. TOML migration layer handles cross-section moves (`agent.temperature` → `intelligence.temperature`, `memory.context_injection` → `agent.context_from_memory`). User config lives at `~/.openjarvis/config.toml`. TOML sections: `[engine]`, `[engine.ollama]`, `[engine.vllm]`, `[engine.sglang]`, `[engine.llamacpp]`, `[engine.mlx]`, `[intelligence]`, `[agent]`, `[tools.storage]`, `[tools.mcp]`, `[learning]`, `[learning.routing]`, `[learning.intelligence]`, `[learning.agent]`, `[learning.metrics]`, `[memory]` (backward-compat), `[server]`, `[telemetry]`, `[traces]`, `[channel]`, `[channel.whatsapp_baileys]`, `[security]`, `[sandbox]`, `[scheduler]`.
|
||||
- `events.py` — Pub/sub event bus for inter-pillar telemetry (synchronous dispatch). EventType values: INFERENCE_START/END, TOOL_CALL_START/END, MEMORY_STORE/RETRIEVE, AGENT_TURN_START/END, TELEMETRY_RECORD, TRACE_STEP/COMPLETE, CHANNEL_MESSAGE_RECEIVED/SENT, SECURITY_SCAN/ALERT/BLOCK, SCHEDULER_TASK_START/END.
|
||||
- `config.py` — `JarvisConfig` dataclass hierarchy with TOML loader. Config classes for each pillar/subsystem. TOML sections: `[engine]` (+ nested `[engine.ollama]`, `[engine.vllm]`, `[engine.sglang]`, `[engine.llamacpp]`, `[engine.mlx]`, `[engine.lmstudio]`), `[intelligence]`, `[agent]`, `[tools.storage]`, `[tools.mcp]`, `[tools.browser]`, `[learning]` (+ nested routing/intelligence/agent/metrics), `[server]`, `[telemetry]`, `[traces]`, `[channel]`, `[security]` (+ `[security.capabilities]`, `ssrf_protection`, `rate_limit_*`), `[sandbox]`, `[scheduler]`, `[workflow]`, `[sessions]`, `[a2a]`. Backward-compat: `engine.ollama_host` → `engine.ollama.host`, `agent.default_tools` → `agent.tools`, TOML migration for cross-section moves.
|
||||
- `events.py` — Pub/sub event bus (synchronous dispatch). ~30 EventType values covering inference, tools, memory, agents, telemetry, traces, channels, security, scheduler, workflow, skills, sessions, A2A.
|
||||
|
||||
### Docker & Deployment
|
||||
|
||||
- `Dockerfile` — Multi-stage build: Python 3.12-slim, installs `.[server]`, entrypoint `jarvis serve`
|
||||
- `Dockerfile.gpu` — NVIDIA CUDA 12.4 runtime variant
|
||||
- `Dockerfile.gpu.rocm` — AMD ROCm 6.2 runtime variant (multi-stage build)
|
||||
- `docker-compose.yml` — Services: `jarvis` (port 8000) + `ollama` (port 11434)
|
||||
- `docker-compose.gpu.rocm.yml` — ROCm override file (use with `-f docker-compose.yml -f docker-compose.gpu.rocm.yml`)
|
||||
- `deploy/systemd/openjarvis.service` — systemd unit file
|
||||
- `deploy/launchd/com.openjarvis.plist` — macOS launchd plist
|
||||
- `Dockerfile` — Multi-stage: Python 3.12-slim, `.[server]`, entrypoint `jarvis serve`
|
||||
- `Dockerfile.gpu` — NVIDIA CUDA 12.4 variant
|
||||
- `Dockerfile.gpu.rocm` — AMD ROCm 6.2 variant
|
||||
- `docker-compose.yml` — `jarvis` (8000) + `ollama` (11434). ROCm override: `docker-compose.gpu.rocm.yml`
|
||||
- `deploy/systemd/openjarvis.service`, `deploy/launchd/com.openjarvis.plist`
|
||||
|
||||
### Query Flow
|
||||
|
||||
User query → Security scanning (input) → Intelligence resolves model (default_model → preferred_engine → fallback chain) → Agentic Logic (determine tools/memory needs) → Memory retrieval → Context injection with source attribution → Inference Engine generates response → Security scanning (output) → Trace recorded to SQLite (full interaction sequence) → Telemetry recorded → Learning policies update from accumulated traces.
|
||||
User query → Security scanning (input) → Intelligence resolves model → Agentic Logic (tools/memory) → Memory retrieval → Context injection → Engine generates → Security scanning (output) → Trace recorded → Telemetry recorded → Learning policies update.
|
||||
|
||||
### API Surface
|
||||
|
||||
OpenAI-compatible server via `jarvis serve`: `POST /v1/chat/completions`, `GET /v1/models`, `GET /v1/channels`, `POST /v1/channels/send`, `GET /v1/channels/status` with SSE streaming.
|
||||
OpenAI-compatible server via `jarvis serve`:
|
||||
- **Core**: `POST /v1/chat/completions`, `GET /v1/models`, `GET /health`
|
||||
- **Channels**: `GET /v1/channels`, `POST /v1/channels/send`, `GET /v1/channels/status`
|
||||
- **Agents**: `GET /v1/agents`, `POST /v1/agents`, `DELETE /v1/agents/{id}`, `POST /v1/agents/{id}/message`
|
||||
- **Memory**: `POST /v1/memory/store`, `POST /v1/memory/search`, `GET /v1/memory/stats`
|
||||
- **Traces**: `GET /v1/traces`, `GET /v1/traces/{id}`
|
||||
- **Telemetry**: `GET /v1/telemetry/stats`, `GET /v1/telemetry/energy`
|
||||
- **Learning**: `GET /v1/learning/stats`, `GET /v1/learning/policy`
|
||||
- **Skills**: `GET /v1/skills`, `POST /v1/skills`, `DELETE /v1/skills/{name}`
|
||||
- **Sessions**: `GET /v1/sessions`, `GET /v1/sessions/{id}`
|
||||
- **Budget**: `GET /v1/budget`, `PUT /v1/budget/limits`
|
||||
- **Metrics**: `GET /metrics` (Prometheus-compatible)
|
||||
- **WebSocket**: `WS /v1/chat/stream` (JSON chunked streaming)
|
||||
- SSE streaming on `/v1/chat/completions` with `stream=true`
|
||||
|
||||
## Key Design Patterns
|
||||
|
||||
- **Registry pattern:** All extensible components use `@XRegistry.register("name")` decorator for registration and runtime discovery. New implementations are added by decorating a class — no factory modifications needed.
|
||||
- **Registry pattern:** All extensible components use `@XRegistry.register("name")` decorator for registration and runtime discovery.
|
||||
- **ABC interfaces:** Each pillar defines an ABC. Implement the ABC + register via decorator to add a new backend.
|
||||
- **Offline-first:** Cloud APIs are optional. All core functionality works without network.
|
||||
- **Hardware-aware:** Auto-detect GPU vendor/model/VRAM via `nvidia-smi`, `rocm-smi`, `system_profiler`, `/proc/cpuinfo`. Recommend engine accordingly.
|
||||
- **Telemetry opt-in:** When enabled, `InstrumentedEngine` wraps the inference engine to transparently record timing, tokens, energy, cost to SQLite via event bus. Agents call `engine.generate()` without awareness of telemetry. `TelemetryAggregator` provides read-only query/aggregation over stored records.
|
||||
- **Backward-compat shims:** `memory/` re-exports from `tools/storage/`, `intelligence/_stubs.py` re-exports `RouterPolicy`/`QueryAnalyzer` from `learning/_stubs.py`, `intelligence/router.py` re-exports `HeuristicRouter`/`build_routing_context`/`DefaultQueryAnalyzer` from `learning/router.py`, `agents/react.py` re-exports `NativeReActAgent` as `ReActAgent` (old import path still works), registry alias `"react"` → `NativeReActAgent`. Old import paths continue to work. Config backward-compat: `engine.ollama_host` → `engine.ollama.host`, `agent.default_tools` → `agent.tools`, `learning.default_policy` → `learning.routing.policy`, etc. TOML migration: `agent.temperature` → `intelligence.temperature`, `memory.context_injection` → `agent.context_from_memory`.
|
||||
- **`ensure_registered()` pattern:** Benchmark and learning modules use lazy registration via `ensure_registered()` to survive registry clearing in tests.
|
||||
- **Telemetry opt-in:** `InstrumentedEngine` wraps inference transparently. Agents unaware of telemetry.
|
||||
- **Backward-compat shims:** `memory/` re-exports from `tools/storage/`, `intelligence/` re-exports from `learning/`, `agents/react.py` re-exports as `ReActAgent`, registry alias `"react"` → `NativeReActAgent`. Old import paths and config keys continue to work.
|
||||
- **`ensure_registered()` pattern:** Benchmark and learning modules use lazy registration to survive registry clearing in tests.
|
||||
|
||||
## Development Phases
|
||||
|
||||
| Version | Phase | Delivers |
|
||||
|---------|-------|----------|
|
||||
| v0.1 | Phase 0 | Scaffolding, registries, core types, config, CLI skeleton |
|
||||
| v0.2 | Phase 1 | Intelligence + Inference — `jarvis ask` works end-to-end |
|
||||
| v0.3 | Phase 2 | Memory backends, document indexing, context injection |
|
||||
| v0.4 | Phase 3 | Agents, tool system, OpenAI-compatible API server |
|
||||
| v0.5 | Phase 4 | Learning implementations, telemetry aggregation, `--router` CLI, `jarvis telemetry` |
|
||||
| v1.0 | Phase 5 | SDK, OpenClaw infrastructure, benchmarks, Docker, documentation |
|
||||
| v1.1 | Phase 6 | Trace system, trace-driven learning, pluggable agent architectures |
|
||||
| v1.2 | Phase 7 | 5-pillar restructuring: Intelligence ABCs, memory→tools/storage, MCP tool management, composition layer (SystemBuilder/JarvisSystem), InstrumentedEngine, structured learning (SFT/AgentAdvisor/ICL), config schema update |
|
||||
| v1.3 | Phase 8 | Intelligence = "The Model": routing moved to Learning, enriched IntelligenceConfig (model_path, checkpoint_path, quantization, preferred_engine, provider), intelligence-driven engine selection, backward-compat shims |
|
||||
| v1.4 | Phase 9 | Pillar-aligned config: generation params in Intelligence, nested engine/learning configs, agent objective/system_prompt/context_from_memory, structured learning sub-policies (routing/intelligence/agent/metrics), TOML migration layer |
|
||||
| v1.5 | Phase 10 | Agent restructuring: BaseAgent helpers (`_emit_turn_start/end`, `_build_messages`, `_generate`, `_max_turns_result`), ToolUsingAgent intermediate base, `accepts_tools` introspection, NativeReActAgent/NativeOpenHandsAgent renames, real OpenHands SDK integration, CLI/SDK tool-passing bug fix, backward-compat shims |
|
||||
| v1.6 | Phase 11 | NanoClaw subsumption: `ClaudeCodeAgent` (Claude Agent SDK via Node.js subprocess), `WhatsAppBaileysChannel` (Baileys protocol), `ContainerRunner`/`SandboxedAgent` (Docker sandbox with mount security), `TaskScheduler` (cron/interval/once + SQLite + MCP tools + CLI), `SandboxConfig`/`SchedulerConfig`/`WhatsAppBaileysChannelConfig`, SystemBuilder `.sandbox()`/`.scheduler()` |
|
||||
| v1.7 | Phase 12 | Energy Measurement Upgrade: `EnergyMonitor` ABC with multi-vendor support (NVIDIA hw counters, AMD amdsmi, Apple zeus-ml, CPU RAPL sysfs), `create_energy_monitor()` factory with auto-detection, `EnergySample` superset of `GpuSample`, `EnergyBatch` batch-level energy-per-token accounting, `SteadyStateDetector` CV-based thermal equilibrium detection, `EnergyBenchmark` with warmup phase, `InstrumentedEngine` prefers `EnergyMonitor` over legacy `GpuMonitor`, expanded `GPU_SPECS` (B200, MI300X, MI250X, M4 Max, M2 Ultra), `TelemetryRecord`/store schema extended with energy_method/vendor/batch_id/is_warmup/per-component fields, eval runner warmup phase, `--warmup` CLI option, new extras `energy-amd`/`energy-apple`/`energy-all` |
|
||||
| v1.8 | Phase 13 | Install, Hosting, Cross-Hardware, Eval: `jarvis doctor` diagnostic command (8 checks with Rich output + `--json`), `jarvis init` post-setup guidance (engine-specific next steps), README Quick Start section, MLX engine backend (`MLXEngine`, `MLXEngineConfig`, Apple Silicon → `mlx` recommendation), AMD VRAM/multi-GPU detection via `rocm-smi --showmeminfo`/`--showallinfo`, PyTorch MPS device selection in orchestrator trainers, PWA support (vite-plugin-pwa, service worker, manifest, icons), server static file serving fix for PWA files, `Dockerfile.gpu.rocm` + `docker-compose.gpu.rocm.yml` for ROCm, `inference-mlx` extra |
|
||||
| v0.1 | 0 | Scaffolding, registries, core types, config, CLI skeleton |
|
||||
| v0.2 | 1 | Intelligence + Inference — `jarvis ask` end-to-end |
|
||||
| v0.3 | 2 | Memory backends, document indexing, context injection |
|
||||
| v0.4 | 3 | Agents, tool system, OpenAI-compatible API server |
|
||||
| v0.5 | 4 | Learning, telemetry aggregation, `--router` CLI |
|
||||
| v1.0 | 5 | SDK, OpenClaw infra, benchmarks, Docker |
|
||||
| v1.1 | 6 | Trace system, trace-driven learning, pluggable agents |
|
||||
| v1.2 | 7 | 5-pillar restructuring, composition layer, MCP, structured learning |
|
||||
| v1.3 | 8 | Intelligence = "The Model", routing → Learning, engine selection |
|
||||
| v1.4 | 9 | Pillar-aligned config, nested configs, TOML migration |
|
||||
| v1.5 | 10 | Agent restructuring, BaseAgent/ToolUsingAgent, `accepts_tools`, OpenHands SDK |
|
||||
| v1.6 | 11 | NanoClaw subsumption: ClaudeCodeAgent, WhatsApp Baileys, Docker sandbox, TaskScheduler |
|
||||
| v1.7 | 12 | EnergyMonitor ABC (NVIDIA/AMD/Apple/RAPL), EnergyBatch, SteadyStateDetector |
|
||||
| v1.8 | 13 | `jarvis doctor`/`init`, MLX engine, AMD multi-GPU, PWA, ROCm Docker |
|
||||
| v1.9 | 14 | Agent hardening: LoopGuard, RBAC CapabilityPolicy, taint tracking, Merkle audit, Ed25519 |
|
||||
| v2.0 | 15 | WorkflowEngine (DAG), SkillSystem, KnowledgeGraphMemory, SessionStore |
|
||||
| v2.1 | 16 | A2A protocol, MCP templates, WasmRunner, TUI dashboard |
|
||||
| v2.2 | 17 | Production tool parity: FileWrite, ApplyPatch, ShellExec, Git, HTTP, DB, Browser, Agent, Media, PDF tools. SSRF protection, injection scanner, rate limiter, subprocess sandbox, security middleware |
|
||||
| v2.3 | 18 | CLI expansion (20 commands): daemon, chat REPL, agent, workflow, skill, vault, add. API expansion (40+ endpoints): agents, memory, traces, telemetry, learning, skills, sessions, budget, metrics, WebSocket streaming |
|
||||
| v2.4 | 19 | Learning productionization: GRPO (softmax/advantage), BanditRouter (Thompson/UCB1), SkillDiscovery (trace mining), ICL updates (versioning/rollback/quality gates) |
|
||||
| v2.5 | 20 | Tauri 2.0 desktop app: energy dashboard, trace debugger, learning curve visualization, memory browser, admin panel. CI for Linux/macOS/Windows |
|
||||
| v2.6 | 21 | 10 new channels: LINE, Viber, Messenger, Reddit, Mastodon, XMPP, Rocket.Chat, Zulip, Twitch, Nostr |
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# OpenJarvis Desktop
|
||||
|
||||
Tauri 2.0 native desktop application for OpenJarvis with auto-updates, energy monitoring, trace debugging, and learning visualization.
|
||||
|
||||
## Development Setup
|
||||
|
||||
```bash
|
||||
# Prerequisites: Node.js 22+, Rust stable, system deps (see below)
|
||||
|
||||
cd desktop
|
||||
npm install
|
||||
cargo tauri dev # Hot-reload development mode
|
||||
cargo tauri build # Production build
|
||||
```
|
||||
|
||||
### Linux System Dependencies
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev \
|
||||
librsvg2-dev patchelf libxdo-dev
|
||||
```
|
||||
|
||||
## Auto-Update Architecture
|
||||
|
||||
Every push to `main` (touching `desktop/` or the workflow) triggers a CI pipeline that:
|
||||
|
||||
1. Validates TypeScript + Rust (`validate` job)
|
||||
2. Builds for Linux, macOS (ARM + Intel), and Windows (`build-and-release` job)
|
||||
3. Creates/updates a `desktop-latest` pre-release on GitHub Releases
|
||||
4. Uploads platform installers and a signed `latest.json` manifest
|
||||
|
||||
The desktop app checks `latest.json` on startup and every 30 minutes. When a newer version is found, it shows a banner prompting the user to download and relaunch.
|
||||
|
||||
```
|
||||
Push to main -> CI builds -> desktop-latest release -> latest.json
|
||||
|
|
||||
Desktop app checks periodically <-------------------------+
|
||||
-> "Update available" banner
|
||||
-> Download in background
|
||||
-> "Relaunch now" prompt
|
||||
```
|
||||
|
||||
## Releases
|
||||
|
||||
### Rolling (Nightly)
|
||||
|
||||
Automatic on every push to `main`. Users on the desktop app receive updates seamlessly.
|
||||
|
||||
### Stable (Versioned)
|
||||
|
||||
```bash
|
||||
# Bump version in all 3 config files
|
||||
./scripts/bump-desktop-version.sh 1.0.1
|
||||
|
||||
# Commit and tag
|
||||
git add desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml
|
||||
git commit -m "chore(desktop): bump version to 1.0.1"
|
||||
git tag desktop-v1.0.1
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
CI creates a versioned GitHub Release (e.g., `desktop-v1.0.1`) with full installers.
|
||||
|
||||
## Code Signing
|
||||
|
||||
### Update Signing (Required for Auto-Updates)
|
||||
|
||||
Generate a key pair for signing update manifests:
|
||||
|
||||
```bash
|
||||
cargo tauri signer generate -w ~/.tauri/openjarvis.key
|
||||
```
|
||||
|
||||
Set the public key in `src-tauri/tauri.conf.json` under `plugins.updater.pubkey`, then add these GitHub Secrets:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `TAURI_SIGNING_PRIVATE_KEY` | Contents of the `.key` file |
|
||||
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password used during generation |
|
||||
|
||||
### macOS Notarization (Optional)
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `APPLE_CERTIFICATE` | Base64-encoded `.p12` certificate |
|
||||
| `APPLE_CERTIFICATE_PASSWORD` | Certificate password |
|
||||
| `APPLE_SIGNING_IDENTITY` | e.g., `Developer ID Application: Name (TEAMID)` |
|
||||
| `APPLE_ID` | Apple ID email |
|
||||
| `APPLE_PASSWORD` | App-specific password |
|
||||
| `APPLE_TEAM_ID` | 10-character team ID |
|
||||
|
||||
### Windows Authenticode (Optional)
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `WINDOWS_CERTIFICATE` | Base64-encoded `.pfx` certificate |
|
||||
| `WINDOWS_CERTIFICATE_PASSWORD` | Certificate password |
|
||||
|
||||
All signing is optional — unsigned builds work without any secrets configured.
|
||||
|
||||
## Dashboard Panels
|
||||
|
||||
- **Energy** — Real-time power monitoring (recharts)
|
||||
- **Traces** — Timeline inspection with step-type color coding
|
||||
- **Learning** — Policy visualization (GRPO/bandit stats)
|
||||
- **Memory** — Search and stats for memory backends
|
||||
- **Admin** — Health checks, agent management, server control
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenJarvis Desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2273
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "openjarvis-desktop",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@tauri-apps/plugin-process": "^2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+6184
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "openjarvis-desktop"
|
||||
version = "1.0.0"
|
||||
description = "OpenJarvis Desktop — Native AI assistant with energy monitoring, trace debugging, and learning visualization"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 360 B |
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
Binary file not shown.
|
After Width: | Height: | Size: 104 B |
Binary file not shown.
|
After Width: | Height: | Size: 856 B |
@@ -0,0 +1,208 @@
|
||||
use serde::Serialize;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
|
||||
/// Fetch health status from the OpenJarvis API server.
|
||||
#[tauri::command]
|
||||
async fn check_health(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/health", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch energy monitoring data from the API.
|
||||
#[tauri::command]
|
||||
async fn fetch_energy(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/telemetry/energy", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch telemetry statistics from the API.
|
||||
#[tauri::command]
|
||||
async fn fetch_telemetry(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/telemetry/stats", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch recent traces from the API.
|
||||
#[tauri::command]
|
||||
async fn fetch_traces(api_url: String, limit: u32) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/traces?limit={}", api_url, limit);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch a single trace by ID.
|
||||
#[tauri::command]
|
||||
async fn fetch_trace(api_url: String, trace_id: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/traces/{}", api_url, trace_id);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch learning system statistics.
|
||||
#[tauri::command]
|
||||
async fn fetch_learning_stats(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/learning/stats", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch learning policy configuration.
|
||||
#[tauri::command]
|
||||
async fn fetch_learning_policy(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/learning/policy", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch memory backend statistics.
|
||||
#[tauri::command]
|
||||
async fn fetch_memory_stats(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/memory/stats", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Search memory for relevant chunks.
|
||||
#[tauri::command]
|
||||
async fn search_memory(
|
||||
api_url: String,
|
||||
query: String,
|
||||
top_k: u32,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/memory/search", api_url);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({"query": query, "top_k": top_k}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Fetch list of available agents.
|
||||
#[tauri::command]
|
||||
async fn fetch_agents(api_url: String) -> Result<serde_json::Value, String> {
|
||||
let url = format!("{}/v1/agents", api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {}", e))?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid response: {}", e))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Launch the `jarvis` CLI command via shell.
|
||||
#[tauri::command]
|
||||
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
|
||||
let output = tokio::process::Command::new("jarvis")
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--hidden"]),
|
||||
))
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
// Focus the main window if another instance is launched
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.setup(|app| {
|
||||
// Set up system tray menu
|
||||
let _tray = app.tray_by_id("main");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
check_health,
|
||||
fetch_energy,
|
||||
fetch_telemetry,
|
||||
fetch_traces,
|
||||
fetch_trace,
|
||||
fetch_learning_stats,
|
||||
fetch_learning_policy,
|
||||
fetch_memory_stats,
|
||||
search_memory,
|
||||
fetch_agents,
|
||||
run_jarvis_command,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running OpenJarvis Desktop");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
openjarvis_desktop::run();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicknisi/tauri-v2-json-schema/main/tauri-v2-schema.json",
|
||||
"productName": "OpenJarvis",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.openjarvis.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "OpenJarvis",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true,
|
||||
"tooltip": "OpenJarvis"
|
||||
},
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:*; img-src 'self' data: blob:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png"
|
||||
],
|
||||
"category": "Utility",
|
||||
"shortDescription": "On-device AI assistant with energy monitoring and trace debugging",
|
||||
"longDescription": "OpenJarvis Desktop wraps the OpenJarvis research framework in a native desktop application with real-time energy monitoring, trace debugging, learning curve visualization, and memory browsing.",
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "REPLACE_WITH_OUTPUT_OF_CARGO_TAURI_SIGNER_GENERATE",
|
||||
"endpoints": [
|
||||
"https://github.com/jonsf/OpenJarvis/releases/download/desktop-latest/latest.json"
|
||||
]
|
||||
},
|
||||
"notification": {
|
||||
"permissionState": "prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UpdateChecker } from './components/UpdateChecker';
|
||||
import { EnergyDashboard } from './components/EnergyDashboard';
|
||||
import { TraceDebugger } from './components/TraceDebugger';
|
||||
import { LearningCurve } from './components/LearningCurve';
|
||||
import { MemoryBrowser } from './components/MemoryBrowser';
|
||||
import { AdminPanel } from './components/AdminPanel';
|
||||
|
||||
type TabId = 'energy' | 'traces' | 'learning' | 'memory' | 'admin';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'energy', label: 'Energy' },
|
||||
{ id: 'traces', label: 'Traces' },
|
||||
{ id: 'learning', label: 'Learning' },
|
||||
{ id: 'memory', label: 'Memory' },
|
||||
{ id: 'admin', label: 'Admin' },
|
||||
];
|
||||
|
||||
const API_URL = 'http://localhost:8000';
|
||||
|
||||
export function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('energy');
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<header style={styles.header}>
|
||||
<h1 style={styles.title}>OpenJarvis Desktop</h1>
|
||||
<nav style={styles.nav}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
style={{
|
||||
...styles.tabButton,
|
||||
...(activeTab === tab.id ? styles.activeTab : {}),
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<UpdateChecker />
|
||||
|
||||
<main style={styles.main}>
|
||||
{activeTab === 'energy' && <EnergyDashboard apiUrl={API_URL} />}
|
||||
{activeTab === 'traces' && <TraceDebugger apiUrl={API_URL} />}
|
||||
{activeTab === 'learning' && <LearningCurve apiUrl={API_URL} />}
|
||||
{activeTab === 'memory' && <MemoryBrowser apiUrl={API_URL} />}
|
||||
{activeTab === 'admin' && <AdminPanel apiUrl={API_URL} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 24px',
|
||||
borderBottom: '1px solid #313244',
|
||||
backgroundColor: '#181825',
|
||||
},
|
||||
title: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: '#89b4fa',
|
||||
},
|
||||
nav: {
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
},
|
||||
tabButton: {
|
||||
padding: '8px 16px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#a6adc8',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.15s ease',
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: '#313244',
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
main: {
|
||||
padding: '24px',
|
||||
height: 'calc(100vh - 60px)',
|
||||
overflow: 'auto',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,454 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AgentInfo {
|
||||
name: string;
|
||||
key: string;
|
||||
accepts_tools: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents: AgentInfo[];
|
||||
}
|
||||
|
||||
interface ServerInfo {
|
||||
model: string;
|
||||
agent: string;
|
||||
engine: string;
|
||||
version: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
minHeight: 400,
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
marginBottom: 20,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
color: '#89b4fa',
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '6px 0',
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
value: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
healthDot: {
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
marginRight: 8,
|
||||
verticalAlign: 'middle',
|
||||
},
|
||||
healthDotHealthy: {
|
||||
backgroundColor: '#a6e3a1',
|
||||
boxShadow: '0 0 6px #a6e3a166',
|
||||
},
|
||||
healthDotUnhealthy: {
|
||||
backgroundColor: '#f38588',
|
||||
boxShadow: '0 0 6px #f3858866',
|
||||
},
|
||||
healthDotUnknown: {
|
||||
backgroundColor: '#a6adc8',
|
||||
},
|
||||
badge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
badgeTrue: {
|
||||
backgroundColor: '#a6e3a133',
|
||||
color: '#a6e3a1',
|
||||
},
|
||||
badgeFalse: {
|
||||
backgroundColor: '#45475a',
|
||||
color: '#a6adc8',
|
||||
},
|
||||
agentTable: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
fontSize: 13,
|
||||
},
|
||||
th: {
|
||||
textAlign: 'left' as const,
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid #45475a',
|
||||
color: '#89b4fa',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase' as const,
|
||||
},
|
||||
td: {
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid #313244',
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
buttonGroup: {
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
padding: '10px 20px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
startButton: {
|
||||
backgroundColor: '#a6e3a1',
|
||||
color: '#1e1e2e',
|
||||
},
|
||||
stopButton: {
|
||||
backgroundColor: '#f38588',
|
||||
color: '#1e1e2e',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
error: {
|
||||
color: '#f38588',
|
||||
padding: 12,
|
||||
backgroundColor: '#f3858811',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
marginBottom: 16,
|
||||
},
|
||||
commandOutput: {
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
backgroundColor: '#181825',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: '#a6adc8',
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap' as const,
|
||||
wordBreak: 'break-word' as const,
|
||||
},
|
||||
loading: {
|
||||
color: '#a6adc8',
|
||||
textAlign: 'center' as const,
|
||||
padding: 40,
|
||||
},
|
||||
healthStatus: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AdminPanel({ apiUrl }: { apiUrl: string }) {
|
||||
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [commandRunning, setCommandRunning] = useState(false);
|
||||
const [commandOutput, setCommandOutput] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
// Check health
|
||||
const healthResult = await invoke<HealthStatus>('check_health', { apiUrl });
|
||||
setHealthy(healthResult.status === 'ok');
|
||||
|
||||
// Fetch agents
|
||||
try {
|
||||
const agentsResult = await invoke<AgentsResponse>('fetch_agents', { apiUrl });
|
||||
setAgents(agentsResult.agents ?? []);
|
||||
} catch {
|
||||
// Agent list may not be available; keep previous state
|
||||
}
|
||||
|
||||
// Fetch server info (model, engine, uptime)
|
||||
try {
|
||||
const infoResult = await invoke<ServerInfo>('check_health', { apiUrl });
|
||||
// If server exposes extra fields, merge them
|
||||
setServerInfo((prev) => ({
|
||||
model: prev?.model ?? '',
|
||||
agent: prev?.agent ?? '',
|
||||
engine: prev?.engine ?? '',
|
||||
version: prev?.version ?? '',
|
||||
uptime_seconds: prev?.uptime_seconds ?? 0,
|
||||
...infoResult as unknown as Partial<ServerInfo>,
|
||||
}));
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setHealthy(false);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setCommandRunning(true);
|
||||
setCommandOutput(null);
|
||||
try {
|
||||
const output = await invoke<string>('run_jarvis_command', {
|
||||
args: ['serve', '--port', '8000'],
|
||||
});
|
||||
setCommandOutput(output);
|
||||
// Wait a moment then refresh health
|
||||
setTimeout(refresh, 2000);
|
||||
} catch (err) {
|
||||
setCommandOutput(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setCommandRunning(true);
|
||||
setCommandOutput(null);
|
||||
try {
|
||||
const output = await invoke<string>('run_jarvis_command', {
|
||||
args: ['stop'],
|
||||
});
|
||||
setCommandOutput(output);
|
||||
setTimeout(refresh, 2000);
|
||||
} catch (err) {
|
||||
setCommandOutput(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.loading}>Loading system status...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const healthDotStyle =
|
||||
healthy === null
|
||||
? styles.healthDotUnknown
|
||||
: healthy
|
||||
? styles.healthDotHealthy
|
||||
: styles.healthDotUnhealthy;
|
||||
|
||||
const healthLabel =
|
||||
healthy === null ? 'Unknown' : healthy ? 'Healthy' : 'Unhealthy';
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Admin Panel</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<div style={styles.grid}>
|
||||
{/* Health & Engine */}
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>System Health</div>
|
||||
<div style={{ ...styles.row, marginBottom: 8 }}>
|
||||
<div style={styles.healthStatus}>
|
||||
<span style={{ ...styles.healthDot, ...healthDotStyle }} />
|
||||
{healthLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Engine</span>
|
||||
<span style={styles.value}>{serverInfo?.engine || 'N/A'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Model</span>
|
||||
<span style={styles.value}>{serverInfo?.model || 'N/A'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Agent</span>
|
||||
<span style={styles.value}>{serverInfo?.agent || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>System Info</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Version</span>
|
||||
<span style={styles.value}>{serverInfo?.version || '1.0.0'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Uptime</span>
|
||||
<span style={styles.value}>
|
||||
{serverInfo?.uptime_seconds !== undefined
|
||||
? formatUptime(serverInfo.uptime_seconds)
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>API URL</span>
|
||||
<span style={styles.value}>{apiUrl}</span>
|
||||
</div>
|
||||
|
||||
{/* Server controls */}
|
||||
<div style={styles.buttonGroup}>
|
||||
<button
|
||||
style={{
|
||||
...styles.button,
|
||||
...styles.startButton,
|
||||
...(commandRunning ? styles.buttonDisabled : {}),
|
||||
}}
|
||||
onClick={handleStart}
|
||||
disabled={commandRunning}
|
||||
>
|
||||
Start Server
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
...styles.button,
|
||||
...styles.stopButton,
|
||||
...(commandRunning ? styles.buttonDisabled : {}),
|
||||
}}
|
||||
onClick={handleStop}
|
||||
disabled={commandRunning}
|
||||
>
|
||||
Stop Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commandOutput && (
|
||||
<div style={styles.commandOutput}>{commandOutput}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Registry */}
|
||||
{agents.length > 0 && (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Agent Registry ({agents.length})</div>
|
||||
<table style={styles.agentTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Name</th>
|
||||
<th style={styles.th}>Key</th>
|
||||
<th style={styles.th}>Tools</th>
|
||||
<th style={styles.th}>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agents.map((agent) => (
|
||||
<tr key={agent.key}>
|
||||
<td style={styles.td}>{agent.name}</td>
|
||||
<td style={{ ...styles.td, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{agent.key}
|
||||
</td>
|
||||
<td style={styles.td}>
|
||||
<span
|
||||
style={{
|
||||
...styles.badge,
|
||||
...(agent.accepts_tools ? styles.badgeTrue : styles.badgeFalse),
|
||||
}}
|
||||
>
|
||||
{agent.accepts_tools ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...styles.td, color: '#a6adc8', fontSize: 12 }}>
|
||||
{agent.description || '--'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agents.length === 0 && !loading && (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Agent Registry</div>
|
||||
<div style={{ color: '#a6adc8', fontSize: 13, padding: '8px 0' }}>
|
||||
No agents registered or server not reachable.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EnergySample {
|
||||
timestamp: string;
|
||||
power_w: number;
|
||||
energy_j: number;
|
||||
}
|
||||
|
||||
interface EnergyData {
|
||||
total_energy_j?: number;
|
||||
energy_per_token_j?: number;
|
||||
avg_power_w?: number;
|
||||
samples?: EnergySample[];
|
||||
}
|
||||
|
||||
interface TelemetryStats {
|
||||
total_requests?: number;
|
||||
avg_latency_ms?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
interface ChartPoint {
|
||||
time: string;
|
||||
power: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const colors = {
|
||||
bg: '#1e1e2e',
|
||||
surface: '#282840',
|
||||
surfaceHover: '#313150',
|
||||
text: '#cdd6f4',
|
||||
textMuted: '#a6adc8',
|
||||
accent: '#89b4fa',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
red: '#f38ba8',
|
||||
border: '#45475a',
|
||||
} as const;
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
background: colors.bg,
|
||||
color: colors.text,
|
||||
padding: 24,
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
color: colors.text,
|
||||
},
|
||||
liveBadge: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: colors.green,
|
||||
background: 'rgba(166,227,161,0.1)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
liveDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: colors.green,
|
||||
animation: 'pulse 2s infinite',
|
||||
},
|
||||
statsGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
statCard: {
|
||||
background: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
border: `1px solid ${colors.border}`,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
marginBottom: 6,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 26,
|
||||
fontWeight: 700,
|
||||
color: colors.accent,
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
statUnit: {
|
||||
fontSize: 13,
|
||||
fontWeight: 400,
|
||||
color: colors.textMuted,
|
||||
marginLeft: 4,
|
||||
},
|
||||
chartContainer: {
|
||||
background: colors.surface,
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
border: `1px solid ${colors.border}`,
|
||||
marginBottom: 24,
|
||||
},
|
||||
chartTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginBottom: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
emptyState: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 64,
|
||||
color: colors.textMuted,
|
||||
gap: 12,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 40,
|
||||
opacity: 0.4,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
errorBanner: {
|
||||
background: 'rgba(243,139,168,0.1)',
|
||||
border: `1px solid ${colors.red}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 16px',
|
||||
marginBottom: 16,
|
||||
fontSize: 13,
|
||||
color: colors.red,
|
||||
},
|
||||
thermalStatus: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatEnergy(joules: number): string {
|
||||
if (joules >= 1000) {
|
||||
return `${(joules / 1000).toFixed(2)} kJ`;
|
||||
}
|
||||
return `${joules.toFixed(2)} J`;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
function thermalIndicator(avgPower: number): { label: string; color: string } {
|
||||
if (avgPower < 50) return { label: 'Cool', color: colors.green };
|
||||
if (avgPower < 150) return { label: 'Warm', color: colors.yellow };
|
||||
return { label: 'Hot', color: colors.red };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
export function EnergyDashboard({ apiUrl }: { apiUrl: string }) {
|
||||
const [energyData, setEnergyData] = useState<EnergyData | null>(null);
|
||||
const [telemetry, setTelemetry] = useState<TelemetryStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [energy, telem] = await Promise.allSettled([
|
||||
invoke<EnergyData>('fetch_energy', { apiUrl }),
|
||||
invoke<TelemetryStats>('fetch_telemetry', { apiUrl }),
|
||||
]);
|
||||
|
||||
if (energy.status === 'fulfilled') {
|
||||
setEnergyData(energy.value);
|
||||
setError(null);
|
||||
} else {
|
||||
setEnergyData(null);
|
||||
setError(String(energy.reason));
|
||||
}
|
||||
|
||||
if (telem.status === 'fulfilled') {
|
||||
setTelemetry(telem.value);
|
||||
} else {
|
||||
setTelemetry(null);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const timer = setInterval(fetchData, REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchData]);
|
||||
|
||||
// Build chart data from samples
|
||||
const chartData: ChartPoint[] = (energyData?.samples ?? []).map((s) => ({
|
||||
time: formatTimestamp(s.timestamp),
|
||||
power: s.power_w,
|
||||
}));
|
||||
|
||||
const hasEnergyData =
|
||||
energyData !== null &&
|
||||
(energyData.total_energy_j !== undefined ||
|
||||
(energyData.samples !== undefined && energyData.samples.length > 0));
|
||||
|
||||
// --- Empty / error states ---
|
||||
|
||||
if (!loading && !hasEnergyData && !error) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Energy Monitor</h2>
|
||||
</div>
|
||||
<div style={styles.emptyState}>
|
||||
<div style={styles.emptyIcon}>⚡</div>
|
||||
<div style={styles.emptyText}>
|
||||
No energy data available.<br />
|
||||
Ensure an energy monitor backend (NVIDIA, AMD, Apple, or RAPL) is configured.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thermal = thermalIndicator(energyData?.avg_power_w ?? 0);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* Pulse animation injected once */}
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header */}
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Energy Monitor</h2>
|
||||
<span style={styles.liveBadge}>
|
||||
<span style={styles.liveDot} />
|
||||
Live - {REFRESH_INTERVAL_MS / 1000}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
{/* Summary cards */}
|
||||
<div style={styles.statsGrid}>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Energy</div>
|
||||
<div style={styles.statValue}>
|
||||
{energyData?.total_energy_j !== undefined
|
||||
? formatEnergy(energyData.total_energy_j)
|
||||
: '--'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Energy per Token</div>
|
||||
<div style={styles.statValue}>
|
||||
{energyData?.energy_per_token_j !== undefined ? (
|
||||
<>
|
||||
{(energyData.energy_per_token_j * 1000).toFixed(3)}
|
||||
<span style={styles.statUnit}>mJ</span>
|
||||
</>
|
||||
) : (
|
||||
'--'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Avg Power Draw</div>
|
||||
<div style={styles.statValue}>
|
||||
{energyData?.avg_power_w !== undefined ? (
|
||||
<>
|
||||
{energyData.avg_power_w.toFixed(1)}
|
||||
<span style={styles.statUnit}>W</span>
|
||||
</>
|
||||
) : (
|
||||
'--'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Thermal Status</div>
|
||||
<div style={{ ...styles.thermalStatus, color: thermal.color }}>
|
||||
{thermal.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{telemetry?.total_requests !== undefined && (
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Requests</div>
|
||||
<div style={styles.statValue}>{telemetry.total_requests.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telemetry?.total_tokens !== undefined && (
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statLabel}>Total Tokens</div>
|
||||
<div style={styles.statValue}>{telemetry.total_tokens.toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Power chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div style={styles.chartContainer}>
|
||||
<div style={styles.chartTitle}>Power Draw Over Time (W)</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 20, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={colors.border} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={colors.textMuted}
|
||||
tick={{ fill: colors.textMuted, fontSize: 11 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={colors.textMuted}
|
||||
tick={{ fill: colors.textMuted, fontSize: 11 }}
|
||||
unit=" W"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: colors.surface,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: 6,
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
}}
|
||||
labelStyle={{ color: colors.textMuted }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="power"
|
||||
stroke={colors.accent}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: colors.accent }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PolicyConfig {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
update_interval: number;
|
||||
}
|
||||
|
||||
interface RoutingWeight {
|
||||
query_class: string;
|
||||
model: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
interface BanditArm {
|
||||
model: string;
|
||||
pulls: number;
|
||||
reward_mean: number;
|
||||
ucb: number;
|
||||
}
|
||||
|
||||
interface LearningStatsPoint {
|
||||
timestamp: string;
|
||||
accuracy: number;
|
||||
latency_ms: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
interface LearningStats {
|
||||
history: LearningStatsPoint[];
|
||||
icl_example_count: number;
|
||||
discovered_skills_count: number;
|
||||
total_traces: number;
|
||||
total_updates: number;
|
||||
}
|
||||
|
||||
interface LearningPolicy {
|
||||
config: PolicyConfig;
|
||||
routing_weights: RoutingWeight[];
|
||||
bandit_arms: BanditArm[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
minHeight: 400,
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
marginBottom: 20,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.05em',
|
||||
color: '#89b4fa',
|
||||
marginBottom: 12,
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px 0',
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
value: {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
badge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
badgeEnabled: {
|
||||
backgroundColor: '#a6e3a133',
|
||||
color: '#a6e3a1',
|
||||
},
|
||||
badgeDisabled: {
|
||||
backgroundColor: '#f3858833',
|
||||
color: '#f38588',
|
||||
},
|
||||
chartContainer: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
fontSize: 13,
|
||||
},
|
||||
th: {
|
||||
textAlign: 'left' as const,
|
||||
padding: '6px 8px',
|
||||
borderBottom: '1px solid #45475a',
|
||||
color: '#89b4fa',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase' as const,
|
||||
},
|
||||
td: {
|
||||
padding: '6px 8px',
|
||||
borderBottom: '1px solid #313244',
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
weightBar: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#45475a',
|
||||
overflow: 'hidden' as const,
|
||||
marginTop: 4,
|
||||
},
|
||||
weightFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#89b4fa',
|
||||
},
|
||||
error: {
|
||||
color: '#f38588',
|
||||
padding: 12,
|
||||
backgroundColor: '#f3858811',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
},
|
||||
loading: {
|
||||
color: '#a6adc8',
|
||||
textAlign: 'center' as const,
|
||||
padding: 40,
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: '#89b4fa',
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#a6adc8',
|
||||
marginTop: 4,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LearningCurve({ apiUrl }: { apiUrl: string }) {
|
||||
const [stats, setStats] = useState<LearningStats | null>(null);
|
||||
const [policy, setPolicy] = useState<LearningPolicy | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [statsResult, policyResult] = await Promise.all([
|
||||
invoke<LearningStats>('fetch_learning_stats', { apiUrl }),
|
||||
invoke<LearningPolicy>('fetch_learning_policy', { apiUrl }),
|
||||
]);
|
||||
setStats(statsResult);
|
||||
setPolicy(policyResult);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 10_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.loading}>Loading learning data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !stats) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Learning Curve</div>
|
||||
<div style={styles.error}>{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = (stats?.history ?? []).map((point) => ({
|
||||
time: point.timestamp,
|
||||
accuracy: Math.round(point.accuracy * 1000) / 10,
|
||||
latency: Math.round(point.latency_ms),
|
||||
cost: Math.round(point.cost * 10000) / 10000,
|
||||
}));
|
||||
|
||||
const policyConfig = policy?.config;
|
||||
const isGrpo = policyConfig?.name?.toLowerCase().includes('grpo');
|
||||
const isBandit = policyConfig?.name?.toLowerCase().includes('bandit');
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Learning Curve</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
{/* Policy config + counters */}
|
||||
<div style={styles.grid}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Policy Config</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Policy</span>
|
||||
<span style={styles.value}>{policyConfig?.name ?? 'unknown'}</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Status</span>
|
||||
<span
|
||||
style={{
|
||||
...styles.badge,
|
||||
...(policyConfig?.enabled ? styles.badgeEnabled : styles.badgeDisabled),
|
||||
}}
|
||||
>
|
||||
{policyConfig?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<span style={styles.label}>Update interval</span>
|
||||
<span style={styles.value}>{policyConfig?.update_interval ?? 0}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>Counters</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.total_traces ?? 0}</div>
|
||||
<div style={styles.statLabel}>Total traces</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.total_updates ?? 0}</div>
|
||||
<div style={styles.statLabel}>Policy updates</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.icl_example_count ?? 0}</div>
|
||||
<div style={styles.statLabel}>ICL examples</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={styles.statNumber}>{stats?.discovered_skills_count ?? 0}</div>
|
||||
<div style={styles.statLabel}>Discovered skills</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accuracy / latency chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div style={styles.chartContainer}>
|
||||
<div style={styles.cardTitle}>Routing Accuracy Over Time</div>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#45475a" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fill: '#a6adc8', fontSize: 11 }}
|
||||
stroke="#45475a"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tick={{ fill: '#a6adc8', fontSize: 11 }}
|
||||
stroke="#45475a"
|
||||
label={{
|
||||
value: 'Accuracy %',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fill: '#a6adc8', fontSize: 11 },
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fill: '#a6adc8', fontSize: 11 }}
|
||||
stroke="#45475a"
|
||||
label={{
|
||||
value: 'Latency (ms)',
|
||||
angle: 90,
|
||||
position: 'insideRight',
|
||||
style: { fill: '#a6adc8', fontSize: 11 },
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#313244',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: 6,
|
||||
color: '#cdd6f4',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ color: '#cdd6f4', fontSize: 12 }} />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="accuracy"
|
||||
name="Accuracy %"
|
||||
stroke="#89b4fa"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="latency"
|
||||
name="Latency (ms)"
|
||||
stroke="#f9e2af"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GRPO routing weights */}
|
||||
{isGrpo && (policy?.routing_weights ?? []).length > 0 && (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.cardTitle}>GRPO Routing Weights</div>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Query Class</th>
|
||||
<th style={styles.th}>Model</th>
|
||||
<th style={styles.th}>Weight</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policy!.routing_weights.map((rw, i) => (
|
||||
<tr key={i}>
|
||||
<td style={styles.td}>{rw.query_class}</td>
|
||||
<td style={styles.td}>{rw.model}</td>
|
||||
<td style={styles.td}>
|
||||
<span>{(rw.weight * 100).toFixed(1)}%</span>
|
||||
<div style={styles.weightBar}>
|
||||
<div
|
||||
style={{
|
||||
...styles.weightFill,
|
||||
width: `${Math.min(rw.weight * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bandit arm stats */}
|
||||
{isBandit && (policy?.bandit_arms ?? []).length > 0 && (
|
||||
<div style={{ ...styles.card, marginTop: 16 }}>
|
||||
<div style={styles.cardTitle}>Bandit Arm Statistics</div>
|
||||
<table style={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={styles.th}>Model</th>
|
||||
<th style={styles.th}>Pulls</th>
|
||||
<th style={styles.th}>Mean Reward</th>
|
||||
<th style={styles.th}>UCB</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{policy!.bandit_arms.map((arm, i) => (
|
||||
<tr key={i}>
|
||||
<td style={styles.td}>{arm.model}</td>
|
||||
<td style={styles.td}>{arm.pulls}</td>
|
||||
<td style={styles.td}>{arm.reward_mean.toFixed(4)}</td>
|
||||
<td style={styles.td}>{arm.ucb.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MemoryChunk {
|
||||
content: string;
|
||||
score: number;
|
||||
metadata: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: MemoryChunk[];
|
||||
query: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface MemoryStats {
|
||||
backend: string;
|
||||
total_documents: number;
|
||||
total_chunks: number;
|
||||
index_size_bytes: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#1e1e2e',
|
||||
color: '#cdd6f4',
|
||||
padding: 24,
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
minHeight: 400,
|
||||
},
|
||||
header: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
marginBottom: 20,
|
||||
color: '#cdd6f4',
|
||||
},
|
||||
searchBar: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
fontSize: 14,
|
||||
backgroundColor: '#313244',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: 8,
|
||||
color: '#cdd6f4',
|
||||
outline: 'none',
|
||||
},
|
||||
button: {
|
||||
padding: '10px 20px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
backgroundColor: '#89b4fa',
|
||||
color: '#1e1e2e',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
statsPanel: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
statCard: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: '#89b4fa',
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#a6adc8',
|
||||
marginTop: 4,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
resultsList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 12,
|
||||
},
|
||||
resultCard: {
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
borderLeft: '3px solid #89b4fa',
|
||||
},
|
||||
resultContent: {
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
color: '#cdd6f4',
|
||||
marginBottom: 10,
|
||||
wordBreak: 'break-word' as const,
|
||||
},
|
||||
scoreContainer: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
scoreHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
scoreLabel: {
|
||||
fontSize: 12,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
scoreValue: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#89b4fa',
|
||||
},
|
||||
scoreBar: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#45475a',
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
scoreFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#89b4fa',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
metadataRow: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap' as const,
|
||||
gap: 6,
|
||||
marginTop: 8,
|
||||
},
|
||||
metaTag: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
backgroundColor: '#45475a',
|
||||
color: '#a6adc8',
|
||||
},
|
||||
emptyState: {
|
||||
textAlign: 'center' as const,
|
||||
padding: 40,
|
||||
color: '#a6adc8',
|
||||
},
|
||||
error: {
|
||||
color: '#f38588',
|
||||
padding: 12,
|
||||
backgroundColor: '#f3858811',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
marginBottom: 16,
|
||||
},
|
||||
resultCount: {
|
||||
fontSize: 13,
|
||||
color: '#a6adc8',
|
||||
marginBottom: 12,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(1) + ' GB';
|
||||
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max) + '...';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MemoryBrowser({ apiUrl }: { apiUrl: string }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<MemoryChunk[]>([]);
|
||||
const [resultTotal, setResultTotal] = useState(0);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [stats, setStats] = useState<MemoryStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch stats on mount
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<MemoryStats>('fetch_memory_stats', { apiUrl });
|
||||
setStats(result);
|
||||
} catch (err) {
|
||||
// Stats are non-critical; silently ignore
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!query.trim()) return;
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await invoke<SearchResponse>('search_memory', {
|
||||
apiUrl,
|
||||
query: query.trim(),
|
||||
topK: 10,
|
||||
});
|
||||
setResults(response.results ?? []);
|
||||
setResultTotal(response.total ?? (response.results ?? []).length);
|
||||
setHasSearched(true);
|
||||
// Refresh stats after search
|
||||
loadStats();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setResults([]);
|
||||
setHasSearched(true);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, [apiUrl, query, loadStats]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>Memory Browser</div>
|
||||
|
||||
{/* Stats panel */}
|
||||
{stats && (
|
||||
<div style={styles.statsPanel}>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{stats.backend}</div>
|
||||
<div style={styles.statLabel}>Backend</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{stats.total_documents.toLocaleString()}</div>
|
||||
<div style={styles.statLabel}>Documents</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{stats.total_chunks.toLocaleString()}</div>
|
||||
<div style={styles.statLabel}>Chunks</div>
|
||||
</div>
|
||||
<div style={styles.statCard}>
|
||||
<div style={styles.statValue}>{formatBytes(stats.index_size_bytes)}</div>
|
||||
<div style={styles.statLabel}>Index Size</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<div style={styles.searchBar}>
|
||||
<input
|
||||
style={styles.input}
|
||||
type="text"
|
||||
placeholder="Search memory..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
...styles.button,
|
||||
...(searching ? styles.buttonDisabled : {}),
|
||||
}}
|
||||
onClick={handleSearch}
|
||||
disabled={searching || !query.trim()}
|
||||
>
|
||||
{searching ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && results.length === 0 && !error && (
|
||||
<div style={styles.emptyState}>No results found for "{query}"</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<>
|
||||
<div style={styles.resultCount}>
|
||||
Showing {results.length} of {resultTotal} results
|
||||
</div>
|
||||
<div style={styles.resultsList}>
|
||||
{results.map((chunk, i) => {
|
||||
const scorePercent = Math.round(chunk.score * 100);
|
||||
return (
|
||||
<div key={i} style={styles.resultCard}>
|
||||
{/* Score bar */}
|
||||
<div style={styles.scoreContainer}>
|
||||
<div style={styles.scoreHeader}>
|
||||
<span style={styles.scoreLabel}>Relevance</span>
|
||||
<span style={styles.scoreValue}>{scorePercent}%</span>
|
||||
</div>
|
||||
<div style={styles.scoreBar}>
|
||||
<div
|
||||
style={{
|
||||
...styles.scoreFill,
|
||||
width: `${Math.min(scorePercent, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content preview */}
|
||||
<div style={styles.resultContent}>
|
||||
{truncate(chunk.content, 200)}
|
||||
</div>
|
||||
|
||||
{/* Metadata tags */}
|
||||
{Object.keys(chunk.metadata).length > 0 && (
|
||||
<div style={styles.metadataRow}>
|
||||
{Object.entries(chunk.metadata).map(([key, val]) => (
|
||||
<span key={key} style={styles.metaTag}>
|
||||
{key}: {String(val)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasSearched && !stats && (
|
||||
<div style={styles.emptyState}>Enter a query to search memory</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TraceStepData {
|
||||
model?: string;
|
||||
tokens?: number;
|
||||
tool?: string;
|
||||
input?: string;
|
||||
output?: string;
|
||||
backend?: string;
|
||||
results?: number;
|
||||
policy?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TraceStep {
|
||||
step_type: string;
|
||||
duration_ms: number;
|
||||
data: TraceStepData;
|
||||
}
|
||||
|
||||
interface TraceSummary {
|
||||
id: string;
|
||||
query: string;
|
||||
steps: TraceStep[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TraceListResponse {
|
||||
traces: TraceSummary[];
|
||||
}
|
||||
|
||||
interface TraceDetail {
|
||||
id: string;
|
||||
query: string;
|
||||
steps: TraceStep[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STEP_COLORS: Record<string, string> = {
|
||||
route: '#89b4fa',
|
||||
retrieve: '#a6e3a1',
|
||||
generate: '#f9e2af',
|
||||
tool_call: '#cba6f7',
|
||||
respond: '#f38ba8',
|
||||
};
|
||||
|
||||
const DEFAULT_STEP_COLOR = '#9399b2';
|
||||
|
||||
const colors = {
|
||||
bg: '#1e1e2e',
|
||||
surface: '#282840',
|
||||
surfaceHover: '#313150',
|
||||
text: '#cdd6f4',
|
||||
textMuted: '#a6adc8',
|
||||
accent: '#89b4fa',
|
||||
border: '#45475a',
|
||||
red: '#f38ba8',
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
background: colors.bg,
|
||||
color: colors.text,
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
|
||||
// Left panel - trace list
|
||||
listPanel: {
|
||||
width: 320,
|
||||
minWidth: 280,
|
||||
borderRight: `1px solid ${colors.border}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
listHeader: {
|
||||
padding: '20px 16px 12px',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
listTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
marginBottom: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
listSubtitle: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
margin: 0,
|
||||
},
|
||||
listScroll: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '8px 0',
|
||||
},
|
||||
traceItem: {
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
traceItemSelected: {
|
||||
background: colors.surfaceHover,
|
||||
},
|
||||
traceItemId: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
color: colors.accent,
|
||||
marginBottom: 3,
|
||||
},
|
||||
traceItemQuery: {
|
||||
fontSize: 12,
|
||||
color: colors.text,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginBottom: 3,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
traceItemMeta: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
|
||||
// Right panel - detail
|
||||
detailPanel: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
detailHeader: {
|
||||
padding: '20px 24px 16px',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
detailTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
marginBottom: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
detailQuery: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
margin: 0,
|
||||
marginBottom: 8,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
detailStats: {
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
detailStatValue: {
|
||||
fontWeight: 600,
|
||||
color: colors.accent,
|
||||
},
|
||||
detailScroll: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: 24,
|
||||
},
|
||||
timelineContainer: {
|
||||
position: 'relative',
|
||||
paddingLeft: 24,
|
||||
},
|
||||
timelineLine: {
|
||||
position: 'absolute',
|
||||
left: 7,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 2,
|
||||
background: colors.border,
|
||||
},
|
||||
|
||||
// Timeline step
|
||||
stepContainer: {
|
||||
position: 'relative',
|
||||
marginBottom: 16,
|
||||
},
|
||||
stepDot: {
|
||||
position: 'absolute',
|
||||
left: -20,
|
||||
top: 8,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${colors.bg}`,
|
||||
},
|
||||
stepCard: {
|
||||
background: colors.surface,
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
border: `1px solid ${colors.border}`,
|
||||
},
|
||||
stepHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
stepBadge: {
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: 10,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
stepDuration: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
},
|
||||
stepDetails: {
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
stepDetailRow: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
},
|
||||
stepDetailKey: {
|
||||
color: colors.textMuted,
|
||||
minWidth: 60,
|
||||
flexShrink: 0,
|
||||
},
|
||||
stepDetailValue: {
|
||||
color: colors.text,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: 11,
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
expandButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: colors.accent,
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
textAlign: 'left',
|
||||
},
|
||||
|
||||
// Empty state
|
||||
emptyState: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: colors.textMuted,
|
||||
gap: 12,
|
||||
padding: 32,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 40,
|
||||
opacity: 0.4,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorBanner: {
|
||||
background: 'rgba(243,139,168,0.1)',
|
||||
border: `1px solid ${colors.red}`,
|
||||
borderRadius: 8,
|
||||
padding: '10px 16px',
|
||||
margin: 16,
|
||||
fontSize: 13,
|
||||
color: colors.red,
|
||||
},
|
||||
placeholder: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function truncateId(id: string, len: number = 12): string {
|
||||
if (id.length <= len) return id;
|
||||
return id.slice(0, len) + '...';
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1) return '<1ms';
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function stepColor(stepType: string): string {
|
||||
return STEP_COLORS[stepType] ?? DEFAULT_STEP_COLOR;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StepDataView({ data }: { data: TraceStepData }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const entries = Object.entries(data).filter(
|
||||
([, v]) => v !== undefined && v !== null && v !== '',
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show up to 3 entries by default; expand to show all
|
||||
const displayEntries = expanded ? entries : entries.slice(0, 3);
|
||||
const hasMore = entries.length > 3;
|
||||
|
||||
return (
|
||||
<div style={styles.stepDetails}>
|
||||
{displayEntries.map(([key, value]) => (
|
||||
<div key={key} style={styles.stepDetailRow}>
|
||||
<span style={styles.stepDetailKey}>{key}:</span>
|
||||
<span style={styles.stepDetailValue}>
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
style={styles.expandButton}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? 'Show less' : `Show ${entries.length - 3} more fields...`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimelineStepProps {
|
||||
step: TraceStep;
|
||||
}
|
||||
|
||||
function TimelineStep({ step }: TimelineStepProps) {
|
||||
const color = stepColor(step.step_type);
|
||||
|
||||
return (
|
||||
<div style={styles.stepContainer}>
|
||||
<div style={{ ...styles.stepDot, background: color }} />
|
||||
<div style={styles.stepCard}>
|
||||
<div style={styles.stepHeader}>
|
||||
<span
|
||||
style={{
|
||||
...styles.stepBadge,
|
||||
background: `${color}22`,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{step.step_type}
|
||||
</span>
|
||||
<span style={styles.stepDuration}>{formatDuration(step.duration_ms)}</span>
|
||||
</div>
|
||||
{step.data && <StepDataView data={step.data} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TraceDebugger({ apiUrl }: { apiUrl: string }) {
|
||||
const [traces, setTraces] = useState<TraceSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [traceDetail, setTraceDetail] = useState<TraceDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
|
||||
// Fetch trace list
|
||||
const fetchTraces = useCallback(async () => {
|
||||
try {
|
||||
const response = await invoke<TraceListResponse>('fetch_traces', {
|
||||
apiUrl,
|
||||
limit: 50,
|
||||
});
|
||||
setTraces(response.traces ?? []);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
setTraces([]);
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTraces();
|
||||
}, [fetchTraces]);
|
||||
|
||||
// Fetch trace detail when selection changes
|
||||
const fetchDetail = useCallback(
|
||||
async (traceId: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const detail = await invoke<TraceDetail>('fetch_trace', {
|
||||
apiUrl,
|
||||
traceId,
|
||||
});
|
||||
setTraceDetail(detail);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
setTraceDetail(null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[apiUrl],
|
||||
);
|
||||
|
||||
const handleSelectTrace = useCallback(
|
||||
(traceId: string) => {
|
||||
setSelectedId(traceId);
|
||||
fetchDetail(traceId);
|
||||
},
|
||||
[fetchDetail],
|
||||
);
|
||||
|
||||
// Compute totals for detail header
|
||||
const totalDuration =
|
||||
traceDetail?.steps.reduce((sum, s) => sum + s.duration_ms, 0) ?? 0;
|
||||
|
||||
// --- Empty state ---
|
||||
if (!listLoading && traces.length === 0 && !error) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.emptyState}>
|
||||
<div style={styles.emptyIcon}>🔍</div>
|
||||
<div style={styles.emptyText}>
|
||||
No traces available.<br />
|
||||
Traces are recorded when queries are processed through the system.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* Left panel - trace list */}
|
||||
<div style={styles.listPanel}>
|
||||
<div style={styles.listHeader}>
|
||||
<h2 style={styles.listTitle}>Traces</h2>
|
||||
<p style={styles.listSubtitle}>{traces.length} recent traces</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<div style={styles.listScroll}>
|
||||
{traces.map((trace) => {
|
||||
const isSelected = trace.id === selectedId;
|
||||
return (
|
||||
<div
|
||||
key={trace.id}
|
||||
style={{
|
||||
...styles.traceItem,
|
||||
...(isSelected ? styles.traceItemSelected : {}),
|
||||
}}
|
||||
onClick={() => handleSelectTrace(trace.id)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background =
|
||||
colors.surfaceHover;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) {
|
||||
(e.currentTarget as HTMLDivElement).style.background = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={styles.traceItemId}>{truncateId(trace.id)}</div>
|
||||
<div style={styles.traceItemQuery}>{trace.query}</div>
|
||||
<div style={styles.traceItemMeta}>
|
||||
<span>{trace.steps.length} steps</span>
|
||||
<span>{formatTimestamp(trace.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel - trace detail */}
|
||||
<div style={styles.detailPanel}>
|
||||
{!selectedId && (
|
||||
<div style={styles.placeholder}>
|
||||
Select a trace from the list to inspect its steps.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedId && detailLoading && (
|
||||
<div style={styles.placeholder}>Loading trace...</div>
|
||||
)}
|
||||
|
||||
{selectedId && !detailLoading && traceDetail && (
|
||||
<>
|
||||
<div style={styles.detailHeader}>
|
||||
<h3 style={styles.detailTitle}>
|
||||
Trace {truncateId(traceDetail.id)}
|
||||
</h3>
|
||||
<p style={styles.detailQuery}>
|
||||
Query: "{traceDetail.query}"
|
||||
</p>
|
||||
<div style={styles.detailStats}>
|
||||
<span>
|
||||
Steps:{' '}
|
||||
<span style={styles.detailStatValue}>
|
||||
{traceDetail.steps.length}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Total:{' '}
|
||||
<span style={styles.detailStatValue}>
|
||||
{formatDuration(totalDuration)}
|
||||
</span>
|
||||
</span>
|
||||
{traceDetail.created_at && (
|
||||
<span>
|
||||
Created:{' '}
|
||||
<span style={styles.detailStatValue}>
|
||||
{formatTimestamp(traceDetail.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.detailScroll}>
|
||||
{traceDetail.steps.length === 0 ? (
|
||||
<div style={styles.placeholder}>
|
||||
This trace contains no steps.
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.timelineContainer}>
|
||||
<div style={styles.timelineLine} />
|
||||
{traceDetail.steps.map((step, idx) => (
|
||||
<TimelineStep key={idx} step={step} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
type UpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
export function UpdateChecker() {
|
||||
const [state, setState] = useState<UpdateState>('idle');
|
||||
const [version, setVersion] = useState('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const updateRef = useRef<any>(null);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
try {
|
||||
const { check } = await import('@tauri-apps/plugin-updater');
|
||||
const update = await check();
|
||||
if (update) {
|
||||
updateRef.current = update;
|
||||
setVersion(update.version);
|
||||
setState('available');
|
||||
setDismissed(false);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — likely running in browser or no update available
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in a Tauri environment
|
||||
if (typeof window === 'undefined' || !(window as any).__TAURI_INTERNALS__) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkForUpdate();
|
||||
const interval = setInterval(checkForUpdate, CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkForUpdate]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
const update = updateRef.current;
|
||||
if (!update) return;
|
||||
|
||||
setState('downloading');
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
let downloaded = 0;
|
||||
const contentLength = update.contentLength ?? 0;
|
||||
|
||||
await update.downloadAndInstall((event: any) => {
|
||||
if (event.event === 'Started' && event.data?.contentLength) {
|
||||
// Content length received
|
||||
} else if (event.event === 'Progress') {
|
||||
downloaded += event.data?.chunkLength ?? 0;
|
||||
if (contentLength > 0) {
|
||||
setProgress(Math.min(100, Math.round((downloaded / contentLength) * 100)));
|
||||
}
|
||||
} else if (event.event === 'Finished') {
|
||||
setProgress(100);
|
||||
}
|
||||
});
|
||||
|
||||
setState('ready');
|
||||
} catch (e: any) {
|
||||
setErrorMsg(e?.message || 'Download failed');
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRelaunch = useCallback(async () => {
|
||||
try {
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process');
|
||||
await relaunch();
|
||||
} catch {
|
||||
// Fallback: inform user to restart manually
|
||||
setErrorMsg('Please restart the application manually');
|
||||
setState('error');
|
||||
setTimeout(() => setState('idle'), 5000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (state === 'idle' || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div style={styles.banner}>
|
||||
{state === 'available' && (
|
||||
<div style={styles.row}>
|
||||
<span>Update available: <strong>v{version}</strong></span>
|
||||
<div style={styles.actions}>
|
||||
<button style={styles.primaryBtn} onClick={handleDownload}>Download</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'downloading' && (
|
||||
<div style={styles.row}>
|
||||
<span>Downloading update... {progress}%</span>
|
||||
<div style={styles.progressBar}>
|
||||
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && (
|
||||
<div style={styles.row}>
|
||||
<span style={{ color: '#a6e3a1' }}>Update installed.</span>
|
||||
<div style={styles.actions}>
|
||||
<button style={styles.successBtn} onClick={handleRelaunch}>Relaunch now</button>
|
||||
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Later</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'error' && (
|
||||
<div style={styles.row}>
|
||||
<span style={{ color: '#f38ba8' }}>Update error: {errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
banner: {
|
||||
padding: '10px 24px',
|
||||
backgroundColor: '#181825',
|
||||
borderBottom: '1px solid #313244',
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
fontSize: '13px',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
},
|
||||
primaryBtn: {
|
||||
padding: '4px 14px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#89b4fa',
|
||||
color: '#1e1e2e',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
successBtn: {
|
||||
padding: '4px 14px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#a6e3a1',
|
||||
color: '#1e1e2e',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
secondaryBtn: {
|
||||
padding: '4px 14px',
|
||||
border: '1px solid #45475a',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#a6adc8',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
progressBar: {
|
||||
flex: 1,
|
||||
maxWidth: '300px',
|
||||
height: '6px',
|
||||
backgroundColor: '#313244',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#89b4fa',
|
||||
borderRadius: '3px',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Detect whether we're running inside Tauri or in a browser.
|
||||
* In Tauri, `window.__TAURI_INTERNALS__` is set.
|
||||
*/
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke a Tauri command if in Tauri, otherwise fall back to fetch.
|
||||
*/
|
||||
export async function tauriInvoke<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (isTauri()) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<T>(command, args);
|
||||
}
|
||||
// Browser fallback: map command to REST API
|
||||
return browserFallback<T>(command, args);
|
||||
}
|
||||
|
||||
async function browserFallback<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const apiUrl = (args.apiUrl as string) || 'http://localhost:8000';
|
||||
const urlMap: Record<string, string> = {
|
||||
check_health: '/health',
|
||||
fetch_energy: '/v1/telemetry/energy',
|
||||
fetch_telemetry: '/v1/telemetry/stats',
|
||||
fetch_traces: `/v1/traces?limit=${args.limit || 20}`,
|
||||
fetch_trace: `/v1/traces/${args.traceId}`,
|
||||
fetch_learning_stats: '/v1/learning/stats',
|
||||
fetch_learning_policy: '/v1/learning/policy',
|
||||
fetch_memory_stats: '/v1/memory/stats',
|
||||
fetch_agents: '/v1/agents',
|
||||
};
|
||||
|
||||
const path = urlMap[command];
|
||||
if (!path) {
|
||||
throw new Error(`No browser fallback for command: ${command}`);
|
||||
}
|
||||
|
||||
const resp = await fetch(`${apiUrl}${path}`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
||||
}
|
||||
return resp.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for polling a Tauri command at a regular interval.
|
||||
*/
|
||||
export function usePolling<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
intervalMs: number,
|
||||
): { data: T | null; error: string | null; loading: boolean; refresh: () => void } {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await tauriInvoke<T>(command, args);
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [command, JSON.stringify(args)]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/AdminPanel.tsx","./src/components/EnergyDashboard.tsx","./src/components/LearningCurve.tsx","./src/components/MemoryBrowser.tsx","./src/components/TraceDebugger.tsx","./src/hooks/useTauriApi.ts"],"version":"5.7.3"}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/v1': 'http://localhost:8000',
|
||||
'/health': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
# OpenJarvis Roadmap Progress
|
||||
|
||||
Last updated: 2026-02-27
|
||||
|
||||
## Phase 17: Production Tool Parity
|
||||
|
||||
### Core Tools
|
||||
- [x] `FileWriteTool` — `src/openjarvis/tools/file_write.py` + `tests/tools/test_file_write.py` (16 tests)
|
||||
- [x] `ApplyPatchTool` — `src/openjarvis/tools/apply_patch.py` + `tests/tools/test_apply_patch.py` (13 tests)
|
||||
- [x] `ShellExecTool` — `src/openjarvis/tools/shell_exec.py` + `tests/tools/test_shell_exec.py` (19 tests)
|
||||
- [x] `GitTool` (4 ops) — `src/openjarvis/tools/git_tool.py` + `tests/tools/test_git_tool.py` (41 tests)
|
||||
- [x] `HttpRequestTool` — `src/openjarvis/tools/http_request.py` + `tests/tools/test_http_request.py` (21 tests)
|
||||
- [x] `DatabaseQueryTool` — `src/openjarvis/tools/db_query.py` + `tests/tools/test_db_query.py` (26 tests)
|
||||
|
||||
### Inter-Agent Tools
|
||||
- [x] `AgentSpawnTool` — `src/openjarvis/tools/agent_tools.py` + `tests/tools/test_agent_tools.py`
|
||||
- [x] `AgentSendTool` — (same file)
|
||||
- [x] `AgentListTool` — (same file)
|
||||
- [x] `AgentKillTool` — (same file, 22 tests total)
|
||||
|
||||
### Browser Automation
|
||||
- [x] `BrowserNavigateTool` — `src/openjarvis/tools/browser.py` + `tests/tools/test_browser.py`
|
||||
- [x] `BrowserClickTool` — (same file)
|
||||
- [x] `BrowserTypeTool` — (same file)
|
||||
- [x] `BrowserScreenshotTool` — (same file)
|
||||
- [x] `BrowserExtractTool` — (same file, 71 tests total)
|
||||
|
||||
### Media Tools
|
||||
- [x] `ImageGenerateTool` — `src/openjarvis/tools/image_tool.py` + `tests/tools/test_image_tool.py` (12 tests)
|
||||
- [x] `AudioTranscribeTool` — `src/openjarvis/tools/audio_tool.py` + `tests/tools/test_audio_tool.py` (17 tests)
|
||||
- [x] `PDFExtractTool` — `src/openjarvis/tools/pdf_tool.py` + `tests/tools/test_pdf_tool.py` (19 tests)
|
||||
|
||||
### Security Hardening
|
||||
- [x] SSRF protection — `src/openjarvis/security/ssrf.py` + `tests/security/test_ssrf.py` (18 tests)
|
||||
- [x] Subprocess sandbox — `src/openjarvis/security/subprocess_sandbox.py` + `tests/security/test_subprocess_sandbox.py` (11 tests)
|
||||
- [x] Prompt injection scanner — `src/openjarvis/security/injection_scanner.py` + `tests/security/test_injection_scanner.py` (10 tests)
|
||||
- [x] Rate limiting — `src/openjarvis/security/rate_limiter.py` + `tests/security/test_rate_limiter.py` (12 tests)
|
||||
- [x] Security headers middleware — `src/openjarvis/server/middleware.py` + `tests/server/test_middleware.py` (4 tests)
|
||||
|
||||
### Config & Integration
|
||||
- [x] New extras in `pyproject.toml`: `browser`, `media`, `pdf`, channel extras for Phase 21
|
||||
- [x] `ToolsConfig.browser` section added to `config.py` (`BrowserConfig`: headless, timeout_ms, viewport)
|
||||
- [x] `SecurityConfig.ssrf_protection`, `rate_limit_enabled`, `rate_limit_rpm`, `rate_limit_burst` fields
|
||||
|
||||
## Phase 18: CLI & API Expansion
|
||||
|
||||
### CLI Commands
|
||||
- [x] `jarvis start/stop/restart/status` — `src/openjarvis/cli/daemon_cmd.py` + `tests/cli/test_daemon_cmd.py` (7 tests)
|
||||
- [x] `jarvis chat` — `src/openjarvis/cli/chat_cmd.py` + `tests/cli/test_chat_cmd.py` (6 tests)
|
||||
- [x] `jarvis agent` — `src/openjarvis/cli/agent_cmd.py` + `tests/cli/test_agent_cmd.py` (3 tests)
|
||||
- [x] `jarvis workflow` — `src/openjarvis/cli/workflow_cmd.py` + `tests/cli/test_workflow_cmd.py` (4 tests)
|
||||
- [x] `jarvis skill` — `src/openjarvis/cli/skill_cmd.py` + `tests/cli/test_skill_cmd.py` (4 tests)
|
||||
- [x] `jarvis vault` — `src/openjarvis/cli/vault_cmd.py` + `tests/cli/test_vault_cmd.py` (6 tests)
|
||||
- [x] `jarvis add` — `src/openjarvis/cli/add_cmd.py` + `tests/cli/test_add_cmd.py` (5 tests)
|
||||
|
||||
### API Endpoints
|
||||
- [x] Agent API endpoints — `src/openjarvis/server/api_routes.py`
|
||||
- [x] Workflow API endpoints — (same file)
|
||||
- [x] Memory API endpoints — (same file)
|
||||
- [x] Traces API endpoints — (same file)
|
||||
- [x] Telemetry API endpoints — (same file)
|
||||
- [x] Skills API endpoints — (same file)
|
||||
- [x] Sessions API endpoints — (same file)
|
||||
- [x] Budget API endpoints — (same file)
|
||||
- [x] Prometheus metrics — (same file)
|
||||
- [x] Security headers middleware wired into app — `src/openjarvis/server/app.py`
|
||||
- [x] All routes tested — `tests/server/test_api_routes.py` (11 tests)
|
||||
- [x] WebSocket streaming endpoint — `WS /v1/chat/stream` + `tests/server/test_websocket.py` (9 tests)
|
||||
|
||||
## Phase 19: Learning System Productionization
|
||||
- [x] GRPO Router — `src/openjarvis/learning/grpo_policy.py` (replaced stub) + `tests/learning/test_grpo_policy.py` (13 tests)
|
||||
- [x] Multi-Armed Bandit Router — `src/openjarvis/learning/bandit_router.py` + `tests/learning/test_bandit_router.py` (13 tests)
|
||||
- [x] Closed-Loop Skill Discovery — `src/openjarvis/learning/skill_discovery.py` + `tests/learning/test_skill_discovery.py` (10 tests)
|
||||
- [x] Auto-Apply ICL Updates — modified `src/openjarvis/learning/icl_updater.py` + `tests/learning/test_icl_updates.py` (13 tests)
|
||||
- [x] Learning Dashboard API — `GET /v1/learning/stats`, `GET /v1/learning/policy` + `tests/learning/test_learning_api.py` (8 tests)
|
||||
|
||||
## Phase 20: Desktop App (Tauri 2.0)
|
||||
- [x] Tauri scaffold in `desktop/` — `src-tauri/`, `package.json`, `vite.config.ts`, `tsconfig.json`
|
||||
- [x] Tauri Rust backend — `src-tauri/src/lib.rs` with 11 commands (health, energy, telemetry, traces, learning, memory, agents, jarvis CLI)
|
||||
- [x] Tauri plugins — notification, shell, global-shortcut, autostart, updater, single-instance
|
||||
- [x] Energy dashboard — `src/components/EnergyDashboard.tsx` (recharts line chart, auto-refresh)
|
||||
- [x] Trace debugger — `src/components/TraceDebugger.tsx` (dual-panel, color-coded step timeline)
|
||||
- [x] Learning curve visualization — `src/components/LearningCurve.tsx` (GRPO/bandit/ICL stats)
|
||||
- [x] Memory browser — `src/components/MemoryBrowser.tsx` (search + stats)
|
||||
- [x] Admin panel — `src/components/AdminPanel.tsx` (health, agents, server control)
|
||||
- [x] Build successful — `.deb`, `.rpm`, `.AppImage` bundles produced
|
||||
- [x] CI workflow — `.github/workflows/desktop.yml` (Linux/macOS/Windows matrix)
|
||||
|
||||
## Phase 21: Channels
|
||||
- [x] LINE — `src/openjarvis/channels/line_channel.py`
|
||||
- [x] Viber — `src/openjarvis/channels/viber_channel.py`
|
||||
- [x] Facebook Messenger — `src/openjarvis/channels/messenger_channel.py`
|
||||
- [x] Reddit — `src/openjarvis/channels/reddit_channel.py`
|
||||
- [x] Mastodon — `src/openjarvis/channels/mastodon_channel.py`
|
||||
- [x] XMPP — `src/openjarvis/channels/xmpp_channel.py`
|
||||
- [x] Rocket.Chat — `src/openjarvis/channels/rocketchat_channel.py`
|
||||
- [x] Zulip — `src/openjarvis/channels/zulip_channel.py`
|
||||
- [x] Twitch — `src/openjarvis/channels/twitch_channel.py`
|
||||
- [x] Nostr — `src/openjarvis/channels/nostr_channel.py`
|
||||
- [x] All channels tested — `tests/channels/test_channels_phase21.py` (103 tests)
|
||||
|
||||
## Test Summary
|
||||
|
||||
| Phase | New Tests | Cumulative |
|
||||
|-------|-----------|------------|
|
||||
| Pre-existing | ~2,447 | 2,447 |
|
||||
| Phase 17 | ~300 | ~2,747 |
|
||||
| Phase 18 | ~56 | ~2,803 |
|
||||
| Phase 19 | ~49 | ~2,852 |
|
||||
| Phase 21 | ~103 | ~2,923 |
|
||||
| WebSocket + Learning API | ~17 | ~2,940 |
|
||||
| **Verified total** | | **2,997 passed, 42 skipped** |
|
||||
|
||||
## CLAUDE.md
|
||||
- [x] Updated with all new tools, CLI commands, API endpoints, channels, learning policies, desktop app, config fields, and phase table
|
||||
@@ -0,0 +1,43 @@
|
||||
# GLM-4.7-FP8 eval with NativeOpenHandsAgent on GAIA only.
|
||||
# Machine: 8x A100-80GB, engine: vLLM, TP=8
|
||||
|
||||
[meta]
|
||||
name = "glm-4.7-fp8-openhands-gaia"
|
||||
description = "Evaluate GLM-4.7-FP8 via NativeOpenHandsAgent on GAIA"
|
||||
|
||||
[defaults]
|
||||
temperature = 0.0
|
||||
max_tokens = 2048
|
||||
|
||||
[judge]
|
||||
model = "gpt-5-mini-2025-08-07"
|
||||
temperature = 0.0
|
||||
max_tokens = 1024
|
||||
|
||||
[run]
|
||||
max_workers = 4
|
||||
output_dir = "results/glm-4.7-fp8-openhands-gaia/"
|
||||
seed = 42
|
||||
telemetry = true
|
||||
gpu_metrics = true
|
||||
|
||||
# --- Model Under Test ---
|
||||
|
||||
[[models]]
|
||||
name = "zai-org/GLM-4.7-FP8"
|
||||
engine = "vllm"
|
||||
param_count_b = 30.0 # 30B total MoE params
|
||||
active_params_b = 3.0 # ~3B active params per token
|
||||
gpu_peak_tflops = 312.0 # A100 SXM FP16 peak TFLOPS
|
||||
gpu_peak_bandwidth_gb_s = 2039.0 # A100 SXM memory bandwidth
|
||||
num_gpus = 8 # TP=8
|
||||
|
||||
# --- Benchmarks ---
|
||||
|
||||
# Agentic: GAIA — requires file reading, web search, multi-step reasoning
|
||||
[[benchmarks]]
|
||||
name = "gaia"
|
||||
backend = "jarvis-agent"
|
||||
agent = "native_openhands"
|
||||
tools = ["code_interpreter", "web_search", "file_read", "calculator", "think"]
|
||||
max_samples = 50
|
||||
Generated
+4399
-1
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,6 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-pwa": "^0.21"
|
||||
"vite-plugin-pwa": "^0.21.2"
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -523,12 +523,24 @@ body {
|
||||
/* === Streaming Indicator === */
|
||||
.streaming-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.streaming-tool-calls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.streaming-progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.streaming-bar-container {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
|
||||
@@ -18,12 +18,6 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
|
||||
return (
|
||||
<div className={`message-bubble ${message.role}`}>
|
||||
<div className="message-content">
|
||||
{message.content || (message.role === 'assistant' ? '\u200B' : '')}
|
||||
{message.role === 'assistant' && message.content && (
|
||||
<CopyButton text={message.content} />
|
||||
)}
|
||||
</div>
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="tool-calls">
|
||||
{message.toolCalls.map((tc) => (
|
||||
@@ -31,6 +25,12 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-content">
|
||||
{message.content || (message.role === 'assistant' ? '\u200B' : '')}
|
||||
{message.role === 'assistant' && message.content && (
|
||||
<CopyButton text={message.content} />
|
||||
)}
|
||||
</div>
|
||||
<div className="message-meta">
|
||||
<span className="message-time">{formatTime(message.timestamp)}</span>
|
||||
{usage && (
|
||||
|
||||
@@ -20,11 +20,20 @@ export function StreamingIndicator({
|
||||
}: StreamingIndicatorProps) {
|
||||
return (
|
||||
<div className="streaming-indicator">
|
||||
<div className="streaming-bar-container">
|
||||
<div className="streaming-bar" />
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="streaming-tool-calls">
|
||||
{toolCalls.map((tc) => (
|
||||
<ToolCallIndicator key={tc.id} toolCall={tc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="streaming-progress-row">
|
||||
<div className="streaming-bar-container">
|
||||
<div className="streaming-bar" />
|
||||
</div>
|
||||
<span className="streaming-phase">{phase}</span>
|
||||
<span className="streaming-elapsed">{formatElapsed(elapsedMs)}</span>
|
||||
</div>
|
||||
<span className="streaming-phase">{phase}</span>
|
||||
<span className="streaming-elapsed">{formatElapsed(elapsedMs)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,18 @@ export function useChat(conversationId: string | null, model: string) {
|
||||
phase: `Running ${data.tool}...`,
|
||||
activeToolCalls: [...toolCalls],
|
||||
}));
|
||||
// Update message with live tool call progress
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.role === 'assistant') {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
toolCalls: [...toolCalls],
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch {}
|
||||
} else if (eventName === 'tool_call_end') {
|
||||
try {
|
||||
@@ -143,6 +155,18 @@ export function useChat(conversationId: string | null, model: string) {
|
||||
phase: 'Generating response...',
|
||||
activeToolCalls: [...toolCalls],
|
||||
}));
|
||||
// Update message with completed tool call
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.role === 'assistant') {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
toolCalls: [...toolCalls],
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch {}
|
||||
} else {
|
||||
// Content chunk (no event name or event: content)
|
||||
|
||||
@@ -80,7 +80,23 @@ channel-mattermost = []
|
||||
channel-feishu = []
|
||||
channel-bluebubbles = []
|
||||
channel-whatsapp-baileys = []
|
||||
channel-line = ["line-bot-sdk>=3.0"]
|
||||
channel-viber = ["viberbot>=1.0"]
|
||||
channel-messenger = []
|
||||
channel-reddit = ["praw>=7.0"]
|
||||
channel-mastodon = ["Mastodon.py>=1.8"]
|
||||
channel-xmpp = ["slixmpp>=1.8"]
|
||||
channel-rocketchat = ["rocketchat-API>=1.30"]
|
||||
channel-zulip = ["zulip>=0.9"]
|
||||
channel-twitch = ["twitchio>=2.6"]
|
||||
channel-nostr = ["pynostr>=0.6"]
|
||||
browser = ["playwright>=1.40"]
|
||||
media = ["openai>=1.30"]
|
||||
pdf = ["pdfplumber>=0.10"]
|
||||
scheduler = ["croniter>=2.0"]
|
||||
security-signing = ["cryptography>=43"]
|
||||
sandbox-wasm = ["wasmtime>=25"]
|
||||
dashboard = ["textual>=0.80"]
|
||||
docs = [
|
||||
"mkdocs>=1.6",
|
||||
"mkdocs-material>=9.5",
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bump the desktop app version across all 3 config files.
|
||||
# Usage: ./scripts/bump-desktop-version.sh <semver>
|
||||
# Example: ./scripts/bump-desktop-version.sh 1.0.1
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 1.0.1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
|
||||
# Validate semver (major.minor.patch, optional pre-release)
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
|
||||
echo "Error: '$VERSION' is not a valid semver (expected X.Y.Z or X.Y.Z-pre)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DESKTOP_DIR="$(cd "$(dirname "$0")/../desktop" && pwd)"
|
||||
|
||||
# 1. package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = '${DESKTOP_DIR}/package.json';
|
||||
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
pkg.version = '${VERSION}';
|
||||
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
echo "Updated desktop/package.json -> ${VERSION}"
|
||||
|
||||
# 2. tauri.conf.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = '${DESKTOP_DIR}/src-tauri/tauri.conf.json';
|
||||
const conf = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
conf.version = '${VERSION}';
|
||||
fs.writeFileSync(path, JSON.stringify(conf, null, 2) + '\n');
|
||||
"
|
||||
echo "Updated desktop/src-tauri/tauri.conf.json -> ${VERSION}"
|
||||
|
||||
# 3. Cargo.toml
|
||||
sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" "${DESKTOP_DIR}/src-tauri/Cargo.toml"
|
||||
rm -f "${DESKTOP_DIR}/src-tauri/Cargo.toml.bak"
|
||||
echo "Updated desktop/src-tauri/Cargo.toml -> ${VERSION}"
|
||||
|
||||
echo ""
|
||||
echo "Version bumped to ${VERSION} in all 3 files."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " git add desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml"
|
||||
echo " git commit -m \"chore(desktop): bump version to ${VERSION}\""
|
||||
echo " git tag desktop-v${VERSION}"
|
||||
echo " git push origin main --tags"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Agent-to-Agent protocol — Google A2A spec implementation."""
|
||||
from openjarvis.a2a.client import A2AClient
|
||||
from openjarvis.a2a.protocol import A2ARequest, A2AResponse, A2ATask, AgentCard
|
||||
from openjarvis.a2a.server import A2AServer
|
||||
from openjarvis.a2a.tool import A2AAgentTool
|
||||
|
||||
__all__ = [
|
||||
"A2AAgentTool", "A2AClient", "A2ARequest", "A2AResponse",
|
||||
"A2AServer", "A2ATask", "AgentCard",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""A2A client — discover and call external A2A agents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from openjarvis.a2a.protocol import A2ARequest, A2ATask, AgentCard
|
||||
|
||||
|
||||
class A2AClient:
|
||||
"""Client for calling external A2A-compatible agents.
|
||||
|
||||
Discovers agent capabilities via /.well-known/agent.json and
|
||||
sends tasks via /a2a/tasks.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, *, timeout: float = 30.0) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._card: Optional[AgentCard] = None
|
||||
|
||||
def discover(self) -> AgentCard:
|
||||
"""Fetch the agent card from /.well-known/agent.json."""
|
||||
import httpx
|
||||
resp = httpx.get(
|
||||
f"{self._base_url}/.well-known/agent.json",
|
||||
timeout=self._timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._card = AgentCard(
|
||||
name=data.get("name", ""),
|
||||
description=data.get("description", ""),
|
||||
url=data.get("url", self._base_url),
|
||||
version=data.get("version", ""),
|
||||
capabilities=data.get("capabilities", []),
|
||||
skills=data.get("skills", []),
|
||||
)
|
||||
return self._card
|
||||
|
||||
def send_task(self, input_text: str, **kwargs: Any) -> A2ATask:
|
||||
"""Send a task to the remote agent and return the result."""
|
||||
import httpx
|
||||
request = A2ARequest(
|
||||
method="tasks/send",
|
||||
params={
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"text": input_text}],
|
||||
},
|
||||
},
|
||||
)
|
||||
resp = httpx.post(
|
||||
f"{self._base_url}/a2a/tasks",
|
||||
json=request.to_dict(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("result", {})
|
||||
return A2ATask(
|
||||
task_id=result.get("id", ""),
|
||||
state=result.get("state", "unknown"),
|
||||
input_text=result.get("input", input_text),
|
||||
output_text=result.get("output", ""),
|
||||
history=result.get("history", []),
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> A2ATask:
|
||||
"""Get the status of a previously submitted task."""
|
||||
import httpx
|
||||
request = A2ARequest(
|
||||
method="tasks/get",
|
||||
params={"id": task_id},
|
||||
)
|
||||
resp = httpx.post(
|
||||
f"{self._base_url}/a2a/tasks",
|
||||
json=request.to_dict(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("result", {})
|
||||
return A2ATask(
|
||||
task_id=result.get("id", task_id),
|
||||
state=result.get("state", "unknown"),
|
||||
output_text=result.get("output", ""),
|
||||
)
|
||||
|
||||
def cancel_task(self, task_id: str) -> A2ATask:
|
||||
"""Cancel a running task."""
|
||||
import httpx
|
||||
request = A2ARequest(
|
||||
method="tasks/cancel",
|
||||
params={"id": task_id},
|
||||
)
|
||||
resp = httpx.post(
|
||||
f"{self._base_url}/a2a/tasks",
|
||||
json=request.to_dict(),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("result", {})
|
||||
return A2ATask(
|
||||
task_id=result.get("id", task_id),
|
||||
state=result.get("state", "canceled"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["A2AClient"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""A2A protocol types — Google A2A spec (JSON-RPC 2.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class TaskState(str, Enum):
|
||||
SUBMITTED = "submitted"
|
||||
WORKING = "working"
|
||||
INPUT_REQUIRED = "input-required"
|
||||
COMPLETED = "completed"
|
||||
CANCELED = "canceled"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentCard:
|
||||
"""Agent discovery card served at /.well-known/agent.json."""
|
||||
name: str
|
||||
description: str = ""
|
||||
url: str = ""
|
||||
version: str = "1.0.0"
|
||||
capabilities: List[str] = field(default_factory=list)
|
||||
skills: List[str] = field(default_factory=list)
|
||||
authentication: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"url": self.url,
|
||||
"version": self.version,
|
||||
"capabilities": self.capabilities,
|
||||
"skills": self.skills,
|
||||
"authentication": self.authentication,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class A2ATask:
|
||||
"""An A2A task with state machine."""
|
||||
task_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
|
||||
state: TaskState = TaskState.SUBMITTED
|
||||
input_text: str = ""
|
||||
output_text: str = ""
|
||||
history: List[Dict[str, str]] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.task_id,
|
||||
"state": self.state.value,
|
||||
"input": self.input_text,
|
||||
"output": self.output_text,
|
||||
"history": self.history,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class A2ARequest:
|
||||
"""JSON-RPC 2.0 request for A2A."""
|
||||
method: str
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
request_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"method": self.method,
|
||||
"params": self.params,
|
||||
"id": self.request_id,
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class A2AResponse:
|
||||
"""JSON-RPC 2.0 response for A2A."""
|
||||
result: Any = None
|
||||
error: Optional[Dict[str, Any]] = None
|
||||
request_id: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
resp: Dict[str, Any] = {"jsonrpc": "2.0", "id": self.request_id}
|
||||
if self.error:
|
||||
resp["error"] = self.error
|
||||
else:
|
||||
resp["result"] = self.result
|
||||
return resp
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> A2AResponse:
|
||||
parsed = json.loads(data)
|
||||
return cls(
|
||||
result=parsed.get("result"),
|
||||
error=parsed.get("error"),
|
||||
request_id=parsed.get("id", ""),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["A2ARequest", "A2AResponse", "A2ATask", "AgentCard", "TaskState"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""A2A server — exposes agents via /.well-known/agent.json and /a2a/tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from openjarvis.a2a.protocol import (
|
||||
A2AResponse,
|
||||
A2ATask,
|
||||
AgentCard,
|
||||
TaskState,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
|
||||
|
||||
class A2AServer:
|
||||
"""A2A server that processes incoming tasks via agent execution.
|
||||
|
||||
Can be mounted as routes in the FastAPI server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_card: AgentCard,
|
||||
*,
|
||||
handler: Optional[Callable[[str], str]] = None,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._card = agent_card
|
||||
self._handler = handler
|
||||
self._bus = bus
|
||||
self._tasks: Dict[str, A2ATask] = {}
|
||||
|
||||
@property
|
||||
def agent_card(self) -> AgentCard:
|
||||
return self._card
|
||||
|
||||
def handle_request(self, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Process a JSON-RPC 2.0 A2A request."""
|
||||
method = request_data.get("method", "")
|
||||
params = request_data.get("params", {})
|
||||
req_id = request_data.get("id", "")
|
||||
|
||||
if method == "tasks/send":
|
||||
return self._handle_task_send(params, req_id)
|
||||
elif method == "tasks/get":
|
||||
return self._handle_task_get(params, req_id)
|
||||
elif method == "tasks/cancel":
|
||||
return self._handle_task_cancel(params, req_id)
|
||||
else:
|
||||
return A2AResponse(
|
||||
error={"code": -32601, "message": f"Method not found: {method}"},
|
||||
request_id=req_id,
|
||||
).to_dict()
|
||||
|
||||
def _handle_task_send(self, params: Dict[str, Any], req_id: str) -> Dict[str, Any]:
|
||||
"""Handle tasks/send — create and execute a task."""
|
||||
input_text = params.get("message", {}).get("parts", [{}])[0].get("text", "")
|
||||
if not input_text:
|
||||
input_text = params.get("input", "")
|
||||
|
||||
task = A2ATask(input_text=input_text)
|
||||
self._tasks[task.task_id] = task
|
||||
|
||||
if self._bus:
|
||||
self._bus.publish(
|
||||
EventType.A2A_TASK_RECEIVED,
|
||||
{"task_id": task.task_id, "input": input_text},
|
||||
)
|
||||
|
||||
# Execute
|
||||
task.state = TaskState.WORKING
|
||||
try:
|
||||
if self._handler:
|
||||
result = self._handler(input_text)
|
||||
else:
|
||||
result = f"No handler configured for A2A task: {input_text}"
|
||||
task.output_text = result
|
||||
task.state = TaskState.COMPLETED
|
||||
task.history.append({"role": "agent", "content": result})
|
||||
except Exception as exc:
|
||||
task.output_text = str(exc)
|
||||
task.state = TaskState.FAILED
|
||||
|
||||
if self._bus:
|
||||
self._bus.publish(
|
||||
EventType.A2A_TASK_COMPLETED,
|
||||
{"task_id": task.task_id, "state": task.state.value},
|
||||
)
|
||||
|
||||
return A2AResponse(result=task.to_dict(), request_id=req_id).to_dict()
|
||||
|
||||
def _handle_task_get(self, params: Dict[str, Any], req_id: str) -> Dict[str, Any]:
|
||||
"""Handle tasks/get — retrieve task status."""
|
||||
task_id = params.get("id", "")
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return A2AResponse(
|
||||
error={"code": -32602, "message": f"Task not found: {task_id}"},
|
||||
request_id=req_id,
|
||||
).to_dict()
|
||||
return A2AResponse(result=task.to_dict(), request_id=req_id).to_dict()
|
||||
|
||||
def _handle_task_cancel(
|
||||
self, params: Dict[str, Any], req_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle tasks/cancel — cancel a running task."""
|
||||
task_id = params.get("id", "")
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return A2AResponse(
|
||||
error={"code": -32602, "message": f"Task not found: {task_id}"},
|
||||
request_id=req_id,
|
||||
).to_dict()
|
||||
task.state = TaskState.CANCELED
|
||||
return A2AResponse(result=task.to_dict(), request_id=req_id).to_dict()
|
||||
|
||||
def get_routes(self) -> List[Dict[str, Any]]:
|
||||
"""Return route definitions for mounting in a web framework."""
|
||||
return [
|
||||
{
|
||||
"path": "/.well-known/agent.json",
|
||||
"method": "GET",
|
||||
"handler": "agent_card",
|
||||
},
|
||||
{"path": "/a2a/tasks", "method": "POST", "handler": "handle_request"},
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["A2AServer"]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""A2AAgentTool — wraps an external A2A agent as an invocable tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from openjarvis.a2a.client import A2AClient
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
|
||||
class A2AAgentTool(BaseTool):
|
||||
"""Wraps an external A2A agent as a BaseTool.
|
||||
|
||||
Follows the MCPToolAdapter pattern for external tool integration.
|
||||
"""
|
||||
|
||||
tool_id: str
|
||||
|
||||
def __init__(self, client: A2AClient, *, name: str = "") -> None:
|
||||
self._client = client
|
||||
self._name = name or "a2a_agent"
|
||||
self.tool_id = self._name
|
||||
# Try to discover agent info
|
||||
try:
|
||||
card = client.discover()
|
||||
if not name:
|
||||
self._name = f"a2a_{card.name.lower().replace(' ', '_')}"
|
||||
self.tool_id = self._name
|
||||
self._description = card.description or f"External A2A agent: {card.name}"
|
||||
except Exception:
|
||||
self._description = "External A2A agent"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=self._name,
|
||||
description=self._description,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "Input text to send to the remote agent.",
|
||||
},
|
||||
},
|
||||
"required": ["input"],
|
||||
},
|
||||
category="a2a",
|
||||
required_capabilities=["network:fetch"],
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
input_text = params.get("input", "")
|
||||
if not input_text:
|
||||
return ToolResult(
|
||||
tool_name=self._name,
|
||||
content="No input provided.",
|
||||
success=False,
|
||||
)
|
||||
try:
|
||||
task = self._client.send_task(input_text)
|
||||
return ToolResult(
|
||||
tool_name=self._name,
|
||||
content=task.output_text,
|
||||
success=task.state in ("completed", "working"),
|
||||
metadata={"task_id": task.task_id, "state": str(task.state)},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name=self._name,
|
||||
content=f"A2A call failed: {exc}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["A2AAgentTool"]
|
||||
@@ -139,6 +139,41 @@ class BaseAgent(ABC):
|
||||
metadata={"max_turns_exceeded": True},
|
||||
)
|
||||
|
||||
def _check_continuation(
|
||||
self,
|
||||
result: dict,
|
||||
messages: list,
|
||||
*,
|
||||
max_continuations: int = 2,
|
||||
) -> str:
|
||||
"""Re-prompt on ``finish_reason == "length"`` to get complete output.
|
||||
|
||||
Returns the concatenated content after up to *max_continuations*
|
||||
follow-up generate calls.
|
||||
"""
|
||||
content = result.get("content", "")
|
||||
finish_reason = result.get("finish_reason", "")
|
||||
|
||||
for _ in range(max_continuations):
|
||||
if finish_reason != "length":
|
||||
break
|
||||
# Append what we have so far and ask the model to continue
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
messages.append(Message(role=Role.ASSISTANT, content=content))
|
||||
messages.append(
|
||||
Message(
|
||||
role=Role.USER,
|
||||
content="Continue from where you left off.",
|
||||
),
|
||||
)
|
||||
cont = self._generate(messages)
|
||||
continuation = cont.get("content", "")
|
||||
content += continuation
|
||||
finish_reason = cont.get("finish_reason", "")
|
||||
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _strip_think_tags(text: str) -> str:
|
||||
"""Remove ``<think>...</think>`` blocks from model output.
|
||||
@@ -148,9 +183,9 @@ class BaseAgent(ABC):
|
||||
begins directly with reasoning text followed by ``</think>``.
|
||||
"""
|
||||
# Full <think>...</think> blocks
|
||||
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL)
|
||||
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Leading content before a bare </think> (no opening tag)
|
||||
text = re.sub(r"^.*?</think>\s*", "", text, flags=re.DOTALL)
|
||||
text = re.sub(r"^.*?</think>\s*", "", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
return text.strip()
|
||||
|
||||
@abstractmethod
|
||||
@@ -182,6 +217,9 @@ class ToolUsingAgent(BaseAgent):
|
||||
max_turns: int = 10,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
loop_guard_config: Optional[Any] = None,
|
||||
capability_policy: Optional[Any] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
engine, model, bus=bus,
|
||||
@@ -190,8 +228,27 @@ class ToolUsingAgent(BaseAgent):
|
||||
from openjarvis.tools._stubs import ToolExecutor
|
||||
|
||||
self._tools = tools or []
|
||||
self._executor = ToolExecutor(self._tools, bus=bus)
|
||||
_aid = agent_id or getattr(self, "agent_id", "")
|
||||
self._executor = ToolExecutor(
|
||||
self._tools, bus=bus,
|
||||
capability_policy=capability_policy,
|
||||
agent_id=_aid,
|
||||
)
|
||||
self._max_turns = max_turns
|
||||
|
||||
# Loop guard
|
||||
self._loop_guard = None
|
||||
try:
|
||||
from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
if loop_guard_config is None:
|
||||
loop_guard_config = LoopGuardConfig()
|
||||
elif isinstance(loop_guard_config, dict):
|
||||
loop_guard_config = LoopGuardConfig(**loop_guard_config)
|
||||
if loop_guard_config.enabled:
|
||||
self._loop_guard = LoopGuard(loop_guard_config, bus=bus)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["AgentContext", "AgentResult", "BaseAgent", "ToolUsingAgent"]
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Agent loop guard — detect and prevent degenerate tool-calling loops."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LoopGuardConfig:
|
||||
"""Configuration for the loop guard."""
|
||||
enabled: bool = True
|
||||
max_identical_calls: int = 3 # SHA-256 of (tool_name, arguments)
|
||||
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
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LoopVerdict:
|
||||
"""Result of a loop guard check."""
|
||||
blocked: bool = False
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class LoopGuard:
|
||||
"""Detect and prevent degenerate agent loops.
|
||||
|
||||
Features:
|
||||
1. Hash tracking: SHA-256 of (tool_name, args) blocks after max_identical_calls
|
||||
2. Ping-pong detection: Sliding window detects A-B-A-B or A-B-C-A-B-C patterns
|
||||
3. Poll-tool awareness: Tools with spec.metadata["polling"] = True
|
||||
get relaxed budget
|
||||
4. Context overflow recovery: 4-stage compression of message history
|
||||
"""
|
||||
|
||||
def __init__(self, config: LoopGuardConfig, *, bus: Optional[EventBus] = None):
|
||||
self._config = config
|
||||
self._bus = bus
|
||||
# Track call hashes and their counts
|
||||
self._call_counts: dict[str, int] = {}
|
||||
# Track tool name sequence for pattern detection
|
||||
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] = {}
|
||||
|
||||
def check_call(self, tool_name: str, arguments: str) -> LoopVerdict:
|
||||
"""Check whether a tool call should proceed or be blocked."""
|
||||
# 1. Hash tracking — identical calls
|
||||
call_hash = hashlib.sha256(
|
||||
f"{tool_name}:{arguments}".encode()
|
||||
).hexdigest()[:16]
|
||||
self._call_counts[call_hash] = self._call_counts.get(call_hash, 0) + 1
|
||||
if self._call_counts[call_hash] > self._config.max_identical_calls:
|
||||
self._emit_triggered("identical_call", tool_name)
|
||||
return LoopVerdict(
|
||||
blocked=True,
|
||||
reason=(
|
||||
f"Identical call to '{tool_name}' repeated "
|
||||
f"{self._call_counts[call_hash]} times "
|
||||
f"(max {self._config.max_identical_calls})."
|
||||
),
|
||||
)
|
||||
|
||||
# 2. Per-tool budget (polling tools)
|
||||
self._per_tool_counts[tool_name] = self._per_tool_counts.get(tool_name, 0) + 1
|
||||
if self._per_tool_counts[tool_name] > self._config.poll_tool_budget:
|
||||
self._emit_triggered("poll_budget", tool_name)
|
||||
return LoopVerdict(
|
||||
blocked=True,
|
||||
reason=(
|
||||
f"Tool '{tool_name}' exceeded poll budget "
|
||||
f"({self._config.poll_tool_budget})."
|
||||
),
|
||||
)
|
||||
|
||||
# 3. Ping-pong detection
|
||||
self._tool_sequence.append(tool_name)
|
||||
if len(self._tool_sequence) >= self._config.ping_pong_window:
|
||||
if self._detect_ping_pong():
|
||||
self._emit_triggered("ping_pong", tool_name)
|
||||
return LoopVerdict(
|
||||
blocked=True,
|
||||
reason="Repetitive tool-calling pattern detected (ping-pong).",
|
||||
)
|
||||
|
||||
return LoopVerdict()
|
||||
|
||||
def check_response(self, content: str) -> LoopVerdict:
|
||||
"""Check whether an agent response indicates a loop. Reserved for future use."""
|
||||
return LoopVerdict()
|
||||
|
||||
def compress_context(self, messages: list) -> list:
|
||||
"""Apply 4-stage context overflow recovery to message list.
|
||||
|
||||
Stages:
|
||||
1. Summarize old tool results (replace content with "[Tool result truncated]")
|
||||
2. Sliding window — keep only recent messages
|
||||
3. Drop tool call/result pairs from the middle
|
||||
4. Truncate to system + last 2 exchanges
|
||||
"""
|
||||
if len(messages) <= self._config.max_context_messages:
|
||||
return messages
|
||||
|
||||
# Stage 1: Truncate old tool result messages
|
||||
threshold = len(messages) // 2
|
||||
compressed = []
|
||||
for i, msg in enumerate(messages):
|
||||
if (
|
||||
i < threshold
|
||||
and hasattr(msg, 'role')
|
||||
and str(getattr(msg, 'role', '')) == 'tool'
|
||||
):
|
||||
# Replace with truncated version
|
||||
from openjarvis.core.types import Message, Role
|
||||
compressed.append(Message(
|
||||
role=Role.TOOL,
|
||||
content="[Tool result truncated]",
|
||||
tool_call_id=getattr(msg, 'tool_call_id', None),
|
||||
name=getattr(msg, 'name', None),
|
||||
))
|
||||
else:
|
||||
compressed.append(msg)
|
||||
|
||||
if len(compressed) <= self._config.max_context_messages:
|
||||
return compressed
|
||||
|
||||
# Stage 2: Sliding window — keep system messages + recent window
|
||||
system_msgs = [
|
||||
m for m in compressed
|
||||
if hasattr(m, 'role')
|
||||
and str(getattr(m, 'role', '')) == 'system'
|
||||
]
|
||||
non_system = [
|
||||
m for m in compressed
|
||||
if not (
|
||||
hasattr(m, 'role')
|
||||
and str(getattr(m, 'role', '')) == 'system'
|
||||
)
|
||||
]
|
||||
window_size = self._config.max_context_messages - len(system_msgs)
|
||||
if len(non_system) > window_size:
|
||||
non_system = non_system[-window_size:]
|
||||
compressed = system_msgs + non_system
|
||||
|
||||
if len(compressed) <= self._config.max_context_messages:
|
||||
return compressed
|
||||
|
||||
# Stage 3: Drop tool call/result pairs from middle
|
||||
# Keep first 10% and last 50%
|
||||
keep_start = max(len(system_msgs), len(compressed) // 10)
|
||||
keep_end = len(compressed) // 2
|
||||
compressed = compressed[:keep_start] + compressed[-keep_end:]
|
||||
|
||||
if len(compressed) <= self._config.max_context_messages:
|
||||
return compressed
|
||||
|
||||
# Stage 4: Extreme — system + last 2 exchanges (4 messages)
|
||||
return system_msgs + non_system[-4:]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all tracking state."""
|
||||
self._call_counts.clear()
|
||||
self._tool_sequence.clear()
|
||||
self._per_tool_counts.clear()
|
||||
|
||||
def _detect_ping_pong(self) -> bool:
|
||||
"""Detect repeating patterns in tool call sequence."""
|
||||
seq = list(self._tool_sequence)
|
||||
n = len(seq)
|
||||
# Check for period-2 pattern (A-B-A-B)
|
||||
for period in (2, 3):
|
||||
if n >= period * 2:
|
||||
tail = seq[-period * 2:]
|
||||
pattern = tail[:period]
|
||||
if all(tail[i] == pattern[i % period] for i in range(len(tail))):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _emit_triggered(self, reason_type: str, tool_name: str) -> None:
|
||||
"""Publish a LOOP_GUARD_TRIGGERED event."""
|
||||
if self._bus:
|
||||
self._bus.publish(
|
||||
EventType.LOOP_GUARD_TRIGGERED,
|
||||
{"reason_type": reason_type, "tool": tool_name},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["LoopGuard", "LoopGuardConfig", "LoopVerdict"]
|
||||
@@ -126,6 +126,18 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
break
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _strip_tool_call_text(text: str) -> str:
|
||||
"""Remove raw tool call artifacts from final output."""
|
||||
# Remove Action: ... Action Input: ... blocks
|
||||
text = re.sub(
|
||||
r"Action:\s*.+?(?:Action Input:\s*.+?)?(?=\n\n|\Z)",
|
||||
"", 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)
|
||||
return text.strip()
|
||||
|
||||
def _extract_code(self, text: str) -> str | None:
|
||||
"""Extract Python code from markdown code blocks."""
|
||||
match = re.search(r"```python\n(.*?)```", text, re.DOTALL)
|
||||
@@ -174,6 +186,16 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
params[key] = int(val)
|
||||
except ValueError:
|
||||
params[key] = val
|
||||
# key: value format (common in GLM models)
|
||||
if not params:
|
||||
for m in re.finditer(
|
||||
r"(\w+)\s*:\s*(.+?)(?=\n\w+\s*:|$)", raw_params, re.DOTALL
|
||||
):
|
||||
key, val = m.group(1), m.group(2).strip().strip("\"'")
|
||||
try:
|
||||
params[key] = int(val)
|
||||
except ValueError:
|
||||
params[key] = val
|
||||
if params:
|
||||
return (tool_name, _json.dumps(params))
|
||||
return (tool_name, "{}")
|
||||
@@ -214,8 +236,16 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
try:
|
||||
result = self._generate(direct_messages)
|
||||
content = self._strip_think_tags(result.get("content", ""))
|
||||
usage = result.get("usage", {})
|
||||
self._emit_turn_end(turns=1)
|
||||
return AgentResult(content=content, tool_results=[], turns=1)
|
||||
return AgentResult(
|
||||
content=content, tool_results=[], turns=1,
|
||||
metadata={
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
error_str = str(exc)
|
||||
if "400" in error_str:
|
||||
@@ -243,6 +273,7 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
all_tool_results: list[ToolResult] = []
|
||||
turns = 0
|
||||
last_content = ""
|
||||
total_usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
for _turn in range(self._max_turns):
|
||||
turns += 1
|
||||
@@ -269,6 +300,11 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
metadata={"error": True},
|
||||
)
|
||||
|
||||
# Accumulate usage from this generate call
|
||||
usage = result.get("usage", {})
|
||||
for k in total_usage:
|
||||
total_usage[k] += usage.get(k, 0)
|
||||
|
||||
content = result.get("content", "")
|
||||
# Strip think tags so they don't interfere with parsing
|
||||
content = self._strip_think_tags(content)
|
||||
@@ -316,14 +352,19 @@ class NativeOpenHandsAgent(ToolUsingAgent):
|
||||
|
||||
# No code or tool call -- this is the final answer
|
||||
content = self._strip_think_tags(content)
|
||||
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,
|
||||
)
|
||||
|
||||
# Max turns
|
||||
final = self._strip_think_tags(last_content) or "Maximum turns reached."
|
||||
return self._max_turns_result(all_tool_results, turns, content=final)
|
||||
final = self._strip_tool_call_text(final)
|
||||
result = self._max_turns_result(all_tool_results, turns, content=final)
|
||||
result.metadata.update(total_usage)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["NativeOpenHandsAgent"]
|
||||
|
||||
@@ -139,6 +139,23 @@ class NativeReActAgent(ToolUsingAgent):
|
||||
name=parsed["action"],
|
||||
arguments=parsed["action_input"] or "{}",
|
||||
)
|
||||
|
||||
# Loop guard check before execution
|
||||
if self._loop_guard:
|
||||
verdict = self._loop_guard.check_call(
|
||||
tool_call.name, tool_call.arguments,
|
||||
)
|
||||
if verdict.blocked:
|
||||
tool_result = ToolResult(
|
||||
tool_name=tool_call.name,
|
||||
content=f"Loop guard: {verdict.reason}",
|
||||
success=False,
|
||||
)
|
||||
all_tool_results.append(tool_result)
|
||||
observation = f"Observation: {tool_result.content}"
|
||||
messages.append(Message(role=Role.USER, content=observation))
|
||||
continue
|
||||
|
||||
tool_result = self._executor.execute(tool_call)
|
||||
all_tool_results.append(tool_result)
|
||||
|
||||
|
||||
@@ -225,8 +225,9 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
content = result.get("content", "")
|
||||
raw_tool_calls = result.get("tool_calls", [])
|
||||
|
||||
# No tool calls -> final answer
|
||||
# No tool calls -> check continuation, then final answer
|
||||
if not raw_tool_calls:
|
||||
content = self._check_continuation(result, messages)
|
||||
self._emit_turn_end(turns=turns, content_length=len(content))
|
||||
return AgentResult(
|
||||
content=content,
|
||||
@@ -251,8 +252,28 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
tool_calls=tool_calls,
|
||||
))
|
||||
|
||||
# Execute each tool and append results
|
||||
# Execute each tool (with loop guard check) and append results
|
||||
for tc in tool_calls:
|
||||
# Loop guard check before execution
|
||||
if self._loop_guard:
|
||||
verdict = self._loop_guard.check_call(
|
||||
tc.name, tc.arguments,
|
||||
)
|
||||
if verdict.blocked:
|
||||
tool_result = ToolResult(
|
||||
tool_name=tc.name,
|
||||
content=f"Loop guard: {verdict.reason}",
|
||||
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,
|
||||
))
|
||||
continue
|
||||
|
||||
tool_result = self._executor.execute(tc)
|
||||
all_tool_results.append(tool_result)
|
||||
|
||||
|
||||
@@ -88,6 +88,56 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.line_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.viber_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.messenger_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.reddit_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.mastodon_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.xmpp_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.rocketchat_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.zulip_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.twitch_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.channels.nostr_channel # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = [
|
||||
"BaseChannel",
|
||||
"ChannelHandler",
|
||||
|
||||
@@ -26,6 +26,7 @@ class ChannelMessage:
|
||||
content: str
|
||||
message_id: str = ""
|
||||
conversation_id: str = ""
|
||||
session_id: str = ""
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""LineChannel — LINE Messaging API adapter via line-bot-sdk."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("line")
|
||||
class LineChannel(BaseChannel):
|
||||
"""LINE messaging channel adapter.
|
||||
|
||||
Uses the LINE Messaging API via ``line-bot-sdk``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel_access_token:
|
||||
LINE channel access token. Falls back to ``LINE_CHANNEL_ACCESS_TOKEN``
|
||||
env var.
|
||||
channel_secret:
|
||||
LINE channel secret. Falls back to ``LINE_CHANNEL_SECRET`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "line"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channel_access_token: str = "",
|
||||
*,
|
||||
channel_secret: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._channel_access_token = channel_access_token or os.environ.get(
|
||||
"LINE_CHANNEL_ACCESS_TOKEN", ""
|
||||
)
|
||||
self._channel_secret = channel_secret or os.environ.get(
|
||||
"LINE_CHANNEL_SECRET", ""
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._channel_access_token or not self._channel_secret:
|
||||
logger.warning("No LINE channel_access_token or channel_secret configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import linebot # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"line-bot-sdk not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-line]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a push message to a LINE user or group.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
LINE user ID or group ID to send to.
|
||||
content:
|
||||
Text message content.
|
||||
"""
|
||||
if not self._channel_access_token:
|
||||
logger.warning("Cannot send: no LINE credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
from linebot.v3.messaging import (
|
||||
ApiClient,
|
||||
Configuration,
|
||||
MessagingApi,
|
||||
PushMessageRequest,
|
||||
TextMessage,
|
||||
)
|
||||
|
||||
configuration = Configuration(
|
||||
access_token=self._channel_access_token,
|
||||
)
|
||||
with ApiClient(configuration) as api_client:
|
||||
api = MessagingApi(api_client)
|
||||
api.push_message(
|
||||
PushMessageRequest(
|
||||
to=channel,
|
||||
messages=[TextMessage(text=content)],
|
||||
)
|
||||
)
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("line-bot-sdk not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("LINE send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["line"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["LineChannel"]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""MastodonChannel — Mastodon adapter via Mastodon.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("mastodon")
|
||||
class MastodonChannel(BaseChannel):
|
||||
"""Mastodon messaging channel adapter.
|
||||
|
||||
Uses the Mastodon API via ``Mastodon.py``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_base_url:
|
||||
Mastodon instance URL (e.g. ``https://mastodon.social``). Falls back
|
||||
to ``MASTODON_API_BASE_URL`` env var.
|
||||
access_token:
|
||||
Mastodon access token. Falls back to ``MASTODON_ACCESS_TOKEN`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "mastodon"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base_url: str = "",
|
||||
*,
|
||||
access_token: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._api_base_url = api_base_url or os.environ.get(
|
||||
"MASTODON_API_BASE_URL", ""
|
||||
)
|
||||
self._access_token = access_token or os.environ.get(
|
||||
"MASTODON_ACCESS_TOKEN", ""
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._api_base_url or not self._access_token:
|
||||
logger.warning("No Mastodon api_base_url or access_token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import mastodon # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Mastodon.py not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-mastodon]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Post a status or send a direct message on Mastodon.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Visibility level (``public``, ``unlisted``, ``private``,
|
||||
``direct``) or a username to DM (prefix with ``@``).
|
||||
content:
|
||||
Toot / message content.
|
||||
"""
|
||||
if not self._api_base_url or not self._access_token:
|
||||
logger.warning("Cannot send: no Mastodon credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
from mastodon import Mastodon
|
||||
|
||||
client = Mastodon(
|
||||
access_token=self._access_token,
|
||||
api_base_url=self._api_base_url,
|
||||
)
|
||||
|
||||
visibility = "public"
|
||||
if channel in ("public", "unlisted", "private", "direct"):
|
||||
visibility = channel
|
||||
elif channel.startswith("@"):
|
||||
# Direct message — prepend mention
|
||||
visibility = "direct"
|
||||
if not content.startswith(channel):
|
||||
content = f"{channel} {content}"
|
||||
|
||||
in_reply_to_id = conversation_id or None
|
||||
client.status_post(
|
||||
content,
|
||||
visibility=visibility,
|
||||
in_reply_to_id=in_reply_to_id,
|
||||
)
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("Mastodon.py not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Mastodon send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["mastodon"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MastodonChannel"]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""MessengerChannel — Facebook Messenger adapter via pymessenger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("messenger")
|
||||
class MessengerChannel(BaseChannel):
|
||||
"""Facebook Messenger channel adapter.
|
||||
|
||||
Uses the Messenger Platform Send API via ``pymessenger``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
access_token:
|
||||
Facebook page access token. Falls back to ``MESSENGER_ACCESS_TOKEN``
|
||||
env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "messenger"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str = "",
|
||||
*,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._access_token = access_token or os.environ.get(
|
||||
"MESSENGER_ACCESS_TOKEN", ""
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._access_token:
|
||||
logger.warning("No Messenger access_token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import pymessenger # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pymessenger not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-messenger]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Messenger user.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Facebook user PSID to send to.
|
||||
content:
|
||||
Text message content.
|
||||
"""
|
||||
if not self._access_token:
|
||||
logger.warning("Cannot send: no Messenger credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
from pymessenger import Bot
|
||||
|
||||
bot = Bot(self._access_token)
|
||||
bot.send_text_message(channel, content)
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("pymessenger not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Messenger send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["messenger"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["MessengerChannel"]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""NostrChannel — Nostr protocol adapter via pynostr."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("nostr")
|
||||
class NostrChannel(BaseChannel):
|
||||
"""Nostr decentralized messaging channel adapter.
|
||||
|
||||
Uses the Nostr protocol via ``pynostr``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
private_key:
|
||||
Nostr private key (hex or nsec format). Falls back to
|
||||
``NOSTR_PRIVATE_KEY`` env var.
|
||||
relays:
|
||||
Comma-separated list of relay URLs. Falls back to ``NOSTR_RELAYS``
|
||||
env var. Default: ``wss://relay.damus.io``.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "nostr"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
private_key: str = "",
|
||||
*,
|
||||
relays: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._private_key = private_key or os.environ.get("NOSTR_PRIVATE_KEY", "")
|
||||
relays_str = relays or os.environ.get(
|
||||
"NOSTR_RELAYS", "wss://relay.damus.io"
|
||||
)
|
||||
self._relays = [r.strip() for r in relays_str.split(",") if r.strip()]
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._private_key:
|
||||
logger.warning("No Nostr private_key configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import pynostr # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pynostr not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-nostr]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Publish a Nostr event (kind 1 note or kind 4 DM).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
For public notes, this is ignored or used as a tag. For
|
||||
encrypted DMs, this is the recipient public key (hex or npub).
|
||||
content:
|
||||
Note or message content.
|
||||
"""
|
||||
if not self._private_key:
|
||||
logger.warning("Cannot send: no Nostr private_key configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
from pynostr.event import Event
|
||||
from pynostr.key import PrivateKey
|
||||
from pynostr.relay_manager import RelayManager
|
||||
|
||||
if self._private_key.startswith("nsec"):
|
||||
pk = PrivateKey.from_nsec(self._private_key)
|
||||
else:
|
||||
pk = PrivateKey(bytes.fromhex(self._private_key))
|
||||
|
||||
meta = metadata or {}
|
||||
kind = meta.get("kind", 1)
|
||||
|
||||
event = Event(
|
||||
content=content,
|
||||
kind=kind,
|
||||
pub_key=pk.public_key.hex(),
|
||||
)
|
||||
if channel and kind == 4:
|
||||
# Encrypted DM tag
|
||||
event.add_tag("p", channel)
|
||||
pk.sign_event(event)
|
||||
|
||||
relay_manager = RelayManager()
|
||||
for relay_url in self._relays:
|
||||
relay_manager.add_relay(relay_url)
|
||||
relay_manager.open_connections()
|
||||
relay_manager.publish_event(event)
|
||||
relay_manager.close_connections()
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("pynostr not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Nostr send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["nostr"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["NostrChannel"]
|
||||
@@ -0,0 +1,172 @@
|
||||
"""RedditChannel — Reddit adapter via praw."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("reddit")
|
||||
class RedditChannel(BaseChannel):
|
||||
"""Reddit messaging channel adapter.
|
||||
|
||||
Uses the Reddit API via ``praw`` (Python Reddit API Wrapper).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
client_id:
|
||||
Reddit app client ID. Falls back to ``REDDIT_CLIENT_ID`` env var.
|
||||
client_secret:
|
||||
Reddit app client secret. Falls back to ``REDDIT_CLIENT_SECRET`` env var.
|
||||
username:
|
||||
Reddit username. Falls back to ``REDDIT_USERNAME`` env var.
|
||||
password:
|
||||
Reddit password. Falls back to ``REDDIT_PASSWORD`` env var.
|
||||
user_agent:
|
||||
User-agent string for API requests. Falls back to
|
||||
``REDDIT_USER_AGENT`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "reddit"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str = "",
|
||||
*,
|
||||
client_secret: str = "",
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
user_agent: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._client_id = client_id or os.environ.get("REDDIT_CLIENT_ID", "")
|
||||
self._client_secret = client_secret or os.environ.get(
|
||||
"REDDIT_CLIENT_SECRET", ""
|
||||
)
|
||||
self._username = username or os.environ.get("REDDIT_USERNAME", "")
|
||||
self._password = password or os.environ.get("REDDIT_PASSWORD", "")
|
||||
self._user_agent = user_agent or os.environ.get(
|
||||
"REDDIT_USER_AGENT", "openjarvis:v1.0"
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
self._reddit: Any = None
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._client_id or not self._client_secret:
|
||||
logger.warning("No Reddit client_id or client_secret configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import praw # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"praw not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-reddit]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._reddit = None
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message or comment on Reddit.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Subreddit name (without ``r/`` prefix) or a Reddit thing ID to
|
||||
reply to.
|
||||
content:
|
||||
Text content for the submission or comment.
|
||||
"""
|
||||
if not self._client_id or not self._client_secret:
|
||||
logger.warning("Cannot send: no Reddit credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import praw
|
||||
|
||||
reddit = praw.Reddit(
|
||||
client_id=self._client_id,
|
||||
client_secret=self._client_secret,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
user_agent=self._user_agent,
|
||||
)
|
||||
|
||||
if conversation_id:
|
||||
# Reply to an existing comment/submission
|
||||
comment = reddit.comment(conversation_id)
|
||||
comment.reply(content)
|
||||
else:
|
||||
# Submit as a new text post to the subreddit
|
||||
subreddit = reddit.subreddit(channel)
|
||||
title = (metadata or {}).get("title", "OpenJarvis Message")
|
||||
subreddit.submit(title=title, selftext=content)
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("praw not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Reddit send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["reddit"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RedditChannel"]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""RocketChatChannel — Rocket.Chat adapter via rocketchat_API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("rocketchat")
|
||||
class RocketChatChannel(BaseChannel):
|
||||
"""Rocket.Chat messaging channel adapter.
|
||||
|
||||
Uses the Rocket.Chat REST API via ``rocketchat_API``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
url:
|
||||
Rocket.Chat server URL. Falls back to ``ROCKETCHAT_URL`` env var.
|
||||
user:
|
||||
Rocket.Chat username. Falls back to ``ROCKETCHAT_USER`` env var.
|
||||
password:
|
||||
Rocket.Chat password. Falls back to ``ROCKETCHAT_PASSWORD`` env var.
|
||||
auth_token:
|
||||
Rocket.Chat auth token (alternative to password). Falls back to
|
||||
``ROCKETCHAT_AUTH_TOKEN`` env var.
|
||||
user_id:
|
||||
Rocket.Chat user ID (used with auth_token). Falls back to
|
||||
``ROCKETCHAT_USER_ID`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "rocketchat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "",
|
||||
*,
|
||||
user: str = "",
|
||||
password: str = "",
|
||||
auth_token: str = "",
|
||||
user_id: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._url = url or os.environ.get("ROCKETCHAT_URL", "")
|
||||
self._user = user or os.environ.get("ROCKETCHAT_USER", "")
|
||||
self._password = password or os.environ.get("ROCKETCHAT_PASSWORD", "")
|
||||
self._auth_token = auth_token or os.environ.get("ROCKETCHAT_AUTH_TOKEN", "")
|
||||
self._user_id = user_id or os.environ.get("ROCKETCHAT_USER_ID", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._url:
|
||||
logger.warning("No Rocket.Chat URL configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
has_password_auth = self._user and self._password
|
||||
has_token_auth = self._auth_token and self._user_id
|
||||
if not has_password_auth and not has_token_auth:
|
||||
logger.warning("No Rocket.Chat credentials configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import rocketchat_API # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"rocketchat_API not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-rocketchat]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Rocket.Chat channel or DM.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Rocket.Chat channel name or room ID.
|
||||
content:
|
||||
Text message content.
|
||||
"""
|
||||
if not self._url:
|
||||
logger.warning("Cannot send: no Rocket.Chat URL configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
from rocketchat_API import RocketChat
|
||||
|
||||
if self._auth_token and self._user_id:
|
||||
rocket = RocketChat(
|
||||
server_url=self._url,
|
||||
auth_token=self._auth_token,
|
||||
user_id=self._user_id,
|
||||
)
|
||||
else:
|
||||
rocket = RocketChat(
|
||||
user=self._user,
|
||||
password=self._password,
|
||||
server_url=self._url,
|
||||
)
|
||||
|
||||
rocket.chat_post_message(content, channel=channel)
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("rocketchat_API not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Rocket.Chat send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["rocketchat"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RocketChatChannel"]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""TwitchChannel — Twitch chat adapter via twitchio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("twitch")
|
||||
class TwitchChannel(BaseChannel):
|
||||
"""Twitch chat messaging channel adapter.
|
||||
|
||||
Uses the Twitch IRC/EventSub API via ``twitchio``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
access_token:
|
||||
Twitch OAuth access token. Falls back to ``TWITCH_ACCESS_TOKEN``
|
||||
env var.
|
||||
client_id:
|
||||
Twitch application client ID. Falls back to ``TWITCH_CLIENT_ID``
|
||||
env var.
|
||||
nick:
|
||||
Bot nickname for IRC. Falls back to ``TWITCH_NICK`` env var.
|
||||
initial_channels:
|
||||
Comma-separated list of channels to join. Falls back to
|
||||
``TWITCH_CHANNELS`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "twitch"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str = "",
|
||||
*,
|
||||
client_id: str = "",
|
||||
nick: str = "",
|
||||
initial_channels: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._access_token = access_token or os.environ.get(
|
||||
"TWITCH_ACCESS_TOKEN", ""
|
||||
)
|
||||
self._client_id = client_id or os.environ.get("TWITCH_CLIENT_ID", "")
|
||||
self._nick = nick or os.environ.get("TWITCH_NICK", "")
|
||||
self._initial_channels = initial_channels or os.environ.get(
|
||||
"TWITCH_CHANNELS", ""
|
||||
)
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._access_token:
|
||||
logger.warning("No Twitch access_token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import twitchio # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"twitchio not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-twitch]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a chat message to a Twitch channel.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Twitch channel name (without ``#`` prefix).
|
||||
content:
|
||||
Chat message content.
|
||||
|
||||
Note
|
||||
----
|
||||
For send-only usage this uses the Twitch Helix API to send a chat
|
||||
message. A full interactive bot would use the twitchio event loop.
|
||||
"""
|
||||
if not self._access_token:
|
||||
logger.warning("Cannot send: no Twitch credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
url = "https://api.twitch.tv/helix/chat/messages"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._access_token}",
|
||||
"Client-Id": self._client_id,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"broadcaster_id": channel,
|
||||
"sender_id": self._nick,
|
||||
"message": content,
|
||||
}
|
||||
|
||||
resp = httpx.post(url, json=payload, headers=headers, timeout=10.0)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning("Twitch API returned status %d", resp.status_code)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Twitch send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["twitch"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TwitchChannel"]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""ViberChannel — Viber adapter via viberbot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("viber")
|
||||
class ViberChannel(BaseChannel):
|
||||
"""Viber messaging channel adapter.
|
||||
|
||||
Uses the Viber Bot API via ``viberbot``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
auth_token:
|
||||
Viber bot auth token. Falls back to ``VIBER_AUTH_TOKEN`` env var.
|
||||
name:
|
||||
Bot display name. Falls back to ``VIBER_BOT_NAME`` env var.
|
||||
avatar:
|
||||
Bot avatar URL. Falls back to ``VIBER_BOT_AVATAR`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "viber"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth_token: str = "",
|
||||
*,
|
||||
name: str = "",
|
||||
avatar: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._auth_token = auth_token or os.environ.get("VIBER_AUTH_TOKEN", "")
|
||||
self._name = name or os.environ.get("VIBER_BOT_NAME", "OpenJarvis")
|
||||
self._avatar = avatar or os.environ.get("VIBER_BOT_AVATAR", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._auth_token:
|
||||
logger.warning("No Viber auth_token configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import viberbot # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"viberbot not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-viber]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Viber user.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Viber user ID to send to.
|
||||
content:
|
||||
Text message content.
|
||||
"""
|
||||
if not self._auth_token:
|
||||
logger.warning("Cannot send: no Viber credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
from viberbot import Api, BotConfiguration
|
||||
from viberbot.api.messages.text_message import TextMessage
|
||||
|
||||
config = BotConfiguration(
|
||||
name=self._name,
|
||||
avatar=self._avatar,
|
||||
auth_token=self._auth_token,
|
||||
)
|
||||
api = Api(config)
|
||||
api.send_messages(channel, [TextMessage(text=content)])
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("viberbot not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Viber send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["viber"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ViberChannel"]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""XMPPChannel — XMPP/Jabber adapter via slixmpp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("xmpp")
|
||||
class XMPPChannel(BaseChannel):
|
||||
"""XMPP (Jabber) messaging channel adapter.
|
||||
|
||||
Uses the XMPP protocol via ``slixmpp``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
jid:
|
||||
XMPP JID (e.g. ``bot@example.com``). Falls back to ``XMPP_JID``
|
||||
env var.
|
||||
password:
|
||||
XMPP account password. Falls back to ``XMPP_PASSWORD`` env var.
|
||||
server:
|
||||
Optional XMPP server hostname override. Falls back to
|
||||
``XMPP_SERVER`` env var.
|
||||
port:
|
||||
XMPP server port (default 5222). Falls back to ``XMPP_PORT`` env var.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "xmpp"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jid: str = "",
|
||||
*,
|
||||
password: str = "",
|
||||
server: str = "",
|
||||
port: int = 5222,
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._jid = jid or os.environ.get("XMPP_JID", "")
|
||||
self._password = password or os.environ.get("XMPP_PASSWORD", "")
|
||||
self._server = server or os.environ.get("XMPP_SERVER", "")
|
||||
self._port = int(os.environ.get("XMPP_PORT", str(port)))
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._jid or not self._password:
|
||||
logger.warning("No XMPP jid or password configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import slixmpp # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"slixmpp not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-xmpp]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send an XMPP message.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Recipient JID (user or MUC room).
|
||||
content:
|
||||
Text message content.
|
||||
"""
|
||||
if not self._jid or not self._password:
|
||||
logger.warning("Cannot send: no XMPP credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import slixmpp
|
||||
|
||||
xmpp = slixmpp.ClientXMPP(self._jid, self._password)
|
||||
msg_type = (metadata or {}).get("type", "chat")
|
||||
|
||||
msg = xmpp.make_message(
|
||||
mto=channel,
|
||||
mbody=content,
|
||||
mtype=msg_type,
|
||||
)
|
||||
msg.send()
|
||||
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
except ImportError:
|
||||
logger.debug("slixmpp not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("XMPP send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["xmpp"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["XMPPChannel"]
|
||||
@@ -0,0 +1,170 @@
|
||||
"""ZulipChannel — Zulip adapter via zulip Python bindings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ChannelRegistry.register("zulip")
|
||||
class ZulipChannel(BaseChannel):
|
||||
"""Zulip messaging channel adapter.
|
||||
|
||||
Uses the Zulip API via the official ``zulip`` Python package.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
email:
|
||||
Zulip bot email address. Falls back to ``ZULIP_EMAIL`` env var.
|
||||
api_key:
|
||||
Zulip bot API key. Falls back to ``ZULIP_API_KEY`` env var.
|
||||
site:
|
||||
Zulip server URL (e.g. ``https://yourorg.zulipchat.com``).
|
||||
Falls back to ``ZULIP_SITE`` env var.
|
||||
zuliprc:
|
||||
Path to a ``~/.zuliprc`` config file. Falls back to ``ZULIP_RC``
|
||||
env var. If provided, ``email``/``api_key``/``site`` are ignored.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "zulip"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
email: str = "",
|
||||
*,
|
||||
api_key: str = "",
|
||||
site: str = "",
|
||||
zuliprc: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._email = email or os.environ.get("ZULIP_EMAIL", "")
|
||||
self._api_key = api_key or os.environ.get("ZULIP_API_KEY", "")
|
||||
self._site = site or os.environ.get("ZULIP_SITE", "")
|
||||
self._zuliprc = zuliprc or os.environ.get("ZULIP_RC", "")
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
has_explicit_creds = self._email and self._api_key and self._site
|
||||
if not has_explicit_creds and not self._zuliprc:
|
||||
logger.warning("No Zulip credentials or zuliprc configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
try:
|
||||
import zulip # noqa: F401
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"zulip not installed. Install with: "
|
||||
"pip install 'openjarvis[channel-zulip]'"
|
||||
)
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send a message to a Zulip stream or user.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Stream name for stream messages, or email address for direct
|
||||
messages.
|
||||
content:
|
||||
Message content (Markdown supported).
|
||||
"""
|
||||
try:
|
||||
import zulip
|
||||
|
||||
if self._zuliprc:
|
||||
client = zulip.Client(config_file=self._zuliprc)
|
||||
else:
|
||||
client = zulip.Client(
|
||||
email=self._email,
|
||||
api_key=self._api_key,
|
||||
site=self._site,
|
||||
)
|
||||
|
||||
meta = metadata or {}
|
||||
msg_type = meta.get("type", "stream")
|
||||
topic = meta.get("topic", "OpenJarvis")
|
||||
|
||||
request: Dict[str, Any] = {
|
||||
"type": msg_type,
|
||||
"content": content,
|
||||
}
|
||||
if msg_type == "stream":
|
||||
request["to"] = channel
|
||||
request["topic"] = topic
|
||||
else:
|
||||
# Direct / private message
|
||||
request["to"] = [channel]
|
||||
|
||||
result = client.send_message(request)
|
||||
if result.get("result") == "success":
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning("Zulip API returned: %s", result.get("msg", ""))
|
||||
return False
|
||||
except ImportError:
|
||||
logger.debug("zulip not installed")
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("Zulip send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["zulip"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(self, channel: str, content: str, conversation_id: str) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ZulipChannel"]
|
||||
@@ -5,16 +5,23 @@ from __future__ import annotations
|
||||
import click
|
||||
|
||||
import openjarvis
|
||||
from openjarvis.cli.add_cmd import add
|
||||
from openjarvis.cli.agent_cmd import agent
|
||||
from openjarvis.cli.ask import ask
|
||||
from openjarvis.cli.bench_cmd import bench
|
||||
from openjarvis.cli.channel_cmd import channel
|
||||
from openjarvis.cli.chat_cmd import chat
|
||||
from openjarvis.cli.daemon_cmd import restart, start, status, stop
|
||||
from openjarvis.cli.doctor_cmd import doctor
|
||||
from openjarvis.cli.init_cmd import init
|
||||
from openjarvis.cli.memory_cmd import memory
|
||||
from openjarvis.cli.model import model
|
||||
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.vault_cmd import vault
|
||||
from openjarvis.cli.workflow_cmd import workflow
|
||||
|
||||
|
||||
@click.group(help="OpenJarvis — modular AI assistant backend")
|
||||
@@ -25,6 +32,7 @@ def cli() -> None:
|
||||
|
||||
cli.add_command(init, "init")
|
||||
cli.add_command(ask, "ask")
|
||||
cli.add_command(chat, "chat")
|
||||
cli.add_command(serve, "serve")
|
||||
cli.add_command(model, "model")
|
||||
cli.add_command(memory, "memory")
|
||||
@@ -33,6 +41,15 @@ cli.add_command(bench, "bench")
|
||||
cli.add_command(channel, "channel")
|
||||
cli.add_command(scheduler, "scheduler")
|
||||
cli.add_command(doctor, "doctor")
|
||||
cli.add_command(agent, "agent")
|
||||
cli.add_command(workflow, "workflow")
|
||||
cli.add_command(skill, "skill")
|
||||
cli.add_command(start, "start")
|
||||
cli.add_command(stop, "stop")
|
||||
cli.add_command(restart, "restart")
|
||||
cli.add_command(status, "status")
|
||||
cli.add_command(vault, "vault")
|
||||
cli.add_command(add, "add")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""``jarvis add`` — quick MCP server setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
|
||||
_MCP_CONFIG_DIR = DEFAULT_CONFIG_DIR / "mcp"
|
||||
|
||||
|
||||
# Known MCP server templates
|
||||
_MCP_TEMPLATES = {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env_key": "GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"description": "GitHub API (repos, issues, PRs)",
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
|
||||
"env_key": None,
|
||||
"description": "Local filesystem operations",
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env_key": "SLACK_BOT_TOKEN",
|
||||
"description": "Slack workspace integration",
|
||||
},
|
||||
"postgres": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-postgres"],
|
||||
"env_key": "POSTGRES_CONNECTION_STRING",
|
||||
"description": "PostgreSQL database",
|
||||
},
|
||||
"brave-search": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env_key": "BRAVE_API_KEY",
|
||||
"description": "Brave web search",
|
||||
},
|
||||
"memory": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"],
|
||||
"env_key": None,
|
||||
"description": "Knowledge graph memory",
|
||||
},
|
||||
"puppeteer": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||
"env_key": None,
|
||||
"description": "Browser automation via Puppeteer",
|
||||
},
|
||||
"google-maps": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
|
||||
"env_key": "GOOGLE_MAPS_API_KEY",
|
||||
"description": "Google Maps API",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("server_name")
|
||||
@click.option("--key", default=None, help="API key or token for the server.")
|
||||
@click.option(
|
||||
"--args", "extra_args", default=None,
|
||||
help="Additional arguments (comma-separated).",
|
||||
)
|
||||
def add(server_name: str, key: str | None, extra_args: str | None) -> None:
|
||||
"""Add an MCP server configuration.
|
||||
|
||||
Quick setup for common MCP servers:
|
||||
|
||||
jarvis add github --key TOKEN
|
||||
jarvis add filesystem
|
||||
jarvis add slack --key TOKEN
|
||||
|
||||
Known servers: github, filesystem, slack, postgres, brave-search,
|
||||
memory, puppeteer, google-maps
|
||||
"""
|
||||
console = Console(stderr=True)
|
||||
|
||||
template = _MCP_TEMPLATES.get(server_name)
|
||||
if template is None:
|
||||
console.print(f"[red]Unknown MCP server: {server_name}[/red]")
|
||||
console.print("[dim]Known servers:[/dim]")
|
||||
for name, tmpl in _MCP_TEMPLATES.items():
|
||||
console.print(f" [cyan]{name}[/cyan] — {tmpl['description']}")
|
||||
sys.exit(1)
|
||||
|
||||
# Build server config
|
||||
config = {
|
||||
"command": template["command"],
|
||||
"args": list(template["args"]),
|
||||
}
|
||||
|
||||
# Add extra args
|
||||
if extra_args:
|
||||
config["args"].extend(a.strip() for a in extra_args.split(","))
|
||||
|
||||
# Handle API key
|
||||
env = {}
|
||||
env_key = template["env_key"]
|
||||
if env_key:
|
||||
if key:
|
||||
env[env_key] = key
|
||||
else:
|
||||
console.print(
|
||||
f"[yellow]Tip: Pass --key to set {env_key},"
|
||||
" or set it as an environment variable.[/yellow]"
|
||||
)
|
||||
if env:
|
||||
config["env"] = env
|
||||
|
||||
# Save to MCP config dir
|
||||
_MCP_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
config_file = _MCP_CONFIG_DIR / f"{server_name}.json"
|
||||
config_file.write_text(json.dumps(config, indent=2))
|
||||
|
||||
console.print(
|
||||
f"[green]Added MCP server: {server_name}[/green]\n"
|
||||
f" Config: {config_file}\n"
|
||||
f" Description: {template['description']}"
|
||||
)
|
||||
if env_key and not key:
|
||||
console.print(f" [dim]Set {env_key} env var or re-run with --key[/dim]")
|
||||
|
||||
|
||||
__all__ = ["add"]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""``jarvis agent`` — agent lifecycle management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
@click.group()
|
||||
def agent() -> None:
|
||||
"""Manage agents — list, spawn, kill, info."""
|
||||
|
||||
|
||||
@agent.command("list")
|
||||
def list_agents() -> None:
|
||||
"""List available agent types and running agents."""
|
||||
console = Console(stderr=True)
|
||||
|
||||
# List registered agent types
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401 -- trigger registration
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
table = Table(title="Registered Agent Types")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Class", style="green")
|
||||
table.add_column("Accepts Tools", style="yellow")
|
||||
|
||||
for key in sorted(AgentRegistry.keys()):
|
||||
cls = AgentRegistry.get(key)
|
||||
accepts = "Yes" if getattr(cls, "accepts_tools", False) else "No"
|
||||
table.add_row(key, cls.__name__, accepts)
|
||||
|
||||
console.print(table)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error listing agents: {exc}[/red]")
|
||||
|
||||
# List running agents from agent_tools if available
|
||||
try:
|
||||
from openjarvis.tools.agent_tools import _SPAWNED_AGENTS
|
||||
|
||||
if _SPAWNED_AGENTS:
|
||||
table2 = Table(title="Running Agents")
|
||||
table2.add_column("ID", style="cyan")
|
||||
table2.add_column("Type", style="green")
|
||||
table2.add_column("Status", style="yellow")
|
||||
for aid, info in _SPAWNED_AGENTS.items():
|
||||
table2.add_row(aid, info.get("agent_type", ""), info.get("status", ""))
|
||||
console.print(table2)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
@agent.command()
|
||||
@click.argument("agent_key")
|
||||
def info(agent_key: str) -> None:
|
||||
"""Show detailed info about an agent type."""
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401 -- trigger registration
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
if not AgentRegistry.contains(agent_key):
|
||||
console.print(f"[red]Unknown agent: {agent_key}[/red]")
|
||||
return
|
||||
|
||||
cls = AgentRegistry.get(agent_key)
|
||||
console.print(f"[bold]{agent_key}[/bold] — {cls.__name__}")
|
||||
console.print(f" Module: {cls.__module__}")
|
||||
console.print(f" Accepts tools: {getattr(cls, 'accepts_tools', False)}")
|
||||
if cls.__doc__:
|
||||
console.print(f" Description: {cls.__doc__.strip().splitlines()[0]}")
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
|
||||
|
||||
__all__ = ["agent"]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""``jarvis chat`` — interactive multi-turn chat REPL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
|
||||
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
||||
"""Read user input with graceful EOF handling."""
|
||||
try:
|
||||
return input(prompt)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
|
||||
@click.option("-m", "--model", "model_name", default=None, help="Model to use.")
|
||||
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
|
||||
@click.option("--tools", default=None, help="Comma-separated tool names.")
|
||||
@click.option("--system", "system_prompt", default=None, help="Custom system prompt.")
|
||||
def chat(
|
||||
engine_key: str | None,
|
||||
model_name: str | None,
|
||||
agent_name: str | None,
|
||||
tools: str | None,
|
||||
system_prompt: str | None,
|
||||
) -> None:
|
||||
"""Start an interactive multi-turn chat session.
|
||||
|
||||
Commands during chat:
|
||||
/quit, /exit — end session
|
||||
/clear — clear conversation history
|
||||
/model — show current model
|
||||
/help — show available commands
|
||||
/history — show conversation history
|
||||
"""
|
||||
console = Console(stderr=True)
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Resolve engine
|
||||
from openjarvis.engine import get_engine
|
||||
from openjarvis.intelligence import register_builtin_models
|
||||
|
||||
register_builtin_models()
|
||||
|
||||
resolved = get_engine(config, engine_key)
|
||||
if resolved is None:
|
||||
console.print("[red]No inference engine available.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
engine_name, engine = resolved
|
||||
model = model_name or config.intelligence.default_model
|
||||
if not model:
|
||||
from openjarvis.engine import discover_engines, discover_models
|
||||
|
||||
all_engines = discover_engines(config)
|
||||
all_models = discover_models(all_engines)
|
||||
engine_models = all_models.get(engine_name, [])
|
||||
if engine_models:
|
||||
model = engine_models[0]
|
||||
else:
|
||||
console.print("[red]No model available.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# Resolve agent (optional)
|
||||
agent = None
|
||||
agent_key = agent_name or config.agent.default_agent
|
||||
if agent_key and agent_key != "none":
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401 — trigger registration
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
if AgentRegistry.contains(agent_key):
|
||||
agent_cls = AgentRegistry.get(agent_key)
|
||||
kwargs: dict = {"bus": EventBus()}
|
||||
|
||||
if getattr(agent_cls, "accepts_tools", False) and tools:
|
||||
import openjarvis.tools # noqa: F401 — trigger registration
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.tools._stubs import BaseTool
|
||||
|
||||
tool_instances = []
|
||||
for tname in tools.split(","):
|
||||
tname = tname.strip()
|
||||
if ToolRegistry.contains(tname):
|
||||
tcls = ToolRegistry.get(tname)
|
||||
if isinstance(tcls, type) and issubclass(
|
||||
tcls, BaseTool
|
||||
):
|
||||
tool_instances.append(tcls())
|
||||
elif isinstance(tcls, BaseTool):
|
||||
tool_instances.append(tcls)
|
||||
if tool_instances:
|
||||
kwargs["tools"] = tool_instances
|
||||
kwargs["max_turns"] = config.agent.max_turns
|
||||
|
||||
agent = agent_cls(engine, model, **kwargs)
|
||||
except Exception as exc:
|
||||
console.print(f"[yellow]Agent '{agent_key}' failed: {exc}[/yellow]")
|
||||
|
||||
# Print banner
|
||||
console.print(
|
||||
f"[green bold]OpenJarvis Chat[/green bold]\n"
|
||||
f" Engine: [cyan]{engine_name}[/cyan] Model: [cyan]{model}[/cyan]"
|
||||
f" Agent: [cyan]{agent_key or 'direct'}[/cyan]\n"
|
||||
f" Type /help for commands, /quit to exit.\n"
|
||||
)
|
||||
|
||||
# Conversation state
|
||||
history: List[Message] = []
|
||||
if system_prompt:
|
||||
history.append(Message(role=Role.SYSTEM, content=system_prompt))
|
||||
|
||||
# REPL loop
|
||||
while True:
|
||||
user_input = _read_input()
|
||||
if user_input is None:
|
||||
console.print("\n[dim]Goodbye![/dim]")
|
||||
break
|
||||
|
||||
user_input = user_input.strip()
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# Handle slash commands
|
||||
cmd = user_input.lower()
|
||||
if cmd in ("/quit", "/exit", "/q"):
|
||||
console.print("[dim]Goodbye![/dim]")
|
||||
break
|
||||
elif cmd == "/clear":
|
||||
history = []
|
||||
if system_prompt:
|
||||
history.append(Message(role=Role.SYSTEM, content=system_prompt))
|
||||
console.print("[dim]History cleared.[/dim]")
|
||||
continue
|
||||
elif cmd == "/model":
|
||||
console.print(
|
||||
f"Model: [cyan]{model}[/cyan] "
|
||||
f"Engine: [cyan]{engine_name}[/cyan]"
|
||||
)
|
||||
continue
|
||||
elif cmd == "/help":
|
||||
console.print(
|
||||
"[bold]Commands:[/bold]\n"
|
||||
" /quit, /exit — end session\n"
|
||||
" /clear — clear conversation\n"
|
||||
" /model — show model info\n"
|
||||
" /history — show conversation\n"
|
||||
" /help — this message"
|
||||
)
|
||||
continue
|
||||
elif cmd == "/history":
|
||||
if not history:
|
||||
console.print("[dim]No history yet.[/dim]")
|
||||
else:
|
||||
for msg in history:
|
||||
role_str = msg.role if isinstance(msg.role, str) else msg.role.value
|
||||
role = role_str.upper()
|
||||
console.print(
|
||||
f"[bold]{role}:[/bold] {msg.content[:200]}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Add user message
|
||||
history.append(Message(role=Role.USER, content=user_input))
|
||||
|
||||
# Generate response
|
||||
try:
|
||||
if agent is not None:
|
||||
response = agent.run(user_input)
|
||||
content = (
|
||||
response.content
|
||||
if hasattr(response, "content")
|
||||
else str(response)
|
||||
)
|
||||
else:
|
||||
result = engine.generate(history, model=model)
|
||||
content = (
|
||||
result.get("content", "")
|
||||
if isinstance(result, dict)
|
||||
else str(result)
|
||||
)
|
||||
|
||||
history.append(Message(role=Role.ASSISTANT, content=content))
|
||||
console.print()
|
||||
console.print(Markdown(content))
|
||||
console.print()
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[dim]Generation interrupted.[/dim]")
|
||||
except Exception as exc:
|
||||
console.print(f"\n[red]Error: {exc}[/red]\n")
|
||||
|
||||
|
||||
__all__ = ["chat"]
|
||||
@@ -0,0 +1,175 @@
|
||||
"""``jarvis start|stop|restart|status`` — daemon management commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR, load_config
|
||||
|
||||
_PID_FILE = DEFAULT_CONFIG_DIR / "server.pid"
|
||||
_LOG_FILE = DEFAULT_CONFIG_DIR / "server.log"
|
||||
|
||||
|
||||
def _read_pid() -> int | None:
|
||||
"""Read PID from pid file, return None if not found or stale."""
|
||||
if not _PID_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
pid = int(_PID_FILE.read_text().strip())
|
||||
# Check if process is still running
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (ValueError, OSError):
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
|
||||
def _write_pid(pid: int) -> None:
|
||||
"""Write PID to pid file."""
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_PID_FILE.write_text(str(pid))
|
||||
|
||||
|
||||
@click.group()
|
||||
def daemon() -> None:
|
||||
"""Manage the OpenJarvis server daemon."""
|
||||
|
||||
|
||||
@daemon.command()
|
||||
@click.option("--host", default=None, help="Bind address.")
|
||||
@click.option("--port", default=None, type=int, help="Port number.")
|
||||
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
|
||||
@click.option("-m", "--model", "model_name", default=None, help="Default model.")
|
||||
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
|
||||
def start(
|
||||
host: str | None,
|
||||
port: int | None,
|
||||
engine_key: str | None,
|
||||
model_name: str | None,
|
||||
agent_name: str | None,
|
||||
) -> None:
|
||||
"""Start the OpenJarvis server as a background daemon."""
|
||||
console = Console(stderr=True)
|
||||
|
||||
existing = _read_pid()
|
||||
if existing is not None:
|
||||
console.print(f"[yellow]Server already running (PID {existing}).[/yellow]")
|
||||
console.print("Use 'jarvis stop' to stop it first, or 'jarvis restart'.")
|
||||
sys.exit(1)
|
||||
|
||||
config = load_config()
|
||||
bind_host = host or config.server.host
|
||||
bind_port = port or config.server.port
|
||||
|
||||
# Build command to run jarvis serve
|
||||
cmd = [sys.executable, "-m", "openjarvis.cli", "serve"]
|
||||
if host:
|
||||
cmd.extend(["--host", host])
|
||||
if port:
|
||||
cmd.extend(["--port", str(port)])
|
||||
if engine_key:
|
||||
cmd.extend(["--engine", engine_key])
|
||||
if model_name:
|
||||
cmd.extend(["--model", model_name])
|
||||
if agent_name:
|
||||
cmd.extend(["--agent", agent_name])
|
||||
|
||||
# Start as background process
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_fh = open(_LOG_FILE, "a") # noqa: SIM115
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
start_new_session=True,
|
||||
)
|
||||
_write_pid(proc.pid)
|
||||
|
||||
console.print(
|
||||
f"[green]OpenJarvis server started[/green] (PID {proc.pid})\n"
|
||||
f" URL: http://{bind_host}:{bind_port}\n"
|
||||
f" Log: {_LOG_FILE}"
|
||||
)
|
||||
|
||||
|
||||
@daemon.command()
|
||||
def stop() -> None:
|
||||
"""Stop the running OpenJarvis server daemon."""
|
||||
console = Console(stderr=True)
|
||||
pid = _read_pid()
|
||||
if pid is None:
|
||||
console.print("[yellow]No running server found.[/yellow]")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
# Wait up to 10 seconds for graceful shutdown
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
break
|
||||
else:
|
||||
# Force kill if still running
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
console.print(f"[green]Server stopped[/green] (PID {pid}).")
|
||||
|
||||
|
||||
@daemon.command()
|
||||
@click.pass_context
|
||||
def restart(ctx: click.Context) -> None:
|
||||
"""Restart the OpenJarvis server daemon."""
|
||||
console = Console(stderr=True)
|
||||
pid = _read_pid()
|
||||
if pid is not None:
|
||||
console.print(f"Stopping server (PID {pid})...")
|
||||
ctx.invoke(stop)
|
||||
ctx.invoke(start)
|
||||
|
||||
|
||||
@daemon.command()
|
||||
def status() -> None:
|
||||
"""Show status of the OpenJarvis server daemon."""
|
||||
console = Console(stderr=True)
|
||||
pid = _read_pid()
|
||||
if pid is None:
|
||||
console.print("[yellow]Server is not running.[/yellow]")
|
||||
return
|
||||
|
||||
# Get process info
|
||||
uptime_info = ""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
uptime = time.time() - proc.create_time()
|
||||
hours, remainder = divmod(int(uptime), 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
uptime_info = f"\n Uptime: {hours}h {minutes}m {seconds}s"
|
||||
except (ImportError, Exception):
|
||||
pass
|
||||
|
||||
config = load_config()
|
||||
console.print(
|
||||
f"[green]Server is running[/green] (PID {pid}){uptime_info}\n"
|
||||
f" URL: http://{config.server.host}:{config.server.port}\n"
|
||||
f" Log: {_LOG_FILE}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["daemon", "start", "stop", "restart", "status"]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""TUI dashboard — terminal-based system monitoring via textual."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class DashboardApp:
|
||||
"""Terminal dashboard for OpenJarvis monitoring.
|
||||
|
||||
Panels:
|
||||
- System status (engine health, model, memory backend)
|
||||
- Live EventBus event stream
|
||||
- Telemetry metrics (throughput, latency, energy)
|
||||
- Agent activity (current runs, tool calls)
|
||||
- Session list (active sessions, last activity)
|
||||
|
||||
Requires: ``pip install openjarvis[dashboard]``
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Any] = None) -> None:
|
||||
self._config = config
|
||||
self._events: List[Dict[str, Any]] = []
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
"""Check if textual is available."""
|
||||
try:
|
||||
import textual # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def run(self) -> None:
|
||||
"""Launch the TUI dashboard."""
|
||||
try:
|
||||
from textual.app import App, ComposeResult # noqa: F401
|
||||
from textual.containers import Container, Horizontal, Vertical # noqa: F401
|
||||
from textual.reactive import reactive # noqa: F401
|
||||
from textual.widgets import ( # noqa: F401
|
||||
DataTable,
|
||||
Footer,
|
||||
Header,
|
||||
Log,
|
||||
Static,
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"TUI dashboard requires 'textual'. "
|
||||
"Install with: pip install openjarvis[dashboard]"
|
||||
)
|
||||
|
||||
class JarvisDashboard(App):
|
||||
"""OpenJarvis TUI Dashboard."""
|
||||
|
||||
TITLE = "OpenJarvis Dashboard"
|
||||
CSS_PATH = None
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: grid;
|
||||
grid-size: 2 2;
|
||||
grid-gutter: 1;
|
||||
}
|
||||
.panel {
|
||||
border: solid green;
|
||||
padding: 1;
|
||||
}
|
||||
#status-panel { row-span: 1; }
|
||||
#events-panel { row-span: 1; }
|
||||
#telemetry-panel { row-span: 1; }
|
||||
#agent-panel { row-span: 1; }
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static(
|
||||
"System Status\n"
|
||||
"─────────────\n"
|
||||
"Engine: checking...\n"
|
||||
"Model: checking...\n"
|
||||
"Memory: checking...",
|
||||
id="status-panel",
|
||||
classes="panel",
|
||||
)
|
||||
yield Log(
|
||||
id="events-panel", classes="panel",
|
||||
)
|
||||
yield Static(
|
||||
"Telemetry\n"
|
||||
"─────────\n"
|
||||
"Throughput: --\n"
|
||||
"Latency: --\n"
|
||||
"Energy: --",
|
||||
id="telemetry-panel",
|
||||
classes="panel",
|
||||
)
|
||||
yield Static(
|
||||
"Agent Activity\n"
|
||||
"──────────────\n"
|
||||
"No active agents.",
|
||||
id="agent-panel",
|
||||
classes="panel",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
events_log = self.query_one("#events-panel", Log)
|
||||
events_log.write_line("Event stream started...")
|
||||
|
||||
# Try to connect to event bus
|
||||
try:
|
||||
from openjarvis.core.events import get_event_bus
|
||||
bus = get_event_bus()
|
||||
|
||||
def _on_event(event: Any) -> None:
|
||||
try:
|
||||
events_log.write_line(
|
||||
f"[{event.event_type.value}] {event.data}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from openjarvis.core.events import EventType
|
||||
for et in EventType:
|
||||
bus.subscribe(et, _on_event)
|
||||
except Exception:
|
||||
events_log.write_line("Could not connect to event bus.")
|
||||
|
||||
# Update status
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
status = self.query_one("#status-panel", Static)
|
||||
lines = ["System Status", "─────────────"]
|
||||
try:
|
||||
from openjarvis.core.config import load_config
|
||||
config = load_config()
|
||||
lines.append(
|
||||
f"Engine: {config.engine.default}"
|
||||
)
|
||||
model = (
|
||||
config.intelligence.default_model
|
||||
or 'auto'
|
||||
)
|
||||
lines.append(f"Model: {model}")
|
||||
backend = (
|
||||
config.tools.storage.default_backend
|
||||
)
|
||||
lines.append(f"Memory: {backend}")
|
||||
sec = (
|
||||
'enabled'
|
||||
if config.security.enabled
|
||||
else 'disabled'
|
||||
)
|
||||
lines.append(f"Security: {sec}")
|
||||
tel = (
|
||||
'enabled'
|
||||
if config.telemetry.enabled
|
||||
else 'disabled'
|
||||
)
|
||||
lines.append(f"Telemetry: {tel}")
|
||||
except Exception:
|
||||
lines.append("Config: not loaded")
|
||||
status.update("\n".join(lines))
|
||||
|
||||
app = JarvisDashboard()
|
||||
app.run()
|
||||
|
||||
|
||||
def launch_dashboard(config: Optional[Any] = None) -> None:
|
||||
"""Convenience function to launch the dashboard."""
|
||||
app = DashboardApp(config=config)
|
||||
if not app.available():
|
||||
raise ImportError(
|
||||
"TUI dashboard requires 'textual'. "
|
||||
"Install with: pip install openjarvis[dashboard]"
|
||||
)
|
||||
app.run()
|
||||
|
||||
|
||||
__all__ = ["DashboardApp", "launch_dashboard"]
|
||||
@@ -92,6 +92,19 @@ def _next_steps_text(engine: str) -> str:
|
||||
"\n"
|
||||
" Run `jarvis doctor` to verify your setup."
|
||||
),
|
||||
"lmstudio": (
|
||||
"Next steps:\n"
|
||||
"\n"
|
||||
" 1. Download LM Studio:\n"
|
||||
" https://lmstudio.ai\n"
|
||||
"\n"
|
||||
" 2. Load a model and start the local server (port 1234)\n"
|
||||
"\n"
|
||||
" 3. Try it out:\n"
|
||||
" jarvis ask \"Hello\"\n"
|
||||
"\n"
|
||||
" Run `jarvis doctor` to verify your setup."
|
||||
),
|
||||
}
|
||||
return steps.get(engine, steps["ollama"])
|
||||
|
||||
|
||||
@@ -117,8 +117,7 @@ def serve(
|
||||
agent_kwargs = {"bus": bus}
|
||||
|
||||
# Load tools for agents that support them
|
||||
tools_agents = ("orchestrator", "react", "openhands")
|
||||
if agent_key in tools_agents:
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
import openjarvis.tools # noqa: F401 # trigger registration
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.tools._stubs import BaseTool
|
||||
@@ -133,7 +132,7 @@ def serve(
|
||||
if tools:
|
||||
agent_kwargs["tools"] = tools
|
||||
|
||||
if agent_key in ("orchestrator", "react", "openhands"):
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
agent_kwargs["max_turns"] = config.agent.max_turns
|
||||
|
||||
agent = agent_cls(engine, model_name, **agent_kwargs)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""``jarvis skill`` — skill management commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
@click.group()
|
||||
def skill() -> None:
|
||||
"""Manage skills — list, install, remove."""
|
||||
|
||||
|
||||
@skill.command("list")
|
||||
def list_skills() -> None:
|
||||
"""List installed skills."""
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
from openjarvis.core.registry import SkillRegistry
|
||||
|
||||
keys = sorted(SkillRegistry.keys())
|
||||
if not keys:
|
||||
console.print("[dim]No skills installed.[/dim]")
|
||||
return
|
||||
table = Table(title="Installed Skills")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Description", style="green")
|
||||
for key in keys:
|
||||
skill_cls = SkillRegistry.get(key)
|
||||
desc = ""
|
||||
if hasattr(skill_cls, "manifest"):
|
||||
m = skill_cls.manifest if not callable(skill_cls.manifest) else None
|
||||
if m and hasattr(m, "description"):
|
||||
desc = m.description[:60]
|
||||
table.add_row(key, desc)
|
||||
console.print(table)
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
|
||||
|
||||
@skill.command()
|
||||
@click.argument("skill_name")
|
||||
def install(skill_name: str) -> None:
|
||||
"""Install a skill from the bundled library."""
|
||||
console = Console(stderr=True)
|
||||
console.print(f"[yellow]Installing skill: {skill_name}[/yellow]")
|
||||
# Skills are discovered from TOML files — point user to the right place
|
||||
console.print(
|
||||
f"[dim]Place skill TOML file in ~/.openjarvis/skills/{skill_name}.toml[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@skill.command()
|
||||
@click.argument("skill_name")
|
||||
def remove(skill_name: str) -> None:
|
||||
"""Remove an installed skill."""
|
||||
console = Console(stderr=True)
|
||||
console.print(f"[yellow]Removing skill: {skill_name}[/yellow]")
|
||||
console.print("[dim]Skill removal not yet implemented.[/dim]")
|
||||
|
||||
|
||||
@skill.command()
|
||||
@click.argument("query", default="")
|
||||
def search(query: str) -> None:
|
||||
"""Search for available skills."""
|
||||
console = Console(stderr=True)
|
||||
if not query:
|
||||
console.print("[dim]Provide a search query.[/dim]")
|
||||
return
|
||||
console.print(f"[dim]Searching for skills matching '{query}'...[/dim]")
|
||||
console.print("[dim]Skill search not yet implemented.[/dim]")
|
||||
|
||||
|
||||
__all__ = ["skill"]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""``jarvis vault`` — encrypted credential store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
|
||||
_VAULT_FILE = DEFAULT_CONFIG_DIR / "vault.enc"
|
||||
_VAULT_KEY_FILE = DEFAULT_CONFIG_DIR / ".vault_key"
|
||||
|
||||
|
||||
def _get_or_create_key() -> bytes:
|
||||
"""Get or create a Fernet encryption key."""
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"cryptography not installed. Install with: "
|
||||
"pip install 'openjarvis[security-signing]'"
|
||||
)
|
||||
|
||||
if _VAULT_KEY_FILE.exists():
|
||||
return _VAULT_KEY_FILE.read_bytes().strip()
|
||||
|
||||
key = Fernet.generate_key()
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_VAULT_KEY_FILE.write_bytes(key)
|
||||
_VAULT_KEY_FILE.chmod(0o600)
|
||||
return key
|
||||
|
||||
|
||||
def _load_vault() -> dict:
|
||||
"""Load and decrypt vault contents."""
|
||||
if not _VAULT_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
key = _get_or_create_key()
|
||||
f = Fernet(key)
|
||||
encrypted = _VAULT_FILE.read_bytes()
|
||||
decrypted = f.decrypt(encrypted)
|
||||
return json.loads(decrypted.decode())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_vault(data: dict) -> None:
|
||||
"""Encrypt and save vault contents."""
|
||||
from cryptography.fernet import Fernet
|
||||
key = _get_or_create_key()
|
||||
f = Fernet(key)
|
||||
plaintext = json.dumps(data).encode()
|
||||
encrypted = f.encrypt(plaintext)
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_VAULT_FILE.write_bytes(encrypted)
|
||||
_VAULT_FILE.chmod(0o600)
|
||||
|
||||
|
||||
@click.group()
|
||||
def vault() -> None:
|
||||
"""Manage encrypted credentials."""
|
||||
|
||||
|
||||
@vault.command("set")
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def vault_set(key: str, value: str) -> None:
|
||||
"""Store a credential in the vault."""
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
data = _load_vault()
|
||||
data[key] = value
|
||||
_save_vault(data)
|
||||
console.print(f"[green]Stored credential: {key}[/green]")
|
||||
except ImportError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@vault.command("get")
|
||||
@click.argument("key")
|
||||
def vault_get(key: str) -> None:
|
||||
"""Retrieve a credential from the vault."""
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
data = _load_vault()
|
||||
if key in data:
|
||||
console.print(data[key])
|
||||
else:
|
||||
console.print(f"[yellow]Key not found: {key}[/yellow]")
|
||||
except ImportError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@vault.command("list")
|
||||
def vault_list() -> None:
|
||||
"""List all stored credential keys."""
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
data = _load_vault()
|
||||
if not data:
|
||||
console.print("[dim]Vault is empty.[/dim]")
|
||||
return
|
||||
table = Table(title="Vault Keys")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value Preview", style="dim")
|
||||
for k, v in sorted(data.items()):
|
||||
preview = v[:4] + "****" if len(v) > 4 else "****"
|
||||
table.add_row(k, preview)
|
||||
console.print(table)
|
||||
except ImportError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
|
||||
|
||||
@vault.command("remove")
|
||||
@click.argument("key")
|
||||
def vault_remove(key: str) -> None:
|
||||
"""Remove a credential from the vault."""
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
data = _load_vault()
|
||||
if key in data:
|
||||
del data[key]
|
||||
_save_vault(data)
|
||||
console.print(f"[green]Removed: {key}[/green]")
|
||||
else:
|
||||
console.print(f"[yellow]Key not found: {key}[/yellow]")
|
||||
except ImportError as exc:
|
||||
console.print(f"[red]{exc}[/red]")
|
||||
|
||||
|
||||
__all__ = ["vault"]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""``jarvis workflow`` — workflow management commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
|
||||
@click.group()
|
||||
def workflow() -> None:
|
||||
"""Manage workflows — list, run, status."""
|
||||
|
||||
|
||||
@workflow.command("list")
|
||||
def list_workflows() -> None:
|
||||
"""List available workflow definitions."""
|
||||
console = Console(stderr=True)
|
||||
try:
|
||||
from openjarvis.workflow.loader import discover_workflows
|
||||
|
||||
workflows = discover_workflows()
|
||||
if not workflows:
|
||||
console.print("[dim]No workflows found.[/dim]")
|
||||
return
|
||||
table = Table(title="Workflows")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Nodes", style="green")
|
||||
for name, wf in workflows.items():
|
||||
table.add_row(name, str(len(wf.nodes) if hasattr(wf, "nodes") else "?"))
|
||||
console.print(table)
|
||||
except ImportError:
|
||||
console.print("[dim]No workflows found. Define workflows in TOML files.[/dim]")
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
|
||||
|
||||
@workflow.command()
|
||||
@click.argument("workflow_name")
|
||||
@click.option("--input", "input_text", default=None, help="Input text for workflow.")
|
||||
def run(workflow_name: str, input_text: str | None) -> None:
|
||||
"""Run a workflow by name."""
|
||||
console = Console(stderr=True)
|
||||
console.print(f"[yellow]Running workflow: {workflow_name}[/yellow]")
|
||||
try:
|
||||
from openjarvis.workflow.loader import discover_workflows
|
||||
|
||||
workflows = discover_workflows()
|
||||
if workflow_name not in workflows:
|
||||
console.print(f"[red]Workflow '{workflow_name}' not found.[/red]")
|
||||
return
|
||||
console.print(f"[green]Workflow '{workflow_name}' started.[/green]")
|
||||
# Full execution would need a JarvisSystem — just report for now
|
||||
console.print(
|
||||
"[dim]Note: Full workflow execution"
|
||||
" requires a running system.[/dim]"
|
||||
)
|
||||
except ImportError:
|
||||
console.print("[red]Workflow system not available.[/red]")
|
||||
except Exception as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
|
||||
|
||||
@workflow.command()
|
||||
def status() -> None:
|
||||
"""Show status of running workflows."""
|
||||
console = Console(stderr=True)
|
||||
console.print("[dim]No workflows currently running.[/dim]")
|
||||
|
||||
|
||||
__all__ = ["workflow"]
|
||||
@@ -250,6 +250,13 @@ class MLXEngineConfig:
|
||||
host: str = "http://localhost:8080"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LMStudioEngineConfig:
|
||||
"""Per-engine config for LM Studio."""
|
||||
|
||||
host: str = "http://localhost:1234"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineConfig:
|
||||
"""Inference engine settings with nested per-engine configs."""
|
||||
@@ -260,6 +267,7 @@ class EngineConfig:
|
||||
sglang: SGLangEngineConfig = field(default_factory=SGLangEngineConfig)
|
||||
llamacpp: LlamaCppEngineConfig = field(default_factory=LlamaCppEngineConfig)
|
||||
mlx: MLXEngineConfig = field(default_factory=MLXEngineConfig)
|
||||
lmstudio: LMStudioEngineConfig = field(default_factory=LMStudioEngineConfig)
|
||||
|
||||
# Backward-compat properties for old flat attribute names
|
||||
@property
|
||||
@@ -316,6 +324,15 @@ class EngineConfig:
|
||||
def mlx_host(self, value: str) -> None:
|
||||
self.mlx.host = value
|
||||
|
||||
@property
|
||||
def lmstudio_host(self) -> str:
|
||||
"""Deprecated: use ``engine.lmstudio.host``."""
|
||||
return self.lmstudio.host
|
||||
|
||||
@lmstudio_host.setter
|
||||
def lmstudio_host(self, value: str) -> None:
|
||||
self.lmstudio.host = value
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IntelligenceConfig:
|
||||
@@ -473,12 +490,23 @@ class MCPConfig:
|
||||
servers: str = "" # JSON list of MCP server configs
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BrowserConfig:
|
||||
"""Browser automation settings (Playwright)."""
|
||||
|
||||
headless: bool = True
|
||||
timeout_ms: int = 30000
|
||||
viewport_width: int = 1280
|
||||
viewport_height: int = 720
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolsConfig:
|
||||
"""Tools pillar settings — wraps storage and MCP configuration."""
|
||||
|
||||
storage: StorageConfig = field(default_factory=StorageConfig)
|
||||
mcp: MCPConfig = field(default_factory=MCPConfig)
|
||||
browser: BrowserConfig = field(default_factory=BrowserConfig)
|
||||
enabled: str = "" # comma-separated default tools
|
||||
|
||||
|
||||
@@ -689,19 +717,31 @@ class ChannelConfig:
|
||||
email: EmailChannelConfig = field(default_factory=EmailChannelConfig)
|
||||
whatsapp: WhatsAppChannelConfig = field(default_factory=WhatsAppChannelConfig)
|
||||
signal: SignalChannelConfig = field(default_factory=SignalChannelConfig)
|
||||
google_chat: GoogleChatChannelConfig = field(default_factory=GoogleChatChannelConfig)
|
||||
google_chat: GoogleChatChannelConfig = field(
|
||||
default_factory=GoogleChatChannelConfig,
|
||||
)
|
||||
irc: IRCChannelConfig = field(default_factory=IRCChannelConfig)
|
||||
webchat: WebChatChannelConfig = field(default_factory=WebChatChannelConfig)
|
||||
teams: TeamsChannelConfig = field(default_factory=TeamsChannelConfig)
|
||||
matrix: MatrixChannelConfig = field(default_factory=MatrixChannelConfig)
|
||||
mattermost: MattermostChannelConfig = field(default_factory=MattermostChannelConfig)
|
||||
feishu: FeishuChannelConfig = field(default_factory=FeishuChannelConfig)
|
||||
bluebubbles: BlueBubblesChannelConfig = field(default_factory=BlueBubblesChannelConfig)
|
||||
bluebubbles: BlueBubblesChannelConfig = field(
|
||||
default_factory=BlueBubblesChannelConfig,
|
||||
)
|
||||
whatsapp_baileys: WhatsAppBaileysChannelConfig = field(
|
||||
default_factory=WhatsAppBaileysChannelConfig,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CapabilitiesConfig:
|
||||
"""RBAC capability system settings."""
|
||||
|
||||
enabled: bool = False
|
||||
policy_path: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SecurityConfig:
|
||||
"""Security guardrails settings."""
|
||||
@@ -714,6 +754,13 @@ class SecurityConfig:
|
||||
pii_scanner: bool = True
|
||||
audit_log_path: str = str(DEFAULT_CONFIG_DIR / "audit.db")
|
||||
enforce_tool_confirmation: bool = True
|
||||
merkle_audit: bool = True
|
||||
signing_key_path: str = ""
|
||||
ssrf_protection: bool = True
|
||||
rate_limit_enabled: bool = False
|
||||
rate_limit_rpm: int = 60
|
||||
rate_limit_burst: int = 10
|
||||
capabilities: CapabilitiesConfig = field(default_factory=CapabilitiesConfig)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -727,6 +774,8 @@ class SandboxConfig:
|
||||
mount_allowlist_path: str = ""
|
||||
max_concurrent: int = 5
|
||||
runtime: str = "docker"
|
||||
wasm_fuel_limit: int = 1_000_000
|
||||
wasm_memory_limit_mb: int = 256
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -738,6 +787,32 @@ class SchedulerConfig:
|
||||
db_path: str = "" # Defaults to ~/.openjarvis/scheduler.db
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkflowConfig:
|
||||
"""Workflow engine settings."""
|
||||
|
||||
enabled: bool = False
|
||||
max_parallel: int = 4
|
||||
default_node_timeout: int = 300
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SessionConfig:
|
||||
"""Cross-channel session settings."""
|
||||
|
||||
enabled: bool = False
|
||||
max_age_hours: float = 24.0
|
||||
consolidation_threshold: int = 100
|
||||
db_path: str = str(DEFAULT_CONFIG_DIR / "sessions.db")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class A2AConfig:
|
||||
"""Agent-to-Agent protocol settings."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class JarvisConfig:
|
||||
"""Top-level configuration for OpenJarvis."""
|
||||
@@ -755,6 +830,9 @@ class JarvisConfig:
|
||||
security: SecurityConfig = field(default_factory=SecurityConfig)
|
||||
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
|
||||
scheduler: SchedulerConfig = field(default_factory=SchedulerConfig)
|
||||
workflow: WorkflowConfig = field(default_factory=WorkflowConfig)
|
||||
sessions: SessionConfig = field(default_factory=SessionConfig)
|
||||
a2a: A2AConfig = field(default_factory=A2AConfig)
|
||||
|
||||
@property
|
||||
def memory(self) -> StorageConfig:
|
||||
@@ -848,6 +926,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
|
||||
"engine", "intelligence", "learning", "agent",
|
||||
"server", "telemetry", "traces", "security",
|
||||
"channel", "tools", "sandbox", "scheduler",
|
||||
"workflow", "sessions", "a2a",
|
||||
)
|
||||
for section_name in top_sections:
|
||||
if section_name in data:
|
||||
@@ -900,6 +979,9 @@ host = "http://localhost:30000"
|
||||
[engine.mlx]
|
||||
host = "http://localhost:8080"
|
||||
|
||||
# [engine.lmstudio]
|
||||
# host = "http://localhost:1234"
|
||||
|
||||
[intelligence]
|
||||
default_model = ""
|
||||
fallback_model = ""
|
||||
@@ -930,6 +1012,12 @@ default_backend = "sqlite"
|
||||
[tools.mcp]
|
||||
enabled = true
|
||||
|
||||
# [tools.browser]
|
||||
# headless = true
|
||||
# timeout_ms = 30000
|
||||
# viewport_width = 1280
|
||||
# viewport_height = 720
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8000
|
||||
@@ -1025,6 +1113,10 @@ scan_output = true
|
||||
secret_scanner = true
|
||||
pii_scanner = true
|
||||
enforce_tool_confirmation = true
|
||||
ssrf_protection = true
|
||||
# rate_limit_enabled = false
|
||||
# rate_limit_rpm = 60
|
||||
# rate_limit_burst = 10
|
||||
|
||||
# [sandbox]
|
||||
# enabled = false
|
||||
@@ -1046,9 +1138,12 @@ enforce_tool_confirmation = true
|
||||
|
||||
|
||||
__all__ = [
|
||||
"A2AConfig",
|
||||
"AgentConfig",
|
||||
"AgentLearningConfig",
|
||||
"BlueBubblesChannelConfig",
|
||||
"BrowserConfig",
|
||||
"CapabilitiesConfig",
|
||||
"ChannelConfig",
|
||||
"DEFAULT_CONFIG_DIR",
|
||||
"DEFAULT_CONFIG_PATH",
|
||||
@@ -1064,6 +1159,7 @@ __all__ = [
|
||||
"IntelligenceLearningConfig",
|
||||
"JarvisConfig",
|
||||
"LearningConfig",
|
||||
"LMStudioEngineConfig",
|
||||
"LlamaCppEngineConfig",
|
||||
"MCPConfig",
|
||||
"MLXEngineConfig",
|
||||
@@ -1078,6 +1174,7 @@ __all__ = [
|
||||
"SchedulerConfig",
|
||||
"SecurityConfig",
|
||||
"ServerConfig",
|
||||
"SessionConfig",
|
||||
"SignalChannelConfig",
|
||||
"SlackChannelConfig",
|
||||
"StorageConfig",
|
||||
@@ -1091,6 +1188,7 @@ __all__ = [
|
||||
"WebhookChannelConfig",
|
||||
"WhatsAppBaileysChannelConfig",
|
||||
"WhatsAppChannelConfig",
|
||||
"WorkflowConfig",
|
||||
"detect_hardware",
|
||||
"generate_default_toml",
|
||||
"load_config",
|
||||
|
||||
@@ -41,6 +41,23 @@ class EventType(str, Enum):
|
||||
SCHEDULER_TASK_END = "scheduler_task_end"
|
||||
BATCH_START = "batch_start"
|
||||
BATCH_END = "batch_end"
|
||||
# Phase 14 — Agent Hardening & Security
|
||||
TOOL_TIMEOUT = "tool_timeout"
|
||||
LOOP_GUARD_TRIGGERED = "loop_guard_triggered"
|
||||
CAPABILITY_DENIED = "capability_denied"
|
||||
TAINT_VIOLATION = "taint_violation"
|
||||
# Phase 15 — Workflow, Skills, Sessions
|
||||
WORKFLOW_START = "workflow_start"
|
||||
WORKFLOW_NODE_START = "workflow_node_start"
|
||||
WORKFLOW_NODE_END = "workflow_node_end"
|
||||
WORKFLOW_END = "workflow_end"
|
||||
SKILL_EXECUTE_START = "skill_execute_start"
|
||||
SKILL_EXECUTE_END = "skill_execute_end"
|
||||
SESSION_START = "session_start"
|
||||
SESSION_END = "session_end"
|
||||
# Phase 16 — A2A Protocol
|
||||
A2A_TASK_RECEIVED = "a2a_task_received"
|
||||
A2A_TASK_COMPLETED = "a2a_task_completed"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -133,6 +133,10 @@ class LearningRegistry(RegistryBase[Any]):
|
||||
"""Registry for learning policies."""
|
||||
|
||||
|
||||
class SkillRegistry(RegistryBase[Any]):
|
||||
"""Registry for skill manifests."""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentRegistry",
|
||||
"BenchmarkRegistry",
|
||||
@@ -143,5 +147,6 @@ __all__ = [
|
||||
"ModelRegistry",
|
||||
"RegistryBase",
|
||||
"RouterPolicyRegistry",
|
||||
"SkillRegistry",
|
||||
"ToolRegistry",
|
||||
]
|
||||
|
||||
@@ -15,6 +15,7 @@ _HOST_MAP: Dict[str, str | None] = {
|
||||
"llamacpp": "llamacpp_host",
|
||||
"sglang": "sglang_host",
|
||||
"mlx": "mlx_host",
|
||||
"lmstudio": "lmstudio_host",
|
||||
"cloud": None,
|
||||
"litellm": None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""LM Studio inference engine backend (OpenAI-compatible API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.core.registry import EngineRegistry
|
||||
from openjarvis.engine._openai_compat import _OpenAICompatibleEngine
|
||||
|
||||
|
||||
@EngineRegistry.register("lmstudio")
|
||||
class LMStudioEngine(_OpenAICompatibleEngine):
|
||||
"""LM Studio backend — thin wrapper over the shared OpenAI-compatible base."""
|
||||
|
||||
engine_id = "lmstudio"
|
||||
_default_host = "http://localhost:1234"
|
||||
|
||||
|
||||
__all__ = ["LMStudioEngine"]
|
||||
@@ -35,6 +35,15 @@ def ensure_registered() -> None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from openjarvis.learning.bandit_router import (
|
||||
ensure_registered as _reg_bandit,
|
||||
)
|
||||
|
||||
_reg_bandit()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from openjarvis.learning.trace_policy import (
|
||||
ensure_registered as _reg_trace,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Bandit router — Thompson Sampling / UCB for query→model selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from openjarvis.core.registry import RouterPolicyRegistry
|
||||
from openjarvis.core.types import RoutingContext
|
||||
|
||||
|
||||
def _derive_query_class(context: RoutingContext) -> str:
|
||||
"""Derive a query class string from RoutingContext fields."""
|
||||
if context.has_code:
|
||||
return "code"
|
||||
if context.has_math:
|
||||
return "math"
|
||||
if context.query_length < 50:
|
||||
return "short"
|
||||
if context.query_length > 500:
|
||||
return "long"
|
||||
return "general"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ArmStats:
|
||||
"""Statistics for a single arm (model)."""
|
||||
successes: int = 0 # alpha for Beta distribution
|
||||
failures: int = 0 # beta for Beta distribution
|
||||
total_reward: float = 0.0
|
||||
pulls: int = 0
|
||||
|
||||
@property
|
||||
def mean_reward(self) -> float:
|
||||
return self.total_reward / self.pulls if self.pulls > 0 else 0.0
|
||||
|
||||
|
||||
class BanditRouterPolicy:
|
||||
"""Multi-armed bandit router using Thompson Sampling or UCB.
|
||||
|
||||
Each (query_class, model) pair is an arm. Rewards come from
|
||||
trace outcomes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
strategy: Literal["thompson", "ucb"] = "thompson",
|
||||
exploration_factor: float = 2.0, # UCB exploration constant
|
||||
min_pulls: int = 3, # minimum pulls before trusting estimates
|
||||
reward_threshold: float = 0.5, # reward above this = success
|
||||
) -> None:
|
||||
self._strategy = strategy
|
||||
self._exploration = exploration_factor
|
||||
self._min_pulls = min_pulls
|
||||
self._reward_threshold = reward_threshold
|
||||
# query_class -> model -> ArmStats
|
||||
self._arms: Dict[str, Dict[str, ArmStats]] = defaultdict(
|
||||
lambda: defaultdict(ArmStats)
|
||||
)
|
||||
self._total_pulls = 0
|
||||
|
||||
def route(self, context: RoutingContext, models: List[str]) -> str:
|
||||
"""Select model using the configured bandit strategy."""
|
||||
if not models:
|
||||
raise ValueError("No models available")
|
||||
|
||||
query_class = _derive_query_class(context)
|
||||
arms = self._arms[query_class]
|
||||
|
||||
# Ensure all models have arms
|
||||
for m in models:
|
||||
if m not in arms:
|
||||
arms[m] = ArmStats()
|
||||
|
||||
# Check minimum pulls — explore uniformly first
|
||||
under_explored = [m for m in models if arms[m].pulls < self._min_pulls]
|
||||
if under_explored:
|
||||
return random.choice(under_explored)
|
||||
|
||||
if self._strategy == "thompson":
|
||||
return self._thompson_select(models, arms)
|
||||
else:
|
||||
return self._ucb_select(models, arms)
|
||||
|
||||
def _thompson_select(self, models: List[str], arms: Dict[str, ArmStats]) -> str:
|
||||
"""Thompson Sampling: sample from Beta(alpha, beta) per arm."""
|
||||
best_model = models[0]
|
||||
best_sample = -1.0
|
||||
|
||||
for m in models:
|
||||
stats = arms[m]
|
||||
alpha = stats.successes + 1 # Prior: Beta(1,1)
|
||||
beta = stats.failures + 1
|
||||
sample = random.betavariate(alpha, beta)
|
||||
if sample > best_sample:
|
||||
best_sample = sample
|
||||
best_model = m
|
||||
|
||||
return best_model
|
||||
|
||||
def _ucb_select(self, models: List[str], arms: Dict[str, ArmStats]) -> str:
|
||||
"""UCB1: select arm with highest upper confidence bound."""
|
||||
best_model = models[0]
|
||||
best_ucb = -1.0
|
||||
|
||||
total = max(self._total_pulls, 1)
|
||||
for m in models:
|
||||
stats = arms[m]
|
||||
if stats.pulls == 0:
|
||||
return m # Unexplored arm gets priority
|
||||
|
||||
mean = stats.mean_reward
|
||||
exploration = self._exploration * math.sqrt(
|
||||
math.log(total) / stats.pulls
|
||||
)
|
||||
ucb_value = mean + exploration
|
||||
|
||||
if ucb_value > best_ucb:
|
||||
best_ucb = ucb_value
|
||||
best_model = m
|
||||
|
||||
return best_model
|
||||
|
||||
def update(self, query_class: str, model: str, reward: float) -> None:
|
||||
"""Update arm statistics with observed reward."""
|
||||
stats = self._arms[query_class][model]
|
||||
stats.pulls += 1
|
||||
stats.total_reward += reward
|
||||
if reward >= self._reward_threshold:
|
||||
stats.successes += 1
|
||||
else:
|
||||
stats.failures += 1
|
||||
self._total_pulls += 1
|
||||
|
||||
def get_stats(self, query_class: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get arm statistics."""
|
||||
if query_class:
|
||||
arms = self._arms.get(query_class, {})
|
||||
return {
|
||||
m: {
|
||||
"pulls": s.pulls,
|
||||
"mean_reward": s.mean_reward,
|
||||
"successes": s.successes,
|
||||
"failures": s.failures,
|
||||
}
|
||||
for m, s in arms.items()
|
||||
}
|
||||
return {
|
||||
qc: {
|
||||
m: {"pulls": s.pulls, "mean_reward": s.mean_reward}
|
||||
for m, s in arms.items()
|
||||
}
|
||||
for qc, arms in self._arms.items()
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all state."""
|
||||
self._arms.clear()
|
||||
self._total_pulls = 0
|
||||
|
||||
|
||||
def ensure_registered() -> None:
|
||||
"""Register BanditRouterPolicy if not already present."""
|
||||
if not RouterPolicyRegistry.contains("bandit"):
|
||||
RouterPolicyRegistry.register_value("bandit", BanditRouterPolicy)
|
||||
|
||||
|
||||
ensure_registered()
|
||||
|
||||
__all__ = ["ArmStats", "BanditRouterPolicy"]
|
||||
@@ -1,29 +1,175 @@
|
||||
"""GRPO-based router policy — stub for Phase 5."""
|
||||
"""GRPO router — Group Relative Policy Optimization for query→model routing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import math
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.core.registry import RouterPolicyRegistry
|
||||
from openjarvis.core.types import RoutingContext
|
||||
from openjarvis.learning._stubs import RouterPolicy
|
||||
|
||||
|
||||
class GRPORouterPolicy(RouterPolicy):
|
||||
"""Placeholder for GRPO-trained router policy (Phase 5).
|
||||
def _derive_query_class(context: RoutingContext) -> str:
|
||||
"""Derive a query class string from RoutingContext fields."""
|
||||
if context.has_code:
|
||||
return "code"
|
||||
if context.has_math:
|
||||
return "math"
|
||||
if context.query_length < 50:
|
||||
return "short"
|
||||
if context.query_length > 500:
|
||||
return "long"
|
||||
return "general"
|
||||
|
||||
Raises ``NotImplementedError`` until training infrastructure is ready.
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GRPOSample:
|
||||
"""A single sample in a GRPO group."""
|
||||
query_class: str
|
||||
model: str
|
||||
reward: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class GRPOState:
|
||||
"""Persistent state for GRPO policy weights."""
|
||||
# model -> query_class -> weight (log probability)
|
||||
weights: Dict[str, Dict[str, float]] = field(
|
||||
default_factory=lambda: defaultdict(
|
||||
lambda: defaultdict(float)
|
||||
),
|
||||
)
|
||||
# Track sample counts for min_samples threshold
|
||||
sample_counts: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
|
||||
total_updates: int = 0
|
||||
|
||||
|
||||
class GRPORouterPolicy:
|
||||
"""Group Relative Policy Optimization for routing queries to models.
|
||||
|
||||
Groups samples by query_class, computes relative advantage within each
|
||||
group (reward - mean_reward) / std, and updates policy weights via
|
||||
softmax gradient.
|
||||
|
||||
Falls back to random selection when insufficient samples exist.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self._kwargs = kwargs
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
learning_rate: float = 0.1,
|
||||
min_samples: int = 5,
|
||||
group_size: int = 4,
|
||||
temperature: float = 1.0,
|
||||
) -> None:
|
||||
self._lr = learning_rate
|
||||
self._min_samples = min_samples
|
||||
self._group_size = group_size
|
||||
self._temperature = temperature
|
||||
self._state = GRPOState()
|
||||
self._sample_buffer: List[GRPOSample] = []
|
||||
|
||||
def select_model(self, context: RoutingContext) -> str:
|
||||
raise NotImplementedError(
|
||||
"GRPORouterPolicy is not yet implemented. "
|
||||
"GRPO training will be available in Phase 5."
|
||||
def route(self, context: RoutingContext, models: List[str]) -> str:
|
||||
"""Select the best model for the given routing context."""
|
||||
if not models:
|
||||
raise ValueError("No models available for routing")
|
||||
|
||||
query_class = _derive_query_class(context)
|
||||
|
||||
# Check if we have enough samples
|
||||
if self._state.sample_counts.get(query_class, 0) < self._min_samples:
|
||||
return random.choice(models)
|
||||
|
||||
# Compute softmax probabilities from weights
|
||||
scores = []
|
||||
for m in models:
|
||||
w = self._state.weights.get(m, {}).get(query_class, 0.0)
|
||||
scores.append(w / self._temperature)
|
||||
|
||||
# Softmax
|
||||
max_score = max(scores)
|
||||
exp_scores = [math.exp(s - max_score) for s in scores]
|
||||
total = sum(exp_scores)
|
||||
probs = [e / total for e in exp_scores]
|
||||
|
||||
# Sample from distribution
|
||||
r = random.random()
|
||||
cumulative = 0.0
|
||||
for i, p in enumerate(probs):
|
||||
cumulative += p
|
||||
if r <= cumulative:
|
||||
return models[i]
|
||||
return models[-1]
|
||||
|
||||
def add_sample(self, query_class: str, model: str, reward: float) -> None:
|
||||
"""Add a training sample to the buffer."""
|
||||
self._sample_buffer.append(GRPOSample(
|
||||
query_class=query_class, model=model, reward=reward,
|
||||
))
|
||||
self._state.sample_counts[query_class] = (
|
||||
self._state.sample_counts.get(query_class, 0) + 1
|
||||
)
|
||||
|
||||
def update(self) -> Dict[str, Any]:
|
||||
"""Run GRPO update on accumulated samples.
|
||||
|
||||
Groups samples by query_class, computes relative advantages,
|
||||
and updates policy weights.
|
||||
|
||||
Returns stats about the update.
|
||||
"""
|
||||
if not self._sample_buffer:
|
||||
return {"updated": False, "reason": "no samples"}
|
||||
|
||||
# Group by query_class
|
||||
groups: Dict[str, List[GRPOSample]] = defaultdict(list)
|
||||
for sample in self._sample_buffer:
|
||||
groups[sample.query_class].append(sample)
|
||||
|
||||
updates_applied = 0
|
||||
for qc, samples in groups.items():
|
||||
if len(samples) < 2:
|
||||
continue # Need at least 2 for relative comparison
|
||||
|
||||
# Compute group statistics
|
||||
rewards = [s.reward for s in samples]
|
||||
mean_r = sum(rewards) / len(rewards)
|
||||
var_r = sum((r - mean_r) ** 2 for r in rewards) / len(rewards)
|
||||
std_r = math.sqrt(var_r) if var_r > 0 else 1.0
|
||||
|
||||
# Compute advantages and update weights
|
||||
for sample in samples:
|
||||
advantage = (sample.reward - mean_r) / std_r
|
||||
self._state.weights[sample.model][qc] += (
|
||||
self._lr * advantage
|
||||
)
|
||||
updates_applied += 1
|
||||
|
||||
self._state.total_updates += 1
|
||||
processed = len(self._sample_buffer)
|
||||
self._sample_buffer.clear()
|
||||
|
||||
return {
|
||||
"updated": True,
|
||||
"samples_processed": processed,
|
||||
"groups": len(groups),
|
||||
"updates_applied": updates_applied,
|
||||
"total_updates": self._state.total_updates,
|
||||
}
|
||||
|
||||
@property
|
||||
def state(self) -> GRPOState:
|
||||
"""Access the current policy state."""
|
||||
return self._state
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all state."""
|
||||
self._state = GRPOState()
|
||||
self._sample_buffer.clear()
|
||||
|
||||
|
||||
def ensure_registered() -> None:
|
||||
"""Register GRPORouterPolicy if not already present."""
|
||||
@@ -33,4 +179,4 @@ def ensure_registered() -> None:
|
||||
|
||||
ensure_registered()
|
||||
|
||||
__all__ = ["GRPORouterPolicy"]
|
||||
__all__ = ["GRPORouterPolicy", "GRPOSample", "GRPOState"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.registry import LearningRegistry
|
||||
from openjarvis.learning._stubs import AgentLearningPolicy
|
||||
@@ -24,12 +24,17 @@ class ICLUpdaterPolicy(AgentLearningPolicy):
|
||||
min_score: float = 0.7,
|
||||
max_examples: int = 20,
|
||||
min_skill_occurrences: int = 3,
|
||||
auto_apply: bool = False,
|
||||
) -> None:
|
||||
self._min_score = min_score
|
||||
self._max_examples = max_examples
|
||||
self._min_skill_occurrences = min_skill_occurrences
|
||||
self._auto_apply = auto_apply
|
||||
self._examples: List[Dict[str, Any]] = []
|
||||
self._skills: List[Dict[str, Any]] = []
|
||||
# Versioned example database for add_example / rollback
|
||||
self._example_db: List[Dict[str, Any]] = []
|
||||
self._version: int = 0
|
||||
|
||||
def update(self, trace_store: Any, **kwargs: object) -> Dict[str, Any]:
|
||||
"""Analyze traces and extract ICL examples + skills."""
|
||||
@@ -116,6 +121,105 @@ class ICLUpdaterPolicy(AgentLearningPolicy):
|
||||
skills.sort(key=lambda x: x["occurrences"], reverse=True)
|
||||
return skills
|
||||
|
||||
# -- Versioned example database methods ------------------------------------
|
||||
|
||||
def add_example(
|
||||
self,
|
||||
query: str,
|
||||
response: str,
|
||||
outcome: float,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Add an ICL example if it meets the quality threshold.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query:
|
||||
The user query that produced this example.
|
||||
response:
|
||||
The agent/model response.
|
||||
outcome:
|
||||
Quality score in [0, 1].
|
||||
metadata:
|
||||
Optional metadata dict attached to the example.
|
||||
|
||||
Returns
|
||||
-------
|
||||
True if the example was accepted, False if rejected (below threshold).
|
||||
"""
|
||||
if outcome < self._min_score:
|
||||
return False
|
||||
|
||||
self._version += 1
|
||||
entry: Dict[str, Any] = {
|
||||
"query": query,
|
||||
"response": response,
|
||||
"outcome": outcome,
|
||||
"metadata": metadata or {},
|
||||
"version": self._version,
|
||||
}
|
||||
self._example_db.append(entry)
|
||||
|
||||
# Trim to max_examples (remove oldest first)
|
||||
if len(self._example_db) > self._max_examples:
|
||||
self._example_db = self._example_db[-self._max_examples:]
|
||||
|
||||
return True
|
||||
|
||||
def rollback(self, version: int) -> None:
|
||||
"""Remove all examples added after the given version.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
version:
|
||||
The version checkpoint to rollback to. All examples with
|
||||
``version > checkpoint`` are removed.
|
||||
"""
|
||||
self._example_db = [
|
||||
ex for ex in self._example_db if ex["version"] <= version
|
||||
]
|
||||
self._version = version
|
||||
|
||||
def get_examples(
|
||||
self,
|
||||
query_class: str = "",
|
||||
top_k: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Retrieve the best examples, optionally filtered by query class.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
query_class:
|
||||
If non-empty, only return examples whose query contains this
|
||||
substring (case-insensitive).
|
||||
top_k:
|
||||
Maximum number of examples to return.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Up to *top_k* examples sorted by outcome (descending).
|
||||
"""
|
||||
pool = self._example_db
|
||||
if query_class:
|
||||
lc = query_class.lower()
|
||||
pool = [ex for ex in pool if lc in ex["query"].lower()]
|
||||
|
||||
# Sort by outcome descending, take top_k
|
||||
ranked = sorted(pool, key=lambda ex: ex["outcome"], reverse=True)
|
||||
return ranked[:top_k]
|
||||
|
||||
@property
|
||||
def version(self) -> int:
|
||||
"""Current version counter."""
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def example_db(self) -> List[Dict[str, Any]]:
|
||||
"""Return a copy of the versioned example database."""
|
||||
return list(self._example_db)
|
||||
|
||||
# -- Original property accessors ------------------------------------------
|
||||
|
||||
@property
|
||||
def examples(self) -> List[Dict[str, Any]]:
|
||||
return list(self._examples)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Skill discovery -- mine recurring tool sequences from traces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DiscoveredSkill:
|
||||
"""A skill discovered from trace analysis."""
|
||||
name: str
|
||||
description: str
|
||||
tool_sequence: List[str] # ordered tool names
|
||||
frequency: int # how often this sequence appeared
|
||||
avg_outcome: float # average outcome score
|
||||
example_inputs: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillDiscovery:
|
||||
"""Mine recurring tool sequences from trace data to auto-generate skills.
|
||||
|
||||
Analyzes TraceStore data for patterns like:
|
||||
- "web_search -> file_write" (research-then-save)
|
||||
- "file_read -> calculator -> file_write" (read-compute-save)
|
||||
|
||||
When a sequence appears >= min_frequency times with positive outcomes,
|
||||
it's surfaced as a DiscoveredSkill that can be registered.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
min_frequency: int = 3,
|
||||
min_sequence_length: int = 2,
|
||||
max_sequence_length: int = 4,
|
||||
min_outcome: float = 0.5,
|
||||
) -> None:
|
||||
self._min_freq = min_frequency
|
||||
self._min_len = min_sequence_length
|
||||
self._max_len = max_sequence_length
|
||||
self._min_outcome = min_outcome
|
||||
self._discovered: List[DiscoveredSkill] = []
|
||||
|
||||
def analyze_traces(self, traces: List[Any]) -> List[DiscoveredSkill]:
|
||||
"""Analyze a list of traces for recurring tool sequences.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
traces:
|
||||
List of Trace objects (or dicts with 'steps' and 'outcome' keys).
|
||||
Each trace should have steps with 'step_type' and 'tool_name'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
List of DiscoveredSkill objects meeting frequency and outcome thresholds.
|
||||
"""
|
||||
# Extract tool sequences from traces
|
||||
sequence_data: Dict[Tuple[str, ...], List[float]] = defaultdict(list)
|
||||
sequence_inputs: Dict[Tuple[str, ...], List[str]] = defaultdict(list)
|
||||
|
||||
for trace in traces:
|
||||
tool_calls = self._extract_tool_sequence(trace)
|
||||
outcome = self._extract_outcome(trace)
|
||||
query = self._extract_query(trace)
|
||||
|
||||
if len(tool_calls) < self._min_len:
|
||||
continue
|
||||
|
||||
# Generate all subsequences of valid length
|
||||
upper = min(self._max_len + 1, len(tool_calls) + 1)
|
||||
for length in range(self._min_len, upper):
|
||||
for start in range(len(tool_calls) - length + 1):
|
||||
seq = tuple(tool_calls[start:start + length])
|
||||
sequence_data[seq].append(outcome)
|
||||
if query and len(sequence_inputs[seq]) < 3:
|
||||
sequence_inputs[seq].append(query)
|
||||
|
||||
# Filter by frequency and outcome
|
||||
discovered = []
|
||||
for seq, outcomes in sequence_data.items():
|
||||
freq = len(outcomes)
|
||||
avg_outcome = sum(outcomes) / len(outcomes) if outcomes else 0.0
|
||||
|
||||
if freq >= self._min_freq and avg_outcome >= self._min_outcome:
|
||||
name = "_".join(seq)
|
||||
desc = f"Auto-discovered skill: {' -> '.join(seq)} (seen {freq} times)"
|
||||
discovered.append(DiscoveredSkill(
|
||||
name=name,
|
||||
description=desc,
|
||||
tool_sequence=list(seq),
|
||||
frequency=freq,
|
||||
avg_outcome=avg_outcome,
|
||||
example_inputs=sequence_inputs.get(seq, []),
|
||||
))
|
||||
|
||||
# Sort by frequency * outcome (quality score)
|
||||
discovered.sort(key=lambda s: s.frequency * s.avg_outcome, reverse=True)
|
||||
self._discovered = discovered
|
||||
return discovered
|
||||
|
||||
def _extract_tool_sequence(self, trace: Any) -> List[str]:
|
||||
"""Extract ordered list of tool names from a trace."""
|
||||
if isinstance(trace, dict):
|
||||
steps = trace.get("steps", [])
|
||||
elif hasattr(trace, "steps"):
|
||||
steps = trace.steps
|
||||
else:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
for step in steps:
|
||||
if isinstance(step, dict):
|
||||
if step.get("step_type") == "tool_call":
|
||||
name = step.get("tool_name", step.get("name", ""))
|
||||
if name:
|
||||
tools.append(name)
|
||||
elif hasattr(step, "step_type"):
|
||||
st = step.step_type
|
||||
is_tool = str(st) == "tool_call" or (
|
||||
hasattr(st, "value") and st.value == "tool_call"
|
||||
)
|
||||
if is_tool:
|
||||
name = getattr(
|
||||
step, "tool_name", getattr(step, "name", ""),
|
||||
)
|
||||
if name:
|
||||
tools.append(name)
|
||||
return tools
|
||||
|
||||
def _extract_outcome(self, trace: Any) -> float:
|
||||
"""Extract outcome score from a trace."""
|
||||
if isinstance(trace, dict):
|
||||
return float(trace.get("outcome", 0.0))
|
||||
return float(getattr(trace, "outcome", 0.0))
|
||||
|
||||
def _extract_query(self, trace: Any) -> str:
|
||||
"""Extract the original query from a trace."""
|
||||
if isinstance(trace, dict):
|
||||
return trace.get("query", "")
|
||||
return getattr(trace, "query", "")
|
||||
|
||||
@property
|
||||
def discovered_skills(self) -> List[DiscoveredSkill]:
|
||||
"""Return the most recently discovered skills."""
|
||||
return list(self._discovered)
|
||||
|
||||
def to_skill_manifests(self) -> List[Dict[str, Any]]:
|
||||
"""Convert discovered skills to TOML-compatible manifest dicts."""
|
||||
manifests = []
|
||||
for skill in self._discovered:
|
||||
manifests.append({
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"steps": [
|
||||
{"tool": tool, "params": {}} for tool in skill.tool_sequence
|
||||
],
|
||||
"metadata": {
|
||||
"auto_discovered": True,
|
||||
"frequency": skill.frequency,
|
||||
"avg_outcome": skill.avg_outcome,
|
||||
},
|
||||
})
|
||||
return manifests
|
||||
|
||||
|
||||
__all__ = ["DiscoveredSkill", "SkillDiscovery"]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""WASM sandbox — lightweight isolation via Wasmtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WasmResult:
|
||||
"""Result from a WASM execution."""
|
||||
success: bool = True
|
||||
output: str = ""
|
||||
duration_seconds: float = 0.0
|
||||
fuel_consumed: int = 0
|
||||
memory_used_bytes: int = 0
|
||||
|
||||
|
||||
class WasmRunner:
|
||||
"""Execute WASM modules with resource limits.
|
||||
|
||||
Uses wasmtime-py for sub-100ms isolation. Supplements Docker-based
|
||||
ContainerRunner for lightweight, fast sandboxing.
|
||||
|
||||
Requires: ``pip install openjarvis[sandbox-wasm]``
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fuel_limit: int = 1_000_000,
|
||||
memory_limit_mb: int = 256,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._fuel_limit = fuel_limit
|
||||
self._memory_limit_mb = memory_limit_mb
|
||||
self._timeout = timeout
|
||||
|
||||
@staticmethod
|
||||
def available() -> bool:
|
||||
"""Check if wasmtime is available."""
|
||||
try:
|
||||
import wasmtime # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def run(
|
||||
self,
|
||||
wasm_bytes: bytes,
|
||||
input_data: Optional[Dict[str, Any]] = None,
|
||||
) -> WasmResult:
|
||||
"""Execute a WASM module with input data.
|
||||
|
||||
The module is expected to export a ``run`` function that takes
|
||||
a pointer and length and returns a pointer and length.
|
||||
For simpler modules, we attempt to call ``_start`` (WASI).
|
||||
"""
|
||||
try:
|
||||
import wasmtime
|
||||
except ImportError:
|
||||
return WasmResult(
|
||||
success=False,
|
||||
output=(
|
||||
"wasmtime not installed. Install with: "
|
||||
"pip install openjarvis[sandbox-wasm]"
|
||||
),
|
||||
)
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
# Configure engine with fuel metering
|
||||
config = wasmtime.Config()
|
||||
config.consume_fuel = True
|
||||
engine = wasmtime.Engine(config)
|
||||
|
||||
# Create store with fuel limit
|
||||
store = wasmtime.Store(engine)
|
||||
store.set_fuel(self._fuel_limit)
|
||||
|
||||
# Compile module
|
||||
module = wasmtime.Module(engine, wasm_bytes)
|
||||
|
||||
# Set up WASI if needed
|
||||
wasi_config = wasmtime.WasiConfig()
|
||||
wasi_config.inherit_stdout()
|
||||
wasi_config.inherit_stderr()
|
||||
store.set_wasi(wasi_config)
|
||||
|
||||
# Create linker and link WASI
|
||||
linker = wasmtime.Linker(engine)
|
||||
linker.define_wasi()
|
||||
|
||||
# Instantiate
|
||||
instance = linker.instantiate(store, module)
|
||||
|
||||
# Try to call _start (WASI entry point)
|
||||
start_func = instance.exports(store).get("_start")
|
||||
if start_func and isinstance(start_func, wasmtime.Func):
|
||||
start_func(store)
|
||||
|
||||
fuel_remaining = store.get_fuel()
|
||||
fuel_consumed = self._fuel_limit - fuel_remaining
|
||||
|
||||
duration = time.time() - t0
|
||||
return WasmResult(
|
||||
success=True,
|
||||
output="WASM module executed successfully.",
|
||||
duration_seconds=duration,
|
||||
fuel_consumed=fuel_consumed,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
duration = time.time() - t0
|
||||
return WasmResult(
|
||||
success=False,
|
||||
output=f"WASM execution error: {exc}",
|
||||
duration_seconds=duration,
|
||||
)
|
||||
|
||||
def validate(self, wasm_bytes: bytes) -> bool:
|
||||
"""Validate that bytes represent a valid WASM module."""
|
||||
try:
|
||||
import wasmtime
|
||||
config = wasmtime.Config()
|
||||
engine = wasmtime.Engine(config)
|
||||
wasmtime.Module.validate(engine, wasm_bytes)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_sandbox_runner(config: Any = None) -> Any:
|
||||
"""Factory: select Docker or WASM runner based on config/availability."""
|
||||
if config and getattr(config, "runtime", "") == "wasm":
|
||||
runner = WasmRunner(
|
||||
fuel_limit=getattr(config, "wasm_fuel_limit", 1_000_000),
|
||||
memory_limit_mb=getattr(config, "wasm_memory_limit_mb", 256),
|
||||
timeout=getattr(config, "timeout", 30),
|
||||
)
|
||||
if runner.available():
|
||||
return runner
|
||||
|
||||
# Fall back to Docker ContainerRunner
|
||||
try:
|
||||
from openjarvis.sandbox.runner import ContainerRunner
|
||||
return ContainerRunner(
|
||||
image=getattr(config, "image", "openjarvis-sandbox:latest"),
|
||||
timeout=getattr(config, "timeout", 300),
|
||||
)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["WasmResult", "WasmRunner", "create_sandbox_runner"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Security guardrails — scanners, engine wrapper, and audit logging."""
|
||||
"""Security guardrails — scanners, engine wrapper, audit, SSRF."""
|
||||
|
||||
from openjarvis.security._stubs import BaseScanner
|
||||
from openjarvis.security.audit import AuditLogger
|
||||
@@ -9,6 +9,7 @@ from openjarvis.security.file_policy import (
|
||||
)
|
||||
from openjarvis.security.guardrails import GuardrailsEngine, SecurityBlockError
|
||||
from openjarvis.security.scanner import PIIScanner, SecretScanner
|
||||
from openjarvis.security.ssrf import check_ssrf, is_private_ip
|
||||
from openjarvis.security.types import (
|
||||
RedactionMode,
|
||||
ScanFinding,
|
||||
@@ -32,6 +33,8 @@ __all__ = [
|
||||
"SecurityEvent",
|
||||
"SecurityEventType",
|
||||
"ThreatLevel",
|
||||
"check_ssrf",
|
||||
"filter_sensitive_paths",
|
||||
"is_private_ip",
|
||||
"is_sensitive_file",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Audit logger — persist security events to SQLite."""
|
||||
"""Audit logger — persist security events to SQLite with Merkle hash chain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
@@ -45,21 +46,42 @@ class AuditLogger:
|
||||
event_type TEXT,
|
||||
findings_json TEXT,
|
||||
content_preview TEXT,
|
||||
action_taken TEXT
|
||||
action_taken TEXT,
|
||||
row_hash TEXT DEFAULT '',
|
||||
prev_hash TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.commit()
|
||||
self._migrate_schema()
|
||||
|
||||
if bus is not None:
|
||||
bus.subscribe(EventType.SECURITY_SCAN, self._on_event)
|
||||
bus.subscribe(EventType.SECURITY_ALERT, self._on_event)
|
||||
bus.subscribe(EventType.SECURITY_BLOCK, self._on_event)
|
||||
|
||||
def _migrate_schema(self) -> None:
|
||||
"""Add row_hash/prev_hash columns if missing (schema migration)."""
|
||||
columns = {
|
||||
row[1]
|
||||
for row in self._conn.execute(
|
||||
"PRAGMA table_info(security_events)"
|
||||
).fetchall()
|
||||
}
|
||||
if "row_hash" not in columns:
|
||||
self._conn.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN row_hash TEXT DEFAULT ''"
|
||||
)
|
||||
if "prev_hash" not in columns:
|
||||
self._conn.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN prev_hash TEXT DEFAULT ''"
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# -- public API ----------------------------------------------------------
|
||||
|
||||
def log(self, event: SecurityEvent) -> None:
|
||||
"""Insert a security event into the audit log."""
|
||||
"""Insert a security event into the audit log with Merkle hash chain."""
|
||||
findings_json = json.dumps([
|
||||
{
|
||||
"pattern_name": f.pattern_name,
|
||||
@@ -71,11 +93,21 @@ class AuditLogger:
|
||||
}
|
||||
for f in event.findings
|
||||
])
|
||||
|
||||
# Compute hash chain
|
||||
prev_hash = self.tail_hash()
|
||||
hash_input = (
|
||||
f"{prev_hash}|{event.timestamp}|{event.event_type.value}"
|
||||
f"|{findings_json}|{event.content_preview}|{event.action_taken}"
|
||||
)
|
||||
row_hash = hashlib.sha256(hash_input.encode()).hexdigest()
|
||||
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO security_events
|
||||
(timestamp, event_type, findings_json, content_preview, action_taken)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
(timestamp, event_type, findings_json, content_preview,
|
||||
action_taken, row_hash, prev_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event.timestamp,
|
||||
@@ -83,6 +115,8 @@ class AuditLogger:
|
||||
findings_json,
|
||||
event.content_preview,
|
||||
event.action_taken,
|
||||
row_hash,
|
||||
prev_hash,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
@@ -139,6 +173,49 @@ class AuditLogger:
|
||||
)
|
||||
return events
|
||||
|
||||
def tail_hash(self) -> str:
|
||||
"""Return the hash of the last row in the chain, or empty string."""
|
||||
row = self._conn.execute(
|
||||
"SELECT row_hash FROM security_events ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return row[0] if row and row[0] else ""
|
||||
|
||||
def verify_chain(self) -> Tuple[bool, Optional[int]]:
|
||||
"""Verify the Merkle hash chain integrity.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple
|
||||
``(True, None)`` if the chain is valid, or
|
||||
``(False, row_id)`` where *row_id* is the first broken link.
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, timestamp, event_type, findings_json,"
|
||||
" content_preview, action_taken, row_hash, prev_hash"
|
||||
" FROM security_events ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
expected_prev = ""
|
||||
for row in rows:
|
||||
rid, ts, etype, fj, preview, action, stored_hash, stored_prev = row
|
||||
# Skip rows that predate the Merkle upgrade
|
||||
if not stored_hash:
|
||||
continue
|
||||
# Verify prev_hash link
|
||||
if stored_prev != expected_prev:
|
||||
return False, rid
|
||||
# Verify row_hash
|
||||
hash_input = (
|
||||
f"{stored_prev}|{ts}|{etype}"
|
||||
f"|{fj}|{preview}|{action}"
|
||||
)
|
||||
computed = hashlib.sha256(hash_input.encode()).hexdigest()
|
||||
if computed != stored_hash:
|
||||
return False, rid
|
||||
expected_prev = stored_hash
|
||||
|
||||
return True, None
|
||||
|
||||
def count(self) -> int:
|
||||
"""Return the total number of logged security events."""
|
||||
row = self._conn.execute(
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""RBAC capability system — fine-grained permission model for tool dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class Capability(str, Enum):
|
||||
"""Fine-grained capability labels."""
|
||||
FILE_READ = "file:read"
|
||||
FILE_WRITE = "file:write"
|
||||
NETWORK_FETCH = "network:fetch"
|
||||
CODE_EXECUTE = "code:execute"
|
||||
MEMORY_READ = "memory:read"
|
||||
MEMORY_WRITE = "memory:write"
|
||||
CHANNEL_SEND = "channel:send"
|
||||
TOOL_INVOKE = "tool:invoke"
|
||||
SCHEDULE_CREATE = "schedule:create"
|
||||
SYSTEM_ADMIN = "system:admin"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CapabilityGrant:
|
||||
"""A single capability grant for an agent."""
|
||||
capability: str # Capability value or glob pattern
|
||||
pattern: str = "*" # resource glob pattern
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AgentPolicy:
|
||||
"""Policy for a specific agent."""
|
||||
agent_id: str
|
||||
grants: List[CapabilityGrant] = field(default_factory=list)
|
||||
deny: List[str] = field(default_factory=list) # explicit denials
|
||||
|
||||
|
||||
class CapabilityPolicy:
|
||||
"""RBAC capability policy for tool dispatch.
|
||||
|
||||
Checks whether an agent has the required capability to invoke a tool.
|
||||
Policy can be loaded from a JSON file or configured programmatically.
|
||||
|
||||
Default policy: if no explicit policy exists for an agent, all
|
||||
capabilities are granted (open by default). Set ``default_deny=True``
|
||||
to flip to deny-by-default.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy_path: Optional[str] = None,
|
||||
default_deny: bool = False,
|
||||
) -> None:
|
||||
self._policies: Dict[str, AgentPolicy] = {}
|
||||
self._default_deny = default_deny
|
||||
if policy_path:
|
||||
self._load_file(Path(policy_path))
|
||||
|
||||
def grant(self, agent_id: str, capability: str, pattern: str = "*") -> None:
|
||||
"""Grant a capability to an agent."""
|
||||
policy = self._policies.setdefault(
|
||||
agent_id, AgentPolicy(agent_id=agent_id),
|
||||
)
|
||||
policy.grants.append(CapabilityGrant(capability=capability, pattern=pattern))
|
||||
|
||||
def deny(self, agent_id: str, capability: str) -> None:
|
||||
"""Explicitly deny a capability to an agent."""
|
||||
policy = self._policies.setdefault(
|
||||
agent_id, AgentPolicy(agent_id=agent_id),
|
||||
)
|
||||
policy.deny.append(capability)
|
||||
|
||||
def check(self, agent_id: str, capability: str, resource: str = "") -> bool:
|
||||
"""Check whether *agent_id* has *capability* for *resource*.
|
||||
|
||||
Returns True if allowed, False if denied.
|
||||
"""
|
||||
policy = self._policies.get(agent_id)
|
||||
if policy is None:
|
||||
# No explicit policy — use default
|
||||
return not self._default_deny
|
||||
|
||||
# Explicit denials take precedence
|
||||
for denied in policy.deny:
|
||||
if fnmatch.fnmatch(capability, denied):
|
||||
return False
|
||||
|
||||
# Check grants
|
||||
for grant in policy.grants:
|
||||
if fnmatch.fnmatch(capability, grant.capability):
|
||||
if resource and grant.pattern != "*":
|
||||
if fnmatch.fnmatch(resource, grant.pattern):
|
||||
return True
|
||||
else:
|
||||
return True
|
||||
|
||||
# No matching grant found
|
||||
return not self._default_deny
|
||||
|
||||
def list_grants(self, agent_id: str) -> List[CapabilityGrant]:
|
||||
"""List all grants for an agent."""
|
||||
policy = self._policies.get(agent_id)
|
||||
return list(policy.grants) if policy else []
|
||||
|
||||
def list_agents(self) -> List[str]:
|
||||
"""List all agents with explicit policies."""
|
||||
return list(self._policies.keys())
|
||||
|
||||
def _load_file(self, path: Path) -> None:
|
||||
"""Load policy from a JSON file."""
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
for agent_data in data.get("agents", []):
|
||||
agent_id = agent_data["agent_id"]
|
||||
for grant_data in agent_data.get("grants", []):
|
||||
self.grant(
|
||||
agent_id,
|
||||
grant_data["capability"],
|
||||
grant_data.get("pattern", "*"),
|
||||
)
|
||||
for denied in agent_data.get("deny", []):
|
||||
self.deny(agent_id, denied)
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
pass
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Save policy to a JSON file."""
|
||||
agents = []
|
||||
for agent_id, policy in self._policies.items():
|
||||
agents.append({
|
||||
"agent_id": agent_id,
|
||||
"grants": [
|
||||
{"capability": g.capability, "pattern": g.pattern}
|
||||
for g in policy.grants
|
||||
],
|
||||
"deny": policy.deny,
|
||||
})
|
||||
path.write_text(json.dumps({"agents": agents}, indent=2))
|
||||
|
||||
|
||||
# Default capability requirements for built-in tools
|
||||
DEFAULT_TOOL_CAPABILITIES: Dict[str, List[str]] = {
|
||||
"file_read": [Capability.FILE_READ],
|
||||
"web_search": [Capability.NETWORK_FETCH],
|
||||
"code_interpreter": [Capability.CODE_EXECUTE],
|
||||
"memory_store": [Capability.MEMORY_WRITE],
|
||||
"memory_retrieve": [Capability.MEMORY_READ],
|
||||
"memory_search": [Capability.MEMORY_READ],
|
||||
"memory_index": [Capability.MEMORY_WRITE],
|
||||
"schedule_task": [Capability.SCHEDULE_CREATE],
|
||||
"channel_send": [Capability.CHANNEL_SEND],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentPolicy",
|
||||
"Capability",
|
||||
"CapabilityGrant",
|
||||
"CapabilityPolicy",
|
||||
"DEFAULT_TOOL_CAPABILITIES",
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Prompt injection scanner — detect malicious patterns in text."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from openjarvis.security.types import ScanFinding, ThreatLevel
|
||||
|
||||
# Threat level ordering for comparison
|
||||
_THREAT_ORDER = [
|
||||
ThreatLevel.LOW,
|
||||
ThreatLevel.MEDIUM,
|
||||
ThreatLevel.HIGH,
|
||||
ThreatLevel.CRITICAL,
|
||||
]
|
||||
|
||||
# Injection patterns: (regex, name, threat_level, description)
|
||||
_INJECTION_PATTERNS = [
|
||||
# System prompt override attempts
|
||||
(
|
||||
r"(?i)ignore\s+(all\s+)?(previous|prior|above)"
|
||||
r"\s+(instructions?|prompts?|rules?)",
|
||||
"prompt_override",
|
||||
ThreatLevel.HIGH,
|
||||
"Attempt to override system instructions",
|
||||
),
|
||||
(
|
||||
r"(?i)you\s+are\s+now\s+(?:a\s+)?(?:different|new|my)",
|
||||
"identity_override",
|
||||
ThreatLevel.HIGH,
|
||||
"Attempt to change AI identity",
|
||||
),
|
||||
(
|
||||
r"(?i)disregard\s+(?:all\s+)?(?:previous|prior|your)"
|
||||
r"\s+(?:instructions?|programming|rules?)",
|
||||
"prompt_override",
|
||||
ThreatLevel.HIGH,
|
||||
"Attempt to disregard instructions",
|
||||
),
|
||||
# Shell/code injection via prompt
|
||||
(
|
||||
r"(?i)(?:execute|run|eval)\s*\(\s*['\"]",
|
||||
"code_injection",
|
||||
ThreatLevel.HIGH,
|
||||
"Code execution attempt in prompt",
|
||||
),
|
||||
(
|
||||
r"(?:;|\||&&)\s*(?:rm|curl|wget|nc|ncat"
|
||||
r"|bash|sh|python|perl)\s",
|
||||
"shell_injection",
|
||||
ThreatLevel.HIGH,
|
||||
"Shell command injection",
|
||||
),
|
||||
# Data exfiltration
|
||||
(
|
||||
r"(?i)(?:send|post|upload|exfiltrate|transmit)"
|
||||
r"\s+(?:(?:to|data|all|everything)\s+)*"
|
||||
r"(?:to\s+)?(?:https?://|my\s+server)",
|
||||
"exfiltration",
|
||||
ThreatLevel.HIGH,
|
||||
"Data exfiltration attempt",
|
||||
),
|
||||
(
|
||||
r"(?i)base64\s+encode\s+(?:and\s+)?"
|
||||
r"(?:send|include|append)",
|
||||
"exfiltration",
|
||||
ThreatLevel.MEDIUM,
|
||||
"Encoded exfiltration attempt",
|
||||
),
|
||||
# Jailbreak patterns
|
||||
(
|
||||
r"(?i)(?:DAN|do\s+anything\s+now)"
|
||||
r"\s+(?:mode|prompt|jailbreak)",
|
||||
"jailbreak",
|
||||
ThreatLevel.HIGH,
|
||||
"DAN jailbreak attempt",
|
||||
),
|
||||
(
|
||||
r"(?i)pretend\s+(?:you\s+)?(?:have\s+)?no"
|
||||
r"\s+(?:restrictions?|limitations?|rules?|filters?)",
|
||||
"jailbreak",
|
||||
ThreatLevel.MEDIUM,
|
||||
"Restriction bypass attempt",
|
||||
),
|
||||
# Delimiter injection
|
||||
(
|
||||
r"```(?:system|assistant)\b",
|
||||
"delimiter_injection",
|
||||
ThreatLevel.MEDIUM,
|
||||
"Role delimiter injection",
|
||||
),
|
||||
(
|
||||
r"<\|(?:im_start|im_end|system|assistant)\|>",
|
||||
"delimiter_injection",
|
||||
ThreatLevel.HIGH,
|
||||
"Chat template injection",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InjectionScanResult:
|
||||
"""Result of an injection scan."""
|
||||
is_clean: bool
|
||||
findings: List[ScanFinding]
|
||||
threat_level: ThreatLevel # highest threat found
|
||||
|
||||
|
||||
class InjectionScanner:
|
||||
"""Scan text for prompt injection patterns.
|
||||
|
||||
Implements pattern-based detection for common injection techniques:
|
||||
- System prompt overrides
|
||||
- Shell/code injection
|
||||
- Data exfiltration attempts
|
||||
- Jailbreak patterns
|
||||
- Delimiter injection
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._patterns = [
|
||||
(re.compile(pat), name, level, desc)
|
||||
for pat, name, level, desc in _INJECTION_PATTERNS
|
||||
]
|
||||
|
||||
def scan(self, text: str) -> InjectionScanResult:
|
||||
"""Scan text for injection patterns."""
|
||||
findings: List[ScanFinding] = []
|
||||
max_threat = ThreatLevel.LOW
|
||||
|
||||
for pattern, name, level, desc in self._patterns:
|
||||
for match in pattern.finditer(text):
|
||||
findings.append(ScanFinding(
|
||||
pattern_name=name,
|
||||
matched_text=match.group(0)[:100],
|
||||
threat_level=level,
|
||||
start=match.start(),
|
||||
end=match.end(),
|
||||
description=desc,
|
||||
))
|
||||
idx = _THREAT_ORDER.index(level)
|
||||
if idx > _THREAT_ORDER.index(max_threat):
|
||||
max_threat = level
|
||||
|
||||
return InjectionScanResult(
|
||||
is_clean=len(findings) == 0,
|
||||
findings=findings,
|
||||
threat_level=max_threat if findings else ThreatLevel.LOW,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["InjectionScanner", "InjectionScanResult"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Rate limiter -- token bucket algorithm for per-agent/per-tool throttling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
__all__ = ["RateLimitConfig", "RateLimiter", "TokenBucket"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limiting."""
|
||||
requests_per_minute: int = 60
|
||||
burst_size: int = 10 # max tokens in bucket
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class TokenBucket:
|
||||
"""Thread-safe token bucket for rate limiting."""
|
||||
|
||||
def __init__(self, rate: float, capacity: int) -> None:
|
||||
self._rate = rate # tokens per second
|
||||
self._capacity = capacity
|
||||
self._tokens = float(capacity)
|
||||
self._last_refill = time.monotonic()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def consume(self, tokens: int = 1) -> Tuple[bool, float]:
|
||||
"""Try to consume tokens. Returns (allowed, wait_seconds)."""
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_refill
|
||||
self._tokens = min(
|
||||
self._capacity,
|
||||
self._tokens + elapsed * self._rate,
|
||||
)
|
||||
self._last_refill = now
|
||||
|
||||
if self._tokens >= tokens:
|
||||
self._tokens -= tokens
|
||||
return True, 0.0
|
||||
else:
|
||||
wait = (tokens - self._tokens) / self._rate
|
||||
return False, wait
|
||||
|
||||
@property
|
||||
def available(self) -> float:
|
||||
"""Current available tokens (approximate)."""
|
||||
with self._lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_refill
|
||||
return min(self._capacity, self._tokens + elapsed * self._rate)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Rate limiter with per-key token buckets.
|
||||
|
||||
Keys are typically "agent_id:tool_name" or just "agent_id".
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[RateLimitConfig] = None) -> None:
|
||||
self._config = config or RateLimitConfig()
|
||||
self._buckets: Dict[str, TokenBucket] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def check(self, key: str) -> Tuple[bool, float]:
|
||||
"""Check if request is allowed for key. Returns (allowed, wait_seconds)."""
|
||||
if not self._config.enabled:
|
||||
return True, 0.0
|
||||
|
||||
bucket = self._get_bucket(key)
|
||||
return bucket.consume()
|
||||
|
||||
def _get_bucket(self, key: str) -> TokenBucket:
|
||||
"""Get or create a bucket for the given key."""
|
||||
with self._lock:
|
||||
if key not in self._buckets:
|
||||
rate = self._config.requests_per_minute / 60.0
|
||||
self._buckets[key] = TokenBucket(
|
||||
rate=rate,
|
||||
capacity=self._config.burst_size,
|
||||
)
|
||||
return self._buckets[key]
|
||||
|
||||
def reset(self, key: Optional[str] = None) -> None:
|
||||
"""Reset rate limit state for a key or all keys."""
|
||||
with self._lock:
|
||||
if key:
|
||||
self._buckets.pop(key, None)
|
||||
else:
|
||||
self._buckets.clear()
|
||||
|
||||
@property
|
||||
def config(self) -> RateLimitConfig:
|
||||
return self._config
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Ed25519 signing — supply chain integrity for agent and skill manifests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KeyPair:
|
||||
"""Ed25519 key pair."""
|
||||
private_key: bytes
|
||||
public_key: bytes
|
||||
|
||||
|
||||
def generate_keypair() -> KeyPair:
|
||||
"""Generate a new Ed25519 key pair.
|
||||
|
||||
Requires the ``cryptography`` package
|
||||
(``pip install openjarvis[security-signing]``).
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
private_bytes = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PrivateFormat.Raw,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
public_bytes = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
return KeyPair(private_key=private_bytes, public_key=public_bytes)
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Ed25519 signing requires the 'cryptography' package. "
|
||||
"Install with: pip install openjarvis[security-signing]"
|
||||
) from exc
|
||||
|
||||
|
||||
def sign(data: bytes, private_key: bytes) -> bytes:
|
||||
"""Sign *data* with an Ed25519 *private_key*.
|
||||
|
||||
Returns the raw 64-byte signature.
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
key = Ed25519PrivateKey.from_private_bytes(private_key)
|
||||
return key.sign(data)
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Ed25519 signing requires the 'cryptography' package."
|
||||
) from exc
|
||||
|
||||
|
||||
def verify(data: bytes, signature: bytes, public_key: bytes) -> bool:
|
||||
"""Verify an Ed25519 *signature* on *data* with *public_key*.
|
||||
|
||||
Returns True if valid, False otherwise.
|
||||
"""
|
||||
try:
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
key = Ed25519PublicKey.from_public_bytes(public_key)
|
||||
try:
|
||||
key.verify(signature, data)
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Ed25519 signing requires the 'cryptography' package."
|
||||
) from exc
|
||||
|
||||
|
||||
def sign_b64(data: bytes, private_key: bytes) -> str:
|
||||
"""Sign and return base64-encoded signature string."""
|
||||
raw = sign(data, private_key)
|
||||
return base64.b64encode(raw).decode("ascii")
|
||||
|
||||
|
||||
def verify_b64(data: bytes, signature_b64: str, public_key: bytes) -> bool:
|
||||
"""Verify a base64-encoded signature."""
|
||||
try:
|
||||
raw = base64.b64decode(signature_b64)
|
||||
except Exception:
|
||||
return False
|
||||
return verify(data, raw, public_key)
|
||||
|
||||
|
||||
def load_public_key(path: str) -> bytes:
|
||||
"""Load a raw 32-byte Ed25519 public key from a file."""
|
||||
from pathlib import Path
|
||||
raw = Path(path).read_bytes()
|
||||
# If base64-encoded (common), decode
|
||||
if len(raw) > 32:
|
||||
try:
|
||||
raw = base64.b64decode(raw.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return raw
|
||||
|
||||
|
||||
def save_keypair(keypair: KeyPair, private_path: str, public_path: str) -> None:
|
||||
"""Save keypair to files (base64-encoded)."""
|
||||
from pathlib import Path
|
||||
Path(private_path).write_text(base64.b64encode(keypair.private_key).decode())
|
||||
Path(public_path).write_text(base64.b64encode(keypair.public_key).decode())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"KeyPair",
|
||||
"generate_keypair",
|
||||
"load_public_key",
|
||||
"save_keypair",
|
||||
"sign",
|
||||
"sign_b64",
|
||||
"verify",
|
||||
"verify_b64",
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""SSRF protection — block requests to private IPs and cloud metadata endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
# Cloud metadata endpoints to block
|
||||
_BLOCKED_HOSTS = frozenset({
|
||||
"169.254.169.254", # AWS/GCP/Azure metadata
|
||||
"metadata.google.internal",
|
||||
"metadata.google.com",
|
||||
"100.100.100.200", # Alibaba Cloud metadata
|
||||
})
|
||||
|
||||
_BLOCKED_CIDR = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"), # link-local
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"), # unique local
|
||||
ipaddress.ip_network("fe80::/10"), # link-local v6
|
||||
]
|
||||
|
||||
|
||||
def is_private_ip(ip_str: str) -> bool:
|
||||
"""Check if an IP address is private/reserved."""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip_str)
|
||||
return any(addr in net for net in _BLOCKED_CIDR)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def check_ssrf(url: str) -> Optional[str]:
|
||||
"""Check a URL for SSRF vulnerabilities. Returns error message or None if safe."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return "No hostname in URL"
|
||||
|
||||
# Check blocked hosts
|
||||
if hostname in _BLOCKED_HOSTS:
|
||||
return f"Blocked host: {hostname} (cloud metadata endpoint)"
|
||||
|
||||
# DNS resolution check
|
||||
try:
|
||||
resolved = socket.getaddrinfo(
|
||||
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM,
|
||||
)
|
||||
for family, stype, proto, canonname, sockaddr in resolved:
|
||||
ip = sockaddr[0]
|
||||
if is_private_ip(ip):
|
||||
return f"URL resolves to private IP: {ip}"
|
||||
except socket.gaierror:
|
||||
pass # DNS resolution failed — allow (will fail at request time)
|
||||
|
||||
return None # Safe
|
||||
|
||||
|
||||
__all__ = ["check_ssrf", "is_private_ip"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Subprocess sandbox — secure process execution with environment isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# Safe environment variables to pass through
|
||||
_SAFE_ENV_VARS = frozenset({
|
||||
"PATH", "HOME", "USER", "LANG", "TERM", "SHELL",
|
||||
"LC_ALL", "LC_CTYPE", "TMPDIR", "TZ",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SandboxResult:
|
||||
"""Result of a sandboxed subprocess execution."""
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
returncode: int = -1
|
||||
timed_out: bool = False
|
||||
killed: bool = False
|
||||
|
||||
|
||||
def build_safe_env(
|
||||
passthrough: Optional[List[str]] = None,
|
||||
extra: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Build a sanitized environment dict.
|
||||
|
||||
Only copies safe vars from current env, plus any in passthrough list.
|
||||
Extra vars are added directly.
|
||||
"""
|
||||
env: Dict[str, str] = {}
|
||||
allowed = _SAFE_ENV_VARS | frozenset(passthrough or [])
|
||||
for key in allowed:
|
||||
val = os.environ.get(key)
|
||||
if val is not None:
|
||||
env[key] = val
|
||||
if extra:
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def kill_process_tree(pid: int) -> None:
|
||||
"""Kill a process and all its children (best effort)."""
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
|
||||
def run_sandboxed(
|
||||
command: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
working_dir: Optional[str] = None,
|
||||
env_passthrough: Optional[List[str]] = None,
|
||||
env_extra: Optional[Dict[str, str]] = None,
|
||||
max_output_bytes: int = 102_400,
|
||||
) -> SandboxResult:
|
||||
"""Execute a command in a sandboxed subprocess.
|
||||
|
||||
Features:
|
||||
- Clean environment (only safe vars passed through)
|
||||
- Timeout enforcement with process tree kill
|
||||
- Output truncation
|
||||
- New process group for clean cleanup
|
||||
"""
|
||||
env = build_safe_env(passthrough=env_passthrough, extra=env_extra)
|
||||
cwd = working_dir if working_dir and os.path.isdir(working_dir) else None
|
||||
|
||||
result = SandboxResult()
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env,
|
||||
cwd=cwd,
|
||||
preexec_fn=os.setsid, # New process group
|
||||
)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
result.stdout = stdout[:max_output_bytes] if stdout else ""
|
||||
result.stderr = stderr[:max_output_bytes] if stderr else ""
|
||||
result.returncode = proc.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
kill_process_tree(proc.pid)
|
||||
proc.wait(timeout=5)
|
||||
result.timed_out = True
|
||||
result.killed = True
|
||||
result.returncode = -1
|
||||
result.stdout = "(timed out)"
|
||||
result.stderr = ""
|
||||
except OSError as exc:
|
||||
result.stderr = f"Execution error: {exc}"
|
||||
result.returncode = -1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
__all__ = ["SandboxResult", "build_safe_env", "kill_process_tree", "run_sandboxed"]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Taint tracking — information flow control.
|
||||
|
||||
Prevents data leakage through tool chains.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, FrozenSet, Optional, Set
|
||||
|
||||
|
||||
class TaintLabel(str, Enum):
|
||||
"""Labels for tainted data."""
|
||||
PII = "pii"
|
||||
SECRET = "secret"
|
||||
USER_PRIVATE = "user_private"
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaintSet:
|
||||
"""Immutable set of taint labels attached to data."""
|
||||
labels: FrozenSet[TaintLabel] = field(default_factory=frozenset)
|
||||
|
||||
def union(self, other: TaintSet) -> TaintSet:
|
||||
"""Merge two taint sets."""
|
||||
return TaintSet(labels=self.labels | other.labels)
|
||||
|
||||
def has(self, label: TaintLabel) -> bool:
|
||||
"""Check if a specific label is present."""
|
||||
return label in self.labels
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.labels)
|
||||
|
||||
@classmethod
|
||||
def from_labels(cls, *labels: TaintLabel) -> TaintSet:
|
||||
"""Create from one or more labels."""
|
||||
return cls(labels=frozenset(labels))
|
||||
|
||||
|
||||
# Sink policy: which taint labels are forbidden for each tool
|
||||
# If a tool appears here, data with any of the listed labels MUST NOT
|
||||
# be passed to that tool.
|
||||
SINK_POLICY: Dict[str, Set[TaintLabel]] = {
|
||||
"web_search": {TaintLabel.PII, TaintLabel.SECRET},
|
||||
"channel_send": {TaintLabel.SECRET},
|
||||
"code_interpreter": {TaintLabel.SECRET},
|
||||
}
|
||||
|
||||
# Patterns for auto-detecting taint in text
|
||||
_PII_PATTERNS = [
|
||||
re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), # SSN
|
||||
re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"), # email
|
||||
re.compile(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b"), # credit card
|
||||
re.compile(r"\b\+?1?\s*\(?[2-9]\d{2}\)?\s*[-.\s]?\d{3}\s*[-.\s]?\d{4}\b"), # phone
|
||||
]
|
||||
|
||||
_SECRET_PATTERNS = [
|
||||
re.compile(r"(?:sk|pk|api)[_-][a-zA-Z0-9]{20,}"), # API keys
|
||||
re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}"), # GitHub tokens
|
||||
re.compile(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----"), # Private keys
|
||||
re.compile(
|
||||
r"(?:bearer|token|password|secret|key)\s*[=:]\s*\S{8,}",
|
||||
re.IGNORECASE,
|
||||
), # Generic secrets
|
||||
]
|
||||
|
||||
|
||||
def check_taint(tool_name: str, taint: TaintSet) -> Optional[str]:
|
||||
"""Check if *taint* labels violate the sink policy for *tool_name*.
|
||||
|
||||
Returns a violation description string, or None if clean.
|
||||
"""
|
||||
forbidden = SINK_POLICY.get(tool_name)
|
||||
if forbidden is None:
|
||||
return None
|
||||
violations = taint.labels & forbidden
|
||||
if violations:
|
||||
labels_str = ", ".join(
|
||||
v.value
|
||||
for v in sorted(violations, key=lambda x: x.value)
|
||||
)
|
||||
return (
|
||||
f"Data with labels [{labels_str}] "
|
||||
f"cannot be sent to '{tool_name}'."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def declassify(taint: TaintSet, remove: TaintLabel, reason: str) -> TaintSet:
|
||||
"""Remove a taint label with an explicit reason (for audit).
|
||||
|
||||
The *reason* is not stored on the TaintSet itself but should be
|
||||
logged externally for accountability.
|
||||
"""
|
||||
return TaintSet(labels=taint.labels - {remove})
|
||||
|
||||
|
||||
def auto_detect_taint(text: str) -> TaintSet:
|
||||
"""Auto-detect taint labels in text content.
|
||||
|
||||
Uses regex patterns to detect PII and secrets in tool output.
|
||||
"""
|
||||
labels: set[TaintLabel] = set()
|
||||
|
||||
for pattern in _PII_PATTERNS:
|
||||
if pattern.search(text):
|
||||
labels.add(TaintLabel.PII)
|
||||
break
|
||||
|
||||
for pattern in _SECRET_PATTERNS:
|
||||
if pattern.search(text):
|
||||
labels.add(TaintLabel.SECRET)
|
||||
break
|
||||
|
||||
return TaintSet(labels=frozenset(labels))
|
||||
|
||||
|
||||
def propagate_taint(
|
||||
input_taint: TaintSet,
|
||||
output_text: str,
|
||||
) -> TaintSet:
|
||||
"""Propagate taint: union of input taint with auto-detected output taint."""
|
||||
output_taint = auto_detect_taint(output_text)
|
||||
return input_taint.union(output_taint)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SINK_POLICY",
|
||||
"TaintLabel",
|
||||
"TaintSet",
|
||||
"auto_detect_taint",
|
||||
"check_taint",
|
||||
"declassify",
|
||||
"propagate_taint",
|
||||
]
|
||||
@@ -0,0 +1,599 @@
|
||||
"""Extended API routes for agents, workflows, memory, traces, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
# ---- Request/Response models ----
|
||||
|
||||
class AgentCreateRequest(BaseModel):
|
||||
agent_type: str
|
||||
tools: Optional[List[str]] = None
|
||||
agent_id: Optional[str] = None
|
||||
|
||||
class AgentMessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
class MemoryStoreRequest(BaseModel):
|
||||
content: str
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
class MemorySearchRequest(BaseModel):
|
||||
query: str
|
||||
top_k: int = 5
|
||||
|
||||
class BudgetLimitsRequest(BaseModel):
|
||||
max_tokens_per_day: Optional[int] = None
|
||||
max_requests_per_hour: Optional[int] = None
|
||||
|
||||
|
||||
# ---- Agent routes ----
|
||||
|
||||
agents_router = APIRouter(prefix="/v1/agents", tags=["agents"])
|
||||
|
||||
@agents_router.get("")
|
||||
async def list_agents(request: Request):
|
||||
"""List available agent types and running agents."""
|
||||
registered = []
|
||||
try:
|
||||
import openjarvis.agents # noqa: F401 — side-effect registration
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
for key in sorted(AgentRegistry.keys()):
|
||||
cls = AgentRegistry.get(key)
|
||||
registered.append({
|
||||
"key": key,
|
||||
"class": cls.__name__,
|
||||
"accepts_tools": getattr(cls, "accepts_tools", False),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
running = []
|
||||
try:
|
||||
from openjarvis.tools.agent_tools import _SPAWNED_AGENTS
|
||||
running = [
|
||||
{"id": k, **v} for k, v in _SPAWNED_AGENTS.items()
|
||||
]
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return {"registered": registered, "running": running}
|
||||
|
||||
@agents_router.post("")
|
||||
async def create_agent(req: AgentCreateRequest, request: Request):
|
||||
"""Spawn a new agent."""
|
||||
try:
|
||||
from openjarvis.tools.agent_tools import AgentSpawnTool
|
||||
tool = AgentSpawnTool()
|
||||
params = {"agent_type": req.agent_type}
|
||||
if req.tools:
|
||||
params["tools"] = ",".join(req.tools)
|
||||
if req.agent_id:
|
||||
params["agent_id"] = req.agent_id
|
||||
result = tool.execute(**params)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=400, detail=result.content)
|
||||
return {
|
||||
"status": "created",
|
||||
"content": result.content,
|
||||
"metadata": result.metadata,
|
||||
}
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="Agent tools not available")
|
||||
|
||||
@agents_router.delete("/{agent_id}")
|
||||
async def kill_agent(agent_id: str, request: Request):
|
||||
"""Kill a running agent."""
|
||||
try:
|
||||
from openjarvis.tools.agent_tools import AgentKillTool
|
||||
tool = AgentKillTool()
|
||||
result = tool.execute(agent_id=agent_id)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=404, detail=result.content)
|
||||
return {"status": "stopped", "agent_id": agent_id}
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="Agent tools not available")
|
||||
|
||||
@agents_router.post("/{agent_id}/message")
|
||||
async def message_agent(agent_id: str, req: AgentMessageRequest, request: Request):
|
||||
"""Send a message to a running agent."""
|
||||
try:
|
||||
from openjarvis.tools.agent_tools import AgentSendTool
|
||||
tool = AgentSendTool()
|
||||
result = tool.execute(agent_id=agent_id, message=req.message)
|
||||
if not result.success:
|
||||
raise HTTPException(status_code=404, detail=result.content)
|
||||
return {"status": "sent", "content": result.content}
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="Agent tools not available")
|
||||
|
||||
|
||||
# ---- Memory routes ----
|
||||
|
||||
memory_router = APIRouter(prefix="/v1/memory", tags=["memory"])
|
||||
|
||||
@memory_router.post("/store")
|
||||
async def memory_store(req: MemoryStoreRequest, request: Request):
|
||||
"""Store content in memory."""
|
||||
try:
|
||||
from openjarvis.tools.storage.sqlite import SQLiteMemory
|
||||
backend = SQLiteMemory()
|
||||
backend.store(req.content, metadata=req.metadata or {})
|
||||
return {"status": "stored"}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
@memory_router.post("/search")
|
||||
async def memory_search(req: MemorySearchRequest, request: Request):
|
||||
"""Search memory for relevant content."""
|
||||
try:
|
||||
from openjarvis.tools.storage.sqlite import SQLiteMemory
|
||||
backend = SQLiteMemory()
|
||||
results = backend.search(req.query, top_k=req.top_k)
|
||||
items = [
|
||||
{"content": r.content, "score": r.score, "metadata": r.metadata}
|
||||
for r in results
|
||||
]
|
||||
return {"results": items}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
@memory_router.get("/stats")
|
||||
async def memory_stats(request: Request):
|
||||
"""Get memory backend statistics."""
|
||||
try:
|
||||
from openjarvis.tools.storage.sqlite import SQLiteMemory
|
||||
backend = SQLiteMemory()
|
||||
stats = backend.stats()
|
||||
return stats
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
# ---- Traces routes ----
|
||||
|
||||
traces_router = APIRouter(prefix="/v1/traces", tags=["traces"])
|
||||
|
||||
@traces_router.get("")
|
||||
async def list_traces(request: Request, limit: int = 20):
|
||||
"""List recent traces."""
|
||||
try:
|
||||
from openjarvis.traces.store import TraceStore
|
||||
store = TraceStore()
|
||||
traces = store.recent(limit=limit)
|
||||
items = [
|
||||
t.to_dict() if hasattr(t, "to_dict") else str(t)
|
||||
for t in traces
|
||||
]
|
||||
return {"traces": items}
|
||||
except Exception as exc:
|
||||
return {"traces": [], "error": str(exc)}
|
||||
|
||||
@traces_router.get("/{trace_id}")
|
||||
async def get_trace(trace_id: str, request: Request):
|
||||
"""Get a specific trace by ID."""
|
||||
try:
|
||||
from openjarvis.traces.store import TraceStore
|
||||
store = TraceStore()
|
||||
trace = store.get(trace_id)
|
||||
if trace is None:
|
||||
raise HTTPException(status_code=404, detail="Trace not found")
|
||||
return trace.to_dict() if hasattr(trace, 'to_dict') else {"id": trace_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
# ---- Telemetry routes ----
|
||||
|
||||
telemetry_router = APIRouter(prefix="/v1/telemetry", tags=["telemetry"])
|
||||
|
||||
@telemetry_router.get("/stats")
|
||||
async def telemetry_stats(request: Request):
|
||||
"""Get aggregated telemetry statistics."""
|
||||
try:
|
||||
from openjarvis.telemetry.aggregator import TelemetryAggregator
|
||||
agg = TelemetryAggregator()
|
||||
return agg.summary()
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
@telemetry_router.get("/energy")
|
||||
async def telemetry_energy(request: Request):
|
||||
"""Get energy monitoring data."""
|
||||
try:
|
||||
from openjarvis.telemetry.aggregator import TelemetryAggregator
|
||||
agg = TelemetryAggregator()
|
||||
return agg.energy_summary()
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
# ---- Skills routes ----
|
||||
|
||||
skills_router = APIRouter(prefix="/v1/skills", tags=["skills"])
|
||||
|
||||
@skills_router.get("")
|
||||
async def list_skills(request: Request):
|
||||
"""List installed skills."""
|
||||
try:
|
||||
from openjarvis.core.registry import SkillRegistry
|
||||
skills = []
|
||||
for key in sorted(SkillRegistry.keys()):
|
||||
skills.append({"name": key})
|
||||
return {"skills": skills}
|
||||
except Exception:
|
||||
return {"skills": []}
|
||||
|
||||
@skills_router.post("")
|
||||
async def install_skill(request: Request):
|
||||
"""Install a skill (placeholder)."""
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Use TOML files in ~/.openjarvis/skills/",
|
||||
}
|
||||
|
||||
@skills_router.delete("/{skill_name}")
|
||||
async def remove_skill(skill_name: str, request: Request):
|
||||
"""Remove a skill (placeholder)."""
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Skill removal not yet supported via API",
|
||||
}
|
||||
|
||||
|
||||
# ---- Sessions routes ----
|
||||
|
||||
sessions_router = APIRouter(prefix="/v1/sessions", tags=["sessions"])
|
||||
|
||||
@sessions_router.get("")
|
||||
async def list_sessions(request: Request, limit: int = 20):
|
||||
"""List active sessions."""
|
||||
try:
|
||||
from openjarvis.sessions.store import SessionStore
|
||||
store = SessionStore()
|
||||
sessions = store.recent(limit=limit)
|
||||
items = [
|
||||
s.to_dict() if hasattr(s, "to_dict") else str(s)
|
||||
for s in sessions
|
||||
]
|
||||
return {"sessions": items}
|
||||
except Exception as exc:
|
||||
return {"sessions": [], "error": str(exc)}
|
||||
|
||||
@sessions_router.get("/{session_id}")
|
||||
async def get_session(session_id: str, request: Request):
|
||||
"""Get a specific session."""
|
||||
try:
|
||||
from openjarvis.sessions.store import SessionStore
|
||||
store = SessionStore()
|
||||
session = store.get(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return session.to_dict() if hasattr(session, 'to_dict') else {"id": session_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
# ---- Budget routes ----
|
||||
|
||||
budget_router = APIRouter(prefix="/v1/budget", tags=["budget"])
|
||||
|
||||
_budget_limits: Dict[str, Any] = {
|
||||
"max_tokens_per_day": None,
|
||||
"max_requests_per_hour": None,
|
||||
}
|
||||
_budget_usage: Dict[str, int] = {
|
||||
"tokens_today": 0,
|
||||
"requests_this_hour": 0,
|
||||
}
|
||||
|
||||
@budget_router.get("")
|
||||
async def get_budget(request: Request):
|
||||
"""Get current budget usage and limits."""
|
||||
return {"limits": _budget_limits, "usage": _budget_usage}
|
||||
|
||||
@budget_router.put("/limits")
|
||||
async def set_budget_limits(req: BudgetLimitsRequest, request: Request):
|
||||
"""Update budget limits."""
|
||||
if req.max_tokens_per_day is not None:
|
||||
_budget_limits["max_tokens_per_day"] = req.max_tokens_per_day
|
||||
if req.max_requests_per_hour is not None:
|
||||
_budget_limits["max_requests_per_hour"] = req.max_requests_per_hour
|
||||
return {"status": "updated", "limits": _budget_limits}
|
||||
|
||||
|
||||
# ---- Prometheus metrics ----
|
||||
|
||||
metrics_router = APIRouter(tags=["metrics"])
|
||||
|
||||
@metrics_router.get("/metrics")
|
||||
async def prometheus_metrics(request: Request):
|
||||
"""Prometheus-compatible metrics endpoint."""
|
||||
try:
|
||||
from openjarvis.telemetry.aggregator import TelemetryAggregator
|
||||
agg = TelemetryAggregator()
|
||||
stats = agg.summary()
|
||||
|
||||
lines = [
|
||||
"# HELP openjarvis_requests_total Total requests processed",
|
||||
"# TYPE openjarvis_requests_total counter",
|
||||
f"openjarvis_requests_total {stats.get('total_requests', 0)}",
|
||||
"# HELP openjarvis_tokens_total Total tokens generated",
|
||||
"# TYPE openjarvis_tokens_total counter",
|
||||
f"openjarvis_tokens_total {stats.get('total_tokens', 0)}",
|
||||
"# HELP openjarvis_latency_avg_ms Average latency in milliseconds",
|
||||
"# TYPE openjarvis_latency_avg_ms gauge",
|
||||
f"openjarvis_latency_avg_ms {stats.get('avg_latency_ms', 0)}",
|
||||
]
|
||||
from starlette.responses import PlainTextResponse
|
||||
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/plain")
|
||||
except Exception:
|
||||
from starlette.responses import PlainTextResponse
|
||||
return PlainTextResponse(
|
||||
"# No metrics available\n", media_type="text/plain"
|
||||
)
|
||||
|
||||
|
||||
# ---- WebSocket streaming routes ----
|
||||
|
||||
websocket_router = APIRouter(tags=["websocket"])
|
||||
|
||||
|
||||
@websocket_router.websocket("/v1/chat/stream")
|
||||
async def websocket_chat_stream(websocket: WebSocket):
|
||||
"""Stream chat responses over a WebSocket connection.
|
||||
|
||||
Accepts JSON messages of the form::
|
||||
|
||||
{"message": "...", "model": "...", "agent": "..."}
|
||||
|
||||
Sends back JSON chunks::
|
||||
|
||||
{"type": "chunk", "content": "..."} -- per-token streaming
|
||||
{"type": "done", "content": "..."} -- final assembled response
|
||||
{"type": "error", "detail": "..."} -- on failure
|
||||
"""
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Invalid JSON"},
|
||||
)
|
||||
continue
|
||||
|
||||
message = data.get("message")
|
||||
if not message:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "Missing 'message' field"},
|
||||
)
|
||||
continue
|
||||
|
||||
model = data.get("model") or getattr(
|
||||
websocket.app.state, "model", "default",
|
||||
)
|
||||
engine = getattr(websocket.app.state, "engine", None)
|
||||
if engine is None:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": "No engine configured"},
|
||||
)
|
||||
continue
|
||||
|
||||
messages = [{"role": "user", "content": message}]
|
||||
|
||||
try:
|
||||
# Prefer streaming if the engine supports it
|
||||
stream_fn = getattr(engine, "stream", None)
|
||||
if stream_fn is not None and (
|
||||
inspect.isasyncgenfunction(stream_fn)
|
||||
or callable(stream_fn)
|
||||
):
|
||||
full_content = ""
|
||||
try:
|
||||
gen = stream_fn(messages, model=model)
|
||||
# Handle both async and sync generators
|
||||
if inspect.isasyncgen(gen):
|
||||
async for token in gen:
|
||||
full_content += token
|
||||
await websocket.send_json(
|
||||
{"type": "chunk", "content": token},
|
||||
)
|
||||
else:
|
||||
# Sync generator — iterate in a thread to avoid
|
||||
# blocking the event loop
|
||||
for token in gen:
|
||||
full_content += token
|
||||
await websocket.send_json(
|
||||
{"type": "chunk", "content": token},
|
||||
)
|
||||
except TypeError:
|
||||
# stream() didn't return an iterable; fall back to
|
||||
# generate()
|
||||
result = engine.generate(messages, model=model)
|
||||
content = result.get("content", "") if isinstance(
|
||||
result, dict,
|
||||
) else str(result)
|
||||
full_content = content
|
||||
await websocket.send_json(
|
||||
{"type": "chunk", "content": content},
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "done", "content": full_content},
|
||||
)
|
||||
else:
|
||||
# No stream method — single-shot generate
|
||||
result = engine.generate(messages, model=model)
|
||||
content = result.get("content", "") if isinstance(
|
||||
result, dict,
|
||||
) else str(result)
|
||||
await websocket.send_json(
|
||||
{"type": "chunk", "content": content},
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "done", "content": content},
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "detail": str(exc)},
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
pass # Client disconnected — nothing to clean up
|
||||
|
||||
|
||||
# ---- Learning routes ----
|
||||
|
||||
learning_router = APIRouter(prefix="/v1/learning", tags=["learning"])
|
||||
|
||||
@learning_router.get("/stats")
|
||||
async def learning_stats(request: Request):
|
||||
"""Return learning system statistics across all sub-policies."""
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# GRPO policy state
|
||||
try:
|
||||
from openjarvis.learning.grpo_policy import GRPORouterPolicy
|
||||
policy = GRPORouterPolicy()
|
||||
state = policy.state
|
||||
result["grpo"] = {
|
||||
"available": True,
|
||||
"total_updates": state.total_updates,
|
||||
"sample_counts": dict(state.sample_counts),
|
||||
"weight_count": sum(
|
||||
len(qc_weights) for qc_weights in state.weights.values()
|
||||
),
|
||||
}
|
||||
except Exception:
|
||||
result["grpo"] = {"available": False}
|
||||
|
||||
# Bandit router state
|
||||
try:
|
||||
from openjarvis.learning.bandit_router import BanditRouterPolicy
|
||||
policy = BanditRouterPolicy()
|
||||
stats = policy.get_stats()
|
||||
result["bandit"] = {
|
||||
"available": True,
|
||||
"query_classes": len(stats),
|
||||
"arms": stats,
|
||||
}
|
||||
except Exception:
|
||||
result["bandit"] = {"available": False}
|
||||
|
||||
# ICL updater
|
||||
try:
|
||||
from openjarvis.learning.icl_updater import ICLUpdaterPolicy
|
||||
updater = ICLUpdaterPolicy()
|
||||
result["icl"] = {
|
||||
"available": True,
|
||||
"example_count": len(updater.examples),
|
||||
"example_db_count": len(updater.example_db),
|
||||
"version": updater.version,
|
||||
}
|
||||
except Exception:
|
||||
result["icl"] = {"available": False}
|
||||
|
||||
# Skill discovery
|
||||
try:
|
||||
from openjarvis.learning.skill_discovery import SkillDiscovery
|
||||
discovery = SkillDiscovery()
|
||||
result["skill_discovery"] = {
|
||||
"available": True,
|
||||
"discovered_count": len(discovery.discovered_skills),
|
||||
}
|
||||
except Exception:
|
||||
result["skill_discovery"] = {"available": False}
|
||||
|
||||
return result
|
||||
|
||||
@learning_router.get("/policy")
|
||||
async def learning_policy(request: Request):
|
||||
"""Return current routing policy configuration."""
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# Load config and extract learning section
|
||||
try:
|
||||
from openjarvis.core.config import load_config
|
||||
config = load_config()
|
||||
lc = config.learning
|
||||
result["enabled"] = lc.enabled
|
||||
result["update_interval"] = lc.update_interval
|
||||
result["auto_update"] = lc.auto_update
|
||||
result["routing"] = {
|
||||
"policy": lc.routing.policy,
|
||||
"min_samples": lc.routing.min_samples,
|
||||
}
|
||||
result["intelligence"] = {
|
||||
"policy": lc.intelligence.policy,
|
||||
}
|
||||
result["agent"] = {
|
||||
"policy": lc.agent.policy,
|
||||
}
|
||||
result["metrics"] = {
|
||||
"accuracy_weight": lc.metrics.accuracy_weight,
|
||||
"latency_weight": lc.metrics.latency_weight,
|
||||
"cost_weight": lc.metrics.cost_weight,
|
||||
"efficiency_weight": lc.metrics.efficiency_weight,
|
||||
}
|
||||
except Exception:
|
||||
result["enabled"] = False
|
||||
result["routing"] = {"policy": "heuristic", "min_samples": 5}
|
||||
result["intelligence"] = {"policy": "none"}
|
||||
result["agent"] = {"policy": "none"}
|
||||
result["metrics"] = {}
|
||||
|
||||
# Include GRPO weights if the routing policy is grpo
|
||||
if result.get("routing", {}).get("policy") == "grpo":
|
||||
try:
|
||||
from openjarvis.learning.grpo_policy import GRPORouterPolicy
|
||||
policy = GRPORouterPolicy()
|
||||
state = policy.state
|
||||
result["grpo_weights"] = {
|
||||
model: dict(qc_weights)
|
||||
for model, qc_weights in state.weights.items()
|
||||
}
|
||||
except Exception:
|
||||
result["grpo_weights"] = {}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def include_all_routes(app) -> None:
|
||||
"""Include all extended API routers in a FastAPI app."""
|
||||
app.include_router(agents_router)
|
||||
app.include_router(memory_router)
|
||||
app.include_router(traces_router)
|
||||
app.include_router(telemetry_router)
|
||||
app.include_router(skills_router)
|
||||
app.include_router(sessions_router)
|
||||
app.include_router(budget_router)
|
||||
app.include_router(metrics_router)
|
||||
app.include_router(websocket_router)
|
||||
app.include_router(learning_router)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"include_all_routes",
|
||||
"agents_router",
|
||||
"memory_router",
|
||||
"traces_router",
|
||||
"telemetry_router",
|
||||
"skills_router",
|
||||
"sessions_router",
|
||||
"budget_router",
|
||||
"metrics_router",
|
||||
"websocket_router",
|
||||
"learning_router",
|
||||
]
|
||||
@@ -9,6 +9,7 @@ from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from openjarvis.server.api_routes import include_all_routes
|
||||
from openjarvis.server.dashboard import dashboard_router
|
||||
from openjarvis.server.routes import router
|
||||
|
||||
@@ -111,12 +112,25 @@ def create_app(
|
||||
app.state.agent = agent
|
||||
app.state.bus = bus
|
||||
app.state.engine_name = engine_name
|
||||
app.state.agent_name = agent_name or (getattr(agent, "agent_id", None) if agent else None)
|
||||
app.state.agent_name = agent_name or (
|
||||
getattr(agent, "agent_id", None) if agent else None
|
||||
)
|
||||
app.state.channel_bridge = channel_bridge
|
||||
app.state.session_start = time.time()
|
||||
|
||||
app.include_router(router)
|
||||
app.include_router(dashboard_router)
|
||||
include_all_routes(app)
|
||||
|
||||
# Add security headers middleware
|
||||
try:
|
||||
from openjarvis.server.middleware import create_security_middleware
|
||||
|
||||
middleware_cls = create_security_middleware()
|
||||
if middleware_cls is not None:
|
||||
app.add_middleware(middleware_cls)
|
||||
except Exception:
|
||||
pass # middleware is best-effort
|
||||
|
||||
# Serve static frontend assets if the static/ directory exists
|
||||
static_dir = pathlib.Path(__file__).parent / "static"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user