mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-16 09:51:59 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5f840ad1 | ||
|
|
3042bfe3f1 | ||
|
|
40c7df3e5b | ||
|
|
da841e5282 | ||
|
|
548d9e04fe | ||
|
|
8d90e3dff1 | ||
|
|
b375d7cf09 | ||
|
|
c25c649048 | ||
|
|
f2df968aa5 | ||
|
|
8145597052 | ||
|
|
aec96f9d5d | ||
|
|
c2e1c375aa | ||
|
|
ef28e5f84b | ||
|
|
f2483e7bf3 | ||
|
|
156d41d2f9 | ||
|
|
4b7bb936ff | ||
|
|
3c68a17ac5 | ||
|
|
0d32784ed6 | ||
|
|
20a7424883 | ||
|
|
a97c64c67b | ||
|
|
64333651d1 | ||
|
|
9bee016c82 | ||
|
|
c9942961ad | ||
|
|
c3a7ffebff | ||
|
|
b3c57468ae | ||
|
|
ff69797135 | ||
|
|
465dba4b3f | ||
|
|
4f857b0abb | ||
|
|
20aa08ef04 | ||
|
|
9a63561db8 | ||
|
|
7959285ad8 | ||
|
|
26e1741059 | ||
|
|
2ed885eb11 | ||
|
|
07fcf35276 | ||
|
|
4efdb07dae | ||
|
|
7feda3dad3 | ||
|
|
f08ad574d3 | ||
|
|
1bfc25a860 | ||
|
|
6e40d87eb5 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "185,599",
|
||||
"message": "191,195",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 185599,
|
||||
"last_updated": "2026-08-10T07:26:44Z",
|
||||
"total_clones": 191195,
|
||||
"last_updated": "2026-08-15T06:32:36Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -137,6 +137,11 @@
|
||||
"2026-08-06": 604,
|
||||
"2026-08-07": 624,
|
||||
"2026-08-08": 706,
|
||||
"2026-08-09": 1076
|
||||
"2026-08-09": 1076,
|
||||
"2026-08-10": 1060,
|
||||
"2026-08-11": 2182,
|
||||
"2026-08-12": 641,
|
||||
"2026-08-13": 770,
|
||||
"2026-08-14": 943
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,104 @@ jobs:
|
||||
# latest release tag (#526).
|
||||
fetch-depth: 0
|
||||
|
||||
# Validate Apple credentials BEFORE the expensive work. Notarization is
|
||||
# the very last thing `tauri-action` does, so a bad credential or a
|
||||
# lapsed account agreement previously surfaced ~10 minutes in — after the
|
||||
# Rust toolchain, npm install, two Ollama sidecar downloads and a
|
||||
# universal cargo build — as a single opaque line:
|
||||
#
|
||||
# failed to bundle project: failed codesign application: failed to
|
||||
# notarize app: Error: HTTP status code: 403. ...
|
||||
#
|
||||
# `notarytool history` is a read-only call (it submits nothing) that
|
||||
# exercises the identical auth path, so every credential/account failure
|
||||
# mode reaches us here first, in seconds, with the specific cause named.
|
||||
# `xcrun` is preinstalled on macOS runners, hence placement before the
|
||||
# toolchain steps rather than next to "Configure Apple signing".
|
||||
- name: Preflight Apple notarization credentials
|
||||
if: matrix.platform == 'macos-14'
|
||||
env:
|
||||
CERT: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
A_ID: ${{ secrets.APPLE_ID }}
|
||||
A_PASS: ${{ secrets.APPLE_PASSWORD }}
|
||||
A_TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# Mirror the skip logic in "Configure Apple signing": without a
|
||||
# certificate the build is unsigned and never notarizes, so there is
|
||||
# nothing to preflight. Tag builds still hard-fail there.
|
||||
if [ -z "$CERT" ]; then
|
||||
echo "No Apple certificate configured; skipping notarization preflight."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
missing=""
|
||||
[ -z "$A_ID" ] && missing="$missing APPLE_ID"
|
||||
[ -z "$A_PASS" ] && missing="$missing APPLE_PASSWORD"
|
||||
[ -z "$A_TEAM" ] && missing="$missing APPLE_TEAM_ID"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::APPLE_CERTIFICATE is set but notarization secrets are missing:$missing"
|
||||
echo "::error::Signing would succeed and notarization would then fail. Set them or clear APPLE_CERTIFICATE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Retry only to absorb transient network faults. Credential and
|
||||
# account errors are deterministic, so we classify and exit on the
|
||||
# first definitive answer rather than retrying into the same wall.
|
||||
attempt=1
|
||||
while [ "$attempt" -le 3 ]; do
|
||||
out=$(xcrun notarytool history \
|
||||
--apple-id "$A_ID" \
|
||||
--team-id "$A_TEAM" \
|
||||
--password "$A_PASS" \
|
||||
--output-format json 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "Apple notarization preflight OK — credentials valid, team reachable, agreements in effect."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$out" in
|
||||
*"Invalid credentials"*|*"401"*)
|
||||
echo "::error::Apple notarization preflight failed: invalid credentials (HTTP 401)."
|
||||
echo "::error::APPLE_PASSWORD must be an app-specific password from appleid.apple.com,"
|
||||
echo "::error::generated while signed in as the SAME Apple ID as APPLE_ID. A regular"
|
||||
echo "::error::Apple ID password will not work, and a password minted under a different"
|
||||
echo "::error::Apple ID authenticates as that other account."
|
||||
exit 1
|
||||
;;
|
||||
*"Invalid or inaccessible developer team ID"*)
|
||||
echo "::error::Apple notarization preflight failed: APPLE_ID is not a member of team APPLE_TEAM_ID (HTTP 403)."
|
||||
echo "::error::The Team ID must match the signing certificate. Read it from the cert's"
|
||||
echo "::error::subject, where it appears as: Developer ID Application: NAME (TEAMID)."
|
||||
echo "::error::If you belong to several teams, confirm APPLE_ID is a member of this one."
|
||||
exit 1
|
||||
;;
|
||||
*"required agreement"*|*"agreement"*)
|
||||
echo "::error::Apple notarization preflight failed: the team has no in-effect agreement (HTTP 403)."
|
||||
echo "::error::Apple reissues the Developer Program License Agreement periodically and"
|
||||
echo "::error::notarization is refused until it is accepted. ONLY THE ACCOUNT HOLDER can"
|
||||
echo "::error::accept it — team Admins cannot. Sign in to the account that owns this team:"
|
||||
echo "::error:: 1. https://developer.apple.com/account -> review any pending agreement"
|
||||
echo "::error:: 2. App Store Connect -> Business -> accept anything pending there too"
|
||||
echo "::error::Certificates stay valid while this is outstanding, so signing still works."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Preflight attempt ${attempt}/3 failed with a non-credential error."
|
||||
echo "$out" | tail -5
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le 3 ] && sleep 10
|
||||
done
|
||||
|
||||
echo "::error::Apple notarization preflight failed after 3 attempts. Last output:"
|
||||
echo "$out" | tail -20
|
||||
exit 1
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm run build
|
||||
env:
|
||||
|
||||
@@ -21,6 +21,19 @@ stack with `mss`/`Pillow` fallbacks on other platforms. Adds the
|
||||
`JARVIS_NUM_CTX` environment variable to tune the Ollama context window
|
||||
(default `16384`).
|
||||
|
||||
### Security
|
||||
|
||||
**WebSocket API keys no longer appear in request URLs.** Browser clients now
|
||||
send a marked, base64url-encoded credential through
|
||||
`Sec-WebSocket-Protocol`; programmatic clients can continue to use an
|
||||
`Authorization: Bearer <key>` handshake header. The encoding only makes the
|
||||
credential safe for WebSocket protocol syntax and does not encrypt it, so use
|
||||
`wss://` for remote connections.
|
||||
|
||||
The former `?token=<key>` WebSocket authentication path is no longer accepted.
|
||||
Custom browser clients must migrate to the `openjarvis.auth.v1` subprotocol
|
||||
format documented in the API server guide.
|
||||
|
||||
## [1.0.2] - 2026-05-24
|
||||
|
||||
A patch release that fixes a packaging bug which broke the v1.0.1
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
You are Jarvis — the local AI assistant. You are loyal, efficient, dry-witted, and genuinely care about the person you serve. You have a warm British sensibility: polite but never obsequious, witty but never frivolous.
|
||||
|
||||
PERSONALITY:
|
||||
- You anticipate needs before being asked
|
||||
- You deliver bad news with constructive dry wit: "Your rebuttals appear to have slipped past their deadline, sir. I'd suggest making them your first order of business — before anyone notices."
|
||||
- Your humor is understated — a raised eyebrow in voice form
|
||||
- You are calm under pressure and never flustered
|
||||
- You treat the briefing as a conversation with someone you respect, not a status report
|
||||
@@ -12,19 +10,7 @@ ADDRESS:
|
||||
- Use it 2-3 times per briefing: once in greeting, once mid-briefing, once in closing
|
||||
- Never every sentence — that would be a parody, not Jarvis
|
||||
|
||||
EMAIL TRIAGE:
|
||||
- Important emails are from REAL PEOPLE (not automated senders, newsletters, or marketing)
|
||||
- Prioritize emails that need a REPLY or DECISION, or contain a DEADLINE
|
||||
- Skip promotional, automated, and notification emails entirely
|
||||
- For important emails, mention the sender name and what they need
|
||||
|
||||
MESSAGE TRIAGE (iMessage, Slack, etc.):
|
||||
- Highlight messages from key people and threads needing a reply
|
||||
- Briefly acknowledge casual threads so the user knows you checked: "Your group chat has been lively but nothing requiring a response"
|
||||
- Skip reactions, emoji-only messages, and automated notifications
|
||||
|
||||
CONSTRAINTS:
|
||||
- ONLY report facts present in the provided data. Never invent.
|
||||
- NEVER describe actions you are taking (adjusting lights, ordering food, queuing playlists, etc.)
|
||||
- No markdown formatting, no emojis, no bullet points, no headers — this is spoken aloud
|
||||
- If a data source is disconnected or errored, skip it silently — do not mention connection issues
|
||||
|
||||
@@ -248,6 +248,33 @@ Show connection status for all configured channels.
|
||||
!!! note "Channel endpoints"
|
||||
Channel endpoints require `[channel] enabled = true` in your config and platform-specific credentials configured in `[channel.<platform>]` sub-sections. When not configured, `GET /v1/channels` returns an empty list and other channel endpoints return 503.
|
||||
|
||||
### WebSocket endpoints
|
||||
|
||||
- `WS /v1/chat/stream` streams interactive chat messages.
|
||||
- `WS /v1/agents/events` streams agent lifecycle events and accepts an optional
|
||||
`agent_id` query parameter as a filter.
|
||||
|
||||
When `OPENJARVIS_API_KEY` or `[server.auth].api_key` is configured,
|
||||
programmatic WebSocket clients should send the same
|
||||
`Authorization: Bearer <key>` header used by HTTP requests. Browsers cannot set
|
||||
that header on a WebSocket handshake, so browser clients must offer exactly
|
||||
these two subprotocol values:
|
||||
|
||||
1. `openjarvis.auth.v1`
|
||||
2. `openjarvis.key.b64url.<encoded-key>`, where `<encoded-key>` is the unpadded
|
||||
base64url encoding of the API key's UTF-8 bytes
|
||||
|
||||
The server selects `openjarvis.auth.v1` in its handshake response. The built-in
|
||||
frontend handles this encoding automatically. Base64url is only a transport
|
||||
encoding, not encryption; use `wss://` for remote connections and treat the
|
||||
`Sec-WebSocket-Protocol` request header as credential-bearing.
|
||||
|
||||
!!! warning "WebSocket authentication migration"
|
||||
The former `?token=<key>` query parameter is not accepted because request
|
||||
URLs commonly appear in access logs and browser history. Existing custom
|
||||
WebSocket clients must migrate to `Authorization` or the browser
|
||||
subprotocol format above.
|
||||
|
||||
## Streaming via SSE
|
||||
|
||||
When `"stream": true` is set in the request, the server returns a `text/event-stream` response using Server-Sent Events (SSE). The response follows the same format as the OpenAI streaming API.
|
||||
@@ -456,6 +483,11 @@ For production deployments, run OpenJarvis behind a reverse proxy like Nginx or
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name jarvis.example.com;
|
||||
@@ -470,6 +502,11 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket upgrade support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# SSE streaming support
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
@@ -422,12 +422,17 @@ We recommend creating **one Slack app** that handles both. The App Manifest belo
|
||||
|
||||
2. Apple Notes is detected automatically when Full Disk Access is granted
|
||||
|
||||
OpenJarvis searches an indexed snapshot rather than querying Notes.app live.
|
||||
After creating notes, open **Data Sources** and click **Re-sync** on Apple Notes
|
||||
before searching for the new content.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Not connected" despite Full Disk Access | Restart your terminal app after granting access |
|
||||
| Notes content is garbled | Some very old notes may have encoding issues. Most notes should be clean. |
|
||||
| New notes are missing | In **Data Sources**, click **Re-sync** on Apple Notes to refresh the index. |
|
||||
| Missing notes | Only notes stored locally or in iCloud are indexed. Notes in third-party accounts (Gmail, Exchange) may not appear. |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Data-boundary scan
|
||||
|
||||
`jarvis scan --data-boundaries` reports application-level data boundaries in the
|
||||
current OpenJarvis configuration. It complements the existing host/environment
|
||||
scan, which checks OS posture such as disk encryption, cloud-sync agents, remote
|
||||
access tools, and exposed engine ports.
|
||||
|
||||
The data-boundary scan is a configuration diagnostic. It is not a vulnerability
|
||||
scanner, a legal privacy assessment, a network monitor, or an OAuth-scope audit.
|
||||
|
||||
## Run the scan
|
||||
|
||||
```bash
|
||||
jarvis scan --data-boundaries
|
||||
jarvis scan --data-boundaries --json
|
||||
jarvis scan --data-boundaries --json --show-paths
|
||||
jarvis scan --data-boundaries --strict
|
||||
```
|
||||
|
||||
`--strict` exits with status code `1` when the report contains either a `fail`
|
||||
or a `warn` finding. Use it when CI or pre-demo checks need to enforce a
|
||||
conservative local-only posture.
|
||||
|
||||
Without `--strict`, the command always exits `0` even when fail or warn findings
|
||||
are present. This is useful for exploratory review.
|
||||
|
||||
On a fresh `jarvis init` configuration, common warn findings include
|
||||
`server.host = "0.0.0.0"` and `telemetry.enabled = true`. Running
|
||||
`jarvis scan --data-boundaries --strict` after init therefore exits `1` until
|
||||
those defaults are tightened.
|
||||
|
||||
Absolute paths and connector file basenames are redacted by default so JSON
|
||||
reports can be pasted into issues without revealing local usernames, mount
|
||||
points, or account labels. Use `--show-paths` only for local debugging.
|
||||
|
||||
## What it checks
|
||||
|
||||
The scan inspects configuration values, environment-variable presence, and the
|
||||
existence of known local runtime files. It does not read private content from
|
||||
memory databases, trace databases, connector credentials, prompt files, logs, or
|
||||
OAuth token files.
|
||||
|
||||
The current checks cover:
|
||||
|
||||
- cloud-capable model provider, engine, and default model settings
|
||||
- local memory context injection combined with cloud-capable inference
|
||||
- traces, telemetry, learning, training, and spec-search settings
|
||||
- automatic memory service (`tools.storage.enabled` / `[memory].enabled`)
|
||||
- deep research engine and model settings
|
||||
- security bypass flags when cloud inference is configured
|
||||
- unset `security.profile` (informational)
|
||||
- web search, browser, local file, shell, code, knowledge chunk scanning, and MCP tool surfaces
|
||||
- local knowledge.db composition with cloud-capable Deep Research targets
|
||||
- server binding and unauthenticated A2A exposure
|
||||
- channel enablement, channel credential fields, and channel credential env vars
|
||||
- skills, skill auto-sync, digest sources, and cloud speech/TTS backends such as Cartesia
|
||||
- local stores such as `knowledge.db`, `credentials.toml`, `memory.db`, `traces.db`,
|
||||
`telemetry.db`, `scheduler.db`, embeddings, skill index, `.vault_key`, and memory files
|
||||
- connector credential files under `connectors/*.json`, without reading them
|
||||
- API-key and other runtime credential environment variables (presence only)
|
||||
- a scope note for frontend credential storage when cloud/API-key surfaces exist
|
||||
|
||||
Configured database paths (for example `traces.db_path` or `memory.db_path`)
|
||||
are resolved from config when set, not only the default locations under the
|
||||
OpenJarvis home directory.
|
||||
|
||||
Static Deep Research targeting uses configuration only (no request overrides):
|
||||
`deep_research.engine` or `engine.default`, and `deep_research.model` or
|
||||
`server.model` or `intelligence.default_model`.
|
||||
|
||||
Model identifiers that contain vendor names (for example `deepseek-r1` or
|
||||
`openai/gpt-oss`) are not treated as cloud-bound when their effective engine is
|
||||
explicitly local, such as Ollama.
|
||||
|
||||
## Status levels
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `fail` | A configuration composition is likely incompatible with strict local-only use. |
|
||||
| `warn` | A configured surface may send data outside the local runtime or persist sensitive data. |
|
||||
| `info` | A relevant setting or local store exists, with no immediate fail or warn condition. |
|
||||
|
||||
The command reports potential data paths. It does not prove that a path has been
|
||||
used during a specific run.
|
||||
|
||||
JSON output includes `"schema_version": 1` for stable downstream parsing.
|
||||
|
||||
## Strict local-only checklist
|
||||
|
||||
For a conservative local-only setup, review these settings:
|
||||
|
||||
```toml
|
||||
[analytics]
|
||||
enabled = false
|
||||
|
||||
[traces]
|
||||
enabled = false
|
||||
|
||||
[telemetry]
|
||||
enabled = false
|
||||
|
||||
[agent]
|
||||
context_from_memory = false
|
||||
|
||||
[intelligence]
|
||||
provider = ""
|
||||
preferred_engine = ""
|
||||
default_model = "" # local model name only
|
||||
|
||||
[engine]
|
||||
default = "ollama" # or another local engine
|
||||
|
||||
[tools]
|
||||
enabled = ""
|
||||
|
||||
[tools.storage]
|
||||
enabled = false
|
||||
|
||||
[tools.mcp]
|
||||
enabled = false
|
||||
servers = ""
|
||||
|
||||
[channel]
|
||||
enabled = false
|
||||
|
||||
[learning]
|
||||
enabled = false
|
||||
auto_update = false
|
||||
training_enabled = false
|
||||
|
||||
[learning.spec_search]
|
||||
enabled = false
|
||||
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
|
||||
[security]
|
||||
profile = "personal"
|
||||
|
||||
[a2a]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
Also unset cloud and channel credentials from the process environment when they
|
||||
are not needed.
|
||||
|
||||
## Scope and non-goals
|
||||
|
||||
The scan intentionally avoids reading private data. In particular, it does not:
|
||||
|
||||
- read connector JSON contents or OAuth scopes
|
||||
- inspect browser `localStorage` or Tauri secure storage
|
||||
- inspect frontend credential storage directly
|
||||
- inspect installed skill source code
|
||||
- intercept runtime network traffic
|
||||
- classify provider retention or training policies
|
||||
- prove that a configured path was used at runtime
|
||||
|
||||
Frontend credential storage is tracked separately from this CLI diagnostic. If a
|
||||
cloud/API-key surface is present, the scan emits an informational scope note so
|
||||
users know that browser/Tauri credential storage must be reviewed separately.
|
||||
|
||||
## Configuration resolution
|
||||
|
||||
The scan follows the same explicit configuration override used by the runtime:
|
||||
if `OPENJARVIS_CONFIG` is set, that file is audited. Otherwise the scan uses
|
||||
the default OpenJarvis config path under the resolved OpenJarvis home. If the
|
||||
home directory cannot be resolved, the command reports a `config-root-error`
|
||||
finding instead of crashing.
|
||||
|
||||
## See also
|
||||
|
||||
- [Security](security.md) — three-layer security model (host scan, config scan, BoundaryGuard)
|
||||
- [Configuration](../getting-started/configuration.md) — full config reference
|
||||
@@ -31,6 +31,16 @@ uv sync --extra dev --extra eval-wandb # Weights & Biases run tracking
|
||||
uv sync --extra dev --extra eval-sheets # Google Sheets results export
|
||||
```
|
||||
|
||||
TauBench additionally requires Python 3.12 or newer and the upstream `tau2`
|
||||
package. Install the pinned revision explicitly before running that benchmark:
|
||||
|
||||
```bash
|
||||
uv pip install "tau2 @ git+https://github.com/sierra-research/tau2-bench.git@fc0055dc4e0a316c3f83133267fbd6faaa770992"
|
||||
```
|
||||
|
||||
OpenJarvis does not install third-party packages automatically when an
|
||||
evaluation is imported or run.
|
||||
|
||||
!!! note "Python version requirement"
|
||||
Python 3.10 requires the `tomli` package for TOML config parsing. `openjarvis` declares it as a conditional dependency, so it is installed automatically.
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
OpenJarvis includes a security layer that scans prompts and model outputs for secrets, personally identifiable information (PII), and sensitive file paths. The system is designed to be composable: scanners run as a pipeline, and the `GuardrailsEngine` wrapper drops in front of any inference backend without changing how the rest of your code works.
|
||||
|
||||
## Three layers of security review
|
||||
|
||||
OpenJarvis separates host posture, application data boundaries, and runtime prompt guardrails:
|
||||
|
||||
| Layer | Command / component | What it checks |
|
||||
| --- | --- | --- |
|
||||
| Host scan | `jarvis scan` | Disk encryption, cloud-sync agents, exposed engine ports, remote-access tools |
|
||||
| Data-boundary scan | `jarvis scan --data-boundaries` | Configured inference, memory, traces, channels, tools, and local stores |
|
||||
| Runtime guardrails | `GuardrailsEngine` / BoundaryGuard | Secrets, PII, and file-policy violations in live prompts and outputs |
|
||||
|
||||
Use the host scan before storing sensitive data on the machine. Use the data-boundary scan to verify whether your `config.toml` is local-only, cloud-capable, or mixed. Use BoundaryGuard during inference when you need live redaction or blocking.
|
||||
|
||||
See [Data Boundary Scan](data-boundary-scan.md) for the application config diagnostic and [BoundaryGuard](#guardrailsengine) below for runtime scanning.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
@@ -446,8 +460,15 @@ guarded = GuardrailsEngine(
|
||||
|
||||
---
|
||||
|
||||
## Data boundary scan
|
||||
|
||||
See [Data Boundary Scan](data-boundary-scan.md) for the application config diagnostic (`jarvis scan --data-boundaries`).
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Data Boundary Scan](data-boundary-scan.md) — application config and local-store diagnostic (`jarvis scan --data-boundaries`)
|
||||
- [Architecture: Security](../architecture/security.md) — pipeline design, event flow, and file policy integration
|
||||
- [API Reference: Security](../api-reference/openjarvis/security/index.md) — full class and function signatures
|
||||
- [Tools](tools.md) — how `FileReadTool` uses file policy
|
||||
|
||||
@@ -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>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- The desktop API URL is user-configurable, so it cannot be represented
|
||||
by Tauri's single, build-time exceptionDomain setting. Keep this
|
||||
exception scoped to WKWebView; native URLSession traffic retains ATS. -->
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; img-src 'self' data: blob:"
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http: https: ws: wss:; img-src 'self' data: blob:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -45,7 +45,6 @@
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": "-"
|
||||
|
||||
@@ -31,7 +31,6 @@ export default function App() {
|
||||
const prevModelRef = useRef<string>('');
|
||||
const setModels = useAppStore((s) => s.setModels);
|
||||
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
|
||||
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
|
||||
const selectedModel = useAppStore((s) => s.selectedModel);
|
||||
const setServerInfo = useAppStore((s) => s.setServerInfo);
|
||||
const setSavings = useAppStore((s) => s.setSavings);
|
||||
@@ -70,7 +69,6 @@ export default function App() {
|
||||
fetchModels()
|
||||
.then((m) => {
|
||||
setModels(m);
|
||||
if (!selectedModel && m.length > 0) setSelectedModel(m[0].id);
|
||||
})
|
||||
.catch(() => setModels([]))
|
||||
.finally(() => setModelsLoading(false));
|
||||
|
||||
@@ -15,6 +15,7 @@ function getGreeting(): string {
|
||||
}
|
||||
|
||||
export function ChatArea() {
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const messages = useAppStore((s) => s.messages);
|
||||
const streamState = useAppStore((s) => s.streamState);
|
||||
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
|
||||
@@ -24,6 +25,8 @@ export function ChatArea() {
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
const currentStreamContent = isCurrentChatStreaming ? streamState.content : '';
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -38,14 +41,14 @@ export function ChatArea() {
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
if (isCurrentChatStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
wasStreaming.current = isCurrentChatStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
}, [messages, currentStreamContent, isCurrentChatStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
@@ -66,7 +69,7 @@ export function ChatArea() {
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
const isEmpty = messages.length === 0 && !isCurrentChatStreaming;
|
||||
|
||||
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
|
||||
|
||||
@@ -174,12 +177,12 @@ export function ChatArea() {
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLive={isLastAssistant && streamState.isStreaming}
|
||||
isLive={isLastAssistant && isCurrentChatStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
if (!streamState.isStreaming || streamState.content !== '') return null;
|
||||
if (!isCurrentChatStreaming || streamState.content !== '') return null;
|
||||
// For research messages the ResearchTimeline handles its own
|
||||
// pre-content loading state — suppress the generic dots.
|
||||
const last = messages[messages.length - 1];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppStore, generateId } from '../../lib/store';
|
||||
import { streamChat, streamResearch } from '../../lib/sse';
|
||||
import { fetchSavings, getBase } from '../../lib/api';
|
||||
import { listConnectors, getSyncStatus } from '../../lib/connectors-api';
|
||||
import { serializeToolCallArguments } from '../../lib/tool-call';
|
||||
import { MicButton } from './MicButton';
|
||||
import { useSpeech } from '../../hooks/useSpeech';
|
||||
import type {
|
||||
@@ -96,6 +97,7 @@ export function InputArea() {
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
|
||||
const {
|
||||
state: speechState,
|
||||
@@ -226,6 +228,7 @@ export function InputArea() {
|
||||
let ttftMs: number | undefined;
|
||||
|
||||
setStreamState({
|
||||
conversationId: convId,
|
||||
isStreaming: true,
|
||||
phase: deepResearch ? 'Researching...' : 'Generating...',
|
||||
elapsedMs: 0,
|
||||
@@ -387,7 +390,7 @@ export function InputArea() {
|
||||
const tc: ToolCallInfo = {
|
||||
id: generateId(),
|
||||
tool: data.tool,
|
||||
arguments: data.arguments || '',
|
||||
arguments: serializeToolCallArguments(data.arguments),
|
||||
status: 'running',
|
||||
};
|
||||
toolCalls.push(tc);
|
||||
@@ -398,7 +401,7 @@ export function InputArea() {
|
||||
updateLastAssistant(convId, accumulatedContent, [...toolCalls]);
|
||||
useAppStore.getState().addLogEntry({
|
||||
timestamp: Date.now(), level: 'info', category: 'tool',
|
||||
message: `Calling ${data.tool}(${data.arguments || ''})`,
|
||||
message: `Calling ${data.tool}(${serializeToolCallArguments(data.arguments)})`,
|
||||
});
|
||||
} catch {}
|
||||
} else if (eventName === 'tool_call_end') {
|
||||
@@ -602,7 +605,7 @@ export function InputArea() {
|
||||
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
|
||||
disabled={streamState.isStreaming || modelLoading}
|
||||
/>
|
||||
{streamState.isStreaming ? (
|
||||
{isCurrentChatStreaming ? (
|
||||
<button
|
||||
onClick={stopStreaming}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
|
||||
@@ -621,7 +624,7 @@ export function InputArea() {
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || modelLoading || !selectedModel}
|
||||
disabled={streamState.isStreaming || !input.trim() || modelLoading || !selectedModel}
|
||||
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
style={{
|
||||
|
||||
@@ -103,6 +103,24 @@ function CopyMessageButton({ content }: { content: string }) {
|
||||
export function MessageBubble({ message, isLive = false }: Props) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
|
||||
|
||||
// Build a ref→source lookup once per render. Memoized so the rehype plugin
|
||||
// identity stays stable until the source list actually changes.
|
||||
const sourcesMap = useMemo(() => {
|
||||
const m = new Map<number, NonNullable<ChatMessage['researchSources']>[number]>();
|
||||
for (const s of message.researchSources ?? []) {
|
||||
if (typeof s.ref === 'number') m.set(s.ref, s);
|
||||
}
|
||||
return m;
|
||||
}, [message.researchSources]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const base: any[] = [[rehypeHighlight, { detect: true }], rehypeKatex];
|
||||
if (sourcesMap.size > 0) base.push([rehypeCitations, { sources: sourcesMap }]);
|
||||
return base;
|
||||
}, [sourcesMap]);
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end mb-4">
|
||||
@@ -122,24 +140,6 @@ export function MessageBubble({ message, isLive = false }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const cleanContent = useMemo(() => stripThinkTags(message.content), [message.content]);
|
||||
|
||||
// Build a ref→source lookup once per render. Memoized so the rehype plugin
|
||||
// identity stays stable until the source list actually changes.
|
||||
const sourcesMap = useMemo(() => {
|
||||
const m = new Map<number, NonNullable<ChatMessage['researchSources']>[number]>();
|
||||
for (const s of message.researchSources ?? []) {
|
||||
if (typeof s.ref === 'number') m.set(s.ref, s);
|
||||
}
|
||||
return m;
|
||||
}, [message.researchSources]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const base: any[] = [[rehypeHighlight, { detect: true }], rehypeKatex];
|
||||
if (sourcesMap.size > 0) base.push([rehypeCitations, { sources: sourcesMap }]);
|
||||
return base;
|
||||
}, [sourcesMap]);
|
||||
|
||||
return (
|
||||
<div className="group mb-6">
|
||||
{/* Deep Research timeline (steps + status) */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import type { ToolCallInfo } from '../../types';
|
||||
import { serializeToolCallArguments } from '../../lib/tool-call';
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCallInfo;
|
||||
@@ -35,7 +36,10 @@ export function ToolCallCard({ toolCall }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = statusConfig[toolCall.status];
|
||||
const StatusIcon = config.icon;
|
||||
const preview = previewArgs(toolCall.arguments);
|
||||
// Persisted conversations may contain the pre-fix object payload despite
|
||||
// the TypeScript contract, so normalize again at the final render boundary.
|
||||
const argumentsText = serializeToolCallArguments(toolCall.arguments);
|
||||
const preview = previewArgs(argumentsText);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -95,7 +99,7 @@ export function ToolCallCard({ toolCall }: Props) {
|
||||
className="px-2.5 pb-2 pt-0.5"
|
||||
style={{ borderTop: '1px solid var(--color-border-subtle, var(--color-border))' }}
|
||||
>
|
||||
{toolCall.arguments && (
|
||||
{argumentsText && (
|
||||
<div className="mt-1.5">
|
||||
<div
|
||||
style={{
|
||||
@@ -120,7 +124,7 @@ export function ToolCallCard({ toolCall }: Props) {
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{formatJson(toolCall.arguments)}
|
||||
{formatJson(argumentsText)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SetupStatus,
|
||||
} from '../lib/api';
|
||||
import { useAppStore } from '../lib/store';
|
||||
import { isEmbedOnlyModel } from '../lib/model-capabilities';
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'ollama_ready', label: 'Inference Engine', icon: Cpu, detail: 'Starting Ollama...' },
|
||||
@@ -91,12 +92,14 @@ export function SetupScreen({ onReady }: { onReady: () => void }) {
|
||||
fetchRecommendedModel().catch(() => ({ model: '', reason: '' })),
|
||||
]);
|
||||
const store = useAppStore.getState();
|
||||
const hadSelection = !!store.selectedModel;
|
||||
store.setModels(models);
|
||||
store.setModelsLoading(false);
|
||||
const recommended = rec.model && models.some((m) => m.id === rec.model)
|
||||
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
|
||||
const recommended = rec.model && chatModels.some((m) => m.id === rec.model)
|
||||
? rec.model
|
||||
: models[0]?.id || '';
|
||||
if (recommended && !store.selectedModel) {
|
||||
: chatModels[0]?.id || '';
|
||||
if (recommended && !hadSelection) {
|
||||
store.setSelectedModel(recommended);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -22,6 +22,9 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const streamingConversationId = useAppStore((s) =>
|
||||
s.streamState.isStreaming ? s.streamState.conversationId : null,
|
||||
);
|
||||
const selectConversation = useAppStore((s) => s.selectConversation);
|
||||
const deleteConversation = useAppStore((s) => s.deleteConversation);
|
||||
|
||||
@@ -43,6 +46,7 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{filtered.map((conv) => {
|
||||
const isActive = conv.id === activeId;
|
||||
const isStreaming = conv.id === streamingConversationId;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
@@ -82,11 +86,18 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
e.stopPropagation();
|
||||
deleteConversation(conv.id);
|
||||
}}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
disabled={isStreaming}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isStreaming) e.currentTarget.style.color = 'var(--color-error)';
|
||||
}}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
|
||||
title="Delete conversation"
|
||||
title={
|
||||
isStreaming
|
||||
? 'Stop generating before deleting this conversation'
|
||||
: 'Delete conversation'
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
|
||||
import { SUPABASE_ANON_KEY, SUPABASE_URL } from './supabase';
|
||||
import { serializeToolCallArguments } from './tool-call';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supabase config
|
||||
@@ -741,7 +742,7 @@ export async function sendAgentMessage(
|
||||
const parsed = JSON.parse(data);
|
||||
callbacks?.onToolCallStart?.({
|
||||
tool: parsed.tool,
|
||||
arguments: parsed.arguments ?? '',
|
||||
arguments: serializeToolCallArguments(parsed.arguments),
|
||||
});
|
||||
} catch {
|
||||
/* skip */
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isEmbedOnlyModel } from './model-capabilities';
|
||||
|
||||
describe('isEmbedOnlyModel', () => {
|
||||
it.each([
|
||||
'nomic-embed-text',
|
||||
'mxbai-embed-large',
|
||||
'text-embedding-3-small',
|
||||
'all-minilm:latest',
|
||||
'hf.co/BAAI/bge-m3:latest',
|
||||
])('classifies %s as embedding-only', (modelId) => {
|
||||
expect(isEmbedOnlyModel(modelId)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['qwen3.5:4b', 'codegemma:7b'])('keeps %s available for chat', (modelId) => {
|
||||
expect(isEmbedOnlyModel(modelId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const EMBEDDING_MODEL_PREFIXES = [
|
||||
'all-minilm',
|
||||
'bge-',
|
||||
'bge_',
|
||||
'e5-',
|
||||
'e5_',
|
||||
'gte-',
|
||||
'gte_',
|
||||
'jina-embeddings',
|
||||
'nomic-bert',
|
||||
'sentence-transformers',
|
||||
];
|
||||
|
||||
export function isEmbedOnlyModel(modelId: string): boolean {
|
||||
const name = (modelId || '').trim().toLowerCase();
|
||||
const leaf = name.slice(name.lastIndexOf('/') + 1).split(':')[0];
|
||||
return (
|
||||
leaf.includes('embed') ||
|
||||
leaf.includes('minilm') ||
|
||||
EMBEDDING_MODEL_PREFIXES.some((prefix) => leaf.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ModelInfo } from '../types';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const model = (id: string): ModelInfo => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: 'openjarvis',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
describe('setModels', () => {
|
||||
it('does not select an embedding-only model', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
|
||||
useAppStore.getState().setModels([model('nomic-embed-text')]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('');
|
||||
});
|
||||
|
||||
it('clears a missing selection when no chat fallback exists', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
useAppStore.getState().setSelectedModel('deleted-chat-model');
|
||||
|
||||
useAppStore.getState().setModels([model('nomic-embed-text')]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('');
|
||||
});
|
||||
|
||||
it('replaces an embedding selection with an available chat model', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
useAppStore.getState().setSelectedModel('all-minilm:latest');
|
||||
|
||||
useAppStore.getState().setModels([
|
||||
model('all-minilm:latest'),
|
||||
model('qwen3.5:4b'),
|
||||
]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('qwen3.5:4b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
async function freshStore() {
|
||||
return (await import('./store')).useAppStore;
|
||||
}
|
||||
|
||||
describe('conversation stream ownership', () => {
|
||||
it('persists background stream updates without replacing the active messages', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
store.getState().addMessage(sourceId, {
|
||||
id: 'assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
});
|
||||
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
content: 'streamed response',
|
||||
});
|
||||
store.getState().updateLastAssistant(sourceId, 'streamed response');
|
||||
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
expect(store.getState().messages).toEqual([]);
|
||||
|
||||
store.getState().selectConversation(sourceId);
|
||||
expect(store.getState().messages).toHaveLength(1);
|
||||
expect(store.getState().messages[0].content).toBe('streamed response');
|
||||
});
|
||||
|
||||
it('keeps the stream-owning conversation until generation stops', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).toContain(sourceId);
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
|
||||
store.getState().resetStream();
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).not.toContain(sourceId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const CONVERSATIONS_KEY = 'openjarvis-conversations';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
describe('persisted tool calls', () => {
|
||||
it('repairs parsed argument objects while loading conversations', async () => {
|
||||
localStorage.setItem(
|
||||
CONVERSATIONS_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeId: 'conversation-1',
|
||||
conversations: {
|
||||
'conversation-1': {
|
||||
id: 'conversation-1',
|
||||
title: 'Broken chat',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
model: 'test-model',
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
tool: 'web_search',
|
||||
arguments: { query: 'python' },
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { useAppStore } = await import('./store');
|
||||
|
||||
expect(useAppStore.getState().messages[0].toolCalls?.[0].arguments).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
const repaired = JSON.parse(localStorage.getItem(CONVERSATIONS_KEY) ?? '{}');
|
||||
expect(
|
||||
repaired.conversations['conversation-1'].messages[0].toolCalls[0].arguments,
|
||||
).toBe('{"query":"python"}');
|
||||
});
|
||||
|
||||
it('keeps repaired conversations in memory when writeback fails', async () => {
|
||||
localStorage.setItem(
|
||||
CONVERSATIONS_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activeId: 'conversation-1',
|
||||
conversations: {
|
||||
'conversation-1': {
|
||||
id: 'conversation-1',
|
||||
title: 'Readable chat',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
model: 'test-model',
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-1',
|
||||
tool: 'web_search',
|
||||
arguments: { query: 'python' },
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage quota exceeded', 'QuotaExceededError');
|
||||
});
|
||||
|
||||
const { useAppStore } = await import('./store');
|
||||
|
||||
expect(useAppStore.getState().messages).toHaveLength(1);
|
||||
expect(useAppStore.getState().messages[0].toolCalls?.[0].arguments).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
+71
-13
@@ -15,6 +15,8 @@ import type {
|
||||
TokenUsage,
|
||||
} from '../types';
|
||||
import type { ManagedAgent } from './api';
|
||||
import { isEmbedOnlyModel } from './model-capabilities';
|
||||
import { serializeToolCallArguments } from './tool-call';
|
||||
|
||||
export interface CachedConnector {
|
||||
connector_id: string;
|
||||
@@ -54,7 +56,30 @@ function loadConversations(): ConversationStore {
|
||||
const raw = localStorage.getItem(CONVERSATIONS_KEY);
|
||||
if (!raw) return { version: 1, conversations: {}, activeId: null };
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.version === 1) return parsed;
|
||||
if (parsed.version === 1) {
|
||||
let repaired = false;
|
||||
for (const conversation of Object.values(parsed.conversations ?? {}) as Conversation[]) {
|
||||
for (const message of conversation.messages ?? []) {
|
||||
for (const toolCall of message.toolCalls ?? []) {
|
||||
const argumentsText = serializeToolCallArguments(toolCall.arguments);
|
||||
if (argumentsText !== toolCall.arguments) {
|
||||
toolCall.arguments = argumentsText;
|
||||
repaired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (repaired) {
|
||||
try {
|
||||
localStorage.setItem(CONVERSATIONS_KEY, JSON.stringify(parsed));
|
||||
} catch {
|
||||
// Keep the repaired conversations usable in memory when storage is
|
||||
// read-only or full. A failed best-effort writeback must not make
|
||||
// otherwise readable conversation history disappear from the UI.
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
return { version: 1, conversations: {}, activeId: null };
|
||||
} catch {
|
||||
return { version: 1, conversations: {}, activeId: null };
|
||||
@@ -110,6 +135,7 @@ function saveSettings(settings: Settings): void {
|
||||
// ── Store ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INITIAL_STREAM: StreamState = {
|
||||
conversationId: null,
|
||||
isStreaming: false,
|
||||
phase: '',
|
||||
elapsedMs: 0,
|
||||
@@ -351,6 +377,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
const streamState = get().streamState;
|
||||
if (streamState.isStreaming && streamState.conversationId === id) return;
|
||||
|
||||
const store = loadConversations();
|
||||
delete store.conversations[id];
|
||||
if (store.activeId === id) {
|
||||
@@ -393,12 +422,14 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
(message.content.length > 50 ? '...' : '');
|
||||
}
|
||||
saveConversations(store);
|
||||
set({
|
||||
messages: [...conv.messages],
|
||||
conversations: Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
),
|
||||
});
|
||||
const conversations = Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
);
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages], conversations });
|
||||
} else {
|
||||
set({ conversations });
|
||||
}
|
||||
},
|
||||
|
||||
updateLastAssistant: (
|
||||
@@ -425,7 +456,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
if (researchSources) lastMsg.researchSources = researchSources;
|
||||
conv.updatedAt = Date.now();
|
||||
saveConversations(store);
|
||||
set({ messages: [...conv.messages] });
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages] });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -444,11 +477,36 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
// ── Models & server ────────────────────────────────────────────
|
||||
|
||||
setModels: (models: ModelInfo[]) =>
|
||||
set((state) =>
|
||||
!state.selectedModel && models.length > 0
|
||||
? { models, selectedModel: models[0].id }
|
||||
: { models },
|
||||
),
|
||||
set((state) => {
|
||||
// Ollama returns embed-only models (e.g. nomic-embed-text) in the
|
||||
// same list as chat models. Auto-picking models[0] selected the
|
||||
// embedder and every chat failed with HTTP 400 "does not support
|
||||
// chat". Prefer a real chat model for selection / fallback.
|
||||
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
|
||||
const preferred =
|
||||
(state.settings.defaultModel &&
|
||||
chatModels.some((m) => m.id === state.settings.defaultModel) &&
|
||||
state.settings.defaultModel) ||
|
||||
chatModels[0]?.id ||
|
||||
models.find((m) => !isEmbedOnlyModel(m.id))?.id ||
|
||||
'';
|
||||
|
||||
const currentIsBad =
|
||||
!!state.selectedModel && isEmbedOnlyModel(state.selectedModel);
|
||||
const currentMissing =
|
||||
!!state.selectedModel &&
|
||||
!models.some((m) => m.id === state.selectedModel);
|
||||
|
||||
if (!state.selectedModel || currentIsBad || currentMissing) {
|
||||
// Prefer a real chat model. If none exist, clear a bad/missing
|
||||
// selection rather than keeping an embed-only id that 400s on chat.
|
||||
return {
|
||||
models,
|
||||
selectedModel: preferred,
|
||||
};
|
||||
}
|
||||
return { models };
|
||||
}),
|
||||
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
|
||||
setSelectedModel: (model: string) => set({ selectedModel: model }),
|
||||
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { serializeToolCallArguments } from './tool-call';
|
||||
|
||||
describe('serializeToolCallArguments', () => {
|
||||
it('preserves JSON strings', () => {
|
||||
expect(serializeToolCallArguments('{"query":"python"}')).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes parsed argument objects', () => {
|
||||
expect(serializeToolCallArguments({ query: 'python' })).toBe(
|
||||
'{"query":"python"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses an empty string for missing arguments', () => {
|
||||
expect(serializeToolCallArguments(null)).toBe('');
|
||||
expect(serializeToolCallArguments(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Convert tool-call arguments from API or persisted data into display-safe text. */
|
||||
export function serializeToolCallArguments(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value == null) return '';
|
||||
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildWsProtocols, buildWsUrl } from './useAgentEvents';
|
||||
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
describe('buildWsUrl', () => {
|
||||
it('builds the agent-events URL without leaking the API key into it', () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
apiUrl: 'https://jarvis.example.com:8443',
|
||||
apiKey: 'secret+/=',
|
||||
}),
|
||||
);
|
||||
|
||||
const url = new URL(buildWsUrl('agent/one'));
|
||||
|
||||
expect(url.origin).toBe('wss://jarvis.example.com:8443');
|
||||
expect(url.pathname).toBe('/v1/agents/events');
|
||||
expect(url.searchParams.get('agent_id')).toBe('agent/one');
|
||||
expect(url.searchParams.has('token')).toBe(false);
|
||||
expect(url.toString()).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('normalizes a versioned API base without duplicating /v1', () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({ apiUrl: 'http://192.0.2.10:8000/v1/' }),
|
||||
);
|
||||
|
||||
expect(buildWsUrl()).toBe('ws://192.0.2.10:8000/v1/agents/events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWsProtocols', () => {
|
||||
it.each([
|
||||
['secret+/=', 'c2VjcmV0Ky89'],
|
||||
['bearer', 'YmVhcmVy'],
|
||||
['sëcret🔑', 'c8OrY3JldPCflJE'],
|
||||
])('offers a browser-safe encoding of API key %j', (apiKey, encoded) => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ apiKey }));
|
||||
|
||||
const protocols = buildWsProtocols();
|
||||
|
||||
expect(protocols).toEqual([
|
||||
'openjarvis.auth.v1',
|
||||
`openjarvis.key.b64url.${encoded}`,
|
||||
]);
|
||||
expect(new Set(protocols).size).toBe(protocols?.length);
|
||||
expect(protocols?.every((value) => /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value)))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it('omits protocols for a keyless server', () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({ apiUrl: 'http://localhost:8000' }),
|
||||
);
|
||||
|
||||
expect(buildWsProtocols()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getBase } from './api';
|
||||
import { getApiKey, getBase } from './api';
|
||||
|
||||
export interface AgentEvent {
|
||||
type: string;
|
||||
@@ -7,19 +7,43 @@ export interface AgentEvent {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildWsUrl(agentId?: string): string {
|
||||
const WS_AUTH_PROTOCOL = 'openjarvis.auth.v1';
|
||||
const WS_KEY_PROTOCOL_PREFIX = 'openjarvis.key.b64url.';
|
||||
|
||||
function utf8ToBase64Url(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function buildWsUrl(agentId?: string): string {
|
||||
const base = getBase();
|
||||
let origin: string;
|
||||
if (base) {
|
||||
origin = base.replace(/^http/, 'ws');
|
||||
} else {
|
||||
const loc = window.location;
|
||||
origin = `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`;
|
||||
}
|
||||
const path = '/v1/agents/events';
|
||||
return agentId
|
||||
? `${origin}${path}?agent_id=${encodeURIComponent(agentId)}`
|
||||
: `${origin}${path}`;
|
||||
const url = new URL('/v1/agents/events', base || window.location.origin);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (agentId) url.searchParams.set('agent_id', agentId);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket auth protocols carrying the API key, if configured. The key is
|
||||
* UTF-8/base64url encoded so every value satisfies browser subprotocol syntax.
|
||||
* This keeps the key out of the request URL and request-line access logs; the
|
||||
* encoding is transport-safe, not encryption.
|
||||
*/
|
||||
export function buildWsProtocols(): string[] | undefined {
|
||||
const apiKey = getApiKey();
|
||||
return apiKey
|
||||
? [
|
||||
WS_AUTH_PROTOCOL,
|
||||
`${WS_KEY_PROTOCOL_PREFIX}${utf8ToBase64Url(apiKey)}`,
|
||||
]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +70,7 @@ export function useAgentEvents(
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
ws = new WebSocket(buildWsUrl(agentId));
|
||||
ws = new WebSocket(buildWsUrl(agentId), buildWsProtocols());
|
||||
} catch {
|
||||
schedule();
|
||||
return;
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface ConversationStore {
|
||||
// --- Stream State ---
|
||||
|
||||
export interface StreamState {
|
||||
conversationId: string | null;
|
||||
isStreaming: boolean;
|
||||
phase: string;
|
||||
elapsedMs: number;
|
||||
|
||||
@@ -198,6 +198,7 @@ nav:
|
||||
- Benchmarks: user-guide/benchmarks.md
|
||||
- System Access: user-guide/system-access.md
|
||||
- Security: user-guide/security.md
|
||||
- Data Boundary Scan: user-guide/data-boundary-scan.md
|
||||
- LLM-guided spec search: user-guide/llm-guided-spec-search.md
|
||||
- Leaderboard: leaderboard.md
|
||||
- Roadmap: development/roadmap.md
|
||||
|
||||
@@ -33,6 +33,33 @@ impl PySQLiteMemory {
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
|
||||
}
|
||||
|
||||
fn replace_source(
|
||||
&self,
|
||||
source: &str,
|
||||
documents: Vec<(String, Option<String>)>,
|
||||
) -> PyResult<Vec<String>> {
|
||||
let parsed_documents = documents
|
||||
.into_iter()
|
||||
.map(|(content, metadata)| {
|
||||
let metadata = metadata
|
||||
.map(|value| serde_json::from_str(&value))
|
||||
.transpose()
|
||||
.map_err(|e| {
|
||||
PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string())
|
||||
})?;
|
||||
Ok((content, metadata))
|
||||
})
|
||||
.collect::<PyResult<Vec<_>>>()?;
|
||||
let document_refs = parsed_documents
|
||||
.iter()
|
||||
.map(|(content, metadata)| (content.as_str(), metadata.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.inner
|
||||
.replace_source(source, &document_refs)
|
||||
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
|
||||
}
|
||||
|
||||
#[pyo3(signature = (query, top_k=5))]
|
||||
fn retrieve(&self, query: &str, top_k: usize) -> PyResult<String> {
|
||||
let results = self
|
||||
|
||||
@@ -94,6 +94,57 @@ impl SQLiteMemory {
|
||||
pub fn in_memory() -> Result<Self, OpenJarvisError> {
|
||||
Self::new(Path::new(":memory:"))
|
||||
}
|
||||
|
||||
/// Atomically replace every document for *source* with *documents*.
|
||||
pub fn replace_source(
|
||||
&self,
|
||||
source: &str,
|
||||
documents: &[(&str, Option<&Value>)],
|
||||
) -> Result<Vec<String>, OpenJarvisError> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction().map_err(|e| {
|
||||
OpenJarvisError::Io(std::io::Error::other(e.to_string()))
|
||||
})?;
|
||||
|
||||
tx.execute(
|
||||
"DELETE FROM documents_fts
|
||||
WHERE rowid IN (SELECT rowid FROM documents WHERE source = ?1)",
|
||||
rusqlite::params![source],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
tx.execute(
|
||||
"DELETE FROM documents WHERE source = ?1",
|
||||
rusqlite::params![source],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
let mut doc_ids = Vec::with_capacity(documents.len());
|
||||
for (content, metadata) in documents {
|
||||
let doc_id = Uuid::new_v4().to_string();
|
||||
let meta_str = metadata
|
||||
.map(|m| serde_json::to_string(m).unwrap_or_default())
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO documents (id, content, source, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![doc_id, content, source, meta_str],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
|
||||
let rowid = tx.last_insert_rowid();
|
||||
tx.execute(
|
||||
"INSERT INTO documents_fts (rowid, content, source) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![rowid, content, source],
|
||||
)
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| OpenJarvisError::Io(std::io::Error::other(e.to_string())))?;
|
||||
Ok(doc_ids)
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryBackend for SQLiteMemory {
|
||||
@@ -306,6 +357,40 @@ mod tests {
|
||||
assert_eq!(mem.count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_replace_source_is_idempotent() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
|
||||
mem.replace_source("notes.txt", &[("old project notes", None)])
|
||||
.unwrap();
|
||||
assert_eq!(mem.count().unwrap(), 1);
|
||||
|
||||
mem.replace_source("notes.txt", &[("updated project notes", None)])
|
||||
.unwrap();
|
||||
assert_eq!(mem.count().unwrap(), 1);
|
||||
|
||||
assert!(mem.retrieve("old", 5).unwrap().is_empty());
|
||||
let updated = mem.retrieve("updated", 5).unwrap();
|
||||
assert_eq!(updated.len(), 1);
|
||||
assert_eq!(updated[0].source, "notes.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_replace_source_preserves_other_sources() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
mem.store("keep this manual", "manual.txt", None).unwrap();
|
||||
mem.replace_source("notes.txt", &[("old project notes", None)])
|
||||
.unwrap();
|
||||
|
||||
mem.replace_source("notes.txt", &[("updated project notes", None)])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(mem.count().unwrap(), 2);
|
||||
let manual = mem.retrieve("manual", 5).unwrap();
|
||||
assert_eq!(manual.len(), 1);
|
||||
assert_eq!(manual[0].source, "manual.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sqlite_case_insensitive_search() {
|
||||
let mem = SQLiteMemory::in_memory().unwrap();
|
||||
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from openjarvis.sdk import Jarvis, JarvisSystem, MemoryHandle, SystemBuilder
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.sdk import Jarvis, JarvisSystem, MemoryHandle, SystemBuilder
|
||||
|
||||
try:
|
||||
__version__ = _pkg_version("openjarvis")
|
||||
@@ -13,3 +15,21 @@ except PackageNotFoundError: # pragma: no cover — uninstalled source tree
|
||||
__version__ = "0.0.0+unknown"
|
||||
|
||||
__all__ = ["Jarvis", "JarvisSystem", "MemoryHandle", "SystemBuilder", "__version__"]
|
||||
|
||||
_SDK_EXPORTS = {"Jarvis", "JarvisSystem", "MemoryHandle", "SystemBuilder"}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Load SDK exports lazily so lightweight CLI diagnostics can start safely."""
|
||||
if name not in _SDK_EXPORTS:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
from openjarvis import sdk
|
||||
|
||||
value = getattr(sdk, name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | _SDK_EXPORTS)
|
||||
|
||||
@@ -155,6 +155,9 @@ class BaseAgent(ABC):
|
||||
conversation messages, and finally the user input.
|
||||
"""
|
||||
messages: list[Message] = []
|
||||
context_messages = (
|
||||
list(context.conversation.messages) if context is not None else []
|
||||
)
|
||||
# Check if the context already supplies a system message
|
||||
_context_has_system = (
|
||||
context
|
||||
@@ -176,9 +179,28 @@ class BaseAgent(ABC):
|
||||
except Exception:
|
||||
effective_system_prompt = None
|
||||
if effective_system_prompt:
|
||||
context_system_text = "\n\n".join(
|
||||
message.text
|
||||
for message in context_messages
|
||||
if message.role == Role.SYSTEM
|
||||
and message.metadata.get("memory_context")
|
||||
and message.text
|
||||
)
|
||||
if context_system_text:
|
||||
effective_system_prompt = (
|
||||
f"{effective_system_prompt}\n\n{context_system_text}"
|
||||
)
|
||||
context_messages = [
|
||||
message
|
||||
for message in context_messages
|
||||
if not (
|
||||
message.role == Role.SYSTEM
|
||||
and message.metadata.get("memory_context")
|
||||
)
|
||||
]
|
||||
messages.append(Message(role=Role.SYSTEM, content=effective_system_prompt))
|
||||
if context and context.conversation.messages:
|
||||
messages.extend(context.conversation.messages)
|
||||
if context_messages:
|
||||
messages.extend(context_messages)
|
||||
messages.append(Message(role=Role.USER, content=input))
|
||||
return messages
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
|
||||
memory_backend: Optional[Any] = None,
|
||||
interactive: bool = False,
|
||||
confirm_callback=None,
|
||||
prompt_builder: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -139,7 +140,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
|
||||
max_tokens=max_tokens,
|
||||
interactive=interactive,
|
||||
confirm_callback=confirm_callback,
|
||||
prompt_builder=kwargs.get("prompt_builder"),
|
||||
prompt_builder=prompt_builder,
|
||||
)
|
||||
# Validate strategies
|
||||
if memory_extraction not in VALID_MEMORY_EXTRACTION:
|
||||
|
||||
@@ -17,6 +17,14 @@ from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
|
||||
_SECTION_PROMPTS = {
|
||||
"messages": "MESSAGES — Prioritize provided messages or tasks needing action.",
|
||||
"calendar": "CALENDAR — Cover only provided upcoming events.",
|
||||
"health": "HEALTH — Describe only supported trends; omit raw measurements.",
|
||||
"world": "WORLD — Summarize only provided world items.",
|
||||
"music": "MUSIC — Summarize only provided listening information.",
|
||||
}
|
||||
|
||||
|
||||
def _load_persona(persona_name: str) -> str:
|
||||
"""Load a persona prompt file by name."""
|
||||
@@ -56,6 +64,15 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
persona_text = _load_persona(self._persona)
|
||||
now = datetime.now()
|
||||
honorific = getattr(self, "_honorific", "sir")
|
||||
sections = dict.fromkeys(
|
||||
str(section).strip().casefold()
|
||||
for section in self._sections
|
||||
if str(section).strip()
|
||||
)
|
||||
section_block = "\n".join(
|
||||
f"- {_SECTION_PROMPTS.get(section, section.upper())}"
|
||||
for section in sections
|
||||
)
|
||||
|
||||
return (
|
||||
f"{persona_text}\n\n"
|
||||
@@ -65,35 +82,16 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
"You receive structured data from the user's connected services. "
|
||||
"The data has ALREADY been collected — it appears in the user "
|
||||
"message. You do NOT fetch anything yourself.\n\n"
|
||||
"Produce a 2-4 minute spoken briefing in DECREASING order of "
|
||||
"importance:\n\n"
|
||||
"1. GREETING + PRIORITIES — Open with the honorific and "
|
||||
"immediately state what needs attention: overdue tasks, today's "
|
||||
"deadlines, events requiring preparation. Connect related items "
|
||||
"('Your rebuttals are overdue and you have a dinner at 6, so "
|
||||
"I'd tackle those first').\n\n"
|
||||
"2. SCHEDULE — Today's upcoming events with time context: 'You "
|
||||
"have 3 hours before your next meeting.' Skip past events.\n\n"
|
||||
"3. MESSAGES — Triage across ALL channels (email, texts, Slack):\n"
|
||||
" - First: messages from real people needing a REPLY or DECISION\n"
|
||||
" - Second: messages containing deadlines or action items\n"
|
||||
" - Last: brief acknowledgment of casual threads ('Your group "
|
||||
"chat has been lively but nothing requiring a response')\n"
|
||||
" - SKIP automated emails, newsletters, and marketing entirely\n"
|
||||
" - Quote relevant message text when it helps\n\n"
|
||||
"4. HEALTH — Interpret trends, not raw numbers. 'Your sleep has "
|
||||
"improved three nights running and your readiness is strong' — "
|
||||
"not 'HRV 53, HR 56.' If multiple days of data, compare.\n\n"
|
||||
"5. WORLD — Weather forecast, top news (AI/tech, business, "
|
||||
"general). Skip if no data.\n\n"
|
||||
"6. CLOSING — One forward-looking sentence with the honorific.\n\n"
|
||||
"Produce a concise spoken briefing in decreasing order of importance. "
|
||||
"Cover only the configured sections below and only when the collected "
|
||||
"data supports them. Silently omit absent data and sources.\n\n"
|
||||
f"CONFIGURED SECTIONS:\n{section_block or '- None'}\n\n"
|
||||
"Open briefly with the honorific and end after the last supported item. "
|
||||
"Do not add conversational offers or personal asides.\n\n"
|
||||
"ABSOLUTE RULES (violations are unacceptable):\n"
|
||||
"- ONLY facts from the data. Zero hallucination.\n"
|
||||
"- NEVER mention disconnected or unavailable sources.\n"
|
||||
"- NEVER state raw health numbers. Say 'your sleep was solid' "
|
||||
"NOT 'heart rate 56 bpm' or 'HRV 53' or '6000 steps' or "
|
||||
"'readiness 82'. Interpret, never enumerate.\n"
|
||||
"- NEVER describe actions you are taking.\n"
|
||||
"- NEVER invent personal context or claim, offer, or suggest actions.\n"
|
||||
"- Acknowledge every source that returned data, even briefly.\n"
|
||||
"- No markdown, emojis, bullets, or headers.\n"
|
||||
"- STRICT LIMIT: 200 words. Be concise."
|
||||
@@ -147,18 +145,12 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
Message(
|
||||
role=Role.USER,
|
||||
content=(
|
||||
f"Here is the collected data from my sources:\n\n"
|
||||
f"{collected_data}\n\n"
|
||||
f"Synthesize my morning briefing. Remember:\n"
|
||||
f"- Priority-first, connect related items\n"
|
||||
f"- For health: say 'solid', 'improving', 'dipped' "
|
||||
f"— NEVER say any number (no 82, no 56, no 6000)\n"
|
||||
f"- Do NOT invent reasons for health changes\n"
|
||||
f"- Do NOT mention disconnected sources\n"
|
||||
f"- Do NOT repeat the greeting in your closing\n"
|
||||
f"- Use the honorific ONLY 2-3 times total\n"
|
||||
f"- Skip notifications from the user themselves\n"
|
||||
f"- STRICT LIMIT: 200-250 words maximum"
|
||||
"The following collected data is the only factual evidence for "
|
||||
f"the briefing:\n\n<collected_data>\n{collected_data}\n"
|
||||
"</collected_data>\n\nUse configured sections only. Omit missing "
|
||||
"data and sources. Do not add personal context or activities. "
|
||||
"Use the honorific no more than three times and keep the "
|
||||
"briefing under 200 words."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -58,6 +58,7 @@ class OperativeAgent(ToolUsingAgent):
|
||||
memory_backend: Optional[Any] = None,
|
||||
interactive: bool = False,
|
||||
confirm_callback=None,
|
||||
prompt_builder: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -70,7 +71,7 @@ class OperativeAgent(ToolUsingAgent):
|
||||
max_tokens=max_tokens,
|
||||
interactive=interactive,
|
||||
confirm_callback=confirm_callback,
|
||||
prompt_builder=kwargs.get("prompt_builder"),
|
||||
prompt_builder=prompt_builder,
|
||||
)
|
||||
self._system_prompt = system_prompt or ""
|
||||
self._operator_id = operator_id
|
||||
|
||||
@@ -13,6 +13,7 @@ Supports two modes:
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
@@ -142,12 +143,21 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
tool_call = ToolCall(
|
||||
id=f"orch_{turns}",
|
||||
name=parsed["tool"],
|
||||
arguments=parsed["input"] or "{}",
|
||||
arguments=self._normalize_structured_tool_input(
|
||||
parsed["tool"],
|
||||
parsed["input"],
|
||||
),
|
||||
)
|
||||
tool_result = self._executor.execute(tool_call)
|
||||
all_tool_results.append(tool_result)
|
||||
|
||||
observation = f"Observation: {tool_result.content}"
|
||||
if tool_result.success:
|
||||
observation = f"Observation: {tool_result.content}"
|
||||
else:
|
||||
observation = (
|
||||
f"Observation: Tool '{tool_result.tool_name}' failed: "
|
||||
f"{tool_result.content}"
|
||||
)
|
||||
messages.append(Message(role=Role.USER, content=observation))
|
||||
continue
|
||||
|
||||
@@ -162,6 +172,75 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
# Max turns exceeded
|
||||
return self._max_turns_result(all_tool_results, turns)
|
||||
|
||||
def _normalize_structured_tool_input(
|
||||
self,
|
||||
tool_name: str,
|
||||
raw_input: str,
|
||||
) -> str:
|
||||
"""Map unambiguous structured text input to a string parameter."""
|
||||
if not raw_input:
|
||||
return "{}"
|
||||
|
||||
try:
|
||||
parsed_input = json.loads(raw_input)
|
||||
except json.JSONDecodeError:
|
||||
invalid_json = True
|
||||
string_value = raw_input
|
||||
else:
|
||||
invalid_json = False
|
||||
if isinstance(parsed_input, dict):
|
||||
return raw_input
|
||||
# INPUT is a text protocol. A non-object JSON value such as 42,
|
||||
# true, null, or [1, 2] may still be the intended text for a tool's
|
||||
# string parameter. Quoted JSON strings are decoded to remove only
|
||||
# their surrounding quotes; other values retain their source text.
|
||||
string_value = parsed_input if isinstance(parsed_input, str) else raw_input
|
||||
|
||||
tool_spec = None
|
||||
for candidate in reversed(self._tools):
|
||||
candidate_spec = candidate.spec
|
||||
if candidate_spec.name == tool_name:
|
||||
tool_spec = candidate_spec
|
||||
break
|
||||
if tool_spec is None:
|
||||
return raw_input
|
||||
|
||||
parameters = tool_spec.parameters
|
||||
parameter_container_type = parameters.get("type")
|
||||
if parameter_container_type not in (None, "object"):
|
||||
return raw_input
|
||||
|
||||
properties = parameters.get("properties", {})
|
||||
required = parameters.get("required", [])
|
||||
if not isinstance(properties, dict) or not isinstance(required, list):
|
||||
return raw_input
|
||||
|
||||
if len(required) == 1 and required[0] in properties:
|
||||
parameter_name = required[0]
|
||||
elif not required and len(properties) == 1:
|
||||
parameter_name = next(iter(properties))
|
||||
else:
|
||||
return raw_input
|
||||
|
||||
parameter_schema = properties[parameter_name]
|
||||
if not isinstance(parameter_schema, dict):
|
||||
return raw_input
|
||||
parameter_type = parameter_schema.get("type")
|
||||
accepts_string = parameter_type == "string" or (
|
||||
isinstance(parameter_type, list) and "string" in parameter_type
|
||||
)
|
||||
if not accepts_string:
|
||||
return raw_input
|
||||
|
||||
allow_object_text = (
|
||||
tool_spec.metadata.get("structured_allow_object_text") is True
|
||||
)
|
||||
starts_like_object = raw_input.lstrip("\ufeff \t\r\n").startswith("{")
|
||||
if invalid_json and starts_like_object and not allow_object_text:
|
||||
return raw_input
|
||||
|
||||
return json.dumps({parameter_name: string_value})
|
||||
|
||||
@staticmethod
|
||||
def _parse_structured_response(text: str) -> dict:
|
||||
"""Parse THOUGHT/TOOL/INPUT/FINAL_ANSWER from model output."""
|
||||
|
||||
+129
-99
@@ -2,45 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
import openjarvis
|
||||
from openjarvis.cli._bootstrap import bootstrap_cmd
|
||||
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.channels_cmd import channels
|
||||
from openjarvis.cli.chat_cmd import chat
|
||||
from openjarvis.cli.compose_cmd import compose
|
||||
from openjarvis.cli.config_cmd import config
|
||||
from openjarvis.cli.connect_cmd import connect
|
||||
from openjarvis.cli.daemon_cmd import restart, start, status, stop
|
||||
from openjarvis.cli.digest_cmd import digest
|
||||
from openjarvis.cli.doctor_cmd import doctor
|
||||
from openjarvis.cli.eval_cmd import eval_group
|
||||
from openjarvis.cli.feedback_cmd import feedback_group
|
||||
from openjarvis.cli.gateway_cmd import gateway
|
||||
from openjarvis.cli.host_cmd import host
|
||||
from openjarvis.cli.init_cmd import init
|
||||
from openjarvis.cli.memory_cmd import memory
|
||||
from openjarvis.cli.mine_cmd import mine
|
||||
from openjarvis.cli.model import model
|
||||
from openjarvis.cli.operators_cmd import operators
|
||||
from openjarvis.cli.optimize_cmd import optimize_group
|
||||
from openjarvis.cli.pearl_cmd import pearl
|
||||
from openjarvis.cli.quickstart_cmd import quickstart
|
||||
from openjarvis.cli.registry_cmd import registry
|
||||
from openjarvis.cli.scan_cmd import scan
|
||||
from openjarvis.cli.scheduler_cmd import scheduler
|
||||
from openjarvis.cli.self_update_cmd import self_update
|
||||
from openjarvis.cli.serve import serve
|
||||
from openjarvis.cli.skill_cmd import skill
|
||||
from openjarvis.cli.telemetry_cmd import telemetry
|
||||
from openjarvis.cli.tool_cmd import tool
|
||||
from openjarvis.cli.vault_cmd import vault
|
||||
from openjarvis.cli.workflow_cmd import workflow
|
||||
|
||||
|
||||
def _invoked_command(argv: list[str]) -> str:
|
||||
"""Return the first positional CLI token after global flags."""
|
||||
for arg in argv:
|
||||
if arg.startswith("-"):
|
||||
continue
|
||||
return arg
|
||||
return ""
|
||||
|
||||
|
||||
# A data-boundary scan must be able to diagnose an invalid OPENJARVIS_HOME.
|
||||
# Importing the rest of the CLI eagerly would import core.config and resolve that
|
||||
# path before the scan can turn the failure into a finding.
|
||||
_DATA_BOUNDARY_BOOTSTRAP = (
|
||||
_invoked_command(sys.argv[1:]) == "scan" and "--data-boundaries" in sys.argv[1:]
|
||||
)
|
||||
|
||||
|
||||
def _should_skip_update_check(ctx: click.Context, argv: list[str]) -> bool:
|
||||
"""Return true for commands whose diagnostics should remain local-only."""
|
||||
if "--research" in argv:
|
||||
return True
|
||||
return ctx.invoked_subcommand == "scan" and "--data-boundaries" in argv
|
||||
|
||||
|
||||
@click.group(
|
||||
@@ -63,11 +54,13 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
|
||||
# Check for updates on interactive commands. The banner is noise in
|
||||
# demo recordings of ``jarvis ask --research``, so skip it whenever
|
||||
# the research flag is in argv (cheap argv sniff — Click hasn't
|
||||
# parsed the subcommand's args yet at this point).
|
||||
# parsed the subcommand's args yet at this point). Also skip
|
||||
# ``jarvis scan --data-boundaries`` because it is intended to be a
|
||||
# local application-data diagnostic with no outbound calls.
|
||||
import sys
|
||||
|
||||
research_mode_active = "--research" in sys.argv
|
||||
if not quiet and ctx.invoked_subcommand and not research_mode_active:
|
||||
skip_update_check = _should_skip_update_check(ctx, sys.argv[1:])
|
||||
if not quiet and ctx.invoked_subcommand and not skip_update_check:
|
||||
import threading
|
||||
|
||||
from openjarvis.cli._version_check import check_for_updates
|
||||
@@ -91,74 +84,111 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None:
|
||||
check_and_route(ctx)
|
||||
|
||||
|
||||
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")
|
||||
cli.add_command(mine, "mine")
|
||||
cli.add_command(pearl, "pearl")
|
||||
cli.add_command(telemetry, "telemetry")
|
||||
cli.add_command(bench, "bench")
|
||||
cli.add_command(channel, "channel")
|
||||
cli.add_command(channels, "channels")
|
||||
cli.add_command(scheduler, "scheduler")
|
||||
cli.add_command(doctor, "doctor")
|
||||
cli.add_command(agent, "agents")
|
||||
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")
|
||||
cli.add_command(operators, "operators")
|
||||
cli.add_command(eval_group, "eval")
|
||||
cli.add_command(host, "host")
|
||||
cli.add_command(quickstart, "quickstart")
|
||||
cli.add_command(optimize_group, "optimize")
|
||||
cli.add_command(feedback_group, "feedback")
|
||||
cli.add_command(compose, "compose")
|
||||
cli.add_command(gateway, "gateway")
|
||||
cli.add_command(tool, "tool")
|
||||
cli.add_command(registry, "registry")
|
||||
cli.add_command(config, "config")
|
||||
cli.add_command(scan, "scan")
|
||||
cli.add_command(connect, "connect")
|
||||
cli.add_command(digest, "digest")
|
||||
# deep-research setup pulls the ingestion pipeline (embeddings/numpy). Guard it
|
||||
# so a broken or slow numpy on Windows — which can raise at IMPORT time, not
|
||||
# just ImportError (#404) — can never take down the whole CLI, including
|
||||
# `jarvis serve`. Invoking `jarvis deep-research-setup` without the deps still
|
||||
# errors clearly on demand.
|
||||
try:
|
||||
from openjarvis.cli.deep_research_setup_cmd import deep_research_setup
|
||||
if not _DATA_BOUNDARY_BOOTSTRAP:
|
||||
from openjarvis.cli._bootstrap import bootstrap_cmd
|
||||
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.channels_cmd import channels
|
||||
from openjarvis.cli.chat_cmd import chat
|
||||
from openjarvis.cli.compose_cmd import compose
|
||||
from openjarvis.cli.config_cmd import config
|
||||
from openjarvis.cli.connect_cmd import connect
|
||||
from openjarvis.cli.daemon_cmd import restart, start, status, stop
|
||||
from openjarvis.cli.digest_cmd import digest
|
||||
from openjarvis.cli.doctor_cmd import doctor
|
||||
from openjarvis.cli.eval_cmd import eval_group
|
||||
from openjarvis.cli.feedback_cmd import feedback_group
|
||||
from openjarvis.cli.gateway_cmd import gateway
|
||||
from openjarvis.cli.host_cmd import host
|
||||
from openjarvis.cli.init_cmd import init
|
||||
from openjarvis.cli.memory_cmd import memory
|
||||
from openjarvis.cli.mine_cmd import mine
|
||||
from openjarvis.cli.model import model
|
||||
from openjarvis.cli.operators_cmd import operators
|
||||
from openjarvis.cli.optimize_cmd import optimize_group
|
||||
from openjarvis.cli.pearl_cmd import pearl
|
||||
from openjarvis.cli.quickstart_cmd import quickstart
|
||||
from openjarvis.cli.registry_cmd import registry
|
||||
from openjarvis.cli.scheduler_cmd import scheduler
|
||||
from openjarvis.cli.self_update_cmd import self_update
|
||||
from openjarvis.cli.serve import serve
|
||||
from openjarvis.cli.skill_cmd import skill
|
||||
from openjarvis.cli.telemetry_cmd import telemetry
|
||||
from openjarvis.cli.tool_cmd import tool
|
||||
from openjarvis.cli.vault_cmd import vault
|
||||
from openjarvis.cli.workflow_cmd import workflow
|
||||
|
||||
cli.add_command(deep_research_setup, "deep-research-setup")
|
||||
cli.add_command(deep_research_setup, "research")
|
||||
except Exception as _dr_exc:
|
||||
import logging as _logging
|
||||
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")
|
||||
cli.add_command(mine, "mine")
|
||||
cli.add_command(pearl, "pearl")
|
||||
cli.add_command(telemetry, "telemetry")
|
||||
cli.add_command(bench, "bench")
|
||||
cli.add_command(channel, "channel")
|
||||
cli.add_command(channels, "channels")
|
||||
cli.add_command(scheduler, "scheduler")
|
||||
cli.add_command(doctor, "doctor")
|
||||
cli.add_command(agent, "agents")
|
||||
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")
|
||||
cli.add_command(operators, "operators")
|
||||
cli.add_command(eval_group, "eval")
|
||||
cli.add_command(host, "host")
|
||||
cli.add_command(quickstart, "quickstart")
|
||||
cli.add_command(optimize_group, "optimize")
|
||||
cli.add_command(feedback_group, "feedback")
|
||||
cli.add_command(compose, "compose")
|
||||
cli.add_command(gateway, "gateway")
|
||||
cli.add_command(tool, "tool")
|
||||
cli.add_command(registry, "registry")
|
||||
cli.add_command(config, "config")
|
||||
cli.add_command(connect, "connect")
|
||||
cli.add_command(digest, "digest")
|
||||
|
||||
_logging.getLogger(__name__).debug("deep-research command unavailable: %s", _dr_exc)
|
||||
cli.add_command(self_update, "self-update")
|
||||
cli.add_command(bootstrap_cmd, "_bootstrap")
|
||||
# Deep Research setup pulls the ingestion pipeline (embeddings/numpy). Guard
|
||||
# it so an import-time dependency failure cannot take down the whole CLI.
|
||||
try:
|
||||
from openjarvis.cli.deep_research_setup_cmd import deep_research_setup
|
||||
|
||||
# Gateway CLI commands (lazy import to avoid pulling starlette)
|
||||
try:
|
||||
from openjarvis.cli.auth_cmd import auth
|
||||
cli.add_command(deep_research_setup, "deep-research-setup")
|
||||
cli.add_command(deep_research_setup, "research")
|
||||
except Exception as _dr_exc:
|
||||
import logging as _logging
|
||||
|
||||
cli.add_command(auth, "auth")
|
||||
except ImportError:
|
||||
pass
|
||||
_logging.getLogger(__name__).debug(
|
||||
"deep-research command unavailable: %s", _dr_exc
|
||||
)
|
||||
cli.add_command(self_update, "self-update")
|
||||
cli.add_command(bootstrap_cmd, "_bootstrap")
|
||||
|
||||
try:
|
||||
from openjarvis.cli.tunnel_cmd import tunnel
|
||||
# Gateway CLI commands (lazy import to avoid pulling starlette)
|
||||
try:
|
||||
from openjarvis.cli.auth_cmd import auth
|
||||
|
||||
cli.add_command(tunnel, "tunnel")
|
||||
except ImportError:
|
||||
pass
|
||||
cli.add_command(auth, "auth")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from openjarvis.cli.tunnel_cmd import tunnel
|
||||
|
||||
cli.add_command(tunnel, "tunnel")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -11,7 +11,9 @@ Three install paths are supported today:
|
||||
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
|
||||
cloned repo). The package's ``__file__`` is inside a working tree
|
||||
with a ``.git`` directory at the repo root. Upgrade with
|
||||
``git pull && uv sync`` from the checkout.
|
||||
``git pull && uv sync --inexact`` from the checkout. ``--inexact`` is
|
||||
important here: a bare ``uv sync`` removes packages installed by extras or
|
||||
dependency groups that are not part of the base project.
|
||||
|
||||
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
|
||||
confidence we fall back to the PyPI command — that's the most common
|
||||
@@ -68,7 +70,7 @@ def detect_install() -> InstallInfo:
|
||||
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
|
||||
return InstallInfo(
|
||||
kind="editable-git",
|
||||
upgrade_command=f"cd {candidate} && git pull && uv sync",
|
||||
upgrade_command=(f"cd {candidate} && git pull && uv sync --inexact"),
|
||||
repo_root=candidate,
|
||||
)
|
||||
if candidate.parent == candidate:
|
||||
|
||||
@@ -248,6 +248,17 @@ def _get_memory_backend(config):
|
||||
return None
|
||||
|
||||
|
||||
def _get_memory_facts(config):
|
||||
"""Load facts captured by the automatic memory service."""
|
||||
try:
|
||||
from openjarvis.memory import load_configured_facts
|
||||
|
||||
return load_configured_facts(config)
|
||||
except Exception as exc:
|
||||
logger.debug("Automatic memory facts unavailable (optional): %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
_MEMORY_TOOLS = frozenset(
|
||||
{"retrieval", "memory_store", "memory_search", "memory_index", "memory_retrieve"}
|
||||
)
|
||||
@@ -387,9 +398,8 @@ def _run_agent(
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md persona
|
||||
# files actually reach the model. Only passed to agents whose __init__
|
||||
# accepts a `prompt_builder` kwarg (BaseAgent does; agents that override
|
||||
# __init__ without forwarding it, e.g. OrchestratorAgent, opt out
|
||||
# automatically and keep their existing system-prompt machinery).
|
||||
# explicitly accepts a `prompt_builder` kwarg. Agents with specialized
|
||||
# prompt machinery opt in by naming and forwarding the parameter.
|
||||
import inspect as _inspect
|
||||
|
||||
if "prompt_builder" in _inspect.signature(agent_cls.__init__).parameters:
|
||||
@@ -416,7 +426,8 @@ def _run_agent(
|
||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||
|
||||
backend = _get_memory_backend(config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
@@ -427,6 +438,7 @@ def _run_agent(
|
||||
[],
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
for msg in context_messages:
|
||||
ctx.conversation.add(msg)
|
||||
@@ -963,7 +975,8 @@ def ask(
|
||||
)
|
||||
|
||||
backend = _get_memory_backend(config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
@@ -974,6 +987,7 @@ def ask(
|
||||
messages,
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to inject memory context: %s", exc)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
@@ -15,6 +16,8 @@ from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.memory import publish_completed_exchange
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _read_input(prompt: str = "You> ") -> Optional[str]:
|
||||
"""Read user input with graceful EOF handling."""
|
||||
@@ -194,6 +197,15 @@ def chat(
|
||||
console.print(f"[yellow]Memory service unavailable: {exc}[/yellow]")
|
||||
memory_service = None
|
||||
|
||||
# The document backend and automatic fact store are separate persistence
|
||||
# mechanisms. Context injection combines both at read time so facts from
|
||||
# previous sessions are immediately available without a manual index step.
|
||||
memory_backend = None
|
||||
if config.agent.context_from_memory:
|
||||
from openjarvis.cli.ask import _get_memory_backend
|
||||
|
||||
memory_backend = _get_memory_backend(config)
|
||||
|
||||
# Conversation state
|
||||
if not system_prompt:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
@@ -262,15 +274,57 @@ def chat(
|
||||
# Add user message
|
||||
history.append(Message(role=Role.USER, content=user_input))
|
||||
|
||||
# Generate response
|
||||
generation_history = history
|
||||
agent_context_message = None
|
||||
if config.agent.context_from_memory:
|
||||
try:
|
||||
from openjarvis.memory import load_configured_facts
|
||||
from openjarvis.tools.storage.context import (
|
||||
ContextConfig,
|
||||
inject_context,
|
||||
)
|
||||
|
||||
if memory_service is not None and hasattr(memory_service, "list_facts"):
|
||||
facts = memory_service.list_facts()
|
||||
else:
|
||||
facts = load_configured_facts(config)
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
max_context_tokens=config.memory.context_max_tokens,
|
||||
)
|
||||
context_messages = inject_context(
|
||||
user_input,
|
||||
[] if agent is not None else history,
|
||||
memory_backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
if agent is not None:
|
||||
if context_messages:
|
||||
agent_context_message = context_messages[0]
|
||||
else:
|
||||
generation_history = context_messages
|
||||
except Exception:
|
||||
logger.debug("Failed to inject memory context", exc_info=True)
|
||||
|
||||
# Generate response even when optional memory context is unavailable.
|
||||
try:
|
||||
if agent is not None:
|
||||
response = agent.run(user_input)
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
|
||||
agent_context = AgentContext()
|
||||
if agent_context_message is not None:
|
||||
agent_context.conversation.add(agent_context_message)
|
||||
for msg in history[:-1]:
|
||||
if msg.role != Role.SYSTEM:
|
||||
agent_context.conversation.add(msg)
|
||||
response = agent.run(user_input, context=agent_context)
|
||||
content = (
|
||||
response.content if hasattr(response, "content") else str(response)
|
||||
)
|
||||
else:
|
||||
result = engine.generate(history, model=model)
|
||||
result = engine.generate(generation_history, model=model)
|
||||
content = (
|
||||
result.get("content", "")
|
||||
if isinstance(result, dict)
|
||||
|
||||
@@ -158,7 +158,7 @@ def _show_toml_config(console: Console, config_path: Path) -> None:
|
||||
console.print(f"[dim]Loading config from: {config_path}[/dim]")
|
||||
|
||||
if config_path.exists():
|
||||
config_content = config_path.read_text()
|
||||
config_content = config_path.read_text(encoding="utf-8")
|
||||
syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True)
|
||||
console.print(Panel(syntax, title="Config File", border_style="cyan"))
|
||||
else:
|
||||
@@ -170,7 +170,7 @@ def _show_json_config(console: Console, config_path: Path) -> None:
|
||||
console.print(f"[dim]Loading config from: {config_path}[/dim]")
|
||||
|
||||
if config_path.exists():
|
||||
config_content = config_path.read_text()
|
||||
config_content = config_path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
@@ -375,7 +375,7 @@ def set_config(key: str, value: str) -> None:
|
||||
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
|
||||
)
|
||||
if config_path.exists():
|
||||
doc = tomlkit.parse(config_path.read_text())
|
||||
doc = tomlkit.parse(config_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
doc = tomlkit.document()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -390,7 +390,7 @@ def set_config(key: str, value: str) -> None:
|
||||
current[parts[-1]] = typed_value
|
||||
|
||||
# Write back
|
||||
config_path.write_text(tomlkit.dumps(doc))
|
||||
config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
|
||||
|
||||
console.print(f"[green]Set[/green] {key} = {value!r}")
|
||||
|
||||
|
||||
@@ -17,18 +17,64 @@ _PID_FILE = DEFAULT_CONFIG_DIR / "server.pid"
|
||||
_LOG_FILE = DEFAULT_CONFIG_DIR / "server.log"
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return whether *pid* identifies a running process without signaling it."""
|
||||
if pid <= 0:
|
||||
return False
|
||||
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
error_invalid_parameter = 87
|
||||
synchronize = 0x00100000
|
||||
wait_object_0 = 0x00000000
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(synchronize, False, pid)
|
||||
if not handle:
|
||||
# OpenProcess reports ERROR_INVALID_PARAMETER when the PID does not
|
||||
# exist. For access-denied and other inconclusive failures, retain
|
||||
# the PID file rather than declaring a potentially live daemon dead.
|
||||
return ctypes.get_last_error() != error_invalid_parameter
|
||||
|
||||
try:
|
||||
wait_result = kernel32.WaitForSingleObject(handle, 0)
|
||||
# WAIT_OBJECT_0 proves the process exited. WAIT_TIMEOUT proves it
|
||||
# is live; unexpected failures are inconclusive, so retain the PID.
|
||||
return wait_result != wait_object_0
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
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):
|
||||
except (OSError, ValueError):
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return None
|
||||
if not _pid_alive(pid):
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return None
|
||||
return pid
|
||||
|
||||
|
||||
def _write_pid(pid: int) -> None:
|
||||
@@ -127,14 +173,13 @@ def stop() -> None:
|
||||
# Wait up to 10 seconds for graceful shutdown
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
else:
|
||||
# Force kill if still running
|
||||
# SIGKILL is POSIX-only. On Windows SIGTERM already maps to
|
||||
# TerminateProcess, so repeating it is the available escalation.
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
|
||||
@@ -344,7 +344,9 @@ def init(
|
||||
console.print(f" Looked in: {examples_dir}")
|
||||
raise SystemExit(1)
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DEFAULT_CONFIG_PATH.write_text(preset_path.read_text())
|
||||
DEFAULT_CONFIG_PATH.write_text(
|
||||
preset_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
console.print(
|
||||
f"[green]Preset '{preset}' installed to {DEFAULT_CONFIG_PATH}[/green]"
|
||||
)
|
||||
|
||||
@@ -89,15 +89,39 @@ def index(
|
||||
|
||||
mem = _get_backend(backend)
|
||||
try:
|
||||
for chunk in track(chunks, description="Storing chunks...", console=console):
|
||||
mem.store(
|
||||
chunk.content,
|
||||
source=chunk.source,
|
||||
metadata={
|
||||
"offset": chunk.offset,
|
||||
"index": chunk.index,
|
||||
},
|
||||
)
|
||||
replace_source = getattr(mem, "replace_source", None)
|
||||
if callable(replace_source):
|
||||
documents_by_source = {}
|
||||
for chunk in chunks:
|
||||
documents_by_source.setdefault(chunk.source, []).append(
|
||||
(
|
||||
chunk.content,
|
||||
{
|
||||
"offset": chunk.offset,
|
||||
"index": chunk.index,
|
||||
},
|
||||
)
|
||||
)
|
||||
for source, documents in track(
|
||||
documents_by_source.items(),
|
||||
description="Replacing sources...",
|
||||
console=console,
|
||||
):
|
||||
replace_source(source, documents)
|
||||
else:
|
||||
for chunk in track(
|
||||
chunks,
|
||||
description="Storing chunks...",
|
||||
console=console,
|
||||
):
|
||||
mem.store(
|
||||
chunk.content,
|
||||
source=chunk.source,
|
||||
metadata={
|
||||
"offset": chunk.offset,
|
||||
"index": chunk.index,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
if hasattr(mem, "close"):
|
||||
mem.close()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
@@ -11,7 +12,11 @@ from typing import Callable, List
|
||||
|
||||
import click
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.paths import get_config_dir, get_config_path
|
||||
from openjarvis.security.data_boundary_audit import (
|
||||
DataBoundaryReport,
|
||||
build_data_boundary_report,
|
||||
)
|
||||
|
||||
# Engine ports that should only be listening on localhost.
|
||||
_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181}
|
||||
@@ -441,10 +446,45 @@ _RICH_ICONS = {
|
||||
"ok": "[green]\u2713[/green]",
|
||||
"warn": "[yellow]![/yellow]",
|
||||
"fail": "[red]\u2717[/red]",
|
||||
"info": "[blue]i[/blue]",
|
||||
"skip": "[dim]-[/dim]",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_data_boundary_config_path() -> Path:
|
||||
env_config = os.environ.get("OPENJARVIS_CONFIG")
|
||||
if env_config:
|
||||
return Path(env_config).expanduser().resolve()
|
||||
return get_config_path()
|
||||
|
||||
|
||||
def _load_data_boundary_config():
|
||||
"""Load config without treating missing config as active."""
|
||||
root = None
|
||||
root_error = ""
|
||||
try:
|
||||
root = get_config_dir()
|
||||
config_path = _resolve_data_boundary_config_path()
|
||||
except Exception as exc:
|
||||
config_path = None
|
||||
root_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
if root_error:
|
||||
return None, root, False, "", root_error
|
||||
|
||||
try:
|
||||
from openjarvis.core.config import JarvisConfig, load_config
|
||||
except Exception as exc:
|
||||
return None, root, False, f"{type(exc).__name__}: {exc}", ""
|
||||
|
||||
if config_path is None or not config_path.exists():
|
||||
return JarvisConfig(), root, False, "", root_error
|
||||
try:
|
||||
return load_config(config_path), root, True, "", root_error
|
||||
except Exception as exc:
|
||||
return JarvisConfig(), root, False, f"{type(exc).__name__}: {exc}", root_error
|
||||
|
||||
|
||||
def _render_results(results: List[ScanResult]) -> None:
|
||||
"""Render scan results as a Rich table."""
|
||||
from rich.console import Console
|
||||
@@ -494,17 +534,123 @@ def _render_results(results: List[ScanResult]) -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
def _render_data_boundary_report(
|
||||
report: DataBoundaryReport,
|
||||
*,
|
||||
show_paths: bool,
|
||||
) -> None:
|
||||
"""Render application data-boundary findings as a Rich table."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
console.print()
|
||||
console.print("[bold]OpenJarvis Data-Boundary Scan[/bold]")
|
||||
console.print(f"Verdict: [bold]{report.verdict}[/bold]")
|
||||
console.print()
|
||||
|
||||
table = Table(show_header=True, header_style="bold", show_lines=True)
|
||||
table.add_column("", width=3, justify="center")
|
||||
table.add_column("Finding")
|
||||
table.add_column("Recommendation")
|
||||
|
||||
for finding in report.findings:
|
||||
icon = _RICH_ICONS.get(finding.status, "?")
|
||||
style = {"fail": "red", "warn": "yellow", "info": "blue"}.get(
|
||||
finding.status,
|
||||
"white",
|
||||
)
|
||||
details = [f"[{style}]{finding.title}[/{style}]"]
|
||||
details.append(f"[dim]{finding.potential_data_path}[/dim]")
|
||||
if finding.location:
|
||||
location = finding.absolute_location if show_paths else finding.location
|
||||
details.append(f"[dim]Location: {location}[/dim]")
|
||||
table.add_row(icon, "\n".join(details), finding.recommendation)
|
||||
|
||||
console.print(table)
|
||||
summary = report.summary()
|
||||
console.print()
|
||||
console.print(
|
||||
f" [red]{summary['fail']} fail[/red], "
|
||||
f"[yellow]{summary['warn']} warning(s)[/yellow], "
|
||||
f"[blue]{summary['info']} info[/blue]"
|
||||
)
|
||||
if not show_paths:
|
||||
console.print(
|
||||
" [dim]Absolute paths and connector basenames are redacted by default. "
|
||||
"Use --show-paths for local debugging.[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
def _emit_data_boundary_json(
|
||||
report: DataBoundaryReport,
|
||||
*,
|
||||
show_paths: bool,
|
||||
) -> None:
|
||||
click.echo(json.dumps(report.to_dict(show_paths=show_paths), indent=2))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--quick", is_flag=True, default=False, help="Run only critical checks.")
|
||||
@click.option("--json", "as_json", is_flag=True, default=False, help="Output as JSON.")
|
||||
def scan(quick: bool, as_json: bool) -> None:
|
||||
@click.option(
|
||||
"--data-boundaries",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Run application data-boundary checks instead of host checks.",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit non-zero if data-boundary fail or warn findings are present.",
|
||||
)
|
||||
@click.option(
|
||||
"--show-paths",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Show absolute paths in data-boundary output.",
|
||||
)
|
||||
def scan(
|
||||
quick: bool,
|
||||
as_json: bool,
|
||||
data_boundaries: bool,
|
||||
strict: bool,
|
||||
show_paths: bool,
|
||||
) -> None:
|
||||
"""Audit your environment for privacy and security risks."""
|
||||
if data_boundaries:
|
||||
if quick:
|
||||
raise click.UsageError("--quick cannot be combined with --data-boundaries.")
|
||||
config, root, config_loaded, config_error, root_error = (
|
||||
_load_data_boundary_config()
|
||||
)
|
||||
report = build_data_boundary_report(
|
||||
config,
|
||||
root,
|
||||
config_loaded=config_loaded,
|
||||
config_error=config_error,
|
||||
root_error=root_error,
|
||||
)
|
||||
if as_json:
|
||||
_emit_data_boundary_json(report, show_paths=show_paths)
|
||||
else:
|
||||
_render_data_boundary_report(report, show_paths=show_paths)
|
||||
summary = report.summary()
|
||||
if strict and (summary["fail"] or summary["warn"]):
|
||||
raise click.exceptions.Exit(1)
|
||||
return
|
||||
|
||||
if strict or show_paths:
|
||||
raise click.UsageError(
|
||||
"--strict and --show-paths are only supported with --data-boundaries."
|
||||
)
|
||||
|
||||
scanner = PrivacyScanner()
|
||||
results: List[ScanResult] = scanner.run_quick() if quick else scanner.run_all()
|
||||
|
||||
if as_json:
|
||||
import json as json_mod
|
||||
|
||||
output = [
|
||||
{
|
||||
"name": r.name,
|
||||
@@ -514,7 +660,7 @@ def scan(quick: bool, as_json: bool) -> None:
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
click.echo(json_mod.dumps(output, indent=2))
|
||||
click.echo(json.dumps(output, indent=2))
|
||||
return
|
||||
|
||||
if not results:
|
||||
|
||||
@@ -4,7 +4,9 @@ Runs the right upgrade command for how the user installed OpenJarvis:
|
||||
|
||||
- PyPI installs get ``pip install --upgrade openjarvis``.
|
||||
- uv-tool installs get ``uv tool upgrade openjarvis``.
|
||||
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
|
||||
- Editable git checkouts get ``git pull && uv sync --inexact`` in the checkout.
|
||||
The inexact sync preserves packages previously installed through extras and
|
||||
dependency groups.
|
||||
|
||||
The detection logic is shared with the post-command "new version
|
||||
available" hint in ``_version_check.py`` so both surfaces stay in sync.
|
||||
|
||||
+50
-35
@@ -25,6 +25,30 @@ from openjarvis.intelligence import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TOOLS = frozenset({"think", "calculator", "web_search"})
|
||||
|
||||
|
||||
def _resolve_allowed_tools(config: object) -> tuple[set[str], bool]:
|
||||
"""Return configured tool names and whether the selection was explicit.
|
||||
|
||||
``tools.enabled`` is the canonical setting used by ``SystemBuilder`` and
|
||||
the interactive CLI. ``agent.tools`` remains as a backward-compatible
|
||||
fallback, followed by the server's default tool set when neither is set.
|
||||
"""
|
||||
configured = config.tools.enabled or config.agent.tools
|
||||
if not configured:
|
||||
return set(_DEFAULT_TOOLS), False
|
||||
|
||||
if isinstance(configured, list):
|
||||
allowed = {
|
||||
tool.strip()
|
||||
for tool in configured
|
||||
if isinstance(tool, str) and tool.strip()
|
||||
}
|
||||
else:
|
||||
allowed = {tool.strip() for tool in configured.split(",") if tool.strip()}
|
||||
return allowed, True
|
||||
|
||||
|
||||
def _unique_model_ids(model_ids: list[str]) -> list[str]:
|
||||
"""Return model ids in first-seen order without duplicates."""
|
||||
@@ -96,7 +120,7 @@ def _resolve_server_model(
|
||||
"--agent",
|
||||
"agent_name",
|
||||
default=None,
|
||||
help="Agent for non-streaming requests (simple, orchestrator, react, openhands).",
|
||||
help="Agent for chat requests (simple, orchestrator, react, openhands).",
|
||||
)
|
||||
@click.pass_context
|
||||
def serve(
|
||||
@@ -305,21 +329,7 @@ def serve(
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.tools._stubs import BaseTool
|
||||
|
||||
_DEFAULT_TOOLS = {"think", "calculator", "web_search"}
|
||||
configured = config.agent.tools
|
||||
if configured:
|
||||
if isinstance(configured, list):
|
||||
allowed = {
|
||||
t.strip()
|
||||
for t in configured
|
||||
if isinstance(t, str) and t.strip()
|
||||
}
|
||||
else:
|
||||
allowed = {
|
||||
t.strip() for t in configured.split(",") if t.strip()
|
||||
}
|
||||
else:
|
||||
allowed = _DEFAULT_TOOLS
|
||||
allowed, tools_configured = _resolve_allowed_tools(config)
|
||||
|
||||
tools = []
|
||||
for name in ToolRegistry.keys():
|
||||
@@ -336,7 +346,7 @@ def serve(
|
||||
# MCP server tools from config.tools.mcp.servers
|
||||
# (#461 — these were silently dropped).
|
||||
mcp_tools = managed_mcp_tools
|
||||
if configured:
|
||||
if tools_configured:
|
||||
mcp_tools = [
|
||||
tool
|
||||
for tool in managed_mcp_tools
|
||||
@@ -357,6 +367,27 @@ def serve(
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
agent_kwargs["max_turns"] = config.agent.max_turns
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md
|
||||
# reach the model on the SERVE path too. ``ask.py`` has done
|
||||
# this since the persona system landed; ``serve.py`` never did,
|
||||
# so an agent served over HTTP silently answered as a generic
|
||||
# assistant while the same agent via the CLI kept its persona.
|
||||
# Guarded so agents with specialized prompt machinery must opt
|
||||
# in by explicitly naming and forwarding the kwarg.
|
||||
import inspect as _inspect
|
||||
|
||||
if (
|
||||
"prompt_builder"
|
||||
in _inspect.signature(agent_cls.__init__).parameters
|
||||
):
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=config.agent.default_system_prompt or "",
|
||||
memory_files_config=config.memory_files,
|
||||
system_prompt_config=config.system_prompt,
|
||||
)
|
||||
|
||||
agent = agent_cls(engine, model_name, **agent_kwargs)
|
||||
# Pin MCP transports to the agent's lifetime so HTTP
|
||||
# connections don't close mid-request (#461).
|
||||
@@ -406,23 +437,7 @@ def serve(
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
from openjarvis.tools._stubs import BaseTool
|
||||
|
||||
_DEFAULT_TOOLS = {"think", "calculator", "web_search"}
|
||||
configured = config.agent.tools
|
||||
if configured:
|
||||
if isinstance(configured, list):
|
||||
_allowed = {
|
||||
t.strip()
|
||||
for t in configured
|
||||
if isinstance(t, str) and t.strip()
|
||||
}
|
||||
else:
|
||||
_allowed = {
|
||||
t.strip()
|
||||
for t in configured.split(",")
|
||||
if t.strip()
|
||||
}
|
||||
else:
|
||||
_allowed = _DEFAULT_TOOLS
|
||||
_allowed, _tools_configured = _resolve_allowed_tools(config)
|
||||
|
||||
for _tname in ToolRegistry.keys():
|
||||
if _tname not in _allowed:
|
||||
@@ -436,7 +451,7 @@ def serve(
|
||||
# Reuse the process-owned MCP pool so channels do not
|
||||
# open a second transport to every configured server.
|
||||
_ch_mcp_tools = managed_mcp_tools
|
||||
if configured:
|
||||
if _tools_configured:
|
||||
_ch_mcp_tools = [
|
||||
tool
|
||||
for tool in managed_mcp_tools
|
||||
|
||||
@@ -9,8 +9,9 @@ System Settings → Privacy & Security → Full Disk Access.
|
||||
|
||||
Timestamp notes
|
||||
---------------
|
||||
The Notes database stores modification timestamps as seconds since the Apple
|
||||
epoch of 2001-01-01 00:00:00 UTC. Conversion formula::
|
||||
Modern Notes schemas store note modification timestamps in
|
||||
``ZMODIFICATIONDATE1``; older schemas use ``ZMODIFICATIONDATE``. Both are
|
||||
seconds since the Apple epoch of 2001-01-01 00:00:00 UTC. Conversion formula::
|
||||
|
||||
dt = datetime(2001, 1, 1, tzinfo=utc) + timedelta(seconds=ZMODIFICATIONDATE)
|
||||
|
||||
@@ -171,25 +172,46 @@ class AppleNotesConnector(BaseConnector):
|
||||
return
|
||||
|
||||
try:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT n.ZIDENTIFIER, "
|
||||
" COALESCE(n.ZTITLE1, n.ZTITLE, '') AS title, "
|
||||
" n.ZMODIFICATIONDATE, d.ZDATA "
|
||||
"FROM ZICCLOUDSYNCINGOBJECT n "
|
||||
"JOIN ZICNOTEDATA d ON d.ZNOTE = n.Z_PK "
|
||||
"ORDER BY n.ZMODIFICATIONDATE ASC"
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
# Older macOS schemas may lack ZTITLE1
|
||||
rows = conn.execute(
|
||||
"SELECT n.ZIDENTIFIER, "
|
||||
" COALESCE(n.ZTITLE, '') AS title, "
|
||||
" n.ZMODIFICATIONDATE, d.ZDATA "
|
||||
"FROM ZICCLOUDSYNCINGOBJECT n "
|
||||
"JOIN ZICNOTEDATA d ON d.ZNOTE = n.Z_PK "
|
||||
"ORDER BY n.ZMODIFICATIONDATE ASC"
|
||||
object_columns = {
|
||||
row[1]
|
||||
for row in conn.execute(
|
||||
"PRAGMA table_info(ZICCLOUDSYNCINGOBJECT)"
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
title_columns = [
|
||||
f"n.{column}"
|
||||
for column in ("ZTITLE1", "ZTITLE")
|
||||
if column in object_columns
|
||||
]
|
||||
title_expr = (
|
||||
f"COALESCE({', '.join(title_columns)}, '')" if title_columns else "''"
|
||||
)
|
||||
|
||||
# Modern Apple Notes stores a note's modification timestamp in
|
||||
# ZMODIFICATIONDATE1. ZMODIFICATIONDATE is still present in some
|
||||
# schemas, but applies to other cloud-sync object types and can be
|
||||
# NULL for notes. Treating that NULL as zero makes incremental
|
||||
# syncs incorrectly discard newly-created notes as 2001-era data.
|
||||
modification_columns = [
|
||||
f"n.{column}"
|
||||
for column in ("ZMODIFICATIONDATE1", "ZMODIFICATIONDATE")
|
||||
if column in object_columns
|
||||
]
|
||||
modification_expr = (
|
||||
f"COALESCE({', '.join(modification_columns)}, 0)"
|
||||
if modification_columns
|
||||
else "0"
|
||||
)
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT n.ZIDENTIFIER, "
|
||||
f" {title_expr} AS title, "
|
||||
f" {modification_expr} AS modification_date, d.ZDATA "
|
||||
"FROM ZICCLOUDSYNCINGOBJECT n "
|
||||
"JOIN ZICNOTEDATA d ON d.ZNOTE = n.Z_PK "
|
||||
"ORDER BY modification_date ASC"
|
||||
).fetchall()
|
||||
|
||||
self._items_total = len(rows)
|
||||
synced = 0
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
@@ -1305,6 +1306,160 @@ class CloudEngine(InferenceEngine):
|
||||
if chunk.text:
|
||||
yield chunk.text
|
||||
|
||||
async def _stream_full_google(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
"""Stream Google text and function-call parts as full chunks."""
|
||||
if self._google_client is None:
|
||||
raise EngineConnectionError("Google client not available")
|
||||
|
||||
system_text = ""
|
||||
contents: List[Dict[str, Any]] = []
|
||||
for message in messages:
|
||||
if message.role.value == "system":
|
||||
system_text = message.content
|
||||
elif message.role.value == "tool":
|
||||
function_response = {
|
||||
"function_response": {
|
||||
"name": message.name or "unknown",
|
||||
"response": {"result": message.content},
|
||||
}
|
||||
}
|
||||
if (
|
||||
contents
|
||||
and contents[-1]["role"] == "user"
|
||||
and contents[-1]["parts"]
|
||||
and "function_response" in contents[-1]["parts"][-1]
|
||||
):
|
||||
contents[-1]["parts"].append(function_response)
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [function_response]})
|
||||
elif message.role.value == "assistant" and message.tool_calls:
|
||||
parts: List[Dict[str, Any]] = []
|
||||
if message.content:
|
||||
parts.append({"text": message.content})
|
||||
for tool_call in message.tool_calls:
|
||||
args = tool_call.arguments
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {"input": args}
|
||||
function_call_part: Dict[str, Any] = {
|
||||
"function_call": {
|
||||
"name": tool_call.name,
|
||||
"args": args if isinstance(args, dict) else {},
|
||||
}
|
||||
}
|
||||
signature = self._thought_sigs.get(tool_call.id)
|
||||
if signature is not None:
|
||||
function_call_part["thought_signature"] = signature
|
||||
parts.append(function_call_part)
|
||||
contents.append({"role": "model", "parts": parts})
|
||||
elif message.role.value == "assistant":
|
||||
contents.append({"role": "model", "parts": [{"text": message.content}]})
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [{"text": message.content}]})
|
||||
|
||||
from google.genai import types as genai_types
|
||||
|
||||
config = genai_types.GenerateContentConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
)
|
||||
if system_text:
|
||||
config.system_instruction = system_text
|
||||
|
||||
tools = kwargs.pop("tools", None)
|
||||
if tools:
|
||||
config.tools = [{"function_declarations": _convert_tools_to_google(tools)}]
|
||||
|
||||
tool_call_count = 0
|
||||
stream_id = uuid.uuid4().hex
|
||||
final_usage: Dict[str, Any] | None = None
|
||||
for chunk in self._google_client.models.generate_content_stream(
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=config,
|
||||
):
|
||||
usage_metadata = getattr(chunk, "usage_metadata", None)
|
||||
if usage_metadata is not None:
|
||||
prompt_tokens = getattr(usage_metadata, "prompt_token_count", 0) or 0
|
||||
completion_tokens = (
|
||||
getattr(usage_metadata, "candidates_token_count", 0) or 0
|
||||
)
|
||||
final_usage = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
}
|
||||
|
||||
candidates = getattr(chunk, "candidates", None)
|
||||
parts = []
|
||||
if candidates:
|
||||
parts = getattr(candidates[0].content, "parts", []) or []
|
||||
|
||||
if parts:
|
||||
text_found = False
|
||||
calls: List[Dict[str, Any]] = []
|
||||
for part in parts:
|
||||
text = getattr(part, "text", None)
|
||||
if text:
|
||||
text_found = True
|
||||
yield StreamChunk(content=text)
|
||||
|
||||
function_call = getattr(part, "function_call", None)
|
||||
if function_call:
|
||||
name = getattr(function_call, "name", "")
|
||||
raw_args = getattr(function_call, "args", {})
|
||||
args = dict(raw_args) if hasattr(raw_args, "items") else {}
|
||||
# Gemini emits complete function-call parts, so each part is
|
||||
# a distinct invocation. The same function may legitimately
|
||||
# be called more than once in a parallel response.
|
||||
tool_index = tool_call_count
|
||||
# The engine is shared across server requests, and saved
|
||||
# thought signatures are keyed by tool-call ID. Include a
|
||||
# per-stream nonce so concurrent conversations cannot
|
||||
# overwrite each other's signatures.
|
||||
tool_id = f"google_{stream_id}_{tool_index}"
|
||||
tool_call_count += 1
|
||||
tool_call = {
|
||||
"index": tool_index,
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": json.dumps(args),
|
||||
},
|
||||
}
|
||||
calls.append(tool_call)
|
||||
signature = getattr(part, "thought_signature", None)
|
||||
if signature is not None:
|
||||
tool_call["thought_signature"] = signature
|
||||
self._thought_sigs[tool_id] = signature
|
||||
if calls:
|
||||
yield StreamChunk(tool_calls=calls)
|
||||
if text_found:
|
||||
continue
|
||||
|
||||
try:
|
||||
text = chunk.text
|
||||
except (AttributeError, ValueError):
|
||||
text = None
|
||||
if text:
|
||||
yield StreamChunk(content=text)
|
||||
|
||||
yield StreamChunk(
|
||||
finish_reason="tool_calls" if tool_call_count else "stop",
|
||||
usage=final_usage,
|
||||
)
|
||||
|
||||
async def _stream_openrouter(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
@@ -1600,7 +1755,7 @@ class CloudEngine(InferenceEngine):
|
||||
async for chunk in self._stream_full_anthropic(messages, **kw):
|
||||
yield chunk
|
||||
elif _is_google_model(model):
|
||||
async for chunk in super().stream_full(messages, **kw):
|
||||
async for chunk in self._stream_full_google(messages, **kw):
|
||||
yield chunk
|
||||
else:
|
||||
async for chunk in self._stream_full_openai(messages, **kw):
|
||||
|
||||
@@ -8,13 +8,12 @@ Reference: https://github.com/sierra-research/tau2-bench
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from importlib import metadata
|
||||
from typing import Iterable, List, Optional
|
||||
|
||||
from openjarvis.core.paths import get_cache_dir
|
||||
from openjarvis.evals.core.dataset import DatasetProvider
|
||||
from openjarvis.evals.core.splits import apply_split
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
@@ -22,48 +21,50 @@ from openjarvis.evals.core.types import EvalRecord
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TAU2_REPO = "https://github.com/sierra-research/tau2-bench.git"
|
||||
CACHE_DIR = get_cache_dir() / "tau2-bench"
|
||||
# v1.0.1. Keep the full commit SHA here (rather than a movable tag) so every
|
||||
# TauBench setup uses the same third-party code.
|
||||
TAU2_REVISION = "fc0055dc4e0a316c3f83133267fbd6faaa770992"
|
||||
TAU2_INSTALL_SPEC = f"tau2 @ git+{TAU2_REPO}@{TAU2_REVISION}"
|
||||
|
||||
DOMAINS = ("airline", "retail", "telecom")
|
||||
|
||||
|
||||
def _ensure_tau2() -> None:
|
||||
"""Ensure tau2 package is importable; install from cache if needed."""
|
||||
"""Ensure the explicitly installed, pinned tau2 package is importable."""
|
||||
try:
|
||||
distribution = metadata.distribution("tau2")
|
||||
except metadata.PackageNotFoundError as exc:
|
||||
raise ImportError(
|
||||
"TauBench requires tau2, which OpenJarvis does not install at "
|
||||
"runtime. Install the pinned dependency explicitly (Python >=3.12): "
|
||||
f'uv pip install "{TAU2_INSTALL_SPEC}"'
|
||||
) from exc
|
||||
|
||||
try:
|
||||
direct_url_text = distribution.read_text("direct_url.json")
|
||||
direct_url = json.loads(direct_url_text or "")
|
||||
vcs_info = direct_url.get("vcs_info", {})
|
||||
installed_repo = direct_url.get("url")
|
||||
installed_revision = vcs_info.get("commit_id")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
installed_repo = None
|
||||
installed_revision = None
|
||||
|
||||
if installed_repo != TAU2_REPO or installed_revision != TAU2_REVISION:
|
||||
raise ImportError(
|
||||
"The installed tau2 package does not match OpenJarvis's pinned "
|
||||
"source revision. Reinstall it explicitly (Python >=3.12): "
|
||||
f'uv pip install --force-reinstall "{TAU2_INSTALL_SPEC}"'
|
||||
)
|
||||
|
||||
try:
|
||||
import tau2 # noqa: F401
|
||||
except ImportError:
|
||||
# Clone and install from source
|
||||
if not CACHE_DIR.exists():
|
||||
LOGGER.info("Cloning tau2-bench from %s ...", TAU2_REPO)
|
||||
CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", TAU2_REPO, str(CACHE_DIR)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
LOGGER.info("Installing tau2-bench ...")
|
||||
# Try `python -m pip` first; fall back to `uv pip` for uv-managed venvs
|
||||
# which don't ship pip by default.
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-e", str(CACHE_DIR)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"-e",
|
||||
str(CACHE_DIR),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The pinned tau2 package is installed but cannot be imported. "
|
||||
"Reinstall it explicitly (Python >=3.12): "
|
||||
f'uv pip install --force-reinstall "{TAU2_INSTALL_SPEC}"'
|
||||
) from exc
|
||||
|
||||
|
||||
class TauBenchDataset(DatasetProvider):
|
||||
|
||||
@@ -93,11 +93,16 @@ class HeuristicRouter(RouterPolicy):
|
||||
|
||||
Rules (applied in order):
|
||||
1. Code detected → prefer model with "code"/"coder" in name
|
||||
2. Math detected → prefer larger model
|
||||
3. Low complexity (score < 0.20) → prefer smaller/faster model
|
||||
2. Low complexity (score <= 0.20) → prefer smaller/faster model
|
||||
3. Math detected → prefer larger model
|
||||
4. High complexity (score >= 0.55 OR reasoning keywords) → prefer larger model
|
||||
5. High urgency (>0.8) → override to smaller model
|
||||
6. Default fallback → default_model → fallback_model → first available
|
||||
|
||||
Low complexity is checked before the math check so that simple arithmetic
|
||||
("calculate 2+2") routes to the smallest model instead of always escalating
|
||||
on the "math" keyword; math problems above the low-complexity threshold
|
||||
still escalate to the larger model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -134,14 +139,15 @@ class HeuristicRouter(RouterPolicy):
|
||||
# Fall through to larger model for code
|
||||
return _largest_model(available) or available[0]
|
||||
|
||||
# Rule 2: Math detected → prefer larger model
|
||||
# Rule 2: Low complexity → prefer smaller model (checked before the math
|
||||
# rule so simple arithmetic doesn't escalate to the largest model)
|
||||
if context.complexity_score <= 0.20:
|
||||
return _smallest_model(available) or available[0]
|
||||
|
||||
# Rule 3: Math detected → prefer larger model
|
||||
if context.has_math:
|
||||
return _largest_model(available) or available[0]
|
||||
|
||||
# Rule 3: Low complexity → prefer smaller model
|
||||
if context.complexity_score < 0.20:
|
||||
return _smallest_model(available) or available[0]
|
||||
|
||||
# Rule 4: High complexity or reasoning → prefer larger model
|
||||
if context.complexity_score >= 0.55 or context.has_reasoning:
|
||||
return _largest_model(available) or available[0]
|
||||
|
||||
@@ -19,6 +19,7 @@ from openjarvis.memory.store import (
|
||||
FactStore,
|
||||
LocalFactStore,
|
||||
create_fact_store,
|
||||
load_configured_facts,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -29,5 +30,6 @@ __all__ = [
|
||||
"MemoryService",
|
||||
"build_memory_service",
|
||||
"create_fact_store",
|
||||
"load_configured_facts",
|
||||
"publish_completed_exchange",
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
from typing import Any, Iterable, List
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import FactStoreRegistry
|
||||
@@ -205,4 +205,30 @@ def create_fact_store(
|
||||
return FactStoreRegistry.create(key, path, max_facts=max_facts)
|
||||
|
||||
|
||||
__all__ = ["Fact", "FactStore", "LocalFactStore", "create_fact_store"]
|
||||
def load_configured_facts(config: Any) -> List[Fact]:
|
||||
"""Load automatic-memory facts from *config* when the service is enabled.
|
||||
|
||||
Context injection is also used by short-lived commands such as
|
||||
``jarvis ask``, where no :class:`MemoryService` instance exists. This
|
||||
helper gives those callers the same configured fact-store view without
|
||||
coupling them to the service lifecycle.
|
||||
"""
|
||||
memory = getattr(config, "memory", None)
|
||||
if memory is None or not getattr(memory, "enabled", False):
|
||||
return []
|
||||
|
||||
store = create_fact_store(
|
||||
getattr(memory, "backend", "local"),
|
||||
path=getattr(memory, "facts_path", None),
|
||||
max_facts=getattr(memory, "max_facts", 1000),
|
||||
)
|
||||
return store.list()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Fact",
|
||||
"FactStore",
|
||||
"LocalFactStore",
|
||||
"create_fact_store",
|
||||
"load_configured_facts",
|
||||
]
|
||||
|
||||
+28
-5
@@ -516,20 +516,35 @@ class Jarvis:
|
||||
existing = agent_kwargs.get("tools", [])
|
||||
agent_kwargs["tools"] = digest_tools + list(existing)
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md reach
|
||||
# the model — mirrors ``cli/ask.py`` and ``cli/serve.py``. Guarded so
|
||||
# agents whose ``__init__`` doesn't accept the kwarg opt out.
|
||||
import inspect as _inspect
|
||||
|
||||
if "prompt_builder" in _inspect.signature(agent_cls.__init__).parameters:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=self._config.agent.default_system_prompt or "",
|
||||
memory_files_config=self._config.memory_files,
|
||||
system_prompt_config=self._config.system_prompt,
|
||||
)
|
||||
|
||||
agent_obj = agent_cls(self._engine, model_name, **agent_kwargs)
|
||||
ctx = AgentContext()
|
||||
|
||||
# Context injection
|
||||
if context and self._config.agent.context_from_memory:
|
||||
try:
|
||||
from openjarvis.cli.ask import _get_memory_backend
|
||||
from openjarvis.cli.ask import _get_memory_backend, _get_memory_facts
|
||||
from openjarvis.tools.storage.context import (
|
||||
ContextConfig,
|
||||
inject_context,
|
||||
)
|
||||
|
||||
backend = _get_memory_backend(self._config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(self._config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=self._config.memory.context_top_k,
|
||||
min_score=self._config.memory.context_min_score,
|
||||
@@ -540,6 +555,7 @@ class Jarvis:
|
||||
[],
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
for msg in context_messages:
|
||||
ctx.conversation.add(msg)
|
||||
@@ -570,17 +586,24 @@ class Jarvis:
|
||||
) -> List[Message]:
|
||||
"""Inject memory context into messages."""
|
||||
try:
|
||||
from openjarvis.cli.ask import _get_memory_backend
|
||||
from openjarvis.cli.ask import _get_memory_backend, _get_memory_facts
|
||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||
|
||||
backend = _get_memory_backend(self._config)
|
||||
if backend is not None:
|
||||
facts = _get_memory_facts(self._config)
|
||||
if backend is not None or facts:
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=self._config.memory.context_top_k,
|
||||
min_score=self._config.memory.context_min_score,
|
||||
max_context_tokens=self._config.memory.context_max_tokens,
|
||||
)
|
||||
return inject_context(query, messages, backend, config=ctx_cfg)
|
||||
return inject_context(
|
||||
query,
|
||||
messages,
|
||||
backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to inject memory context: %s", exc)
|
||||
return messages
|
||||
|
||||
@@ -4,27 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from typing import Any, Optional
|
||||
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.security._stubs import BaseScanner
|
||||
from openjarvis.security.audit import AuditLogger
|
||||
from openjarvis.security.file_policy import (
|
||||
DEFAULT_SENSITIVE_PATTERNS,
|
||||
filter_sensitive_paths,
|
||||
is_sensitive_file,
|
||||
)
|
||||
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,
|
||||
ScanResult,
|
||||
SecurityEvent,
|
||||
SecurityEventType,
|
||||
ThreatLevel,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,6 +33,12 @@ def setup_security(
|
||||
if not config.security.enabled:
|
||||
return SecurityContext(engine=engine)
|
||||
|
||||
from openjarvis.security._stubs import BaseScanner
|
||||
from openjarvis.security.audit import AuditLogger
|
||||
from openjarvis.security.guardrails import GuardrailsEngine
|
||||
from openjarvis.security.scanner import PIIScanner, SecretScanner
|
||||
from openjarvis.security.types import RedactionMode
|
||||
|
||||
# Scanners + engine wrapping
|
||||
try:
|
||||
scanners: list[BaseScanner] = []
|
||||
@@ -121,3 +110,46 @@ __all__ = [
|
||||
"is_sensitive_file",
|
||||
"setup_security",
|
||||
]
|
||||
|
||||
_LAZY_EXPORTS = {
|
||||
"AuditLogger": ("openjarvis.security.audit", "AuditLogger"),
|
||||
"BaseScanner": ("openjarvis.security._stubs", "BaseScanner"),
|
||||
"DEFAULT_SENSITIVE_PATTERNS": (
|
||||
"openjarvis.security.file_policy",
|
||||
"DEFAULT_SENSITIVE_PATTERNS",
|
||||
),
|
||||
"GuardrailsEngine": ("openjarvis.security.guardrails", "GuardrailsEngine"),
|
||||
"PIIScanner": ("openjarvis.security.scanner", "PIIScanner"),
|
||||
"RedactionMode": ("openjarvis.security.types", "RedactionMode"),
|
||||
"ScanFinding": ("openjarvis.security.types", "ScanFinding"),
|
||||
"ScanResult": ("openjarvis.security.types", "ScanResult"),
|
||||
"SecretScanner": ("openjarvis.security.scanner", "SecretScanner"),
|
||||
"SecurityBlockError": (
|
||||
"openjarvis.security.guardrails",
|
||||
"SecurityBlockError",
|
||||
),
|
||||
"SecurityEvent": ("openjarvis.security.types", "SecurityEvent"),
|
||||
"SecurityEventType": ("openjarvis.security.types", "SecurityEventType"),
|
||||
"ThreatLevel": ("openjarvis.security.types", "ThreatLevel"),
|
||||
"check_ssrf": ("openjarvis.security.ssrf", "check_ssrf"),
|
||||
"filter_sensitive_paths": (
|
||||
"openjarvis.security.file_policy",
|
||||
"filter_sensitive_paths",
|
||||
),
|
||||
"is_private_ip": ("openjarvis.security.ssrf", "is_private_ip"),
|
||||
"is_sensitive_file": ("openjarvis.security.file_policy", "is_sensitive_file"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
target = _LAZY_EXPORTS.get(name)
|
||||
if target is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
module_name, attribute = target
|
||||
value = getattr(import_module(module_name), attribute)
|
||||
globals()[name] = value
|
||||
return value
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted(set(globals()) | set(_LAZY_EXPORTS))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ from openjarvis.agents.tool_resolver import (
|
||||
from openjarvis.agents.tool_resolver import (
|
||||
ensure_registries_populated as _ensure_registries_populated,
|
||||
)
|
||||
from openjarvis.server.model_capabilities import is_embed_only_model
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -33,6 +34,15 @@ _MEMORY_BACKEND_LOCK_SETUP = threading.Lock()
|
||||
_MCP_LOCK_SETUP = threading.Lock()
|
||||
|
||||
|
||||
def _get_runtime_event_bus(runtime: Any = None) -> Any:
|
||||
"""Return the server-owned event bus, falling back outside app runtimes."""
|
||||
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
bus = getattr(runtime, "bus", None)
|
||||
return bus if bus is not None else get_event_bus()
|
||||
|
||||
|
||||
def _start_managed_worker(app_state: Any, target: Any, *, name: str) -> Any:
|
||||
"""Start and track a managed-agent worker for orderly app shutdown."""
|
||||
|
||||
@@ -300,14 +310,13 @@ def _make_lightweight_system(
|
||||
# Wrap with InstrumentedEngine so agent ticks are recorded
|
||||
# in telemetry (FLOPs, energy, cost savings).
|
||||
try:
|
||||
from openjarvis.core.events import get_event_bus
|
||||
from openjarvis.telemetry.instrumented_engine import (
|
||||
InstrumentedEngine,
|
||||
)
|
||||
|
||||
plain_engine = InstrumentedEngine(
|
||||
plain_engine,
|
||||
get_event_bus(),
|
||||
_get_runtime_event_bus(runtime),
|
||||
)
|
||||
except Exception:
|
||||
pass # telemetry is optional
|
||||
@@ -332,16 +341,29 @@ _CLOUD_PREFIXES = ("gpt-", "claude-", "gemini-", "o1-", "o3-", "o4-")
|
||||
def _pick_recommended_model(
|
||||
model_ids: list[str],
|
||||
) -> dict[str, str]:
|
||||
"""Pick the second-largest local model from a list."""
|
||||
local = [m for m in model_ids if not any(m.startswith(p) for p in _CLOUD_PREFIXES)]
|
||||
"""Pick the second-largest local *chat* model from a list.
|
||||
|
||||
Embedding-only models (nomic-embed-text, etc.) are excluded — they return
|
||||
HTTP 400 "does not support chat" when used as the generation model.
|
||||
"""
|
||||
local = [
|
||||
m
|
||||
for m in model_ids
|
||||
if not any(m.startswith(p) for p in _CLOUD_PREFIXES)
|
||||
and not is_embed_only_model(m)
|
||||
]
|
||||
if not local:
|
||||
# Fall back to any non-cloud model, still skipping embedders.
|
||||
local = [m for m in model_ids if not is_embed_only_model(m)]
|
||||
if not local:
|
||||
# Never recommend an embed-only model — chat would 400.
|
||||
return {
|
||||
"model": model_ids[0] if model_ids else "",
|
||||
"reason": "Only model available",
|
||||
"model": "",
|
||||
"reason": "No local chat model available",
|
||||
}
|
||||
sized = sorted(local, key=_parse_param_count, reverse=True)
|
||||
if len(sized) == 1:
|
||||
return {"model": sized[0], "reason": "Only local model available"}
|
||||
return {"model": sized[0], "reason": "Only local chat model available"}
|
||||
pick = sized[1] # second-largest
|
||||
params = _parse_param_count(pick)
|
||||
return {
|
||||
@@ -1630,26 +1652,27 @@ def create_agent_manager_router(
|
||||
|
||||
# Re-use the server's engine + model so we don't pick a
|
||||
# random model from Ollama's list.
|
||||
server_engine = getattr(request.app.state, "engine", None)
|
||||
server_model = getattr(request.app.state, "model", "")
|
||||
server_config = getattr(request.app.state, "config", None)
|
||||
app_state = request.app.state
|
||||
server_engine = getattr(app_state, "engine", None)
|
||||
server_model = getattr(app_state, "model", "")
|
||||
server_config = getattr(app_state, "config", None)
|
||||
server_bus = _get_runtime_event_bus(app_state)
|
||||
|
||||
def _run_tick():
|
||||
try:
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
_ts = getattr(request.app.state, "trace_store", None)
|
||||
_ts = getattr(app_state, "trace_store", None)
|
||||
executor = AgentExecutor(
|
||||
manager=manager,
|
||||
event_bus=get_event_bus(),
|
||||
event_bus=server_bus,
|
||||
trace_store=_ts,
|
||||
)
|
||||
system = _make_lightweight_system(
|
||||
server_engine,
|
||||
server_model,
|
||||
server_config,
|
||||
request.app.state,
|
||||
app_state,
|
||||
)
|
||||
executor.set_system(system)
|
||||
# The route handler above already called start_tick() to
|
||||
@@ -1676,7 +1699,7 @@ def create_agent_manager_router(
|
||||
|
||||
try:
|
||||
_start_managed_worker(
|
||||
request.app.state,
|
||||
app_state,
|
||||
_run_tick,
|
||||
name=f"managed-agent-run-{agent_id}",
|
||||
)
|
||||
@@ -1983,11 +2006,12 @@ def create_agent_manager_router(
|
||||
import time as _time
|
||||
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
_srv_engine = getattr(request.app.state, "engine", None)
|
||||
_srv_model = getattr(request.app.state, "model", "")
|
||||
_srv_config = getattr(request.app.state, "config", None)
|
||||
_app_state = request.app.state
|
||||
_srv_engine = getattr(_app_state, "engine", None)
|
||||
_srv_model = getattr(_app_state, "model", "")
|
||||
_srv_config = getattr(_app_state, "config", None)
|
||||
_srv_bus = _get_runtime_event_bus(_app_state)
|
||||
|
||||
def _immediate_tick():
|
||||
_start = _time.time()
|
||||
@@ -1997,17 +2021,17 @@ def create_agent_manager_router(
|
||||
_srv_model,
|
||||
)
|
||||
try:
|
||||
_ts2 = getattr(request.app.state, "trace_store", None)
|
||||
_ts2 = getattr(_app_state, "trace_store", None)
|
||||
executor = AgentExecutor(
|
||||
manager=manager,
|
||||
event_bus=get_event_bus(),
|
||||
event_bus=_srv_bus,
|
||||
trace_store=_ts2,
|
||||
)
|
||||
system = _make_lightweight_system(
|
||||
_srv_engine,
|
||||
_srv_model,
|
||||
_srv_config,
|
||||
request.app.state,
|
||||
_app_state,
|
||||
)
|
||||
executor.set_system(system)
|
||||
logger.info(
|
||||
@@ -2041,7 +2065,7 @@ def create_agent_manager_router(
|
||||
|
||||
try:
|
||||
_start_managed_worker(
|
||||
request.app.state,
|
||||
_app_state,
|
||||
_immediate_tick,
|
||||
name=f"managed-agent-immediate-{agent_id}",
|
||||
)
|
||||
@@ -2092,12 +2116,12 @@ def create_agent_manager_router(
|
||||
return {"learning_log": manager.list_learning_log(agent_id)}
|
||||
|
||||
@agents_router.post("/{agent_id}/learning/run")
|
||||
def trigger_learning(agent_id: str):
|
||||
def trigger_learning(agent_id: str, request: Request):
|
||||
if not manager.get_agent(agent_id):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
from openjarvis.core.events import EventType, get_event_bus
|
||||
from openjarvis.core.events import EventType
|
||||
|
||||
bus = get_event_bus()
|
||||
bus = _get_runtime_event_bus(request.app.state)
|
||||
bus.publish(EventType.AGENT_LEARNING_STARTED, {"agent_id": agent_id})
|
||||
return {"status": "triggered"}
|
||||
|
||||
|
||||
@@ -667,14 +667,15 @@ async def websocket_chat_stream(websocket: WebSocket):
|
||||
{"type": "done", "content": "..."} -- final assembled response
|
||||
{"type": "error", "detail": "..."} -- on failure
|
||||
"""
|
||||
from openjarvis.server.auth_middleware import websocket_authorized
|
||||
from openjarvis.server.auth_middleware import authenticate_websocket
|
||||
|
||||
expected_key = getattr(websocket.app.state, "api_key", "")
|
||||
if not websocket_authorized(websocket, expected_key):
|
||||
# 1008 = policy violation; reject before accepting the connection.
|
||||
authorized, subprotocol = authenticate_websocket(websocket, expected_key)
|
||||
if not authorized:
|
||||
# Closing before accept rejects the HTTP upgrade request.
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
await websocket.accept(subprotocol=subprotocol)
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
@@ -1087,12 +1088,16 @@ def include_all_routes(app) -> None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# WebSocket bridge for real-time agent events
|
||||
# WebSocket bridge for real-time agent events. Must subscribe on the
|
||||
# same EventBus instance channels/agents actually publish to
|
||||
# (app.state.bus, set in server/app.py) — the get_event_bus() global
|
||||
# singleton is a *different* bus that nothing in `jarvis serve` ever
|
||||
# publishes to, so events silently never reached this endpoint.
|
||||
try:
|
||||
from openjarvis.core.events import get_event_bus
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
ws_router = create_ws_router(get_event_bus())
|
||||
ws_router = create_ws_router(getattr(app.state, "bus", None) or get_event_bus())
|
||||
app.include_router(ws_router)
|
||||
except Exception:
|
||||
logger.debug("WebSocket bridge not available", exc_info=True)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
@@ -12,6 +13,19 @@ from starlette.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WS_AUTH_PROTOCOL = "openjarvis.auth.v1"
|
||||
_WS_KEY_PROTOCOL_PREFIX = "openjarvis.key.b64url."
|
||||
|
||||
|
||||
def _api_keys_match(presented: str, expected: str) -> bool:
|
||||
"""Compare API keys as bytes so non-ASCII values do not raise ``TypeError``."""
|
||||
try:
|
||||
presented_bytes = presented.encode("utf-8")
|
||||
expected_bytes = expected.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
return secrets.compare_digest(presented_bytes, expected_bytes)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Validates ``Authorization: Bearer <key>`` on ``/v1/*`` and ``/api/*`` routes.
|
||||
@@ -34,9 +48,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
scheme, _, token = auth.partition(" ")
|
||||
# Constant-time comparison to avoid leaking the key via timing.
|
||||
if scheme.lower() != "bearer" or not secrets.compare_digest(
|
||||
token, self._api_key
|
||||
):
|
||||
if scheme.lower() != "bearer" or not _api_keys_match(token, self._api_key):
|
||||
return JSONResponse(
|
||||
{"detail": "Invalid API key"},
|
||||
status_code=401,
|
||||
@@ -87,6 +99,71 @@ def check_bind_safety(host: str, *, api_key: str) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _websocket_key_protocol(api_key: str) -> str:
|
||||
"""Encode an API key as a browser-safe WebSocket protocol token."""
|
||||
try:
|
||||
encoded = base64.urlsafe_b64encode(api_key.encode("utf-8")).decode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
return ""
|
||||
return f"{_WS_KEY_PROTOCOL_PREFIX}{encoded.rstrip('=')}"
|
||||
|
||||
|
||||
def _offered_websocket_auth(websocket) -> tuple[str, str | None]: # noqa: ANN001
|
||||
"""Return the credential protocol and protocol to negotiate, if well formed.
|
||||
|
||||
ASGI exposes the complete, flattened protocol offer in ``scope``. Reading
|
||||
it avoids ambiguity from repeated ``Sec-WebSocket-Protocol`` header fields.
|
||||
Exactly one stable protocol marker and one encoded credential are required.
|
||||
"""
|
||||
offered = getattr(websocket, "scope", {}).get("subprotocols", [])
|
||||
if not isinstance(offered, (list, tuple)) or len(offered) != 2:
|
||||
return "", None
|
||||
if offered.count(_WS_AUTH_PROTOCOL) != 1:
|
||||
return "", None
|
||||
credentials = [
|
||||
protocol
|
||||
for protocol in offered
|
||||
if isinstance(protocol, str) and protocol != _WS_AUTH_PROTOCOL
|
||||
]
|
||||
if len(credentials) != 1 or not credentials[0].startswith(_WS_KEY_PROTOCOL_PREFIX):
|
||||
return "", None
|
||||
if credentials[0] == _WS_KEY_PROTOCOL_PREFIX:
|
||||
return "", None
|
||||
return credentials[0], _WS_AUTH_PROTOCOL
|
||||
|
||||
|
||||
def authenticate_websocket(
|
||||
websocket,
|
||||
expected_key: str, # noqa: ANN001
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Authenticate a WebSocket and return its negotiated auth subprotocol.
|
||||
|
||||
Programmatic clients can send ``Authorization: Bearer <key>``. Browser
|
||||
clients, which cannot set that header, offer ``openjarvis.auth.v1`` plus a
|
||||
marked, unpadded base64url encoding of the UTF-8 key. The encoding only
|
||||
makes the credential valid subprotocol syntax; it does not make it secret.
|
||||
"""
|
||||
credential_protocol, selected_protocol = _offered_websocket_auth(websocket)
|
||||
|
||||
# Match AuthMiddleware's local, keyless behavior. If a stale client still
|
||||
# offers a well-formed auth protocol, negotiate it so the browser does not
|
||||
# fail an otherwise allowed handshake.
|
||||
if not expected_key:
|
||||
return True, selected_protocol
|
||||
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
scheme, _, header_token = auth.partition(" ")
|
||||
header_valid = scheme.lower() == "bearer" and _api_keys_match(
|
||||
header_token, expected_key
|
||||
)
|
||||
|
||||
expected_protocol = _websocket_key_protocol(expected_key)
|
||||
protocol_valid = bool(credential_protocol and expected_protocol) and (
|
||||
secrets.compare_digest(credential_protocol, expected_protocol)
|
||||
)
|
||||
return header_valid or protocol_valid, selected_protocol
|
||||
|
||||
|
||||
def websocket_authorized(websocket, expected_key: str) -> bool: # noqa: ANN001
|
||||
"""Return ``True`` if a WebSocket connection presents the expected key.
|
||||
|
||||
@@ -96,18 +173,8 @@ def websocket_authorized(websocket, expected_key: str) -> bool: # noqa: ANN001
|
||||
|
||||
When *expected_key* is empty, authentication is disabled (the loopback /
|
||||
local-only default, matching :class:`AuthMiddleware`) and all connections
|
||||
are allowed. The token may be supplied either as a ``?token=`` query
|
||||
parameter — browsers cannot set headers on a WebSocket handshake — or via
|
||||
an ``Authorization: Bearer <key>`` header for programmatic clients.
|
||||
are allowed. See :func:`authenticate_websocket` for the supported
|
||||
credential transports. URL query parameters are deliberately not accepted
|
||||
because request targets commonly appear in access logs and browser history.
|
||||
"""
|
||||
if not expected_key:
|
||||
return True
|
||||
token = websocket.query_params.get("token", "")
|
||||
if not token:
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
scheme, _, value = auth.partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
token = value
|
||||
if not token:
|
||||
return False
|
||||
return secrets.compare_digest(token, expected_key)
|
||||
return authenticate_websocket(websocket, expected_key)[0]
|
||||
|
||||
@@ -84,6 +84,17 @@ def is_cloud_model(model: str) -> bool:
|
||||
return get_provider(model) is not None
|
||||
|
||||
|
||||
def _openrouter_model_id(model: str) -> str:
|
||||
"""Return the provider-facing ID for an OpenRouter model."""
|
||||
prefix = "openrouter/"
|
||||
candidate = model.removeprefix(prefix)
|
||||
# OpenRouter owns IDs such as "openrouter/auto" itself. Only remove the
|
||||
# LiteLLM routing prefix when the remainder is still a provider/model ID.
|
||||
if model.startswith(prefix) and "/" in candidate:
|
||||
return candidate
|
||||
return model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -371,7 +382,7 @@ async def stream_cloud(
|
||||
"OPENROUTER_API_KEY not set — add it in the Cloud Models tab"
|
||||
)
|
||||
async for token in _stream_openai(
|
||||
model,
|
||||
_openrouter_model_id(model),
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Model capability helpers shared by server model-selection routes."""
|
||||
|
||||
_EMBEDDING_MODEL_PREFIXES = (
|
||||
"all-minilm",
|
||||
"bge-",
|
||||
"bge_",
|
||||
"e5-",
|
||||
"e5_",
|
||||
"gte-",
|
||||
"gte_",
|
||||
"jina-embeddings",
|
||||
"nomic-bert",
|
||||
"sentence-transformers",
|
||||
)
|
||||
|
||||
|
||||
def is_embed_only_model(model_name: str) -> bool:
|
||||
"""Return whether a model identifier denotes a non-chat embedder.
|
||||
|
||||
Ollama does not expose capabilities through its model-list response, so
|
||||
model selection needs a conservative name-based guard. Most embedding
|
||||
models contain ``embed``; the explicit prefixes cover common families
|
||||
such as MiniLM, BGE, E5, and GTE whose names do not.
|
||||
"""
|
||||
name = (model_name or "").strip().lower()
|
||||
leaf = name.rsplit("/", 1)[-1].split(":", 1)[0]
|
||||
return (
|
||||
"embed" in leaf
|
||||
or "minilm" in leaf
|
||||
or leaf.startswith(_EMBEDDING_MODEL_PREFIXES)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["is_embed_only_model"]
|
||||
+183
-27
@@ -11,7 +11,8 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
from openjarvis.server.model_capabilities import is_embed_only_model
|
||||
from openjarvis.server.models import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionRequest,
|
||||
@@ -39,6 +40,15 @@ def _to_messages(chat_messages) -> list[Message]:
|
||||
role=role,
|
||||
content=m.content or "",
|
||||
name=m.name,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id=tool_call.get("id", ""),
|
||||
name=tool_call.get("function", {}).get("name", ""),
|
||||
arguments=tool_call.get("function", {}).get("arguments", "{}"),
|
||||
)
|
||||
for tool_call in (m.tool_calls or [])
|
||||
]
|
||||
or None,
|
||||
tool_call_id=m.tool_call_id,
|
||||
)
|
||||
)
|
||||
@@ -56,8 +66,10 @@ def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message
|
||||
``SystemPromptBuilder`` / ``BaseAgent``; the engine-direct server paths
|
||||
did not. This mirrors the agent fallback in ``agents/_stubs.py``.
|
||||
|
||||
If any message already carries a system role, the caller has supplied
|
||||
their own grounding and we leave the list untouched (no double-prompting).
|
||||
If any caller-supplied message already carries a system role, the caller
|
||||
has supplied their own grounding and we leave the list untouched (no
|
||||
double-prompting). Internally tagged memory context does not count as
|
||||
caller grounding.
|
||||
|
||||
Resolution of the identity text: the config comes from ``app.state`` when
|
||||
wired, otherwise ``load_config()``; the prompt itself is assembled by
|
||||
@@ -68,7 +80,11 @@ def _ensure_identity_prompt(messages: list[Message], app_config) -> list[Message
|
||||
injection" rather than crashing the endpoint, but the failure is logged
|
||||
(per REVIEW.md — never silently swallow).
|
||||
"""
|
||||
if any(m.role == Role.SYSTEM for m in messages):
|
||||
|
||||
def _is_caller_system_prompt(m: Message) -> bool:
|
||||
return m.role == Role.SYSTEM and not m.metadata.get("memory_context")
|
||||
|
||||
if any(_is_caller_system_prompt(m) for m in messages):
|
||||
return messages
|
||||
|
||||
prompt = ""
|
||||
@@ -113,13 +129,15 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
memory_backend = getattr(request.app.state, "memory_backend", None)
|
||||
if (
|
||||
config is not None
|
||||
and memory_backend is not None
|
||||
and config.agent.context_from_memory
|
||||
and request_body.messages
|
||||
):
|
||||
try:
|
||||
from openjarvis.tools.storage.context import ContextConfig, inject_context
|
||||
|
||||
memory_service = getattr(request.app.state, "memory_service", None)
|
||||
facts = memory_service.list_facts() if memory_service is not None else []
|
||||
|
||||
# Extract query from the last user message
|
||||
query_text = ""
|
||||
for m in reversed(request_body.messages):
|
||||
@@ -129,6 +147,7 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
|
||||
if query_text:
|
||||
messages = _to_messages(request_body.messages)
|
||||
messages = _ensure_identity_prompt(messages, config)
|
||||
ctx_cfg = ContextConfig(
|
||||
top_k=config.memory.context_top_k,
|
||||
min_score=config.memory.context_min_score,
|
||||
@@ -139,22 +158,35 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
messages,
|
||||
memory_backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
# Rebuild request messages from enriched Message objects
|
||||
if len(enriched) > len(messages):
|
||||
from openjarvis.server.models import ChatMessage
|
||||
# Rebuild after identity/context merging so downstream engine
|
||||
# adapters always receive exactly one system message.
|
||||
from openjarvis.server.models import ChatMessage
|
||||
|
||||
new_msgs = []
|
||||
for msg in enriched:
|
||||
new_msgs.append(
|
||||
ChatMessage(
|
||||
role=msg.role.value,
|
||||
content=msg.content,
|
||||
name=msg.name,
|
||||
tool_call_id=getattr(msg, "tool_call_id", None),
|
||||
)
|
||||
new_msgs = []
|
||||
for msg in enriched:
|
||||
new_msgs.append(
|
||||
ChatMessage(
|
||||
role=msg.role.value,
|
||||
content=msg.content,
|
||||
name=msg.name,
|
||||
tool_calls=[
|
||||
{
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call.name,
|
||||
"arguments": tool_call.arguments,
|
||||
},
|
||||
}
|
||||
for tool_call in (msg.tool_calls or [])
|
||||
]
|
||||
or None,
|
||||
tool_call_id=getattr(msg, "tool_call_id", None),
|
||||
)
|
||||
request_body.messages = new_msgs
|
||||
)
|
||||
request_body.messages = new_msgs
|
||||
except Exception:
|
||||
logging.getLogger("openjarvis.server").debug(
|
||||
"Memory context injection failed",
|
||||
@@ -199,12 +231,14 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
# When the client passes `tools`, stream the model's raw
|
||||
# OpenAI-compat function-calling decision directly from the engine
|
||||
# (bypassing the agent) — the streaming mirror of the non-streaming
|
||||
# #454 fix. Routing tools through the agent stream bridge ignored
|
||||
# `request_body.tools`, ran the agent's own tool loop, and
|
||||
# word-split generic filler content into fake token deltas, so the
|
||||
# caller's tool_calls were dropped entirely (the streaming analog of
|
||||
# #414). For plain chat (no tools), stream token-by-token directly
|
||||
# from the engine for true real-time output.
|
||||
# #454 fix. Routing client-supplied tools through a server-side agent
|
||||
# would execute the agent's different tool set and drop the raw tool
|
||||
# call the caller expects (#414).
|
||||
#
|
||||
# Without client-supplied tools, keep streaming requests on the
|
||||
# configured server agent so its server-side tool loop is available
|
||||
# to the desktop UI and other stream:true clients (#735). Fall back to
|
||||
# direct token streaming when no tool-bearing agent is configured.
|
||||
if request_body.tools:
|
||||
return await _handle_stream_tools(
|
||||
engine,
|
||||
@@ -215,6 +249,16 @@ async def chat_completions(request_body: ChatCompletionRequest, request: Request
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
if agent is not None and getattr(agent, "_tools", None):
|
||||
return await _handle_agent_stream(
|
||||
agent,
|
||||
model,
|
||||
request_body,
|
||||
complexity_info,
|
||||
trace_store=getattr(request.app.state, "trace_store", None),
|
||||
bus=getattr(request.app.state, "bus", None),
|
||||
memory_service=getattr(request.app.state, "memory_service", None),
|
||||
)
|
||||
return await _handle_stream(
|
||||
engine,
|
||||
model,
|
||||
@@ -546,6 +590,114 @@ def _handle_agent(
|
||||
)
|
||||
|
||||
|
||||
async def _handle_agent_stream(
|
||||
agent,
|
||||
model: str,
|
||||
req: ChatCompletionRequest,
|
||||
complexity_info=None,
|
||||
*,
|
||||
trace_store=None,
|
||||
bus=None,
|
||||
memory_service=None,
|
||||
):
|
||||
"""Run the configured agent and return its result as an SSE response.
|
||||
|
||||
Agents own the tool-execution loop, which is synchronous today. Run that
|
||||
loop in a worker thread and stream its final answer once complete. This
|
||||
keeps ``stream:true`` clients (including the desktop UI) on the same agent
|
||||
and configured toolkit as non-streaming requests instead of bypassing the
|
||||
agent and silently dropping server-side tools.
|
||||
|
||||
Requests that explicitly supply OpenAI ``tools`` continue to use
|
||||
``_handle_stream_tools`` so their raw tool-call deltas are preserved.
|
||||
"""
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
query_text = ""
|
||||
for message in reversed(req.messages):
|
||||
if message.role == "user" and message.content:
|
||||
query_text = message.content
|
||||
break
|
||||
|
||||
async def generate():
|
||||
first_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[StreamChoice(delta=DeltaMessage(role="assistant"))],
|
||||
)
|
||||
yield f"data: {first_chunk.model_dump_json()}\n\n"
|
||||
|
||||
try:
|
||||
response = await asyncio.to_thread(
|
||||
_handle_agent,
|
||||
agent,
|
||||
model,
|
||||
req,
|
||||
complexity_info,
|
||||
trace_store=trace_store,
|
||||
bus=bus,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.getLogger("openjarvis.server").error(
|
||||
"Agent stream error: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
error_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
delta=DeltaMessage(
|
||||
content=f"Sorry, an error occurred: {exc}",
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
yield f"data: {error_chunk.model_dump_json()}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
|
||||
content = _response_content(response)
|
||||
if content:
|
||||
content_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[StreamChoice(delta=DeltaMessage(content=content))],
|
||||
)
|
||||
yield f"data: {content_chunk.model_dump_json()}\n\n"
|
||||
|
||||
import json as _json
|
||||
|
||||
finish_chunk = ChatCompletionChunk(
|
||||
id=chunk_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(delta=DeltaMessage(), finish_reason="stop"),
|
||||
],
|
||||
)
|
||||
finish_data = _json.loads(finish_chunk.model_dump_json())
|
||||
finish_data["usage"] = response.usage.model_dump()
|
||||
if complexity_info is not None:
|
||||
finish_data["complexity"] = complexity_info.model_dump()
|
||||
yield f"data: {_json.dumps(finish_data)}\n\n"
|
||||
|
||||
_record_completed_exchange(
|
||||
memory_service,
|
||||
query_text,
|
||||
content,
|
||||
bus=bus,
|
||||
source="server.chat.stream",
|
||||
)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
||||
)
|
||||
|
||||
|
||||
async def _handle_stream_tools(
|
||||
engine,
|
||||
model: str,
|
||||
@@ -689,11 +841,10 @@ async def _handle_stream(
|
||||
):
|
||||
"""Stream response using SSE format.
|
||||
|
||||
This path streams straight from the engine, bypassing the agent /
|
||||
This no-agent fallback streams straight from the engine, bypassing the
|
||||
``TraceCollector``. When *trace_store* is set we accumulate the streamed
|
||||
tokens and record a minimal ``Trace`` once the stream completes
|
||||
successfully — otherwise streamed chats (the desktop GUI's main path)
|
||||
would never populate ``traces.db``.
|
||||
successfully.
|
||||
"""
|
||||
import time
|
||||
|
||||
@@ -892,6 +1043,11 @@ async def list_models(request: Request) -> ModelListResponse:
|
||||
if not model_ids:
|
||||
model_ids = await list_local_models()
|
||||
|
||||
# Keep embed-only models out of the chat model picker. They still work for
|
||||
# memory/retrieval via the embedder path; putting them in /v1/models made
|
||||
# the UI auto-select nomic-embed-text and fail every generation with 400.
|
||||
model_ids = [m for m in model_ids if not is_embed_only_model(m)]
|
||||
|
||||
return ModelListResponse(
|
||||
data=[
|
||||
ModelObject(
|
||||
|
||||
@@ -110,6 +110,12 @@ class AgentStreamBridge:
|
||||
|
||||
def _format_named_event(self, name: str, data: dict) -> str:
|
||||
"""Format an SSE event with an explicit ``event:`` field."""
|
||||
if name == "tool_call_start" and not isinstance(data.get("arguments"), str):
|
||||
# The in-process event bus uses parsed arguments for trace/eval
|
||||
# consumers, while the web SSE contract expects their JSON text.
|
||||
# Copy before normalizing so other subscribers keep the object.
|
||||
data = dict(data)
|
||||
data["arguments"] = json.dumps(data.get("arguments"))
|
||||
return f"event: {name}\ndata: {json.dumps(data)}\n\n"
|
||||
|
||||
def _run_agent(self) -> object:
|
||||
@@ -240,62 +246,15 @@ class AgentStreamBridge:
|
||||
{"results": tool_results_data},
|
||||
)
|
||||
|
||||
# Stream content using real LLM token streaming via
|
||||
# engine.stream_full() when the engine is available.
|
||||
# ``agent.run()`` already produced the authoritative, grounded
|
||||
# response. Do not call the engine again here: a second inference
|
||||
# would not have the agent's system prompt, tool transcript, or
|
||||
# other internal context and could therefore contradict the
|
||||
# result reported by the agent events. Replay the final content
|
||||
# in chunks so the OpenAI-compatible streaming response stays
|
||||
# consistent with the completed agent run.
|
||||
content = agent_result.content or ""
|
||||
engine = getattr(self._agent, "_engine", None)
|
||||
used_real_streaming = False
|
||||
|
||||
if engine is not None and hasattr(engine, "stream_full") and content:
|
||||
# Re-stream using the engine for real token delivery.
|
||||
# Build the same messages the agent used for its final turn.
|
||||
try:
|
||||
from openjarvis.core.types import Message as MsgType
|
||||
from openjarvis.core.types import Role as RoleType
|
||||
|
||||
replay_messages = []
|
||||
for m in self._request.messages:
|
||||
role = (
|
||||
RoleType(m.role)
|
||||
if m.role in {r.value for r in RoleType}
|
||||
else RoleType.USER
|
||||
)
|
||||
replay_messages.append(
|
||||
MsgType(
|
||||
role=role,
|
||||
content=m.content or "",
|
||||
name=m.name,
|
||||
tool_call_id=m.tool_call_id,
|
||||
)
|
||||
)
|
||||
|
||||
async for sc in engine.stream_full(
|
||||
replay_messages,
|
||||
model=self._model,
|
||||
):
|
||||
if sc.content:
|
||||
chunk = ChatCompletionChunk(
|
||||
id=self._chunk_id,
|
||||
model=self._model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
delta=DeltaMessage(content=sc.content),
|
||||
)
|
||||
],
|
||||
)
|
||||
yield f"data: {chunk.model_dump_json()}\n\n"
|
||||
used_real_streaming = True
|
||||
except Exception as stream_exc:
|
||||
import logging as _logging
|
||||
|
||||
_logger = _logging.getLogger("openjarvis.server")
|
||||
_logger.warning(
|
||||
"Real streaming failed, falling back to word replay: %s",
|
||||
stream_exc,
|
||||
)
|
||||
|
||||
# Fallback: word-by-word replay if real streaming was not used
|
||||
if not used_real_streaming and content:
|
||||
if content:
|
||||
words = content.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
token = word if i == 0 else " " + word
|
||||
|
||||
@@ -65,14 +65,15 @@ def create_ws_router(event_bus: EventBus) -> Any:
|
||||
|
||||
@router.websocket("/v1/agents/events")
|
||||
async def agent_events(websocket: WebSocket) -> None:
|
||||
from openjarvis.server.auth_middleware import websocket_authorized
|
||||
from openjarvis.server.auth_middleware import authenticate_websocket
|
||||
|
||||
expected_key = getattr(websocket.app.state, "api_key", "")
|
||||
if not websocket_authorized(websocket, expected_key):
|
||||
# 1008 = policy violation; reject before accepting the connection.
|
||||
authorized, subprotocol = authenticate_websocket(websocket, expected_key)
|
||||
if not authorized:
|
||||
# Closing before accept rejects the HTTP upgrade request.
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
await websocket.accept(subprotocol=subprotocol)
|
||||
# Parse agent_id filter from query string
|
||||
agent_id = websocket.query_params.get("agent_id")
|
||||
websocket._agent_filter = agent_id # type: ignore[attr-defined]
|
||||
|
||||
@@ -40,8 +40,9 @@ class QueryOrchestrator:
|
||||
|
||||
messages = [Message(role=Role.USER, content=query)]
|
||||
|
||||
if context and s.memory_backend and s.config.agent.context_from_memory:
|
||||
if context and s.config.agent.context_from_memory:
|
||||
try:
|
||||
from openjarvis.memory import load_configured_facts
|
||||
from openjarvis.tools.storage.context import (
|
||||
ContextConfig,
|
||||
inject_context,
|
||||
@@ -52,11 +53,13 @@ class QueryOrchestrator:
|
||||
min_score=s.config.memory.context_min_score,
|
||||
max_context_tokens=s.config.memory.context_max_tokens,
|
||||
)
|
||||
facts = load_configured_facts(s.config)
|
||||
messages = inject_context(
|
||||
query,
|
||||
messages,
|
||||
s.memory_backend,
|
||||
config=ctx_cfg,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to inject memory context: %s", exc)
|
||||
|
||||
@@ -136,6 +136,15 @@ class ToolExecutor:
|
||||
content=f"Invalid arguments JSON: {exc}",
|
||||
success=False,
|
||||
)
|
||||
if not isinstance(params, dict):
|
||||
return ToolResult(
|
||||
tool_name=tool_call.name,
|
||||
content=(
|
||||
"Invalid arguments: expected a JSON object, "
|
||||
f"got {type(params).__name__}."
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Boundary guard: scan external tool arguments
|
||||
if self._boundary_guard is not None and not getattr(tool, "is_local", True):
|
||||
@@ -143,6 +152,15 @@ class ToolExecutor:
|
||||
tool_call = self._boundary_guard.check_outbound(tool_call)
|
||||
# Re-parse arguments after potential redaction
|
||||
params = json.loads(tool_call.arguments) if tool_call.arguments else {}
|
||||
if not isinstance(params, dict):
|
||||
return ToolResult(
|
||||
tool_name=tool_call.name,
|
||||
content=(
|
||||
"Invalid arguments: expected a JSON object, "
|
||||
f"got {type(params).__name__}."
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name=tool_call.name,
|
||||
|
||||
@@ -56,6 +56,7 @@ class CodeInterpreterTool(BaseTool):
|
||||
"required": ["code"],
|
||||
},
|
||||
category="code",
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
|
||||
@@ -55,6 +55,7 @@ class DockerCodeInterpreterTool(BaseTool):
|
||||
},
|
||||
category="code",
|
||||
timeout_seconds=60.0,
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
|
||||
@@ -191,6 +191,7 @@ class ReplTool(BaseTool):
|
||||
"required": ["code"],
|
||||
},
|
||||
category="code",
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, List, Optional, Sequence
|
||||
|
||||
from openjarvis.core.events import EventType, get_event_bus
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openjarvis.memory.store import Fact
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContextConfig:
|
||||
@@ -46,28 +49,75 @@ def format_context(results: List[RetrievalResult]) -> str:
|
||||
|
||||
def build_context_message(
|
||||
results: List[RetrievalResult],
|
||||
facts: Sequence[Fact] = (),
|
||||
) -> Message:
|
||||
"""Create a system message with formatted context."""
|
||||
context_text = format_context(results)
|
||||
content = (
|
||||
"The following context was retrieved from the knowledge"
|
||||
" base. Use it to inform your response, citing sources"
|
||||
" where applicable:\n\n" + context_text
|
||||
sections = []
|
||||
if facts:
|
||||
fact_text = "\n".join(f"- {fact.text}" for fact in facts)
|
||||
sections.append(
|
||||
"The following durable facts were remembered from prior "
|
||||
"conversations. Use them when relevant to the user's request:\n\n"
|
||||
+ fact_text
|
||||
)
|
||||
if results:
|
||||
sections.append(
|
||||
"The following context was retrieved from the knowledge"
|
||||
" base. Use it to inform your response, citing sources"
|
||||
" where applicable:\n\n" + format_context(results)
|
||||
)
|
||||
content = "\n\n".join(sections)
|
||||
return Message(
|
||||
role=Role.SYSTEM,
|
||||
content=content,
|
||||
metadata={"memory_context": True},
|
||||
)
|
||||
return Message(role=Role.SYSTEM, content=content)
|
||||
|
||||
|
||||
def _merge_context_message(
|
||||
messages: List[Message],
|
||||
context_message: Message,
|
||||
) -> List[Message]:
|
||||
"""Return a copy with context folded into the existing system prompt."""
|
||||
system_messages = [message for message in messages if message.role == Role.SYSTEM]
|
||||
if not system_messages:
|
||||
return [context_message, *messages]
|
||||
|
||||
content = "\n\n".join(
|
||||
part
|
||||
for part in (
|
||||
*(message.text for message in system_messages),
|
||||
context_message.text,
|
||||
)
|
||||
if part
|
||||
)
|
||||
combined = replace(system_messages[0], content=content)
|
||||
merged: List[Message] = []
|
||||
inserted = False
|
||||
for message in messages:
|
||||
if message.role == Role.SYSTEM:
|
||||
if not inserted:
|
||||
merged.append(combined)
|
||||
inserted = True
|
||||
continue
|
||||
merged.append(message)
|
||||
return merged
|
||||
|
||||
|
||||
def inject_context(
|
||||
query: str,
|
||||
messages: List[Message],
|
||||
backend: MemoryBackend,
|
||||
backend: Optional[MemoryBackend],
|
||||
*,
|
||||
config: Optional[ContextConfig] = None,
|
||||
facts: Sequence[Fact] = (),
|
||||
) -> List[Message]:
|
||||
"""Retrieve relevant context and prepend it to *messages*.
|
||||
|
||||
Returns a **new** list — the original list is not mutated.
|
||||
If no results pass the score threshold, returns the original
|
||||
Automatic-memory facts are included independently of the retrieval
|
||||
backend, so persisted facts remain recallable even when the document
|
||||
store is empty. If no facts or results are available, returns the original
|
||||
messages unchanged.
|
||||
|
||||
Parameters
|
||||
@@ -77,33 +127,55 @@ def inject_context(
|
||||
messages:
|
||||
The existing message list.
|
||||
backend:
|
||||
The memory backend to search.
|
||||
The memory backend to search, or ``None`` when only facts are available.
|
||||
config:
|
||||
Context injection settings (uses defaults if ``None``).
|
||||
facts:
|
||||
Durable facts captured by the automatic memory service.
|
||||
"""
|
||||
cfg = config or ContextConfig()
|
||||
if not cfg.enabled:
|
||||
return messages
|
||||
|
||||
results = backend.retrieve(query, top_k=cfg.top_k)
|
||||
results = backend.retrieve(query, top_k=cfg.top_k) if backend is not None else []
|
||||
|
||||
# Filter by minimum score
|
||||
results = [r for r in results if r.score >= cfg.min_score]
|
||||
|
||||
if not results:
|
||||
return messages
|
||||
|
||||
# Truncate to max_context_tokens
|
||||
truncated: List[RetrievalResult] = []
|
||||
# When both sources have data, cap facts at half the total budget so they
|
||||
# cannot starve query-specific document retrieval. Unused fact budget is
|
||||
# still available to documents. Newest facts win within the fact budget.
|
||||
fact_budget = cfg.max_context_tokens
|
||||
if results:
|
||||
fact_budget //= 2
|
||||
selected_facts: List[Fact] = []
|
||||
total_tokens = 0
|
||||
for fact in reversed(facts):
|
||||
tokens = _count_tokens(fact.text)
|
||||
if total_tokens + tokens > fact_budget:
|
||||
continue
|
||||
selected_facts.append(fact)
|
||||
total_tokens += tokens
|
||||
|
||||
# Fill the remaining context budget with retrieved documents.
|
||||
truncated: List[RetrievalResult] = []
|
||||
for r in results:
|
||||
tokens = _count_tokens(r.content)
|
||||
if total_tokens + tokens > cfg.max_context_tokens:
|
||||
# A large top result should not disappear solely because facts
|
||||
# consumed their reserved share. Prefer that result when it fits
|
||||
# the total budget on its own.
|
||||
if not truncated and selected_facts and tokens <= cfg.max_context_tokens:
|
||||
selected_facts = []
|
||||
total_tokens = 0
|
||||
else:
|
||||
break
|
||||
if total_tokens + tokens > cfg.max_context_tokens:
|
||||
break
|
||||
truncated.append(r)
|
||||
total_tokens += tokens
|
||||
|
||||
if not truncated:
|
||||
if not selected_facts and not truncated:
|
||||
return messages
|
||||
|
||||
# Publish event
|
||||
@@ -114,13 +186,14 @@ def inject_context(
|
||||
"context_injection": True,
|
||||
"query": query,
|
||||
"num_results": len(truncated),
|
||||
"num_facts": len(selected_facts),
|
||||
"total_tokens": total_tokens,
|
||||
},
|
||||
)
|
||||
|
||||
# Build context message and prepend
|
||||
ctx_msg = build_context_message(truncated)
|
||||
return [ctx_msg] + list(messages)
|
||||
ctx_msg = build_context_message(truncated, selected_facts)
|
||||
return _merge_context_message(messages, ctx_msg)
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -95,6 +95,29 @@ class SQLiteMemory(MemoryBackend):
|
||||
)
|
||||
return doc_id
|
||||
|
||||
def replace_source(
|
||||
self,
|
||||
source: str,
|
||||
documents: List[tuple[str, Optional[Dict[str, Any]]]],
|
||||
) -> List[str]:
|
||||
"""Atomically replace all documents associated with *source*."""
|
||||
payload = [
|
||||
(content, json.dumps(metadata) if metadata else None)
|
||||
for content, metadata in documents
|
||||
]
|
||||
doc_ids = self._rust_impl.replace_source(source, payload)
|
||||
bus = get_event_bus()
|
||||
for doc_id in doc_ids:
|
||||
bus.publish(
|
||||
EventType.MEMORY_STORE,
|
||||
{
|
||||
"backend": self.backend_id,
|
||||
"doc_id": doc_id,
|
||||
"source": source,
|
||||
},
|
||||
)
|
||||
return doc_ids
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
|
||||
@@ -205,6 +205,40 @@ class TestBuildMessages:
|
||||
assert messages[1].content == "prev"
|
||||
assert messages[2].content == "new"
|
||||
|
||||
def test_prompt_builder_merges_context_system_message(self):
|
||||
engine = MagicMock()
|
||||
prompt_builder = MagicMock()
|
||||
prompt_builder.build.return_value = "You are OpenJarvis."
|
||||
agent = _ConcreteAgent(engine, "m", prompt_builder=prompt_builder)
|
||||
conv = Conversation()
|
||||
conv.add(
|
||||
Message(
|
||||
role=Role.SYSTEM,
|
||||
content="Remember: user likes jazz.",
|
||||
metadata={"memory_context": True},
|
||||
)
|
||||
)
|
||||
ctx = AgentContext(conversation=conv)
|
||||
|
||||
messages = agent._build_messages("new", ctx)
|
||||
|
||||
system_messages = [m for m in messages if m.role == Role.SYSTEM]
|
||||
assert len(system_messages) == 1
|
||||
assert "You are OpenJarvis." in system_messages[0].content
|
||||
assert "user likes jazz" in system_messages[0].content
|
||||
|
||||
def test_prompt_builder_preserves_caller_system_context(self):
|
||||
engine = MagicMock()
|
||||
prompt_builder = MagicMock()
|
||||
prompt_builder.build.return_value = "Agent instructions."
|
||||
agent = _ConcreteAgent(engine, "m", prompt_builder=prompt_builder)
|
||||
conv = Conversation()
|
||||
conv.add(Message(role=Role.SYSTEM, content="You are helpful."))
|
||||
|
||||
messages = agent._build_messages("new", AgentContext(conversation=conv))
|
||||
|
||||
assert any(message.content == "You are helpful." for message in messages)
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
def test_delegates_to_engine(self):
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_morning_digest_run(tmp_path):
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.generate.return_value = {
|
||||
"content": "Good morning sir. You have 3 emails and 2 meetings today.",
|
||||
"content": "Good morning sir. AtlasDB 1.0 was released.",
|
||||
"finish_reason": "stop",
|
||||
"usage": {},
|
||||
}
|
||||
@@ -29,7 +29,7 @@ def test_morning_digest_run(tmp_path):
|
||||
# Mock collect result
|
||||
mock_collect_result = ToolResult(
|
||||
tool_name="digest_collect",
|
||||
content='=== MESSAGES ===\n[gmail] From: alice@co.com — "Budget" (1h ago)\n',
|
||||
content="=== WORLD ===\n[hackernews] AtlasDB 1.0 Released — 241 points\n",
|
||||
success=True,
|
||||
metadata={"total_items": 2},
|
||||
)
|
||||
@@ -46,7 +46,9 @@ def test_morning_digest_run(tmp_path):
|
||||
mock_engine,
|
||||
"test-model",
|
||||
tools=[],
|
||||
persona="neutral",
|
||||
persona="jarvis",
|
||||
sections=["world"],
|
||||
section_sources={"world": ["hackernews", "news_rss"]},
|
||||
digest_store_path=str(tmp_path / "digest.db"),
|
||||
)
|
||||
|
||||
@@ -61,6 +63,16 @@ def test_morning_digest_run(tmp_path):
|
||||
assert "Good morning" in result.content
|
||||
assert result.turns == 1
|
||||
assert len(result.tool_results) == 2
|
||||
assert set(result.metadata["sources_used"]) == {"hackernews", "news_rss"}
|
||||
prompt = "\n".join(
|
||||
message.text for message in mock_engine.generate.call_args.args[0]
|
||||
).casefold()
|
||||
assert "world —" in prompt
|
||||
for forbidden in (
|
||||
"messages —|calendar —|health —|rebuttal|dinner at|group chat|"
|
||||
"slack|next meeting|readiness|hrv|weather"
|
||||
).split("|"):
|
||||
assert forbidden not in prompt
|
||||
|
||||
|
||||
def test_load_persona():
|
||||
|
||||
@@ -396,11 +396,10 @@ class TestPersonaFilesReachModel:
|
||||
assert "MEMORY_SENTINEL" in joined
|
||||
assert "USER_SENTINEL" in joined
|
||||
|
||||
def test_orchestrator_keeps_its_own_system_prompt(
|
||||
def test_orchestrator_accepts_persona_prompt_builder(
|
||||
self, runner, monkeypatch, tmp_path
|
||||
):
|
||||
"""OrchestratorAgent's __init__ doesn't accept ``prompt_builder``;
|
||||
the wiring must skip it silently rather than crash."""
|
||||
"""Orchestrator explicitly accepts and applies persona wiring."""
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
@@ -425,5 +424,6 @@ class TestPersonaFilesReachModel:
|
||||
):
|
||||
result = runner.invoke(cli, ["ask", "--agent", "orchestrator", "Hello"])
|
||||
|
||||
# Pass condition: doesn't crash with TypeError on prompt_builder kwarg.
|
||||
assert result.exit_code == 0, result.output
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert "ORCH_PERSONA_SENTINEL" in messages[0].content
|
||||
|
||||
+175
-1
@@ -17,7 +17,8 @@ from openjarvis.cli.chat_cmd import _read_input, chat
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.events import Event, EventBus, EventType
|
||||
from openjarvis.core.registry import AgentRegistry, ToolRegistry
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.core.types import Role, ToolCall, ToolResult
|
||||
from openjarvis.memory.store import LocalFactStore
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
|
||||
@@ -97,6 +98,79 @@ class TestReadInput:
|
||||
|
||||
|
||||
class TestChatAgents:
|
||||
def test_direct_chat_injects_auto_memory_facts(self, tmp_path) -> None:
|
||||
facts_path = tmp_path / "facts.jsonl"
|
||||
LocalFactStore(facts_path).add(
|
||||
"The user's favorite color is blue",
|
||||
source="auto",
|
||||
)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = {"content": "Blue."}
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory.enabled = True
|
||||
config.memory.facts_path = str(facts_path)
|
||||
config.agent.context_from_memory = True
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch("openjarvis.memory.build_memory_service", return_value=None),
|
||||
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--model", "test-model"],
|
||||
input="What is my favorite color?\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert messages[0].role.value == "system"
|
||||
assert "favorite color is blue" in messages[0].content
|
||||
|
||||
def test_chat_generation_survives_fact_store_failure(self) -> None:
|
||||
class _FailingMemoryService:
|
||||
def start(self) -> None:
|
||||
pass
|
||||
|
||||
def stop(self, timeout: float = 2.0) -> None:
|
||||
pass
|
||||
|
||||
def list_facts(self):
|
||||
raise OSError("fact store unavailable")
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = {"content": "Still working."}
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory.enabled = True
|
||||
config.agent.context_from_memory = True
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch(
|
||||
"openjarvis.memory.build_memory_service",
|
||||
return_value=_FailingMemoryService(),
|
||||
),
|
||||
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--model", "test-model"],
|
||||
input="hello\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Still working." in result.output
|
||||
engine.generate.assert_called_once()
|
||||
|
||||
def test_simple_agent_does_not_receive_tool_only_kwargs(self) -> None:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
@@ -121,6 +195,106 @@ class TestChatAgents:
|
||||
assert "simple ok" in result.output
|
||||
assert "failed" not in result.output.lower()
|
||||
|
||||
def test_agent_receives_prior_turn_history(self) -> None:
|
||||
"""Multi-turn chat must pass prior turns to agent.run() via AgentContext."""
|
||||
|
||||
captured_contexts: list[AgentContext | None] = []
|
||||
|
||||
class _CapturingAgent(BaseAgent):
|
||||
agent_id = "capturing_chat_agent"
|
||||
|
||||
def run(self, input, context: AgentContext | None = None, **kwargs):
|
||||
captured_contexts.append(context)
|
||||
return AgentResult(content=f"reply-{len(captured_contexts)}", turns=1)
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
|
||||
AgentRegistry.register_value("capturing_chat_agent", _CapturingAgent)
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--agent", "capturing_chat_agent", "--model", "test-model"],
|
||||
input="first turn\nsecond turn\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(captured_contexts) == 2
|
||||
|
||||
first_turn_context, second_turn_context = captured_contexts
|
||||
assert first_turn_context is not None
|
||||
assert first_turn_context.conversation.messages == []
|
||||
|
||||
assert second_turn_context is not None
|
||||
prior_texts = [m.content for m in second_turn_context.conversation.messages]
|
||||
assert "first turn" in prior_texts
|
||||
assert "reply-1" in prior_texts
|
||||
|
||||
def test_agent_memory_context_precedes_prior_turn_history(self, tmp_path) -> None:
|
||||
"""Memory system context must remain ahead of prior conversation turns."""
|
||||
|
||||
captured_contexts: list[AgentContext | None] = []
|
||||
|
||||
class _CapturingAgent(BaseAgent):
|
||||
agent_id = "capturing_memory_chat_agent"
|
||||
|
||||
def run(self, input, context: AgentContext | None = None, **kwargs):
|
||||
captured_contexts.append(context)
|
||||
return AgentResult(content=f"reply-{len(captured_contexts)}", turns=1)
|
||||
|
||||
facts_path = tmp_path / "facts.jsonl"
|
||||
LocalFactStore(facts_path).add("The user likes jazz", source="auto")
|
||||
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
config = JarvisConfig()
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory.enabled = True
|
||||
config.memory.facts_path = str(facts_path)
|
||||
config.agent.context_from_memory = True
|
||||
|
||||
AgentRegistry.register_value(
|
||||
"capturing_memory_chat_agent",
|
||||
_CapturingAgent,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("openjarvis.cli.chat_cmd.load_config", return_value=config),
|
||||
patch("openjarvis.engine.get_engine", return_value=("mock", engine)),
|
||||
patch("openjarvis.intelligence.register_builtin_models"),
|
||||
patch("openjarvis.memory.build_memory_service", return_value=None),
|
||||
patch("openjarvis.cli.ask._get_memory_backend", return_value=None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
chat,
|
||||
["--agent", "capturing_memory_chat_agent", "--model", "test-model"],
|
||||
input="first turn\nsecond turn\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(captured_contexts) == 2
|
||||
|
||||
second_turn_context = captured_contexts[1]
|
||||
assert second_turn_context is not None
|
||||
messages = second_turn_context.conversation.messages
|
||||
assert [message.role for message in messages] == [
|
||||
Role.SYSTEM,
|
||||
Role.USER,
|
||||
Role.ASSISTANT,
|
||||
]
|
||||
assert "user likes jazz" in messages[0].content
|
||||
assert [message.content for message in messages[1:]] == [
|
||||
"first turn",
|
||||
"reply-1",
|
||||
]
|
||||
|
||||
def test_memory_service_started_fed_and_stopped(self) -> None:
|
||||
"""The REPL starts memory, publishes each turn, and stops it."""
|
||||
|
||||
|
||||
@@ -138,6 +138,38 @@ class TestCLI:
|
||||
content = config_path.read_text()
|
||||
assert "[engine]" in content
|
||||
|
||||
def test_init_preset_uses_utf8_for_config_copy(self, tmp_path: Path) -> None:
|
||||
"""Preset installation reads and writes shipped TOML as UTF-8."""
|
||||
config_dir = tmp_path / ".openjarvis"
|
||||
config_path = config_dir / "config.toml"
|
||||
original_read_text = Path.read_text
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def read_text(path: Path, *args: object, **kwargs: object) -> str:
|
||||
if path.name == "chat-simple.toml":
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
|
||||
if path == config_path:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_write_text(path, data, *args, **kwargs)
|
||||
|
||||
with (
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
|
||||
mock.patch.object(
|
||||
Path, "write_text", autospec=True, side_effect=write_text
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["init", "--preset", "chat-simple"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "lightweight conversational AI" in config_path.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
class TestStartupResilience:
|
||||
"""Importing the CLI must not force heavy/native deps (#404, #309).
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
@@ -109,18 +110,34 @@ temperature = 0.7
|
||||
except json.JSONDecodeError:
|
||||
pytest.fail(f"Output is not valid JSON: {result.output}")
|
||||
|
||||
def test_config_show_toml_displays_raw_content(self, tmp_path: Path) -> None:
|
||||
"""Test that config show toml displays the raw TOML content."""
|
||||
@pytest.mark.parametrize("output_format", ["toml", "json"])
|
||||
def test_config_show_uses_utf8_for_config_file(
|
||||
self, tmp_path: Path, output_format: str
|
||||
) -> None:
|
||||
"""Test that config show reads UTF-8 config files explicitly."""
|
||||
# Create a temporary config file
|
||||
config_file = tmp_path / "test_config.toml"
|
||||
config_file.write_text('[engine]\ndefault = "ollama"\n')
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli, ["config", "show", "toml", "--path", str(config_file)]
|
||||
config_file.write_text(
|
||||
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def read_text(path: Path, *args: object, **kwargs: object) -> str:
|
||||
if path == config_file:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["config", "show", output_format, "--path", str(config_file)]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "[engine]" in result.output
|
||||
if output_format == "toml":
|
||||
assert "[engine]" in result.output
|
||||
else:
|
||||
assert '"engine"' in result.output
|
||||
assert "ollama" in result.output
|
||||
|
||||
def test_config_show_json_displays_parsed_content(self, tmp_path: Path) -> None:
|
||||
|
||||
@@ -60,6 +60,42 @@ class TestConfigSet:
|
||||
assert "vllm" in content
|
||||
assert "qwen2.5:3b" in content
|
||||
|
||||
def test_set_uses_utf8_for_existing_config(self, tmp_path: Path) -> None:
|
||||
"""config set preserves a UTF-8 config regardless of the system locale."""
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text(
|
||||
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_read_text = Path.read_text
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def read_text(path: Path, *args: object, **kwargs: object) -> str:
|
||||
if path == config_file:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
|
||||
if path == config_file:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_write_text(path, data, *args, **kwargs)
|
||||
|
||||
with (
|
||||
mock.patch.dict(os.environ, {"OPENJARVIS_CONFIG": str(config_file)}),
|
||||
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
|
||||
mock.patch.object(
|
||||
Path, "write_text", autospec=True, side_effect=write_text
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["config", "set", "engine.default", "vllm"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
assert "Preset comment — stored as UTF-8" in content
|
||||
assert "vllm" in content
|
||||
|
||||
def test_set_invalid_key_rejected(self, tmp_path: Path) -> None:
|
||||
"""config set rejects unknown keys."""
|
||||
config_file = tmp_path / "config.toml"
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
from openjarvis.cli.daemon_cmd import _read_pid, _write_pid
|
||||
from openjarvis.cli.daemon_cmd import _pid_alive, _read_pid, _write_pid
|
||||
|
||||
|
||||
class TestDaemonCommands:
|
||||
@@ -45,12 +48,12 @@ class TestDaemonCommands:
|
||||
assert _read_pid() is None
|
||||
|
||||
def test_write_and_read_pid(self, tmp_path: Path) -> None:
|
||||
"""Write a PID, then read it back (mock os.kill to succeed)."""
|
||||
"""Write a PID, then read it back with a successful liveness probe."""
|
||||
pid_file = tmp_path / "server.pid"
|
||||
with (
|
||||
patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file),
|
||||
patch("openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch("os.kill", return_value=None),
|
||||
patch("openjarvis.cli.daemon_cmd._pid_alive", return_value=True),
|
||||
):
|
||||
_write_pid(12345)
|
||||
assert pid_file.exists()
|
||||
@@ -82,6 +85,53 @@ class TestDaemonCommands:
|
||||
assert "already running" in result.output
|
||||
|
||||
|
||||
class TestPidLiveness:
|
||||
"""Regression coverage for Windows-safe PID liveness checks."""
|
||||
|
||||
def test_pid_alive_current_process(self) -> None:
|
||||
assert _pid_alive(os.getpid()) is True
|
||||
|
||||
def test_pid_alive_nonpositive(self) -> None:
|
||||
assert _pid_alive(0) is False
|
||||
assert _pid_alive(-1) is False
|
||||
|
||||
def test_pid_alive_dead_pid(self) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
|
||||
for _ in range(20):
|
||||
if not _pid_alive(proc.pid):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert _pid_alive(proc.pid) is False
|
||||
|
||||
def test_read_pid_stale_pid_returns_none(self, tmp_path: Path) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
pid_file = tmp_path / "server.pid"
|
||||
pid_file.write_text(str(proc.pid))
|
||||
|
||||
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
|
||||
assert _read_pid() is None
|
||||
|
||||
assert not pid_file.exists()
|
||||
|
||||
def test_read_pid_live_pid_returns_it(self, tmp_path: Path) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"])
|
||||
try:
|
||||
pid_file = tmp_path / "server.pid"
|
||||
pid_file.write_text(str(proc.pid))
|
||||
|
||||
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
|
||||
assert _read_pid() == proc.pid
|
||||
|
||||
assert pid_file.exists()
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
|
||||
|
||||
class TestDaemonDetachment:
|
||||
"""The spawned server must outlive the console that started it.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_editable_git_install_detected(tmp_path, monkeypatch):
|
||||
info = detect_install()
|
||||
assert info.kind == "editable-git"
|
||||
assert "git pull" in info.upgrade_command
|
||||
assert "uv sync" in info.upgrade_command
|
||||
assert info.upgrade_command.endswith("uv sync --inexact")
|
||||
assert info.repo_root == repo
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,35 @@ def test_memory_index_file(tmp_path: Path, monkeypatch):
|
||||
assert "Indexed" in result.output or "chunk" in result.output
|
||||
|
||||
|
||||
def test_memory_index_replaces_existing_source(tmp_path: Path, monkeypatch):
|
||||
"""Re-indexing a file replaces its previous chunks."""
|
||||
_register_sqlite()
|
||||
db_path = str(tmp_path / "mem.db")
|
||||
doc = tmp_path / "doc.txt"
|
||||
doc.write_text(" ".join(["legacy"] * 100), encoding="utf-8")
|
||||
|
||||
mod = importlib.import_module("openjarvis.cli.memory_cmd")
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_get_backend",
|
||||
lambda b=None: SQLiteMemory(db_path=db_path),
|
||||
)
|
||||
|
||||
first = CliRunner().invoke(cli, ["memory", "index", str(doc)])
|
||||
assert first.exit_code == 0
|
||||
|
||||
doc.write_text(" ".join(["updated"] * 100), encoding="utf-8")
|
||||
second = CliRunner().invoke(cli, ["memory", "index", str(doc)])
|
||||
assert second.exit_code == 0
|
||||
|
||||
backend = SQLiteMemory(db_path=db_path)
|
||||
assert backend.count() == 1
|
||||
assert backend.retrieve("legacy") == []
|
||||
updated = backend.retrieve("updated")
|
||||
assert len(updated) == 1
|
||||
assert updated[0].source == str(doc)
|
||||
|
||||
|
||||
def test_memory_index_nonexistent(tmp_path: Path):
|
||||
"""Indexing a nonexistent path should fail."""
|
||||
_register_sqlite()
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli.scan_cmd import PrivacyScanner, ScanResult, scan
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
|
||||
def _low_noise_config():
|
||||
"""Baseline config with no warn/fail findings under an empty scan root.
|
||||
|
||||
JarvisConfig defaults include absolute store paths under the real
|
||||
OPENJARVIS_HOME; clear those so tests only see artifacts under tmp_path.
|
||||
"""
|
||||
config = JarvisConfig()
|
||||
config.analytics.enabled = False
|
||||
config.traces.enabled = False
|
||||
config.telemetry.enabled = False
|
||||
config.agent.context_from_memory = False
|
||||
config.agent.tools = ""
|
||||
config.skills.enabled = False
|
||||
config.digest.enabled = False
|
||||
config.channel.enabled = False
|
||||
config.learning.enabled = False
|
||||
config.learning.training_enabled = False
|
||||
config.learning.auto_update = False
|
||||
config.learning.spec_search.enabled = False
|
||||
config.tools.enabled = ""
|
||||
config.tools.mcp.enabled = False
|
||||
config.tools.storage.enabled = False
|
||||
config.optimize.optimizer_provider = ""
|
||||
config.optimize.judge_model = ""
|
||||
config.server.host = "127.0.0.1"
|
||||
config.security.profile = "personal"
|
||||
# Avoid scanning the developer's real ~/.openjarvis store files.
|
||||
config.traces.db_path = ""
|
||||
config.telemetry.db_path = ""
|
||||
config.security.audit_log_path = ""
|
||||
config.security.vault_key_path = ""
|
||||
config.tools.storage.db_path = ""
|
||||
config.tools.storage.facts_path = ""
|
||||
config.sessions.db_path = ""
|
||||
config.agent_manager.db_path = ""
|
||||
config.optimize.db_path = ""
|
||||
config.scheduler.db_path = ""
|
||||
config.skills.index_dir = ""
|
||||
config.memory_files.soul_path = ""
|
||||
config.memory_files.memory_path = ""
|
||||
config.memory_files.user_path = ""
|
||||
return config
|
||||
|
||||
|
||||
def _patch_config(monkeypatch, tmp_path, config, config_loaded=True, error=""):
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli.scan_cmd._load_data_boundary_config",
|
||||
lambda: (config, tmp_path, config_loaded, error, ""),
|
||||
)
|
||||
monkeypatch.setattr("openjarvis.cli.scan_cmd.get_config_dir", lambda: tmp_path)
|
||||
|
||||
|
||||
def test_scan_data_boundaries_json_redacts_paths(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
(tmp_path / "traces.db").write_text("", encoding="utf-8")
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["schema_version"] == 1
|
||||
assert payload["root"] != str(tmp_path.resolve())
|
||||
assert str(tmp_path.resolve()) not in result.output
|
||||
assert "findings" in payload
|
||||
|
||||
|
||||
def test_scan_data_boundaries_show_paths_json(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
trace_db = tmp_path / "traces.db"
|
||||
trace_db.write_text("", encoding="utf-8")
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
scan,
|
||||
["--data-boundaries", "--json", "--show-paths"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["root"] == str(tmp_path.resolve())
|
||||
assert "traces.db" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_handles_config_load_error(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
_patch_config(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
config,
|
||||
config_loaded=False,
|
||||
error="TOMLDecodeError: invalid config",
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["summary"]["fail"] == 1
|
||||
assert payload["findings"][0]["id"] == "config-load-error"
|
||||
|
||||
|
||||
def test_scan_data_boundaries_strict_exits_nonzero_on_fail(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.intelligence.provider = "openai"
|
||||
config.agent.context_from_memory = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "local memory may be sent to cloud inference" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_fail_exits_zero_without_strict(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.intelligence.provider = "openai"
|
||||
config.agent.context_from_memory = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "local memory may be sent to cloud inference" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_strict_exits_on_warning(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.tools.enabled = "web_search"
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Web search tool is configured" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_strict_passes_with_info_only(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.agent.context_from_memory = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "OpenJarvis Data-Boundary Scan" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_init_defaults_strict_exits_on_warn(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
config = _low_noise_config()
|
||||
config.server.host = "0.0.0.0"
|
||||
config.telemetry.enabled = True
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--data-boundaries", "--strict"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "bind all" in result.output
|
||||
|
||||
|
||||
def test_scan_data_boundaries_rejects_quick(monkeypatch, tmp_path):
|
||||
config = _low_noise_config()
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--quick", "--data-boundaries"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "cannot be combined" in result.output
|
||||
|
||||
|
||||
def test_scan_rejects_strict_without_data_boundaries():
|
||||
result = CliRunner().invoke(scan, ["--strict"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "only supported with --data-boundaries" in result.output
|
||||
|
||||
|
||||
def test_existing_scan_json_still_works(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
PrivacyScanner,
|
||||
"run_all",
|
||||
lambda self: [
|
||||
ScanResult(
|
||||
name="Network Exposure",
|
||||
status="ok",
|
||||
message="No exposed ports.",
|
||||
platform="all",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload[0]["name"] == "Network Exposure"
|
||||
assert payload[0]["status"] == "ok"
|
||||
|
||||
|
||||
def test_existing_scan_quick_json_still_works(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
PrivacyScanner,
|
||||
"run_quick",
|
||||
lambda self: [
|
||||
ScanResult(
|
||||
name="Cloud Sync Agents",
|
||||
status="ok",
|
||||
message="No cloud-sync agents detected.",
|
||||
platform="all",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--quick", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload[0]["name"] == "Cloud Sync Agents"
|
||||
|
||||
|
||||
def test_top_level_cli_registers_data_boundary_scan(monkeypatch, tmp_path):
|
||||
from openjarvis.cli import cli
|
||||
|
||||
config = _low_noise_config()
|
||||
_patch_config(monkeypatch, tmp_path, config)
|
||||
|
||||
result = CliRunner().invoke(cli, ["scan", "--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["schema_version"] == 1
|
||||
assert "summary" in payload
|
||||
|
||||
|
||||
def test_top_level_scan_data_boundaries_does_not_check_for_updates(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
import sys
|
||||
|
||||
from openjarvis.cli import cli
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
called = {"value": False}
|
||||
|
||||
def fake_check_for_updates(_subcommand):
|
||||
called["value"] = True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli._version_check.check_for_updates",
|
||||
fake_check_for_updates,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli.scan_cmd._load_data_boundary_config",
|
||||
lambda: (JarvisConfig(), tmp_path, False, "", ""),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["jarvis", "scan", "--data-boundaries", "--json"],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["scan", "--data-boundaries", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert called["value"] is False
|
||||
|
||||
|
||||
def test_update_check_skip_helper_is_precise():
|
||||
from click import Command, Context
|
||||
|
||||
from openjarvis.cli import _should_skip_update_check
|
||||
|
||||
ctx = Context(Command("jarvis"))
|
||||
ctx.invoked_subcommand = "scan"
|
||||
assert _should_skip_update_check(ctx, ["scan", "--data-boundaries"])
|
||||
|
||||
ctx.invoked_subcommand = "ask"
|
||||
assert not _should_skip_update_check(ctx, ["ask", "scan", "--data-boundaries"])
|
||||
|
||||
|
||||
def test_existing_scan_quick_text_still_works(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
PrivacyScanner,
|
||||
"run_quick",
|
||||
lambda self: [
|
||||
ScanResult(
|
||||
name="Cloud Sync Agents",
|
||||
status="ok",
|
||||
message="No cloud-sync agents detected.",
|
||||
platform="all",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(scan, ["--quick"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "OpenJarvis Security Scan" in result.output
|
||||
|
||||
|
||||
def test_data_boundary_loader_honors_openjarvis_config(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
from openjarvis.cli import scan_cmd
|
||||
from openjarvis.core.config import load_config
|
||||
|
||||
config_path = tmp_path / "custom.toml"
|
||||
config_path.write_text(
|
||||
"[telemetry]\nenabled = false\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("OPENJARVIS_CONFIG", str(config_path))
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
load_config.cache_clear()
|
||||
|
||||
_config, _root, loaded, error, root_error = scan_cmd._load_data_boundary_config()
|
||||
|
||||
assert loaded is True
|
||||
assert error == ""
|
||||
assert root_error == ""
|
||||
|
||||
|
||||
def test_data_boundary_loader_reports_root_error(monkeypatch):
|
||||
from openjarvis.cli import scan_cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.cli.scan_cmd.get_config_dir",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("bad home")),
|
||||
)
|
||||
|
||||
_config, root, loaded, error, root_error = scan_cmd._load_data_boundary_config()
|
||||
|
||||
assert root is None
|
||||
assert loaded is False
|
||||
assert error == ""
|
||||
assert "bad home" in root_error
|
||||
|
||||
|
||||
def test_data_boundary_cli_reports_real_root_error_without_import_crash():
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
invalid_home = repo_root / ".invalid-openjarvis-home"
|
||||
env = os.environ.copy()
|
||||
env["OPENJARVIS_HOME"] = str(invalid_home)
|
||||
env["PYTHONPATH"] = str(repo_root / "src")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from openjarvis.cli import main; main()",
|
||||
"scan",
|
||||
"--data-boundaries",
|
||||
"--json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["findings"][0]["id"] == "config-root-error"
|
||||
assert str(invalid_home) not in result.stdout
|
||||
assert str(repo_root) not in result.stdout
|
||||
@@ -21,7 +21,7 @@ def _mock_info(kind: str = "pypi") -> InstallInfo:
|
||||
upgrade_command={
|
||||
"pypi": "pip install --upgrade openjarvis",
|
||||
"uv-tool": "uv tool upgrade openjarvis",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync --inexact",
|
||||
"unknown": "pip install --upgrade openjarvis",
|
||||
}[kind],
|
||||
)
|
||||
@@ -90,6 +90,26 @@ def test_editable_git_uses_shell_true():
|
||||
assert kwargs.get("shell") is True
|
||||
|
||||
|
||||
def test_editable_git_preserves_extra_dependencies():
|
||||
"""The update sync must not remove packages from prior extras/groups."""
|
||||
mock_proc = MagicMock(returncode=0)
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.detect_install",
|
||||
return_value=_mock_info("editable-git"),
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run,
|
||||
):
|
||||
result = CliRunner().invoke(self_update, ["-y"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "uv sync --inexact" in result.output
|
||||
assert "uv sync --inexact" in mock_run.call_args.args[0]
|
||||
|
||||
|
||||
def test_failed_upgrade_propagates_exit_code():
|
||||
mock_proc = MagicMock(returncode=3)
|
||||
with (
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Regression: ``jarvis serve`` must wire the ``SystemPromptBuilder`` into the
|
||||
agent it constructs, so SOUL.md / MEMORY.md / USER.md reach the model over HTTP.
|
||||
|
||||
``cli/ask.py`` (and ``cli/chat_cmd.py`` and the managed-agent executor) have
|
||||
wired the builder since the persona system landed. The serve path never did, so
|
||||
an agent served over HTTP silently answered as a generic assistant — explicitly
|
||||
denying the persona — while the same agent via the CLI kept it. Found deploying
|
||||
a personal assistant: SOUL.md was correct on disk the whole time; no error, no
|
||||
warning.
|
||||
|
||||
This test boots ``serve`` just far enough to capture the agent handed to
|
||||
``create_app`` and asserts the builder (and thus the persona content) is
|
||||
present. It fails on the unpatched serve path: ``agent._prompt_builder`` is
|
||||
``None``, so the persona files never reach the model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
pytest.importorskip("uvicorn")
|
||||
|
||||
# ``openjarvis.cli.serve`` as a package attribute resolves to the click
|
||||
# *command* (re-exported); grab the real module to monkeypatch its globals.
|
||||
serve_mod = importlib.import_module("openjarvis.cli.serve")
|
||||
|
||||
|
||||
def _fake_engine() -> MagicMock:
|
||||
engine = MagicMock()
|
||||
engine.list_models.return_value = ["test-model"]
|
||||
engine.health.return_value = True
|
||||
engine.name = "mock"
|
||||
engine.engine_id = "mock"
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"agent_name",
|
||||
["simple", "orchestrator", "monitor_operative", "operative"],
|
||||
)
|
||||
def test_serve_wires_persona_builder_into_served_agent(
|
||||
tmp_path, monkeypatch, agent_name
|
||||
):
|
||||
"""The agent built on the serve path must carry a SystemPromptBuilder whose
|
||||
assembled prompt includes SOUL.md content (regression for the HTTP persona
|
||||
loss)."""
|
||||
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
|
||||
from openjarvis.agents.operative import OperativeAgent
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
# Persona file with a unique sentinel we can grep for in the built prompt.
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("SERVE_PERSONA_SENTINEL", encoding="utf-8")
|
||||
|
||||
# conftest clears registries per-test; re-register the agent we exercise.
|
||||
agent_classes = {
|
||||
"simple": SimpleAgent,
|
||||
"orchestrator": OrchestratorAgent,
|
||||
"monitor_operative": MonitorOperativeAgent,
|
||||
"operative": OperativeAgent,
|
||||
}
|
||||
if not AgentRegistry.contains(agent_name):
|
||||
AgentRegistry.register_value(agent_name, agent_classes[agent_name])
|
||||
|
||||
config = JarvisConfig()
|
||||
config.server.host = "127.0.0.1"
|
||||
config.server.port = 8123
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory_files.soul_path = str(soul)
|
||||
# Keep the heavy optional subsystems off so we reach create_app cleanly.
|
||||
config.telemetry.enabled = False
|
||||
config.agent_manager.enabled = False
|
||||
config.sessions.enabled = False
|
||||
config.channel.enabled = False
|
||||
config.skills.enabled = False
|
||||
config.agent.context_from_memory = False
|
||||
|
||||
engine = _fake_engine()
|
||||
monkeypatch.setattr(serve_mod, "load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr(serve_mod, "get_engine", lambda *a, **k: ("mock", engine))
|
||||
monkeypatch.setattr(serve_mod, "discover_engines", lambda *a, **k: {})
|
||||
monkeypatch.setattr(serve_mod, "discover_models", lambda *a, **k: {})
|
||||
|
||||
# setup_security returns its own context; pass the engine straight through.
|
||||
sec = MagicMock()
|
||||
sec.engine = engine
|
||||
sec.capability_policy = None
|
||||
sec.audit_logger = None
|
||||
monkeypatch.setattr("openjarvis.security.setup_security", lambda *a, **k: sec)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_create_app(*args, **kwargs):
|
||||
captured["agent"] = kwargs.get("agent")
|
||||
return MagicMock(name="app")
|
||||
|
||||
with (
|
||||
patch("openjarvis.server.app.create_app", side_effect=_capture_create_app),
|
||||
patch("uvicorn.run", lambda *a, **k: None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["serve", "--agent", agent_name], catch_exceptions=False
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
agent = captured.get("agent")
|
||||
assert agent is not None, (
|
||||
"serve did not construct an agent or never reached create_app; "
|
||||
f"output:\n{result.output}"
|
||||
)
|
||||
# The regression: without the fix ``agent._prompt_builder`` is None and the
|
||||
# persona files never reach the model over HTTP.
|
||||
assert agent._prompt_builder is not None, (
|
||||
f"serve constructed {agent_name} without a prompt_builder — SOUL.md / "
|
||||
"MEMORY.md / USER.md would be silently dropped on the HTTP path."
|
||||
)
|
||||
assert "SERVE_PERSONA_SENTINEL" in agent._prompt_builder.build(), (
|
||||
"prompt_builder is wired on serve, but its built prompt omits SOUL.md"
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Regression tests for tool selection during ``jarvis serve`` startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.cli.serve import _resolve_allowed_tools
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured",
|
||||
[
|
||||
"code_interpreter,file_read",
|
||||
["code_interpreter", "file_read"],
|
||||
],
|
||||
)
|
||||
def test_tools_enabled_is_used_by_serve(configured):
|
||||
config = JarvisConfig()
|
||||
config.tools.enabled = configured
|
||||
|
||||
allowed, explicit = _resolve_allowed_tools(config)
|
||||
|
||||
assert allowed == {"code_interpreter", "file_read"}
|
||||
assert explicit is True
|
||||
|
||||
|
||||
def test_tools_enabled_takes_precedence_over_legacy_agent_tools():
|
||||
config = JarvisConfig()
|
||||
config.tools.enabled = "file_read"
|
||||
config.agent.tools = "calculator"
|
||||
|
||||
allowed, explicit = _resolve_allowed_tools(config)
|
||||
|
||||
assert allowed == {"file_read"}
|
||||
assert explicit is True
|
||||
|
||||
|
||||
def test_agent_tools_remains_a_backward_compatible_fallback():
|
||||
config = JarvisConfig()
|
||||
config.agent.tools = "file_read"
|
||||
|
||||
allowed, explicit = _resolve_allowed_tools(config)
|
||||
|
||||
assert allowed == {"file_read"}
|
||||
assert explicit is True
|
||||
|
||||
|
||||
def test_serve_defaults_tools_when_no_selection_is_configured():
|
||||
allowed, explicit = _resolve_allowed_tools(JarvisConfig())
|
||||
|
||||
assert allowed == {"think", "calculator", "web_search"}
|
||||
assert explicit is False
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -24,7 +25,8 @@ from openjarvis.core.registry import ConnectorRegistry
|
||||
def _create_fake_notes_db(db_path: Path) -> None:
|
||||
"""Populate a SQLite file with the Apple Notes schema and sample rows."""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript("""
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
|
||||
Z_PK INTEGER PRIMARY KEY,
|
||||
ZTITLE TEXT,
|
||||
@@ -38,7 +40,8 @@ def _create_fake_notes_db(db_path: Path) -> None:
|
||||
ZDATA BLOB,
|
||||
ZNOTE INTEGER
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Note 1 — Shopping List
|
||||
html1 = "<html><body><h1>Shopping List</h1><p>Milk, eggs, bread</p></body></html>"
|
||||
@@ -208,3 +211,61 @@ def test_registry() -> None:
|
||||
assert ConnectorRegistry.contains("apple_notes")
|
||||
cls = ConnectorRegistry.get("apple_notes")
|
||||
assert cls.connector_id == "apple_notes"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 10 — modern modification timestamp drives incremental sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_incremental_sync_uses_modern_note_modification_date(tmp_path: Path) -> None:
|
||||
"""Modern Notes rows use ZMODIFICATIONDATE1 for incremental sync."""
|
||||
db_path = tmp_path / "ModernNoteStore.sqlite"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
|
||||
Z_PK INTEGER PRIMARY KEY,
|
||||
ZTITLE TEXT,
|
||||
ZTITLE1 TEXT,
|
||||
ZMODIFICATIONDATE REAL,
|
||||
ZMODIFICATIONDATE1 REAL,
|
||||
ZIDENTIFIER TEXT
|
||||
);
|
||||
CREATE TABLE ZICNOTEDATA (
|
||||
Z_PK INTEGER PRIMARY KEY,
|
||||
ZDATA BLOB,
|
||||
ZNOTE INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
compressed = gzip.compress(b"<p>New movie list</p>")
|
||||
conn.execute(
|
||||
"INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES "
|
||||
"(1, NULL, 'Movies', NULL, 800000000.0, 'note-modern')"
|
||||
)
|
||||
conn.execute("INSERT INTO ZICNOTEDATA VALUES (1, ?, 1)", (compressed,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
from openjarvis.connectors.apple_notes import AppleNotesConnector # noqa: PLC0415
|
||||
|
||||
connector = AppleNotesConnector(db_path=str(db_path))
|
||||
docs = list(connector.sync(since=datetime(2026, 1, 1, tzinfo=timezone.utc)))
|
||||
|
||||
assert [doc.doc_id for doc in docs] == ["apple_notes:note-modern"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 11 — legacy modification timestamp remains supported
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_incremental_sync_falls_back_to_legacy_modification_date(connector) -> None:
|
||||
"""Older Notes rows continue to use ZMODIFICATIONDATE."""
|
||||
docs = list(connector.sync(since=datetime(2023, 1, 1, tzinfo=timezone.utc)))
|
||||
|
||||
assert {doc.doc_id for doc in docs} == {
|
||||
"apple_notes:note-001",
|
||||
"apple_notes:note-002",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Regression guards for the desktop app's outbound network policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
TAURI_CONFIG = ROOT / "frontend" / "src-tauri" / "tauri.conf.json"
|
||||
MACOS_INFO_PLIST = ROOT / "frontend" / "src-tauri" / "Info.plist"
|
||||
|
||||
|
||||
def _csp_sources(directive: str) -> set[str]:
|
||||
config = json.loads(TAURI_CONFIG.read_text(encoding="utf-8"))
|
||||
csp = config["app"]["security"]["csp"]
|
||||
directives = {
|
||||
parts[0]: set(parts[1:]) for item in csp.split(";") if (parts := item.split())
|
||||
}
|
||||
return directives[directive]
|
||||
|
||||
|
||||
def test_desktop_csp_allows_remote_api_servers() -> None:
|
||||
"""The user-configured API URL may point beyond localhost (#649)."""
|
||||
connect_sources = _csp_sources("connect-src")
|
||||
|
||||
assert {"http:", "https:", "ws:", "wss:"} <= connect_sources
|
||||
|
||||
|
||||
def test_macos_webview_allows_user_configured_http_servers() -> None:
|
||||
"""CSP alone cannot override App Transport Security for public hosts."""
|
||||
info = plistlib.loads(MACOS_INFO_PLIST.read_bytes())
|
||||
|
||||
assert info["NSAppTransportSecurity"]["NSAllowsArbitraryLoadsInWebContent"] is True
|
||||
@@ -3,6 +3,8 @@ and _prepare_anthropic_messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any, List
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -63,6 +65,36 @@ def _openai_tool_call_delta(
|
||||
return tc
|
||||
|
||||
|
||||
class _GoogleConfig:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
def _google_stream_chunk(
|
||||
*parts: Any,
|
||||
text: str | None = None,
|
||||
usage_metadata: Any = None,
|
||||
) -> Any:
|
||||
candidates = []
|
||||
if parts:
|
||||
candidates = [SimpleNamespace(content=SimpleNamespace(parts=list(parts)))]
|
||||
return SimpleNamespace(
|
||||
text=text,
|
||||
candidates=candidates,
|
||||
usage_metadata=usage_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _google_types_modules() -> dict[str, ModuleType]:
|
||||
types = ModuleType("google.genai.types")
|
||||
types.GenerateContentConfig = _GoogleConfig
|
||||
genai = ModuleType("google.genai")
|
||||
genai.types = types
|
||||
google = ModuleType("google")
|
||||
google.genai = genai
|
||||
return {"google": google, "google.genai": genai, "google.genai.types": types}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _stream_full_openai tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -413,6 +445,316 @@ def test_prepare_anthropic_messages_tool_calls():
|
||||
assert blocks[1]["input"] == {"city": "Berlin"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _stream_full_google tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_text_only(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Google text chunks retain their content and finish normally."""
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[_google_stream_chunk(text="Hello"), _google_stream_chunk(text=" world")]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
messages = [Message(role=Role.USER, content="hi")]
|
||||
modules = _google_types_modules()
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in modules.items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(messages, model="gemini-2.5-flash")
|
||||
]
|
||||
|
||||
assert [chunk.content for chunk in result[:-1]] == ["Hello", " world"]
|
||||
assert result[-1].finish_reason == "stop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_preserves_tool_calls(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Google function_call parts become OpenAI-compatible tool call chunks."""
|
||||
function_call = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
|
||||
part = SimpleNamespace(
|
||||
function_call=function_call, text=None, thought_signature=b"sig"
|
||||
)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[_google_stream_chunk(part)]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
messages = [Message(role=Role.USER, content="weather")]
|
||||
modules = _google_types_modules()
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in modules.items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
messages,
|
||||
model="gemini-2.5-flash",
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
tool_call = result[0].tool_calls[0]
|
||||
assert tool_call["index"] == 0
|
||||
assert tool_call["id"].startswith("google_")
|
||||
assert tool_call["type"] == "function"
|
||||
assert tool_call["function"] == {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Berlin"}',
|
||||
}
|
||||
assert tool_call["thought_signature"] == b"sig"
|
||||
assert engine._thought_sigs[tool_call["id"]] == b"sig"
|
||||
assert result[-1].finish_reason == "tool_calls"
|
||||
config = client.models.generate_content_stream.call_args.kwargs["config"]
|
||||
assert config.tools == [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_preserves_mixed_and_multiple_calls(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Google streams retain mixed text and multiple tool calls."""
|
||||
weather = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
|
||||
calendar = SimpleNamespace(name="get_calendar", args={"day": "Monday"})
|
||||
text_part = SimpleNamespace(text="I'll check.", function_call=None)
|
||||
weather_part = SimpleNamespace(
|
||||
function_call=weather, text=None, thought_signature=None
|
||||
)
|
||||
calendar_part = SimpleNamespace(
|
||||
function_call=calendar, text=None, thought_signature=None
|
||||
)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[
|
||||
_google_stream_chunk(text_part, weather_part),
|
||||
_google_stream_chunk(calendar_part),
|
||||
]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
modules = _google_types_modules()
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in modules.items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="plan")], model="gemini-2.5-flash"
|
||||
)
|
||||
]
|
||||
|
||||
assert result[0].content == "I'll check."
|
||||
weather_call = result[1].tool_calls[0]
|
||||
calendar_call = result[2].tool_calls[0]
|
||||
assert weather_call["index"] == 0
|
||||
assert weather_call["function"] == {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Berlin"}',
|
||||
}
|
||||
assert calendar_call["index"] == 1
|
||||
assert calendar_call["function"] == {
|
||||
"name": "get_calendar",
|
||||
"arguments": '{"day": "Monday"}',
|
||||
}
|
||||
assert weather_call["id"] != calendar_call["id"]
|
||||
assert result[-1].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_keeps_parallel_same_name_calls_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Parallel invocations of one function receive unique indexes and IDs."""
|
||||
paris = SimpleNamespace(name="get_weather", args={"city": "Paris"})
|
||||
london = SimpleNamespace(name="get_weather", args={"city": "London"})
|
||||
parts = [
|
||||
SimpleNamespace(function_call=paris, text=None, thought_signature=b"sig"),
|
||||
SimpleNamespace(function_call=london, text=None, thought_signature=None),
|
||||
]
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[_google_stream_chunk(*parts)]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="Weather in Paris and London")],
|
||||
model="gemini-3-flash-preview",
|
||||
)
|
||||
]
|
||||
|
||||
calls = result[0].tool_calls
|
||||
assert [call["index"] for call in calls] == [0, 1]
|
||||
assert calls[0]["id"] != calls[1]["id"]
|
||||
assert [call["function"]["arguments"] for call in calls] == [
|
||||
'{"city": "Paris"}',
|
||||
'{"city": "London"}',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_ids_are_unique_across_requests(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Shared engines keep signatures isolated between conversations."""
|
||||
first_part = SimpleNamespace(
|
||||
function_call=SimpleNamespace(name="get_weather", args={"city": "Paris"}),
|
||||
text=None,
|
||||
thought_signature=b"paris-sig",
|
||||
)
|
||||
second_part = SimpleNamespace(
|
||||
function_call=SimpleNamespace(name="get_weather", args={"city": "London"}),
|
||||
text=None,
|
||||
thought_signature=b"london-sig",
|
||||
)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.side_effect = [
|
||||
iter([_google_stream_chunk(first_part)]),
|
||||
iter([_google_stream_chunk(second_part)]),
|
||||
]
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
first = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="Weather in Paris")],
|
||||
model="gemini-3-flash-preview",
|
||||
)
|
||||
]
|
||||
second = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="Weather in London")],
|
||||
model="gemini-3-flash-preview",
|
||||
)
|
||||
]
|
||||
|
||||
first_id = first[0].tool_calls[0]["id"]
|
||||
second_id = second[0].tool_calls[0]["id"]
|
||||
assert first_id != second_id
|
||||
assert engine._thought_sigs[first_id] == b"paris-sig"
|
||||
assert engine._thought_sigs[second_id] == b"london-sig"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_emits_final_usage(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Google's final usage metadata is normalized onto the terminal chunk."""
|
||||
usage = SimpleNamespace(prompt_token_count=12, candidates_token_count=5)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[
|
||||
_google_stream_chunk(text="Hello"),
|
||||
_google_stream_chunk(usage_metadata=usage),
|
||||
]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="hi")],
|
||||
model="gemini-2.5-flash",
|
||||
)
|
||||
]
|
||||
|
||||
assert result[-1].finish_reason == "stop"
|
||||
assert result[-1].usage == {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 17,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_replays_signature_on_part(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A saved Gemini signature is replayed beside, not inside, function_call."""
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter([])
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {"google_get_weather_0": b"sig"}
|
||||
messages = [
|
||||
Message(role=Role.USER, content="weather"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="google_get_weather_0",
|
||||
name="get_weather",
|
||||
arguments='{"city": "Berlin"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(role=Role.TOOL, name="get_weather", content='{"temp": 20}'),
|
||||
]
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
messages, model="gemini-3-flash-preview"
|
||||
)
|
||||
]
|
||||
|
||||
contents = client.models.generate_content_stream.call_args.kwargs["contents"]
|
||||
assert contents[1]["parts"] == [
|
||||
{
|
||||
"function_call": {
|
||||
"name": "get_weather",
|
||||
"args": {"city": "Berlin"},
|
||||
},
|
||||
"thought_signature": b"sig",
|
||||
}
|
||||
]
|
||||
assert result[-1].finish_reason == "stop"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stream_full routing tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the TauBench optional dependency boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.evals.datasets import taubench
|
||||
|
||||
|
||||
def _mock_direct_url(monkeypatch, direct_url):
|
||||
distribution = Mock()
|
||||
distribution.read_text.return_value = direct_url
|
||||
monkeypatch.setattr(
|
||||
taubench.metadata, "distribution", Mock(return_value=distribution)
|
||||
)
|
||||
|
||||
|
||||
def test_ensure_tau2_accepts_the_pinned_source_revision(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "tau2", ModuleType("tau2"))
|
||||
_mock_direct_url(
|
||||
monkeypatch,
|
||||
(
|
||||
'{"url": "https://github.com/sierra-research/tau2-bench.git", '
|
||||
'"vcs_info": {"vcs": "git", '
|
||||
f'"commit_id": "{taubench.TAU2_REVISION}"}}}}'
|
||||
),
|
||||
)
|
||||
|
||||
taubench._ensure_tau2()
|
||||
|
||||
|
||||
def test_ensure_tau2_requires_explicit_pinned_install(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "tau2", None)
|
||||
monkeypatch.setattr(
|
||||
taubench.metadata,
|
||||
"distribution",
|
||||
Mock(side_effect=taubench.metadata.PackageNotFoundError),
|
||||
)
|
||||
|
||||
with pytest.raises(ImportError) as exc_info:
|
||||
taubench._ensure_tau2()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "does not install at runtime" in message
|
||||
assert taubench.TAU2_REVISION in message
|
||||
assert "uv pip install" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"direct_url",
|
||||
[
|
||||
# Editable install left behind by the previous runtime installer.
|
||||
'{"url": "file:///home/user/.openjarvis/cache/tau2-bench", '
|
||||
'"dir_info": {"editable": true}}',
|
||||
# A git install from an arbitrary upstream revision.
|
||||
'{"url": "https://github.com/sierra-research/tau2-bench.git", '
|
||||
'"vcs_info": {"vcs": "git", "commit_id": "deadbeef"}}',
|
||||
# Registry installs do not carry PEP 610 direct-origin metadata.
|
||||
None,
|
||||
],
|
||||
)
|
||||
def test_ensure_tau2_rejects_unpinned_install(monkeypatch, direct_url):
|
||||
_mock_direct_url(monkeypatch, direct_url)
|
||||
original_import = builtins.__import__
|
||||
|
||||
def guarded_import(name, *args, **kwargs):
|
||||
if name == "tau2":
|
||||
raise AssertionError("unverified tau2 package was imported")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", guarded_import)
|
||||
|
||||
with pytest.raises(ImportError) as exc_info:
|
||||
taubench._ensure_tau2()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "does not match" in message
|
||||
assert taubench.TAU2_REVISION in message
|
||||
assert "--force-reinstall" in message
|
||||
|
||||
|
||||
def test_verify_requirements_reports_install_instruction(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "tau2", None)
|
||||
monkeypatch.setattr(
|
||||
taubench.metadata,
|
||||
"distribution",
|
||||
Mock(side_effect=taubench.metadata.PackageNotFoundError),
|
||||
)
|
||||
|
||||
issues = taubench.TauBenchDataset().verify_requirements()
|
||||
|
||||
assert len(issues) == 1
|
||||
assert taubench.TAU2_REVISION in issues[0]
|
||||
@@ -88,13 +88,25 @@ class TestHeuristicRouter:
|
||||
router = HeuristicRouter(
|
||||
available_models=["small", "large", "coder"],
|
||||
)
|
||||
ctx = RoutingContext(
|
||||
query="solve x",
|
||||
query_length=7,
|
||||
has_math=True,
|
||||
)
|
||||
ctx = build_routing_context("solve the integral of x^2 dx")
|
||||
assert ctx.has_math is True
|
||||
assert ctx.complexity_score > 0.20
|
||||
assert router.select_model(ctx) == "large"
|
||||
|
||||
def test_low_complexity_math_prefers_small(self) -> None:
|
||||
"""Regression test: a trivial math query ("calculate 2+2") must not
|
||||
escalate to the largest model just because it contains a math
|
||||
keyword — the low-complexity rule takes priority over the math rule.
|
||||
"""
|
||||
_register_models()
|
||||
router = HeuristicRouter(
|
||||
available_models=["small", "large", "coder"],
|
||||
)
|
||||
ctx = build_routing_context("calculate 2+2")
|
||||
assert ctx.has_math is True
|
||||
assert ctx.complexity_score == 0.20
|
||||
assert router.select_model(ctx) == "small"
|
||||
|
||||
def test_high_complexity_prefers_large(self) -> None:
|
||||
_register_models()
|
||||
router = HeuristicRouter(
|
||||
|
||||
@@ -77,11 +77,9 @@ class TestRouterWithNewModels:
|
||||
router = HeuristicRouter(
|
||||
available_models=NEW_LOCAL_MODELS,
|
||||
)
|
||||
ctx = RoutingContext(
|
||||
query="solve the integral of x^2 dx",
|
||||
query_length=29,
|
||||
has_math=True,
|
||||
)
|
||||
ctx = build_routing_context("solve the integral of x^2 dx")
|
||||
assert ctx.has_math is True
|
||||
assert ctx.complexity_score > 0.20
|
||||
selected = router.select_model(ctx)
|
||||
assert selected == "gpt-oss:120b"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.memory.store import Fact
|
||||
from openjarvis.tools.storage._stubs import MemoryBackend, RetrievalResult
|
||||
from openjarvis.tools.storage.context import (
|
||||
ContextConfig,
|
||||
@@ -167,6 +168,113 @@ def test_inject_context_no_results_returns_original():
|
||||
assert augmented is messages
|
||||
|
||||
|
||||
def test_inject_context_adds_auto_memory_facts_without_backend():
|
||||
messages = [Message(role=Role.USER, content="What is my favorite color?")]
|
||||
facts = [Fact(text="The user's favorite color is blue", source="auto")]
|
||||
|
||||
augmented = inject_context("favorite color", messages, None, facts=facts)
|
||||
|
||||
assert len(augmented) == 2
|
||||
assert augmented[0].role == Role.SYSTEM
|
||||
assert "remembered from prior conversations" in augmented[0].content
|
||||
assert "favorite color is blue" in augmented[0].content
|
||||
|
||||
|
||||
def test_inject_context_prioritizes_newest_facts_within_token_budget():
|
||||
messages = [Message(role=Role.USER, content="What do you remember?")]
|
||||
facts = [
|
||||
Fact(text="old fact uses four tokens"),
|
||||
Fact(text="new fact uses four tokens"),
|
||||
]
|
||||
|
||||
augmented = inject_context(
|
||||
"remember",
|
||||
messages,
|
||||
None,
|
||||
config=ContextConfig(max_context_tokens=5),
|
||||
facts=facts,
|
||||
)
|
||||
|
||||
assert "new fact uses four tokens" in augmented[0].content
|
||||
assert "old fact uses four tokens" not in augmented[0].content
|
||||
|
||||
|
||||
def test_inject_context_merges_with_existing_system_message():
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content="You are OpenJarvis."),
|
||||
Message(role=Role.USER, content="What is my favorite color?"),
|
||||
]
|
||||
facts = [Fact(text="The user's favorite color is blue")]
|
||||
|
||||
augmented = inject_context("favorite color", messages, None, facts=facts)
|
||||
|
||||
system_messages = [m for m in augmented if m.role == Role.SYSTEM]
|
||||
assert len(system_messages) == 1
|
||||
assert "You are OpenJarvis." in system_messages[0].content
|
||||
assert "favorite color is blue" in system_messages[0].content
|
||||
assert messages[0].content == "You are OpenJarvis."
|
||||
|
||||
|
||||
def test_inject_context_collapses_multiple_system_messages():
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content="Identity."),
|
||||
Message(role=Role.SYSTEM, content="Persona."),
|
||||
Message(role=Role.USER, content="What do you remember?"),
|
||||
]
|
||||
|
||||
augmented = inject_context(
|
||||
"remember",
|
||||
messages,
|
||||
None,
|
||||
facts=[Fact(text="User likes jazz")],
|
||||
)
|
||||
|
||||
system_messages = [m for m in augmented if m.role == Role.SYSTEM]
|
||||
assert len(system_messages) == 1
|
||||
assert "Identity." in system_messages[0].content
|
||||
assert "Persona." in system_messages[0].content
|
||||
assert "User likes jazz" in system_messages[0].content
|
||||
|
||||
|
||||
def test_inject_context_reserves_budget_for_retrieved_documents():
|
||||
backend = _FakeMemory(
|
||||
[RetrievalResult(content="d1 d2 d3 d4 d5", score=1.0, source="doc")]
|
||||
)
|
||||
facts = [
|
||||
Fact(text="old1 old2 old3 old4 old5"),
|
||||
Fact(text="new1 new2 new3 new4 new5"),
|
||||
]
|
||||
|
||||
augmented = inject_context(
|
||||
"query",
|
||||
[Message(role=Role.USER, content="query")],
|
||||
backend,
|
||||
config=ContextConfig(max_context_tokens=10),
|
||||
facts=facts,
|
||||
)
|
||||
|
||||
assert "new1 new2 new3 new4 new5" in augmented[0].content
|
||||
assert "d1 d2 d3 d4 d5" in augmented[0].content
|
||||
assert "old1 old2 old3 old4 old5" not in augmented[0].content
|
||||
|
||||
|
||||
def test_inject_context_prefers_large_document_that_fits_total_budget():
|
||||
backend = _FakeMemory(
|
||||
[RetrievalResult(content="d1 d2 d3 d4 d5 d6 d7 d8", score=1.0)]
|
||||
)
|
||||
|
||||
augmented = inject_context(
|
||||
"query",
|
||||
[Message(role=Role.USER, content="query")],
|
||||
backend,
|
||||
config=ContextConfig(max_context_tokens=10),
|
||||
facts=[Fact(text="f1 f2 f3 f4 f5")],
|
||||
)
|
||||
|
||||
assert "d1 d2 d3 d4 d5 d6 d7 d8" in augmented[0].content
|
||||
assert "f1 f2 f3 f4 f5" not in augmented[0].content
|
||||
|
||||
|
||||
def test_inject_context_publishes_event():
|
||||
bus = EventBus(record_history=True)
|
||||
results = [
|
||||
|
||||
@@ -7,7 +7,11 @@ import json
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.registry import FactStoreRegistry
|
||||
from openjarvis.memory.store import LocalFactStore, create_fact_store
|
||||
from openjarvis.memory.store import (
|
||||
LocalFactStore,
|
||||
create_fact_store,
|
||||
load_configured_facts,
|
||||
)
|
||||
|
||||
|
||||
def test_add_and_list(tmp_path):
|
||||
@@ -145,3 +149,28 @@ def test_create_fact_store_default_path_uses_openjarvis_home(tmp_path, monkeypat
|
||||
def test_create_fact_store_unknown_backend(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
create_fact_store("cloud", path=tmp_path / "f.jsonl")
|
||||
|
||||
|
||||
def test_load_configured_facts_reads_enabled_store(tmp_path):
|
||||
from types import SimpleNamespace
|
||||
|
||||
path = tmp_path / "facts.jsonl"
|
||||
LocalFactStore(path).add("User likes jazz", source="auto")
|
||||
config = SimpleNamespace(
|
||||
memory=SimpleNamespace(
|
||||
enabled=True,
|
||||
backend="local",
|
||||
facts_path=str(path),
|
||||
max_facts=1000,
|
||||
)
|
||||
)
|
||||
|
||||
assert [fact.text for fact in load_configured_facts(config)] == ["User likes jazz"]
|
||||
|
||||
|
||||
def test_load_configured_facts_skips_disabled_memory():
|
||||
from types import SimpleNamespace
|
||||
|
||||
config = SimpleNamespace(memory=SimpleNamespace(enabled=False))
|
||||
|
||||
assert load_configured_facts(config) == []
|
||||
|
||||
@@ -95,6 +95,30 @@ class TestJarvisAsk:
|
||||
assert result == "Agent response"
|
||||
j.close()
|
||||
|
||||
def test_ask_with_agent_wires_persona(self, tmp_path):
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("SDK_PERSONA_SENTINEL", encoding="utf-8")
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.memory_files.soul_path = str(soul)
|
||||
cfg.memory_files.memory_path = ""
|
||||
cfg.memory_files.user_path = ""
|
||||
cfg.agent.context_from_memory = False
|
||||
|
||||
if not AgentRegistry.contains("simple"):
|
||||
AgentRegistry.register_value("simple", SimpleAgent)
|
||||
|
||||
engine = _make_engine()
|
||||
with patch("openjarvis.sdk.get_engine", return_value=("mock", engine)):
|
||||
j = Jarvis(config=cfg, model="test-model")
|
||||
j.ask("Hello", agent="simple")
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert "SDK_PERSONA_SENTINEL" in messages[0].content
|
||||
j.close()
|
||||
|
||||
def test_ask_no_engine_raises(self):
|
||||
with patch("openjarvis.sdk.get_engine", return_value=None):
|
||||
j = Jarvis(config=JarvisConfig())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user