mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-15 09:21:56 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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": "190,252",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 185599,
|
||||
"last_updated": "2026-08-10T07:26:44Z",
|
||||
"total_clones": 190252,
|
||||
"last_updated": "2026-08-14T07:19:51Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -137,6 +137,10 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { 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('authenticates agent events with the configured API key', () => {
|
||||
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.get('token')).toBe('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');
|
||||
});
|
||||
|
||||
it('omits the token for a keyless server', () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({ apiUrl: 'http://localhost:8000' }),
|
||||
);
|
||||
|
||||
const url = new URL(buildWsUrl('agent-one'));
|
||||
|
||||
expect(url.searchParams.has('token')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,16 @@ export interface AgentEvent {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildWsUrl(agentId?: string): string {
|
||||
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);
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) url.searchParams.set('token', apiKey);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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
|
||||
@@ -332,16 +333,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 {
|
||||
|
||||
@@ -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"]
|
||||
+174
-24
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -113,13 +123,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 +141,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 +152,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 +225,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 +243,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 +584,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 +835,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 +1037,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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
@@ -0,0 +1,49 @@
|
||||
"""Regression tests for OpenRouter model ID normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.types import Message
|
||||
from openjarvis.server import cloud_router
|
||||
|
||||
|
||||
def test_get_provider_detects_bare_openrouter_id():
|
||||
assert cloud_router.get_provider("anthropic/claude-haiku-4.5") == "openrouter"
|
||||
|
||||
|
||||
def test_get_provider_detects_litellm_prefixed_openrouter_id():
|
||||
model = "openrouter/anthropic/claude-haiku-4.5"
|
||||
assert cloud_router.get_provider(model) == "openrouter"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"requested_model,expected_forwarded_model",
|
||||
[
|
||||
("anthropic/claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
|
||||
("openrouter/anthropic/claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
|
||||
("openrouter/auto", "openrouter/auto"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_cloud_normalizes_openrouter_model_before_forwarding(
|
||||
monkeypatch, requested_model, expected_forwarded_model
|
||||
):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_stream_openai(model, messages, temperature, max_tokens, **kwargs):
|
||||
captured["model"] = model
|
||||
yield "ok"
|
||||
|
||||
monkeypatch.setattr(cloud_router, "_stream_openai", fake_stream_openai)
|
||||
|
||||
tokens = [
|
||||
token
|
||||
async for token in cloud_router.stream_cloud(
|
||||
requested_model, [Message(role="user", content="hi")]
|
||||
)
|
||||
]
|
||||
|
||||
assert tokens == ["ok"]
|
||||
assert captured["model"] == expected_forwarded_model
|
||||
@@ -213,6 +213,7 @@ class TestStreamingResilience:
|
||||
engine = _make_engine()
|
||||
agent = MagicMock()
|
||||
agent.agent_id = "simple"
|
||||
agent._tools = []
|
||||
agent.run.return_value = AgentResult(
|
||||
content="agent response",
|
||||
turns=1,
|
||||
@@ -265,6 +266,28 @@ class TestModelsEndpointExtended:
|
||||
assert "qwen3.5:9b" in ids
|
||||
assert "qwen3:0.6b" in ids
|
||||
|
||||
def test_models_list_filters_embedding_only_models(self):
|
||||
engine = _make_engine(
|
||||
models=["nomic-embed-text", "all-minilm:latest", "qwen3.5:4b"],
|
||||
)
|
||||
client = TestClient(create_app(engine, "qwen3.5:4b"))
|
||||
|
||||
resp = client.get("/v1/models")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert [m["id"] for m in resp.json()["data"]] == ["qwen3.5:4b"]
|
||||
|
||||
def test_models_list_returns_empty_when_only_embedders_are_installed(self):
|
||||
engine = _make_engine(
|
||||
models=["nomic-embed-text", "hf.co/BAAI/bge-m3:latest"],
|
||||
)
|
||||
client = TestClient(create_app(engine, "nomic-embed-text"))
|
||||
|
||||
resp = client.get("/v1/models")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_models_empty_engine(self):
|
||||
"""When engine.list_models() returns empty, endpoint still succeeds."""
|
||||
engine = _make_engine(models=[])
|
||||
|
||||
@@ -50,3 +50,42 @@ def test_parse_param_count():
|
||||
assert _parse_param_count("qwen3.5:0.8b") == 0.8
|
||||
assert _parse_param_count("qwen3.5:35b") == 35.0
|
||||
assert _parse_param_count("gpt-4o") == 0.0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
def test_recommended_model_skips_embed_only():
|
||||
"""Embed-only models must never be recommended for chat."""
|
||||
from openjarvis.server.agent_manager_routes import _pick_recommended_model
|
||||
|
||||
models = [
|
||||
"nomic-embed-text",
|
||||
"qwen3.5:4b",
|
||||
"mxbai-embed-large",
|
||||
"qwen3.5:9b",
|
||||
]
|
||||
result = _pick_recommended_model(models)
|
||||
assert result["model"] == "qwen3.5:4b"
|
||||
assert "embed" not in result["model"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
def test_recommended_model_embed_only_returns_empty():
|
||||
"""If only embedders are installed, recommend nothing (not nomic-embed)."""
|
||||
from openjarvis.server.agent_manager_routes import _pick_recommended_model
|
||||
|
||||
result = _pick_recommended_model(["nomic-embed-text", "mxbai-embed-large"])
|
||||
assert result["model"] == ""
|
||||
assert "No local chat model" in result["reason"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
def test_is_embed_only_model():
|
||||
from openjarvis.server.model_capabilities import is_embed_only_model
|
||||
|
||||
assert is_embed_only_model("nomic-embed-text")
|
||||
assert is_embed_only_model("mxbai-embed-large")
|
||||
assert is_embed_only_model("text-embedding-3-small")
|
||||
assert is_embed_only_model("all-minilm:latest")
|
||||
assert is_embed_only_model("hf.co/BAAI/bge-m3:latest")
|
||||
assert not is_embed_only_model("qwen3.5:4b")
|
||||
assert not is_embed_only_model("codegemma:7b")
|
||||
|
||||
@@ -11,6 +11,7 @@ fastapi = pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType # noqa: E402
|
||||
from openjarvis.core.types import Role # noqa: E402
|
||||
from openjarvis.server.app import create_app # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -534,6 +535,94 @@ class TestChatCompletions:
|
||||
content += delta_content
|
||||
assert content == "Hello world"
|
||||
|
||||
def test_streaming_without_client_tools_uses_configured_agent(self):
|
||||
"""Server-side tools remain available to streaming web clients (#735)."""
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
executions: list[str] = []
|
||||
|
||||
class _FileReadTool(BaseTool):
|
||||
@property
|
||||
def spec(self):
|
||||
return ToolSpec(
|
||||
name="file_read",
|
||||
description="Read a file",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self, **params):
|
||||
executions.append(params["path"])
|
||||
return ToolResult(
|
||||
tool_name="file_read",
|
||||
content="README fixture contents",
|
||||
success=True,
|
||||
)
|
||||
|
||||
engine = _make_engine(content="ENGINE BYPASS")
|
||||
engine.generate.side_effect = [
|
||||
{
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"name": "file_read",
|
||||
"arguments": '{"path": "README.md"}',
|
||||
}
|
||||
],
|
||||
"usage": {},
|
||||
},
|
||||
{
|
||||
"content": "README fixture contents",
|
||||
"finish_reason": "stop",
|
||||
"usage": {},
|
||||
},
|
||||
]
|
||||
agent = OrchestratorAgent(
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_FileReadTool()],
|
||||
bus=EventBus(),
|
||||
max_turns=3,
|
||||
temperature=0.7,
|
||||
max_tokens=128,
|
||||
system_prompt="Use the configured tools.",
|
||||
)
|
||||
app = create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
agent=agent,
|
||||
bus=EventBus(),
|
||||
config=_test_config(),
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "Read README.md"}],
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
content = ""
|
||||
for line in resp.text.strip().split("\n"):
|
||||
if not line.startswith("data:") or "[DONE]" in line:
|
||||
continue
|
||||
data = json.loads(line[5:].strip())
|
||||
delta = data.get("choices", [{}])[0].get("delta", {})
|
||||
content += delta.get("content") or ""
|
||||
|
||||
assert content == "README fixture contents"
|
||||
assert executions == ["README.md"]
|
||||
assert engine.generate.call_count == 2
|
||||
|
||||
def test_streaming_with_tools_emits_tool_calls_and_bypasses_agent(self):
|
||||
"""Regression for the streaming analog of #414.
|
||||
|
||||
@@ -758,6 +847,47 @@ class TestIdentityPromptInjection:
|
||||
assert len(system_msgs) == 1
|
||||
assert system_msgs[0].content == "Be terse."
|
||||
|
||||
def test_stream_uses_grounded_agent_result_without_replay(self):
|
||||
"""Regression for #734: web streaming emits the agent's final answer."""
|
||||
from openjarvis.core.events import EventBus
|
||||
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
agent = _make_agent(content="My name is Jarvis Prime.")
|
||||
agent._tools = [object()]
|
||||
agent._engine = engine
|
||||
client = TestClient(
|
||||
create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
agent=agent,
|
||||
bus=EventBus(),
|
||||
config=_identity_config(),
|
||||
)
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "who are you?"}],
|
||||
"stream": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
streamed_content = ""
|
||||
for line in resp.text.splitlines():
|
||||
if not line.startswith("data: {"):
|
||||
continue
|
||||
payload = json.loads(line.removeprefix("data: "))
|
||||
choices = payload.get("choices", [])
|
||||
if choices and choices[0]["delta"].get("content"):
|
||||
streamed_content += choices[0]["delta"]["content"]
|
||||
assert streamed_content == "My name is Jarvis Prime."
|
||||
assert captured == []
|
||||
agent.run.assert_called_once()
|
||||
|
||||
def test_direct_injects_identity_when_absent(self):
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
@@ -798,6 +928,101 @@ class TestIdentityPromptInjection:
|
||||
assert len(system_msgs) == 1
|
||||
assert system_msgs[0].content == "Be terse."
|
||||
|
||||
def test_direct_merges_identity_and_auto_memory_into_one_system_message(self):
|
||||
from openjarvis.memory.store import Fact
|
||||
|
||||
class _MemoryService:
|
||||
def list_facts(self):
|
||||
return [Fact(text="The user's favorite color is blue")]
|
||||
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
cfg = _identity_config()
|
||||
cfg.agent.context_from_memory = True
|
||||
client = TestClient(
|
||||
create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
config=cfg,
|
||||
memory_service=_MemoryService(),
|
||||
)
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [{"role": "user", "content": "What is my favorite color?"}],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
messages = engine.generate.call_args.args[0]
|
||||
system_messages = [m for m in messages if m.role == Role.SYSTEM]
|
||||
assert len(system_messages) == 1
|
||||
assert "OpenJarvis" in system_messages[0].content
|
||||
assert "favorite color is blue" in system_messages[0].content
|
||||
|
||||
def test_memory_context_preserves_assistant_tool_calls(self):
|
||||
from openjarvis.memory.store import Fact
|
||||
|
||||
class _MemoryService:
|
||||
def list_facts(self):
|
||||
return [Fact(text="User likes jazz")]
|
||||
|
||||
captured: list = []
|
||||
engine = _make_capturing_engine(captured)
|
||||
cfg = _identity_config()
|
||||
cfg.agent.context_from_memory = True
|
||||
client = TestClient(
|
||||
create_app(
|
||||
engine,
|
||||
"test-model",
|
||||
config=cfg,
|
||||
memory_service=_MemoryService(),
|
||||
)
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/v1/chat/completions",
|
||||
json={
|
||||
"model": "test-model",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Run the lookup"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"arguments": '{"query":"jazz"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "result",
|
||||
"tool_call_id": "call_1",
|
||||
},
|
||||
{"role": "user", "content": "What did it find?"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assistant = next(
|
||||
message for message in messages if message.role == Role.ASSISTANT
|
||||
)
|
||||
assert assistant.tool_calls is not None
|
||||
assert assistant.tool_calls[0].id == "call_1"
|
||||
assert assistant.tool_calls[0].name == "lookup"
|
||||
assert assistant.tool_calls[0].arguments == '{"query":"jazz"}'
|
||||
|
||||
def test_direct_injects_soul_persona_when_present(self, tmp_path):
|
||||
"""Regression: /v1/chat/completions previously injected only the bare
|
||||
``default_system_prompt`` blurb via a hand-rolled lookup, bypassing
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Regression tests for streaming completed agent responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from openjarvis.agents._stubs import AgentResult # noqa: E402
|
||||
from openjarvis.core.events import EventBus # noqa: E402
|
||||
from openjarvis.core.types import ToolResult # noqa: E402
|
||||
from openjarvis.server.models import ChatCompletionRequest # noqa: E402
|
||||
from openjarvis.server.stream_bridge import AgentStreamBridge # noqa: E402
|
||||
|
||||
|
||||
def _streamed_content(events: list[str]) -> str:
|
||||
"""Join assistant content from OpenAI-compatible data chunks."""
|
||||
content = []
|
||||
for event in events:
|
||||
if not event.startswith("data: {"):
|
||||
continue
|
||||
payload = json.loads(event.removeprefix("data: ").strip())
|
||||
choices = payload.get("choices")
|
||||
if choices and choices[0]["delta"].get("content"):
|
||||
content.append(choices[0]["delta"]["content"])
|
||||
return "".join(content)
|
||||
|
||||
|
||||
def test_stream_replays_grounded_agent_result_without_second_inference():
|
||||
grounded_content = "My name is Jarvis. The tool reports 72 degrees."
|
||||
agent = MagicMock()
|
||||
agent._model = "configured-model"
|
||||
agent.run.return_value = AgentResult(
|
||||
content=grounded_content,
|
||||
tool_results=[
|
||||
ToolResult(tool_name="weather", content="72 degrees", success=True)
|
||||
],
|
||||
metadata={"prompt_tokens": 10, "completion_tokens": 12, "total_tokens": 22},
|
||||
)
|
||||
|
||||
async def ungrounded_replay(*args, **kwargs):
|
||||
raise AssertionError("stream_full must not run after agent.run")
|
||||
yield # pragma: no cover
|
||||
|
||||
agent._engine.stream_full = ungrounded_replay
|
||||
request = ChatCompletionRequest(
|
||||
model="requested-model",
|
||||
messages=[{"role": "user", "content": "Who are you, and what's outside?"}],
|
||||
stream=True,
|
||||
)
|
||||
bridge = AgentStreamBridge(agent, EventBus(), request.model, request)
|
||||
|
||||
async def collect_events() -> list[str]:
|
||||
return [event async for event in bridge.stream()]
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
|
||||
assert _streamed_content(events) == grounded_content
|
||||
assert any(event.startswith("event: tool_results\n") for event in events)
|
||||
agent.run.assert_called_once()
|
||||
assert agent._model == "configured-model"
|
||||
|
||||
|
||||
def test_tool_call_start_serializes_arguments_for_sse_without_mutating_event():
|
||||
bridge = object.__new__(AgentStreamBridge)
|
||||
event_data = {
|
||||
"tool": "web_search",
|
||||
"arguments": {"query": "python"},
|
||||
"agent": "agent-1",
|
||||
}
|
||||
|
||||
event = bridge._format_named_event("tool_call_start", event_data)
|
||||
payload = json.loads(event.split("data: ", 1)[1])
|
||||
|
||||
assert payload["arguments"] == '{"query": "python"}'
|
||||
assert event_data["arguments"] == {"query": "python"}
|
||||
|
||||
|
||||
def test_tool_call_start_preserves_already_serialized_arguments():
|
||||
bridge = object.__new__(AgentStreamBridge)
|
||||
|
||||
event = bridge._format_named_event(
|
||||
"tool_call_start",
|
||||
{"tool": "web_search", "arguments": '{"query":"python"}'},
|
||||
)
|
||||
payload = json.loads(event.split("data: ", 1)[1])
|
||||
|
||||
assert payload["arguments"] == '{"query":"python"}'
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
@@ -18,8 +20,10 @@ class _MockEngine(InferenceEngine):
|
||||
def __init__(self, responses: list[str]) -> None:
|
||||
self._responses = list(responses)
|
||||
self._call_idx = 0
|
||||
self.calls = []
|
||||
|
||||
def generate(self, messages, **kwargs) -> dict:
|
||||
self.calls.append(list(messages))
|
||||
if self._call_idx < len(self._responses):
|
||||
content = self._responses[self._call_idx]
|
||||
self._call_idx += 1
|
||||
@@ -58,6 +62,109 @@ class _MockTool(BaseTool):
|
||||
return ToolResult(tool_name="calculator", content=str(expr), success=True)
|
||||
|
||||
|
||||
class _FileReadLikeTool(BaseTool):
|
||||
"""Small test double with one required and one optional parameter."""
|
||||
|
||||
tool_id = "file_read"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="file_read",
|
||||
description="Read a file",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"max_lines": {"type": "integer"},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool_name="file_read",
|
||||
content=params.get("path", ""),
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
class _AmbiguousTool(BaseTool):
|
||||
"""Test double whose bare input cannot map to one parameter safely."""
|
||||
|
||||
tool_id = "copy"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="copy",
|
||||
description="Copy a value",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"destination": {"type": "string"},
|
||||
},
|
||||
"required": ["source", "destination"],
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(tool_name="copy", content="copied", success=True)
|
||||
|
||||
|
||||
class _CodeLikeTool(BaseTool):
|
||||
"""Test double that explicitly accepts object-prefixed source text."""
|
||||
|
||||
tool_id = "code"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="code",
|
||||
description="Execute source code",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"code": {"type": "string"}},
|
||||
"required": ["code"],
|
||||
},
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool_name="code",
|
||||
content=params.get("code", ""),
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
class _UnionStringTool(BaseTool):
|
||||
"""Test double with a JSON Schema union that accepts strings."""
|
||||
|
||||
tool_id = "union_file_read"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="union_file_read",
|
||||
description="Read a nullable path",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": ["string", "null"]}},
|
||||
"required": ["path"],
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool_name="union_file_read",
|
||||
content=params.get("path", ""),
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
# -- Tests -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -80,6 +187,207 @@ class TestStructuredMode:
|
||||
assert result.content == "4"
|
||||
assert result.turns == 2
|
||||
assert len(result.tool_results) == 1
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == "2+2"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_input", "expected"),
|
||||
[
|
||||
("notes/today.md", "notes/today.md"),
|
||||
('"notes/today.md"', "notes/today.md"),
|
||||
('{"path": "notes/today.md"}', "notes/today.md"),
|
||||
("42", "42"),
|
||||
("true", "true"),
|
||||
("false", "false"),
|
||||
("null", "null"),
|
||||
("1e3", "1e3"),
|
||||
('["notes/today.md"]', '["notes/today.md"]'),
|
||||
('["notes/today.md",]', '["notes/today.md",]'),
|
||||
("[draft] notes.md", "[draft] notes.md"),
|
||||
(
|
||||
"[x * 2 for x in range(3)]",
|
||||
"[x * 2 for x in range(3)]",
|
||||
),
|
||||
('"notes/today.md', '"notes/today.md'),
|
||||
('""', ""),
|
||||
('"{draft} notes.md"', "{draft} notes.md"),
|
||||
(r'"C:\\Users\\me\\notes.txt"', r"C:\Users\me\notes.txt"),
|
||||
],
|
||||
)
|
||||
def test_single_string_parameter_accepts_text_input(self, raw_input, expected):
|
||||
"""Structured text maps to the unambiguous string parameter."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: file_read\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_FileReadLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Read my notes")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_input",
|
||||
[
|
||||
'{"path": "notes/today.md",}',
|
||||
'{path: "notes/today.md"}',
|
||||
"{'path': 'notes/today.md'}",
|
||||
'{"unknown": 1, "path": "notes/today.md",}',
|
||||
r'{"pa\u0074h": "notes/today.md",}',
|
||||
'\ufeff{"path": "notes/today.md",}',
|
||||
],
|
||||
)
|
||||
def test_malformed_json_like_input_remains_an_argument_error(self, raw_input):
|
||||
"""Malformed JSON-looking text is not reclassified as a tool value."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: file_read\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_FileReadLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Read my notes")
|
||||
|
||||
assert result.tool_results[0].success is False
|
||||
assert "Invalid arguments JSON" in result.tool_results[0].content
|
||||
assert "Tool 'file_read' failed: Invalid arguments JSON" in (
|
||||
engine.calls[1][-1].content
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_input",
|
||||
[
|
||||
"{'code': value}",
|
||||
'{"code": object()}',
|
||||
"{'nested': {'value': 1}}",
|
||||
],
|
||||
)
|
||||
def test_opted_in_tool_accepts_object_prefixed_text(self, raw_input):
|
||||
"""Explicit raw-text metadata disambiguates dict-shaped source code."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: code\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_CodeLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Execute code")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == raw_input
|
||||
|
||||
def test_valid_object_remains_arguments_for_opted_in_tool(self):
|
||||
"""Raw-text metadata does not override valid JSON argument objects."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
'TOOL: code\nINPUT: {"code": "print(1)"}',
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_CodeLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Execute code")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == "print(1)"
|
||||
|
||||
def test_union_string_schema_accepts_json_scalar_text(self):
|
||||
"""String unions normalize text that also parses as a JSON scalar."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
"TOOL: union_file_read\nINPUT: null",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_UnionStringTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Read the path named null")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == "null"
|
||||
|
||||
def test_ambiguous_json_scalar_gets_stable_object_error(self):
|
||||
"""Ambiguous valid JSON is rejected before tool dispatch."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
"TOOL: copy\nINPUT: 42",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_AmbiguousTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Copy my notes")
|
||||
|
||||
assert result.tool_results[0].success is False
|
||||
assert result.tool_results[0].content == (
|
||||
"Invalid arguments: expected a JSON object, got int."
|
||||
)
|
||||
assert "Tool 'copy' failed: Invalid arguments" in (engine.calls[1][-1].content)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_input",
|
||||
[
|
||||
"notes/today.md",
|
||||
'{"source": "notes/today.md",}',
|
||||
],
|
||||
)
|
||||
def test_multiple_required_parameters_do_not_guess_string_mapping(
|
||||
self,
|
||||
raw_input,
|
||||
):
|
||||
"""Ambiguous bare input remains invalid instead of choosing a field."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: copy\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_AmbiguousTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Copy my notes")
|
||||
|
||||
assert result.tool_results[0].success is False
|
||||
assert "Invalid arguments JSON" in result.tool_results[0].content
|
||||
|
||||
def test_direct_final_answer(self):
|
||||
"""Test that FINAL_ANSWER on first turn works."""
|
||||
|
||||
@@ -127,6 +127,10 @@ class TestCodeInterpreterTool:
|
||||
tool = CodeInterpreterTool()
|
||||
assert tool.tool_id == "code_interpreter"
|
||||
|
||||
def test_structured_object_text_opt_in(self):
|
||||
tool = CodeInterpreterTool()
|
||||
assert tool.spec.metadata["structured_allow_object_text"] is True
|
||||
|
||||
def test_registry_registration(self):
|
||||
ToolRegistry.register_value("code_interpreter", CodeInterpreterTool)
|
||||
assert ToolRegistry.contains("code_interpreter")
|
||||
|
||||
@@ -25,6 +25,7 @@ class TestDockerCodeInterpreterTool:
|
||||
assert spec.name == "code_interpreter_docker"
|
||||
assert "code" in spec.parameters["properties"]
|
||||
assert spec.category == "code"
|
||||
assert spec.metadata["structured_allow_object_text"] is True
|
||||
|
||||
def test_empty_code(self):
|
||||
from openjarvis.tools.code_interpreter_docker import (
|
||||
|
||||
@@ -17,6 +17,10 @@ class TestReplSpec:
|
||||
tool = ReplTool()
|
||||
assert tool.spec.category == "code"
|
||||
|
||||
def test_structured_object_text_opt_in(self):
|
||||
tool = ReplTool()
|
||||
assert tool.spec.metadata["structured_allow_object_text"] is True
|
||||
|
||||
def test_spec_parameters(self):
|
||||
tool = ReplTool()
|
||||
params = tool.spec.parameters
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolExecutor, ToolSpec
|
||||
@@ -50,6 +54,17 @@ class _ErrorTool(BaseTool):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
class _ScalarBoundaryGuard:
|
||||
"""Test guard that rewrites outbound arguments to a JSON scalar."""
|
||||
|
||||
def check_outbound(self, tool_call: ToolCall) -> ToolCall:
|
||||
return ToolCall(
|
||||
id=tool_call.id,
|
||||
name=tool_call.name,
|
||||
arguments=json.dumps("redacted"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolSpec tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -134,6 +149,38 @@ class TestToolExecutor:
|
||||
assert result.success is False
|
||||
assert "Invalid arguments JSON" in result.content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("arguments", "decoded_type"),
|
||||
[
|
||||
("42", "int"),
|
||||
("true", "bool"),
|
||||
("null", "NoneType"),
|
||||
("[]", "list"),
|
||||
('"text"', "str"),
|
||||
],
|
||||
)
|
||||
def test_execute_rejects_non_object_json(self, arguments, decoded_type):
|
||||
executor = ToolExecutor([_EchoTool()])
|
||||
call = ToolCall(id="1", name="echo", arguments=arguments)
|
||||
|
||||
result = executor.execute(call)
|
||||
|
||||
assert result.success is False
|
||||
assert result.content == (
|
||||
f"Invalid arguments: expected a JSON object, got {decoded_type}."
|
||||
)
|
||||
|
||||
def test_execute_revalidates_boundary_guard_arguments(self):
|
||||
tool = _EchoTool()
|
||||
tool.is_local = False
|
||||
executor = ToolExecutor([tool], boundary_guard=_ScalarBoundaryGuard())
|
||||
call = ToolCall(id="1", name="echo", arguments='{"text":"safe"}')
|
||||
|
||||
result = executor.execute(call)
|
||||
|
||||
assert result.success is False
|
||||
assert result.content == ("Invalid arguments: expected a JSON object, got str.")
|
||||
|
||||
def test_execute_empty_arguments(self):
|
||||
executor = ToolExecutor([_EchoTool()])
|
||||
call = ToolCall(id="1", name="echo", arguments="")
|
||||
|
||||
@@ -64,6 +64,8 @@ EXPECTED_TOOLS = {
|
||||
"image_generate",
|
||||
# audio_tool.py
|
||||
"audio_transcribe",
|
||||
# text_to_speech.py
|
||||
"text_to_speech",
|
||||
# knowledge_tools.py
|
||||
"kg_add_entity",
|
||||
"kg_add_relation",
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# Pearl reference oracle (OpenJarvis Phase 0 deliverable)
|
||||
|
||||
Phase 0-B of [Spec B](../../docs/design/2026-05-05-apple-silicon-pearl-mining-design.md)
|
||||
called for "build a Python reference oracle for NoisyGEMM, validate against the
|
||||
Pearl CUDA reference."
|
||||
|
||||
**Phase 0 found the oracle already exists upstream**, in two complementary forms:
|
||||
|
||||
| Layer | Upstream location | What it covers |
|
||||
|---|---|---|
|
||||
| Pure-Rust mining algorithm exposed to Python | `pearl/py-pearl-mining` | The complete `mine()` + `verify_plain_proof()` cycle. CPU-only. Hardware-portable. |
|
||||
| PyTorch reference of production NoisyGEMM | `pearl/miner/miner-base/src/miner_base/noisy_gemm.py` | The same NoisyGEMM that vllm-miner accelerates with H100 CUDA. Bit-exact denoising verified by upstream test (`tests/test_noisy_gemm.py:92`). |
|
||||
|
||||
So this directory contains:
|
||||
|
||||
1. `smoke_test.py` — a runnable script that **actually mines a block on this machine** using the upstream Rust path, demonstrating the v1 architecture works on Apple Silicon (or any platform where `py-pearl-mining` builds).
|
||||
2. This README documenting where the reference math lives.
|
||||
|
||||
## What this is *not*
|
||||
|
||||
This is **not a reimplementation** of NoisyGEMM. The original Spec B planned for that;
|
||||
Phase 0 made it unnecessary. If you're tempted to write `noisy_gemm.py` here, stop —
|
||||
read `pearl/miner/miner-base/src/miner_base/noisy_gemm.py` instead.
|
||||
|
||||
## Setup
|
||||
|
||||
You need:
|
||||
|
||||
- macOS arm64 (M1/M2/M3/M4) **or** Linux x86_64 / aarch64
|
||||
- Python 3.12 (`uv venv --python 3.12 .venv` is the easiest)
|
||||
- Rust 1.78+ (any recent toolchain — verified with 1.94 on macOS arm64)
|
||||
- The Pearl source tree somewhere on disk
|
||||
|
||||
Build the wheel and install it (one-time, ~60 s on a fast Mac, ~5 min on first build):
|
||||
|
||||
```bash
|
||||
# from the Pearl repo root
|
||||
cd py-pearl-mining
|
||||
uv pip install maturin
|
||||
maturin build --release --interpreter "$(which python)"
|
||||
|
||||
# install the resulting wheel
|
||||
uv pip install target/wheels/py_pearl_mining-*.whl
|
||||
```
|
||||
|
||||
Or if Pearl publishes to PyPI in the future:
|
||||
|
||||
```bash
|
||||
uv pip install py-pearl-mining
|
||||
```
|
||||
|
||||
## Run the smoke test
|
||||
|
||||
```bash
|
||||
python smoke_test.py
|
||||
```
|
||||
|
||||
Actual output on Apple Silicon M2 Max (numbers will vary by hardware and run):
|
||||
|
||||
```
|
||||
host: macOS-26.4.1-arm64-arm-64bit (arm64)
|
||||
python: 3.12.1
|
||||
[ok] pearl_mining loaded from <site-packages>/pearl_mining/__init__.py
|
||||
[ok] PUBLICDATA_SIZE=164 MERKLE_LEAF_SIZE=1024
|
||||
[ok] mine(m=256, n=128, k=1024, rank=32) returned a proof in 0.119 s
|
||||
proof.m=256 proof.n=128 proof.k=1024 noise_rank=32
|
||||
a.row_indices=[177, 185, 241, 249] bt.row_indices=[80, 81, 88, 89, 112, 113, 120, 121]
|
||||
[ok] verify_plain_proof: ok=True ('Mining solution verified successfully', 0.2 ms)
|
||||
|
||||
[ok] all checks passed — Pearl mining works on this host
|
||||
```
|
||||
|
||||
The `a.row_indices` and `bt.row_indices` values above are not constants — they're
|
||||
`(offset + ROWS_PATTERN)` and `(offset + COLS_PATTERN)` for whichever offset the
|
||||
miner happened to find a jackpot at. The smoke test verifies the *deltas* match
|
||||
the configured `PeriodicPattern`, not the absolute values.
|
||||
|
||||
If it succeeds, this host can mine Pearl using the OpenJarvis `cpu-pearl` provider
|
||||
(see Spec B §13). If it fails, the `[fail]` line tells you which step broke.
|
||||
|
||||
## What this proves (and what it doesn't)
|
||||
|
||||
**Proves:**
|
||||
|
||||
- The Pearl mining algorithm executes correctly on this host's CPU.
|
||||
- Generated proofs verify under `verify_plain_proof`. (This is the same check
|
||||
validators run on the inputs to the ZK proof.)
|
||||
- The whole stack — `pearl-blake3`, `zk-pow`, `py-pearl-mining` — builds and
|
||||
loads as a native CPython extension.
|
||||
|
||||
**Does NOT prove:**
|
||||
|
||||
- Network-difficulty hashrate. The smoke test uses
|
||||
`nbits=0x1D2FFFFF` (test difficulty), much easier than mainnet. Real mining
|
||||
expected hashrate on Apple Silicon CPU is several orders of magnitude lower
|
||||
per share — see Spec B §1.5.6.
|
||||
- ZK proof generation throughput. The smoke test calls `verify_plain_proof`,
|
||||
not `generate_proof`. Plonky2 STARK proving takes seconds-to-minutes of CPU
|
||||
per block (Spec B Open Q10).
|
||||
- That this host can keep up with the network's block production rate.
|
||||
|
||||
## When to update this
|
||||
|
||||
- When Pearl bumps `py-pearl-mining` API: re-run the smoke test against the
|
||||
new ref pinned in `OpenJarvis/src/openjarvis/mining/_constants.py`.
|
||||
- When Pearl publishes a Mac wheel to PyPI: simplify the install instructions
|
||||
above, drop the local `maturin build` step.
|
||||
- When Spec B v2 adds the PyTorch-MPS reference path: extend `smoke_test.py`
|
||||
with an MPS path comparison. The `miner-base` reference is already in
|
||||
PyTorch, so the v2 smoke test would be a different test invoking
|
||||
`miner_base.NoisyGemm` and comparing CPU vs MPS outputs for parity.
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Pearl mining smoke test — runs an end-to-end mine + verify cycle.
|
||||
|
||||
Verifies that this host can run Pearl's pure-Rust mining algorithm via the
|
||||
`pearl_mining` Python package. Used as Phase 0-B of the OpenJarvis Apple Silicon
|
||||
mining spec ([Spec B]).
|
||||
|
||||
Exit codes:
|
||||
0 all checks passed
|
||||
1 pearl_mining import failed
|
||||
2 mine() failed
|
||||
3 verify_plain_proof rejected the proof
|
||||
4 timing or sanity check failed
|
||||
|
||||
[Spec B]: ../../docs/design/2026-05-05-apple-silicon-pearl-mining-design.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Test fixture values — match upstream Pearl's tests/test_python_api.py so we are
|
||||
# testing the same code path that Pearl's own CI exercises. Do not change
|
||||
# without re-syncing with upstream.
|
||||
DEFAULT_NBITS = 0x1D2FFFFF
|
||||
DEFAULT_M = 256
|
||||
DEFAULT_N = 128
|
||||
DEFAULT_K = 1024
|
||||
DEFAULT_RANK = 32
|
||||
ROWS_PATTERN = [0, 8, 64, 72]
|
||||
COLS_PATTERN = [0, 1, 8, 9, 32, 33, 40, 41]
|
||||
|
||||
|
||||
def _ok(msg: str) -> None:
|
||||
print(f"[ok] {msg}")
|
||||
|
||||
|
||||
def _fail(msg: str, code: int) -> None:
|
||||
print(f"[fail] {msg}")
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"host: {platform.platform()} ({platform.machine()})")
|
||||
print(f"python: {sys.version.split()[0]}")
|
||||
|
||||
try:
|
||||
import pearl_mining
|
||||
except ImportError as e:
|
||||
_fail(f"could not import pearl_mining — install with `uv pip install py-pearl-mining` or build from source: {e}", 1)
|
||||
|
||||
_ok(f"pearl_mining loaded from {pearl_mining.__file__}")
|
||||
_ok(
|
||||
f"PUBLICDATA_SIZE={pearl_mining.PUBLICDATA_SIZE} "
|
||||
f"MERKLE_LEAF_SIZE={pearl_mining.MERKLE_LEAF_SIZE}"
|
||||
)
|
||||
|
||||
block_header = pearl_mining.IncompleteBlockHeader(
|
||||
version=0,
|
||||
prev_block=b"\x00" * 32,
|
||||
merkle_root=b"0123456789abcdef" * 2,
|
||||
timestamp=0x66666666,
|
||||
nbits=DEFAULT_NBITS,
|
||||
)
|
||||
mining_config = pearl_mining.MiningConfiguration(
|
||||
common_dim=DEFAULT_K,
|
||||
rank=DEFAULT_RANK,
|
||||
mma_type=pearl_mining.MMAType.Int7xInt7ToInt32,
|
||||
rows_pattern=pearl_mining.PeriodicPattern.from_list(ROWS_PATTERN),
|
||||
cols_pattern=pearl_mining.PeriodicPattern.from_list(COLS_PATTERN),
|
||||
reserved=pearl_mining.MiningConfiguration.RESERVED,
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
plain_proof = pearl_mining.mine(
|
||||
DEFAULT_M,
|
||||
DEFAULT_N,
|
||||
DEFAULT_K,
|
||||
block_header,
|
||||
mining_config,
|
||||
signal_range=None,
|
||||
wrong_jackpot_hash=False,
|
||||
)
|
||||
except Exception as e:
|
||||
_fail(f"mine() raised: {e!r}", 2)
|
||||
t_mine = time.perf_counter() - t0
|
||||
|
||||
_ok(
|
||||
f"mine(m={DEFAULT_M}, n={DEFAULT_N}, k={DEFAULT_K}, rank={DEFAULT_RANK}) "
|
||||
f"returned a proof in {t_mine:.3f} s"
|
||||
)
|
||||
print(
|
||||
f" proof.m={plain_proof.m} proof.n={plain_proof.n} proof.k={plain_proof.k} "
|
||||
f"noise_rank={plain_proof.noise_rank}"
|
||||
)
|
||||
print(
|
||||
f" a.row_indices={plain_proof.a.row_indices} "
|
||||
f"bt.row_indices={plain_proof.bt.row_indices}"
|
||||
)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
ok, msg = pearl_mining.verify_plain_proof(block_header, plain_proof)
|
||||
t_verify_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if not ok:
|
||||
_fail(f"verify_plain_proof rejected our proof: {msg}", 3)
|
||||
|
||||
_ok(f"verify_plain_proof: ok=True ({msg!r}, {t_verify_ms:.1f} ms)")
|
||||
|
||||
if plain_proof.m != DEFAULT_M or plain_proof.n != DEFAULT_N or plain_proof.k != DEFAULT_K:
|
||||
_fail("plain_proof dimensions do not match request", 4)
|
||||
if plain_proof.noise_rank != DEFAULT_RANK:
|
||||
_fail("plain_proof noise_rank does not match request", 4)
|
||||
|
||||
# Row indices are (offset + base_index) for some valid offset within the
|
||||
# matrix dimension — see threads_partition() in zk-pow/src/ffi/mine.rs.
|
||||
# We can't assert an absolute value (different offsets are valid every run),
|
||||
# but we can assert the deltas match the pattern shape.
|
||||
a_idxs = list(plain_proof.a.row_indices)
|
||||
bt_idxs = list(plain_proof.bt.row_indices)
|
||||
a_deltas = [v - a_idxs[0] for v in a_idxs]
|
||||
bt_deltas = [v - bt_idxs[0] for v in bt_idxs]
|
||||
if a_deltas != ROWS_PATTERN:
|
||||
_fail(f"a.row_indices deltas ({a_deltas}) != ROWS_PATTERN ({ROWS_PATTERN})", 4)
|
||||
if bt_deltas != COLS_PATTERN:
|
||||
_fail(f"bt.row_indices deltas ({bt_deltas}) != COLS_PATTERN ({COLS_PATTERN})", 4)
|
||||
|
||||
print()
|
||||
print("[ok] all checks passed — Pearl mining works on this host")
|
||||
print()
|
||||
print("Note: this used test difficulty (nbits=0x1D2FFFFF), not mainnet.")
|
||||
print("Real-network shares per second will be many orders of magnitude lower.")
|
||||
print("See docs/design/2026-05-05-apple-silicon-pearl-mining-design.md §1.5.6")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user