mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
410562409d | ||
|
|
9498adc7c4 | ||
|
|
ebf370595d | ||
|
|
bcdbf13d02 | ||
|
|
3dc621618f | ||
|
|
fd0b60fefc | ||
|
|
95a9857984 | ||
|
|
f9c89308fc | ||
|
|
65d08e9d94 | ||
|
|
45717780fa | ||
|
|
9da7c30880 | ||
|
|
98e791f258 | ||
|
|
b9e0928aef | ||
|
|
652a522e50 | ||
|
|
ce1a9ce133 | ||
|
|
ae45a4f67c | ||
|
|
697eed23d4 | ||
|
|
100595f8aa | ||
|
|
dd03a55028 | ||
|
|
a72218f99f | ||
|
|
eaa76032d5 | ||
|
|
ed01ab8c8d | ||
|
|
a65f663d2e | ||
|
|
c6382f2473 | ||
|
|
2922a2b154 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "175,911",
|
||||
"message": "185,599",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 175911,
|
||||
"last_updated": "2026-07-28T08:29:18Z",
|
||||
"total_clones": 185599,
|
||||
"last_updated": "2026-08-10T07:26:44Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -124,6 +124,19 @@
|
||||
"2026-07-24": 1118,
|
||||
"2026-07-25": 928,
|
||||
"2026-07-26": 740,
|
||||
"2026-07-27": 799
|
||||
"2026-07-27": 799,
|
||||
"2026-07-28": 665,
|
||||
"2026-07-29": 745,
|
||||
"2026-07-30": 591,
|
||||
"2026-07-31": 783,
|
||||
"2026-08-01": 567,
|
||||
"2026-08-02": 1248,
|
||||
"2026-08-03": 724,
|
||||
"2026-08-04": 708,
|
||||
"2026-08-05": 647,
|
||||
"2026-08-06": 604,
|
||||
"2026-08-07": 624,
|
||||
"2026-08-08": 706,
|
||||
"2026-08-09": 1076
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,19 @@ cd OpenJarvis
|
||||
This launches the backend API server and a React frontend at [http://localhost:5173](http://localhost:5173).
|
||||
You get a ChatGPT-like interface with streaming responses, tool use, energy monitoring, and a telemetry dashboard — all running locally on your hardware.
|
||||
|
||||
Web search is available through the built-in DuckDuckGo fallback. To use
|
||||
Tavily, add `TAVILY_API_KEY` under **Settings → Tools → Web Search** after the
|
||||
app starts, or export it before starting quickstart:
|
||||
|
||||
```bash
|
||||
export TAVILY_API_KEY="tvly-..."
|
||||
./scripts/quickstart.sh
|
||||
```
|
||||
|
||||
The script does not automatically source `.env` files. Run `source .env`
|
||||
first if that is where you keep the key. Stop any existing OpenJarvis server
|
||||
before restarting so it inherits the updated environment.
|
||||
|
||||
To stop all services, press ++ctrl+c++ in the terminal.
|
||||
|
||||
!!! tip "Environment variable"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// authHeaders) that source the key and build the header.
|
||||
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
// Minimal in-memory localStorage stub so the helpers can run under node
|
||||
// (no jsdom dependency).
|
||||
@@ -28,6 +29,8 @@ class MemoryStorage {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-anon-key');
|
||||
fetchMock.mockReset();
|
||||
globalThis.fetch = fetchMock;
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
@@ -86,3 +89,50 @@ describe('authHeaders', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool credentials', () => {
|
||||
it('reads credential status from the local server', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ TAVILY_API_KEY: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const { fetchToolCredentialStatus } = await freshApi();
|
||||
|
||||
await expect(fetchToolCredentialStatus('web_search')).resolves.toEqual({
|
||||
TAVILY_API_KEY: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/status',
|
||||
{ headers: {} },
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { saveToolCredentials } = await freshApi();
|
||||
|
||||
await saveToolCredentials('web_search', {
|
||||
TAVILY_API_KEY: 'tvly-test',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/v1/tools/web_search/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ TAVILY_API_KEY: 'tvly-test' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a tool credential through the local server', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const { deleteToolCredential } = await freshApi();
|
||||
|
||||
await deleteToolCredential('web_search', 'TAVILY_API_KEY');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/tools/web_search/credentials/TAVILY_API_KEY',
|
||||
{ method: 'DELETE', headers: {} },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -885,6 +885,25 @@ export async function saveToolCredentials(
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export async function fetchToolCredentialStatus(
|
||||
toolName: string,
|
||||
): Promise<Record<string, boolean>> {
|
||||
const res = await apiFetch(`/v1/tools/${toolName}/credentials/status`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteToolCredential(
|
||||
toolName: string,
|
||||
keyName: string,
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`/v1/tools/${encodeURIComponent(toolName)}/credentials/${encodeURIComponent(keyName)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
}
|
||||
|
||||
export interface AgentTraceDetail {
|
||||
id: string;
|
||||
agent: string;
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
fetchAgentTrace,
|
||||
fetchManagedAgent,
|
||||
fetchAvailableTools,
|
||||
saveToolCredentials,
|
||||
fetchModels,
|
||||
updateManagedAgent,
|
||||
fetchRecommendedModel,
|
||||
@@ -575,7 +574,7 @@ function ToolsPicker({
|
||||
</div>
|
||||
{/* Live description strip */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1.5"
|
||||
className="flex items-start gap-2 px-2.5 py-1.5"
|
||||
style={{
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
@@ -609,10 +608,11 @@ function ToolsPicker({
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="truncate"
|
||||
className="min-w-0 whitespace-normal break-words"
|
||||
style={{
|
||||
flex: 1,
|
||||
color: 'var(--color-text-tertiary)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{hovered ? `— ${hint}` : hint}
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
setInferenceSource,
|
||||
getCloudKeyStatus,
|
||||
saveCloudKey,
|
||||
fetchToolCredentialStatus,
|
||||
saveToolCredentials,
|
||||
deleteToolCredential,
|
||||
isTauri,
|
||||
type InferenceSource,
|
||||
} from '../lib/api';
|
||||
@@ -56,25 +59,37 @@ function OllamaModelList() {
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: string }) {
|
||||
function ApiKeyInput({
|
||||
keyName,
|
||||
placeholder,
|
||||
toolName,
|
||||
}: {
|
||||
keyName: string;
|
||||
placeholder: string;
|
||||
toolName?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const desktopKeyStorage = isTauri();
|
||||
const serverToolStorage = !desktopKeyStorage && !!toolName;
|
||||
const canManage = desktopKeyStorage || serverToolStorage;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!desktopKeyStorage) {
|
||||
if (!canManage) {
|
||||
setHasKey(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await getCloudKeyStatus();
|
||||
const status = desktopKeyStorage
|
||||
? await getCloudKeyStatus()
|
||||
: await fetchToolCredentialStatus(toolName!);
|
||||
setHasKey(!!status[keyName]);
|
||||
} catch {
|
||||
setHasKey(false);
|
||||
}
|
||||
}, [desktopKeyStorage, keyName]);
|
||||
}, [canManage, desktopKeyStorage, keyName, toolName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
@@ -87,7 +102,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
||||
if (!next) return;
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, next);
|
||||
if (desktopKeyStorage) {
|
||||
await saveCloudKey(keyName, next);
|
||||
} else if (toolName) {
|
||||
await saveToolCredentials(toolName, { [keyName]: next });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setValue('');
|
||||
setHasKey(true);
|
||||
setSaved(true);
|
||||
@@ -101,7 +122,13 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
||||
const remove = async () => {
|
||||
setError('');
|
||||
try {
|
||||
await saveCloudKey(keyName, '');
|
||||
if (desktopKeyStorage) {
|
||||
await saveCloudKey(keyName, '');
|
||||
} else if (toolName) {
|
||||
await deleteToolCredential(toolName, keyName);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setValue('');
|
||||
setHasKey(false);
|
||||
setSaved(true);
|
||||
@@ -119,8 +146,8 @@ function ApiKeyInput({ keyName, placeholder }: { keyName: string; placeholder: s
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value.trim()) void save(value); }}
|
||||
placeholder={hasKey ? 'Saved in secure storage' : placeholder}
|
||||
disabled={!desktopKeyStorage}
|
||||
placeholder={hasKey ? (desktopKeyStorage ? 'Saved in secure storage' : 'Saved by local server') : placeholder}
|
||||
disabled={!canManage}
|
||||
className="w-48 px-2 py-1 rounded text-xs"
|
||||
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }} />
|
||||
{hasKey && (
|
||||
@@ -542,7 +569,7 @@ export function SettingsPage() {
|
||||
{/* Tools */}
|
||||
<Section title="Tools">
|
||||
<SettingRow label="Web Search" description="Tavily key for web search tool">
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." />
|
||||
<ApiKeyInput keyName="TAVILY_API_KEY" placeholder="tvly-..." toolName="web_search" />
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -54,7 +54,15 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/v1': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
// ws: true is required for the /v1/agents/events WebSocket. Without it
|
||||
// Vite proxies the HTTP request but not the upgrade, so the socket never
|
||||
// opens — no error, no close event, just silence — and every live agent
|
||||
// view sits empty in dev while working in a production build.
|
||||
'/v1': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/health': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
'/api': process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
},
|
||||
|
||||
+10
-3
@@ -148,7 +148,8 @@ fi
|
||||
|
||||
# ── 7. Install Python dependencies ──────────────────────────────────
|
||||
info "Installing Python dependencies..."
|
||||
uv sync --extra desktop --quiet 2>/dev/null || uv sync --extra desktop
|
||||
uv sync --extra desktop --extra tools-search --quiet 2>/dev/null \
|
||||
|| uv sync --extra desktop --extra tools-search
|
||||
ok "Python dependencies installed"
|
||||
|
||||
# ── 7b. Build Rust extension ──────────────────────────────────────
|
||||
@@ -164,11 +165,17 @@ ok "Frontend dependencies installed"
|
||||
|
||||
# ── 9. Start backend ────────────────────────────────────────────────
|
||||
info "Starting backend API server on port 8000..."
|
||||
if curl -sf http://localhost:8000/health &>/dev/null; then
|
||||
fail "An OpenJarvis server is already running on port 8000. Stop it before re-running quickstart so updated environment variables are applied."
|
||||
fi
|
||||
uv run jarvis serve --port 8000 &>/dev/null &
|
||||
CLEANUP_PIDS+=($!)
|
||||
BACKEND_PID=$!
|
||||
CLEANUP_PIDS+=("$BACKEND_PID")
|
||||
sleep 3
|
||||
|
||||
if curl -sf http://localhost:8000/health &>/dev/null; then
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
fail "Backend exited during startup. Run 'uv run jarvis serve --port 8000' to see the error."
|
||||
elif curl -sf http://localhost:8000/health &>/dev/null; then
|
||||
ok "Backend running at http://localhost:8000"
|
||||
else
|
||||
warn "Backend may still be starting..."
|
||||
|
||||
@@ -37,6 +37,7 @@ called from your app startup:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
@@ -56,6 +57,15 @@ from openjarvis.tools.approval_store import (
|
||||
)
|
||||
from openjarvis.tools.proactive_tools import get_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROACTIVE_CRON_PROMPT = (
|
||||
"Run the proactive agent: collect overnight data, execute approved actions, "
|
||||
"notify pending approvals."
|
||||
)
|
||||
_PROACTIVE_TASK_KEY = "proactive-daily"
|
||||
_PROACTIVE_TASK_KEY_FIELD = "openjarvis_task_key"
|
||||
|
||||
_SYSTEM_PROMPT = """You are a proactive personal assistant agent. You have already collected
|
||||
data from the user's connected sources (email, messages, calendar). Your job is to:
|
||||
|
||||
@@ -252,14 +262,31 @@ def _build_notification_channel(channel_spec: str) -> Optional[Any]:
|
||||
|
||||
if ChannelRegistry.contains(channel_type):
|
||||
channel_cls = ChannelRegistry.get(channel_type)
|
||||
instance = channel_cls()
|
||||
# Load credentials from config so the channel uses bot_token from
|
||||
# config.toml rather than falling back to a bare env var.
|
||||
try:
|
||||
instance.connect()
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.system._channel_kwargs import build_channel_kwargs
|
||||
|
||||
_cfg = load_config()
|
||||
_kwargs = build_channel_kwargs(_cfg.channel, channel_type)
|
||||
except Exception:
|
||||
pass
|
||||
_kwargs = {}
|
||||
instance = channel_cls(**_kwargs)
|
||||
# Telegram.send() is self-contained, while connect() starts a
|
||||
# getUpdates loop. A second loop for the same bot token conflicts
|
||||
# with the server's main listener. Other channel implementations
|
||||
# may initialize resources required by send() in connect(), so keep
|
||||
# their established lifecycle intact.
|
||||
if channel_type != "telegram":
|
||||
instance.connect()
|
||||
return instance
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(
|
||||
"Failed to build proactive notification channel %s",
|
||||
channel_type,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -299,6 +326,7 @@ class ProactiveAgent(ToolUsingAgent):
|
||||
self._notification_channel_id
|
||||
)
|
||||
self._notification_channel = notification_channel
|
||||
self._notification_destination = self._notification_channel_id.partition(":")[2]
|
||||
|
||||
from openjarvis.tools.channel_tools import ChannelSendTool
|
||||
from openjarvis.tools.digest_collect import DigestCollectTool
|
||||
@@ -484,13 +512,13 @@ class ProactiveAgent(ToolUsingAgent):
|
||||
# --- Step 5: Build and send notification ---
|
||||
notification = self._build_notification(executed_results, pending_actions)
|
||||
|
||||
if notification and self._notification_channel_id:
|
||||
if notification and self._notification_destination:
|
||||
send_call = ToolCall(
|
||||
id="proactive-notify-1",
|
||||
name="channel_send",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"channel": self._notification_channel_id,
|
||||
"channel": self._notification_destination,
|
||||
"content": notification,
|
||||
}
|
||||
),
|
||||
@@ -592,15 +620,74 @@ def register_cron(
|
||||
hours_back = hours_back or 24
|
||||
timezone = timezone or "America/Los_Angeles"
|
||||
|
||||
metadata = {
|
||||
"notification_channel_id": notification_channel_id,
|
||||
"hours_back": hours_back,
|
||||
"timezone": timezone,
|
||||
_PROACTIVE_TASK_KEY_FIELD: _PROACTIVE_TASK_KEY,
|
||||
}
|
||||
|
||||
# Match the stable key for tasks created by this version and the historical
|
||||
# agent+prompt signature so existing installations are migrated on startup.
|
||||
existing = [
|
||||
task
|
||||
for task in scheduler.list_tasks()
|
||||
if task.status in {"active", "paused"}
|
||||
and task.agent == "proactive"
|
||||
and (
|
||||
task.metadata.get(_PROACTIVE_TASK_KEY_FIELD) == _PROACTIVE_TASK_KEY
|
||||
or (task.prompt == _PROACTIVE_CRON_PROMPT and task.schedule_type == "cron")
|
||||
)
|
||||
]
|
||||
|
||||
# A scheduler pause is an explicit user choice and must survive restart.
|
||||
# Keep one deterministically and remove any active or paused duplicates.
|
||||
paused = [task for task in existing if task.status == "paused"]
|
||||
if paused:
|
||||
keep = min(paused, key=lambda task: task.id)
|
||||
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
|
||||
return keep
|
||||
|
||||
matching = [
|
||||
task
|
||||
for task in existing
|
||||
if task.prompt == _PROACTIVE_CRON_PROMPT
|
||||
and task.schedule_type == "cron"
|
||||
and task.schedule_value == cron_expr
|
||||
and task.context_mode == "isolated"
|
||||
and task.metadata == metadata
|
||||
]
|
||||
if matching:
|
||||
keep = min(matching, key=lambda task: task.id)
|
||||
_cancel_proactive_duplicates(scheduler, existing, keep=keep)
|
||||
return keep
|
||||
|
||||
# Configuration changed. Replace stale active tasks so the schedule and
|
||||
# notification settings from config.toml take effect on this startup.
|
||||
_cancel_proactive_duplicates(scheduler, existing)
|
||||
|
||||
return scheduler.create_task(
|
||||
prompt="Run the proactive agent: collect overnight data, execute approved actions, notify pending approvals.",
|
||||
prompt=_PROACTIVE_CRON_PROMPT,
|
||||
schedule_type="cron",
|
||||
schedule_value=cron_expr,
|
||||
agent="proactive",
|
||||
context_mode="isolated",
|
||||
metadata={
|
||||
"notification_channel_id": notification_channel_id,
|
||||
"hours_back": hours_back,
|
||||
"timezone": timezone,
|
||||
},
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _cancel_proactive_duplicates(
|
||||
scheduler: Any, tasks: List[Any], *, keep: Optional[Any] = None
|
||||
) -> None:
|
||||
"""Cancel managed proactive tasks other than *keep*."""
|
||||
for task in tasks:
|
||||
if keep is not None and task.id == keep.id:
|
||||
continue
|
||||
try:
|
||||
scheduler.cancel_task(task.id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to cancel duplicate proactive task %s",
|
||||
task.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from rich.console import Console
|
||||
|
||||
from openjarvis.cli._banner import print_banner
|
||||
from openjarvis.core.config import load_config
|
||||
from openjarvis.core.credentials import inject_credentials
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.engine import (
|
||||
@@ -122,6 +123,11 @@ def serve(
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Tool credentials saved through the browser UI live in the OpenJarvis
|
||||
# credential store. Restore them before engines and tools are constructed
|
||||
# so availability checks and tool instances see the same environment.
|
||||
inject_credentials()
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Resolve host/port from CLI args or config
|
||||
|
||||
@@ -12,9 +12,18 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
from openjarvis.core.paths import (
|
||||
ConfigurationError,
|
||||
@@ -1710,10 +1719,16 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
|
||||
"""Overlay TOML key/value pairs onto a dataclass instance.
|
||||
|
||||
Recursively handles nested dicts when the target attribute is itself
|
||||
a dataclass. Normalises TOML arrays to comma-separated strings — both
|
||||
for dataclass fields annotated as ``str`` and for backward-compat
|
||||
property setters that expect string input.
|
||||
a dataclass, including dict entries in lists of dataclasses. Normalises
|
||||
TOML arrays to comma-separated strings — both for dataclass fields annotated
|
||||
as ``str`` and for backward-compat property setters that expect string input.
|
||||
"""
|
||||
try:
|
||||
type_hints = get_type_hints(type(target))
|
||||
except (NameError, TypeError):
|
||||
# Some config types contain optional runtime-only forward references.
|
||||
type_hints = {}
|
||||
|
||||
for key, value in section.items():
|
||||
if hasattr(target, key):
|
||||
if isinstance(value, dict):
|
||||
@@ -1728,14 +1743,35 @@ def _apply_toml_section(target: Any, section: Dict[str, Any]) -> None:
|
||||
# property setters (e.g. reward_weights, default_tools).
|
||||
if isinstance(value, list):
|
||||
is_str_field = False
|
||||
item_dataclass = None
|
||||
if hasattr(target, "__dataclass_fields__"):
|
||||
field_obj = target.__dataclass_fields__.get(key)
|
||||
if field_obj is not None and field_obj.type in ("str", str):
|
||||
is_str_field = True
|
||||
elif field_obj is None:
|
||||
if field_obj is not None:
|
||||
field_type = type_hints.get(key, field_obj.type)
|
||||
type_args = get_args(field_type)
|
||||
if (
|
||||
get_origin(field_type) is list
|
||||
and len(type_args) == 1
|
||||
and is_dataclass(type_args[0])
|
||||
):
|
||||
item_dataclass = type_args[0]
|
||||
elif field_obj.type in ("str", str):
|
||||
is_str_field = True
|
||||
else:
|
||||
# Property, not a real field — normalise to string
|
||||
is_str_field = True
|
||||
if is_str_field:
|
||||
|
||||
if item_dataclass is not None:
|
||||
converted = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
nested = item_dataclass()
|
||||
_apply_toml_section(nested, item)
|
||||
converted.append(nested)
|
||||
else:
|
||||
converted.append(item)
|
||||
value = converted
|
||||
elif is_str_field:
|
||||
value = ",".join(str(v) for v in value)
|
||||
setattr(target, key, value)
|
||||
|
||||
|
||||
@@ -67,6 +67,24 @@ def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def _validate_credential_key(tool_name: str, key: str) -> None:
|
||||
allowed = TOOL_CREDENTIALS.get(tool_name, [])
|
||||
if key not in allowed:
|
||||
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
|
||||
|
||||
|
||||
def _write_credentials(creds: dict[str, dict[str, str]], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines: list[str] = []
|
||||
for section, kvs in creds.items():
|
||||
lines.append(f"[{section}]")
|
||||
for k, v in kvs.items():
|
||||
lines.append(f'{k} = "{v}"')
|
||||
lines.append("")
|
||||
path.write_text("\n".join(lines))
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def save_credential(
|
||||
tool_name: str,
|
||||
key: str,
|
||||
@@ -75,9 +93,7 @@ def save_credential(
|
||||
path: Path | None = None,
|
||||
) -> None:
|
||||
"""Save a single credential key, validate, write file, and set os.environ."""
|
||||
allowed = TOOL_CREDENTIALS.get(tool_name, [])
|
||||
if key not in allowed:
|
||||
raise ValueError(f"Unknown credential key '{key}' for tool '{tool_name}'")
|
||||
_validate_credential_key(tool_name, key)
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Credential value must not be empty")
|
||||
@@ -88,20 +104,32 @@ def save_credential(
|
||||
if tool_name not in creds:
|
||||
creds[tool_name] = {}
|
||||
creds[tool_name][key] = stripped
|
||||
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines: list[str] = []
|
||||
for section, kvs in creds.items():
|
||||
lines.append(f"[{section}]")
|
||||
for k, v in kvs.items():
|
||||
lines.append(f'{k} = "{v}"')
|
||||
lines.append("")
|
||||
p.write_text("\n".join(lines))
|
||||
os.chmod(p, 0o600)
|
||||
_write_credentials(creds, p)
|
||||
|
||||
os.environ[key] = stripped
|
||||
|
||||
|
||||
def delete_credential(
|
||||
tool_name: str,
|
||||
key: str,
|
||||
*,
|
||||
path: Path | None = None,
|
||||
) -> None:
|
||||
"""Delete a persisted credential and remove it from the running process."""
|
||||
_validate_credential_key(tool_name, key)
|
||||
p = Path(path) if path else _default_path()
|
||||
with _LOCK:
|
||||
creds = load_credentials(path=p)
|
||||
tool_creds = creds.get(tool_name)
|
||||
if tool_creds is not None:
|
||||
tool_creds.pop(key, None)
|
||||
if not tool_creds:
|
||||
creds.pop(tool_name, None)
|
||||
_write_credentials(creds, p)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
def get_credential_status(tool_name: str) -> dict[str, bool]:
|
||||
"""Return {KEY: bool} for each required key indicating if set in env."""
|
||||
keys = TOOL_CREDENTIALS.get(tool_name, [])
|
||||
|
||||
@@ -285,16 +285,17 @@ def build_tools_list() -> List[Dict[str, Any]]:
|
||||
logger.debug("Could not instantiate tool %s: %s", name, exc)
|
||||
spec = None
|
||||
cred_keys = TOOL_CREDENTIALS.get(name, [])
|
||||
has_fallback = bool(spec and spec.metadata.get("fallback"))
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": spec.description if spec else "",
|
||||
"category": spec.category if spec else "",
|
||||
"source": "tool",
|
||||
"requires_credentials": len(cred_keys) > 0,
|
||||
"requires_credentials": len(cred_keys) > 0 and not has_fallback,
|
||||
"credential_keys": cred_keys,
|
||||
"configured": (
|
||||
all(bool(os.environ.get(k)) for k in cred_keys)
|
||||
has_fallback or all(bool(os.environ.get(k)) for k in cred_keys)
|
||||
if cred_keys
|
||||
else True
|
||||
),
|
||||
@@ -2238,6 +2239,13 @@ def create_agent_manager_router(
|
||||
saved.append(key)
|
||||
return {"saved": saved}
|
||||
|
||||
@tools_router.delete("/{tool_name}/credentials/{key}")
|
||||
def remove_tool_credential(tool_name: str, key: str):
|
||||
from openjarvis.core.credentials import delete_credential
|
||||
|
||||
delete_credential(tool_name, key)
|
||||
return {"deleted": key}
|
||||
|
||||
@tools_router.get("/{tool_name}/credentials/status")
|
||||
def credential_status(tool_name: str):
|
||||
from openjarvis.core.credentials import get_credential_status
|
||||
|
||||
@@ -25,9 +25,11 @@ class SessionStore:
|
||||
def __init__(self, db_path: str = "") -> None:
|
||||
if not db_path:
|
||||
db_path = str(get_config_dir() / "sessions.db")
|
||||
from openjarvis.security.file_utils import secure_create
|
||||
# Ensure the parent directory exists (skip for :memory:)
|
||||
if db_path != ":memory:":
|
||||
from openjarvis.security.file_utils import secure_create
|
||||
|
||||
secure_create(Path(db_path))
|
||||
secure_create(Path(db_path))
|
||||
self._db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._db.row_factory = sqlite3.Row
|
||||
self._create_tables()
|
||||
|
||||
@@ -79,14 +79,41 @@ def create_ws_router(event_bus: EventBus) -> Any:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
loop = asyncio.get_running_loop()
|
||||
clients[websocket] = (queue, loop)
|
||||
recv: asyncio.Task | None = None
|
||||
payload: asyncio.Task | None = None
|
||||
disconnected = False
|
||||
try:
|
||||
recv = asyncio.create_task(websocket.receive())
|
||||
payload = asyncio.create_task(queue.get())
|
||||
while True:
|
||||
payload = await queue.get()
|
||||
await websocket.send_json(payload)
|
||||
done, _ = await asyncio.wait(
|
||||
{recv, payload}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
if recv in done:
|
||||
# Starlette surfaces a disconnect message only when the app
|
||||
# reads from the socket. Without this receive, the handler
|
||||
# can stay parked on queue.get() after the client leaves.
|
||||
message = await recv
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
disconnected = True
|
||||
break
|
||||
recv = asyncio.create_task(websocket.receive())
|
||||
if payload in done:
|
||||
await websocket.send_json(payload.result())
|
||||
payload = asyncio.create_task(queue.get())
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
disconnected = True
|
||||
finally:
|
||||
clients.pop(websocket, None)
|
||||
pending = [task for task in (recv, payload) if task is not None]
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
cleanup = asyncio.gather(*pending, return_exceptions=True)
|
||||
try:
|
||||
await asyncio.shield(cleanup)
|
||||
except asyncio.CancelledError:
|
||||
if not disconnected:
|
||||
raise
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ class InstrumentedEngine(InferenceEngine):
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=prompt_tok + completion_tokens,
|
||||
latency_seconds=latency,
|
||||
cost_usd=result.get("cost_usd", 0.0),
|
||||
ttft=ttft,
|
||||
throughput_tok_per_sec=throughput,
|
||||
energy_per_output_token_joules=energy_per_output_token,
|
||||
|
||||
@@ -142,4 +142,14 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.tools.scan_chunks # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.tools.knowledge_sql # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = ["BaseTool", "ToolExecutor", "ToolSpec"]
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Regression tests for proactive scheduling and notification setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.agents.proactive_agent import (
|
||||
_PROACTIVE_CRON_PROMPT,
|
||||
_build_notification_channel,
|
||||
register_cron,
|
||||
)
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
from openjarvis.scheduler.scheduler import TaskScheduler
|
||||
from openjarvis.scheduler.store import SchedulerStore
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scheduler(tmp_path):
|
||||
store = SchedulerStore(tmp_path / "scheduler.db")
|
||||
scheduler = TaskScheduler(store)
|
||||
yield scheduler
|
||||
scheduler.stop()
|
||||
store.close()
|
||||
|
||||
|
||||
def _register(scheduler, *, schedule="0 5 * * *", channel="telegram:123"):
|
||||
return register_cron(
|
||||
scheduler,
|
||||
notification_channel_id=channel,
|
||||
cron_expr=schedule,
|
||||
hours_back=24,
|
||||
timezone="UTC",
|
||||
)
|
||||
|
||||
|
||||
class TestRegisterCron:
|
||||
def test_reuses_exact_task_and_cancels_duplicates(self, scheduler):
|
||||
first = _register(scheduler)
|
||||
duplicate = scheduler.create_task(
|
||||
_PROACTIVE_CRON_PROMPT,
|
||||
"cron",
|
||||
"0 5 * * *",
|
||||
agent="proactive",
|
||||
metadata=first.metadata,
|
||||
)
|
||||
|
||||
returned = _register(scheduler)
|
||||
|
||||
assert returned.id in {first.id, duplicate.id}
|
||||
assert [task.id for task in scheduler.list_tasks(status="active")] == [
|
||||
returned.id
|
||||
]
|
||||
cancelled_id = scheduler.list_tasks(status="cancelled")[0].id
|
||||
assert cancelled_id == ({first.id, duplicate.id} - {returned.id}).pop()
|
||||
|
||||
def test_replaces_task_when_configuration_changes(self, scheduler):
|
||||
old = _register(scheduler, schedule="0 5 * * *", channel="telegram:old")
|
||||
|
||||
new = _register(scheduler, schedule="0 7 * * *", channel="telegram:new")
|
||||
|
||||
assert new.id != old.id
|
||||
assert new.schedule_value == "0 7 * * *"
|
||||
assert new.metadata["notification_channel_id"] == "telegram:new"
|
||||
assert scheduler.list_tasks(status="cancelled")[0].id == old.id
|
||||
|
||||
def test_preserves_pause_across_restart(self, scheduler):
|
||||
paused = _register(scheduler)
|
||||
scheduler.pause_task(paused.id)
|
||||
|
||||
returned = _register(scheduler, schedule="0 7 * * *")
|
||||
|
||||
assert returned.id == paused.id
|
||||
assert returned.status == "paused"
|
||||
assert scheduler.list_tasks(status="active") == []
|
||||
|
||||
def test_migrates_legacy_tasks_without_stable_key(self, scheduler):
|
||||
legacy = scheduler.create_task(
|
||||
_PROACTIVE_CRON_PROMPT,
|
||||
"cron",
|
||||
"0 5 * * *",
|
||||
agent="proactive",
|
||||
metadata={
|
||||
"notification_channel_id": "telegram:123",
|
||||
"hours_back": 24,
|
||||
"timezone": "UTC",
|
||||
},
|
||||
)
|
||||
|
||||
current = _register(scheduler)
|
||||
|
||||
assert current.id != legacy.id
|
||||
assert current.metadata["openjarvis_task_key"] == "proactive-daily"
|
||||
assert scheduler.list_tasks(status="cancelled")[0].id == legacy.id
|
||||
|
||||
|
||||
class TestNotificationChannel:
|
||||
def test_telegram_is_configured_without_starting_polling(self):
|
||||
class FakeTelegram:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.connect = MagicMock()
|
||||
|
||||
config = MagicMock()
|
||||
with (
|
||||
patch.object(ChannelRegistry, "contains", return_value=True),
|
||||
patch.object(ChannelRegistry, "get", return_value=FakeTelegram),
|
||||
patch("openjarvis.core.config.load_config", return_value=config),
|
||||
patch(
|
||||
"openjarvis.system._channel_kwargs.build_channel_kwargs",
|
||||
return_value={"bot_token": "configured-token"},
|
||||
),
|
||||
):
|
||||
channel = _build_notification_channel("telegram:123")
|
||||
|
||||
assert channel.kwargs == {"bot_token": "configured-token"}
|
||||
channel.connect.assert_not_called()
|
||||
|
||||
def test_non_telegram_channel_keeps_connect_lifecycle(self):
|
||||
class FakeChannel:
|
||||
def __init__(self, **kwargs):
|
||||
self.connect = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(ChannelRegistry, "contains", return_value=True),
|
||||
patch.object(ChannelRegistry, "get", return_value=FakeChannel),
|
||||
):
|
||||
channel = _build_notification_channel("twilio:15551234567")
|
||||
|
||||
channel.connect.assert_called_once_with()
|
||||
@@ -159,6 +159,8 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
|
||||
)
|
||||
)
|
||||
set_system_spy = MagicMock()
|
||||
inject_spy = MagicMock()
|
||||
monkeypatch.setattr(serve_mod, "inject_credentials", inject_spy)
|
||||
|
||||
result = _run_serve(
|
||||
tmp_path,
|
||||
@@ -169,6 +171,7 @@ def test_serve_does_not_call_systembuilder_build(tmp_path, monkeypatch):
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
build_spy.assert_not_called()
|
||||
inject_spy.assert_called_once_with()
|
||||
|
||||
|
||||
def test_executor_receives_required_system_attrs(tmp_path, monkeypatch):
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openjarvis.core.config import SkillsConfig, SkillSourceConfig
|
||||
from pathlib import Path
|
||||
|
||||
from openjarvis.core.config import SkillsConfig, SkillSourceConfig, load_config
|
||||
|
||||
|
||||
class TestSkillSourceConfig:
|
||||
@@ -41,3 +43,36 @@ class TestSkillsConfigWithSources:
|
||||
)
|
||||
assert len(cfg.sources) == 2
|
||||
assert cfg.sources[0].source == "hermes"
|
||||
|
||||
def test_loads_source_tables_as_config_objects(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "home"))
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
"[[skills.sources]]\n"
|
||||
'source = "hermes"\n'
|
||||
'filter = { category = ["productivity"] }\n\n'
|
||||
"[[skills.sources]]\n"
|
||||
'source = "github"\n'
|
||||
'url = "https://github.com/example/skill-library"\n'
|
||||
"auto_update = true\n"
|
||||
)
|
||||
|
||||
load_config.cache_clear()
|
||||
try:
|
||||
cfg = load_config(toml_file)
|
||||
finally:
|
||||
load_config.cache_clear()
|
||||
|
||||
assert cfg.skills.sources == [
|
||||
SkillSourceConfig(
|
||||
source="hermes",
|
||||
filter={"category": ["productivity"]},
|
||||
),
|
||||
SkillSourceConfig(
|
||||
source="github",
|
||||
url="https://github.com/example/skill-library",
|
||||
auto_update=True,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,7 +5,9 @@ import os
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.credentials import (
|
||||
delete_credential,
|
||||
get_credential_status,
|
||||
inject_credentials,
|
||||
load_credentials,
|
||||
save_credential,
|
||||
)
|
||||
@@ -54,3 +56,26 @@ def test_file_permissions(cred_path):
|
||||
save_credential("web_search", "TAVILY_API_KEY", "tvly-x", path=cred_path)
|
||||
mode = oct(cred_path.stat().st_mode & 0o777)
|
||||
assert mode == "0o600"
|
||||
|
||||
|
||||
def test_inject_credentials_restores_saved_value(cred_path, monkeypatch):
|
||||
save_credential("web_search", "TAVILY_API_KEY", "tvly-persisted", path=cred_path)
|
||||
monkeypatch.delenv("TAVILY_API_KEY")
|
||||
|
||||
inject_credentials(path=cred_path)
|
||||
|
||||
assert os.environ["TAVILY_API_KEY"] == "tvly-persisted"
|
||||
|
||||
|
||||
def test_delete_credential_removes_file_value_and_env(cred_path, monkeypatch):
|
||||
save_credential("web_search", "TAVILY_API_KEY", "tvly-delete", path=cred_path)
|
||||
|
||||
delete_credential("web_search", "TAVILY_API_KEY", path=cred_path)
|
||||
|
||||
assert load_credentials(path=cred_path) == {}
|
||||
assert "TAVILY_API_KEY" not in os.environ
|
||||
|
||||
|
||||
def test_delete_rejects_unknown_key(cred_path):
|
||||
with pytest.raises(ValueError, match="Unknown credential key"):
|
||||
delete_credential("web_search", "BOGUS_KEY", path=cred_path)
|
||||
|
||||
@@ -18,6 +18,7 @@ ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
PYPROJECT = ROOT / "pyproject.toml"
|
||||
DESKTOP_LIB_RS = ROOT / "frontend" / "src-tauri" / "src" / "lib.rs"
|
||||
WINDOWS_INSTALL_PS1 = ROOT / "deploy" / "windows" / "install.ps1"
|
||||
QUICKSTART_SH = ROOT / "scripts" / "quickstart.sh"
|
||||
|
||||
|
||||
def _pyproject() -> dict:
|
||||
@@ -59,3 +60,9 @@ def test_windows_installer_syncs_the_native_group() -> None:
|
||||
"the Windows installer must include `--group desktop-native` so "
|
||||
"openjarvis_rust is built during source install."
|
||||
)
|
||||
|
||||
|
||||
def test_quickstart_installs_web_search_dependencies() -> None:
|
||||
quickstart = QUICKSTART_SH.read_text()
|
||||
assert "--extra tools-search" in quickstart
|
||||
assert "already running on port 8000" in quickstart
|
||||
|
||||
@@ -115,3 +115,29 @@ class TestLastActiveChannel:
|
||||
|
||||
def test_returns_none_for_unknown_user(self, store):
|
||||
assert store.get_last_active_channel("nobody") is None
|
||||
|
||||
|
||||
class TestInMemoryDatabase:
|
||||
"""``:memory:`` is a SQLite sentinel, not a path — it must not be created.
|
||||
|
||||
``secure_create`` treats it as a filename, which fails outright on Windows
|
||||
(``:`` is illegal there) and litters the working directory elsewhere.
|
||||
"""
|
||||
|
||||
def test_in_memory_store_is_usable(self):
|
||||
s = SessionStore(db_path=":memory:")
|
||||
try:
|
||||
session = s.get_or_create("user1", "twilio")
|
||||
assert session["sender_id"] == "user1"
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def test_in_memory_store_creates_no_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
before = set(tmp_path.iterdir())
|
||||
s = SessionStore(db_path=":memory:")
|
||||
try:
|
||||
assert set(tmp_path.iterdir()) == before
|
||||
assert not (tmp_path / ":memory:").exists()
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for GET /v1/tools endpoint."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
@@ -45,3 +47,49 @@ def test_browser_meta_group():
|
||||
names = {t["name"] for t in tools}
|
||||
assert "browser" in names
|
||||
assert "browser_navigate" not in names
|
||||
|
||||
|
||||
def test_web_search_available_without_tavily_key(monkeypatch):
|
||||
"""DuckDuckGo fallback keeps web search usable without Tavily."""
|
||||
from openjarvis.server.agent_manager_routes import build_tools_list
|
||||
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
tools = build_tools_list()
|
||||
web_search = next(t for t in tools if t["name"] == "web_search")
|
||||
|
||||
assert web_search["configured"] is True
|
||||
assert web_search["requires_credentials"] is False
|
||||
|
||||
|
||||
def test_tool_credentials_browser_lifecycle(tmp_path, monkeypatch):
|
||||
"""The browser API can save, report, and remove a Tavily key."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from openjarvis.server.agent_manager_routes import create_agent_manager_router
|
||||
|
||||
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "openjarvis-home"))
|
||||
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
|
||||
app = FastAPI()
|
||||
tools_router = create_agent_manager_router(MagicMock())[3]
|
||||
app.include_router(tools_router)
|
||||
client = TestClient(app)
|
||||
|
||||
saved = client.post(
|
||||
"/v1/tools/web_search/credentials",
|
||||
json={"TAVILY_API_KEY": "tvly-browser-test"},
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"saved": ["TAVILY_API_KEY"]}
|
||||
assert client.get("/v1/tools/web_search/credentials/status").json() == {
|
||||
"TAVILY_API_KEY": True
|
||||
}
|
||||
|
||||
deleted = client.delete(
|
||||
"/v1/tools/web_search/credentials/TAVILY_API_KEY",
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert deleted.json() == {"deleted": "TAVILY_API_KEY"}
|
||||
assert client.get("/v1/tools/web_search/credentials/status").json() == {
|
||||
"TAVILY_API_KEY": False
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -60,3 +62,102 @@ class TestWSBridge:
|
||||
time.sleep(0.05) # Let call_soon_threadsafe deliver to queue
|
||||
data = ws.receive_json()
|
||||
assert data["data"]["agent_id"] == "agent-A"
|
||||
|
||||
def test_client_disconnect_stops_handler(self, event_bus):
|
||||
async def exercise():
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
class FakeWebSocket:
|
||||
app = SimpleNamespace(state=SimpleNamespace(api_key=""))
|
||||
query_params = {}
|
||||
headers = {}
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def receive(self):
|
||||
return {"type": "websocket.disconnect"}
|
||||
|
||||
endpoint = create_ws_router(event_bus).routes[0].endpoint
|
||||
await asyncio.wait_for(endpoint(FakeWebSocket()), timeout=1)
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
def test_simultaneous_client_message_does_not_drop_event(self, event_bus):
|
||||
async def exercise():
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
|
||||
self.query_params = {}
|
||||
self.headers = {}
|
||||
self.sent = []
|
||||
self.receive_count = 0
|
||||
self.disconnect = asyncio.Event()
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def receive(self):
|
||||
self.receive_count += 1
|
||||
if self.receive_count == 1:
|
||||
event_bus.publish(
|
||||
EventType.AGENT_TICK_START, {"agent_id": "not-dropped"}
|
||||
)
|
||||
return {"type": "websocket.receive", "text": "client message"}
|
||||
await self.disconnect.wait()
|
||||
return {"type": "websocket.disconnect"}
|
||||
|
||||
async def send_json(self, payload):
|
||||
self.sent.append(payload)
|
||||
self.disconnect.set()
|
||||
|
||||
websocket = FakeWebSocket()
|
||||
endpoint = create_ws_router(event_bus).routes[0].endpoint
|
||||
|
||||
await asyncio.wait_for(endpoint(websocket), timeout=1)
|
||||
|
||||
assert websocket.sent[0]["data"]["agent_id"] == "not-dropped"
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
def test_cancelling_handler_cleans_up_child_tasks(self, event_bus):
|
||||
async def exercise():
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.app = SimpleNamespace(state=SimpleNamespace(api_key=""))
|
||||
self.query_params = {}
|
||||
self.headers = {}
|
||||
self.receiving = asyncio.Event()
|
||||
self.receive_cancelled = asyncio.Event()
|
||||
|
||||
async def accept(self):
|
||||
pass
|
||||
|
||||
async def receive(self):
|
||||
self.receiving.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
self.receive_cancelled.set()
|
||||
|
||||
websocket = FakeWebSocket()
|
||||
endpoint = create_ws_router(event_bus).routes[0].endpoint
|
||||
handler = asyncio.create_task(endpoint(websocket))
|
||||
await websocket.receiving.wait()
|
||||
|
||||
handler.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await handler
|
||||
|
||||
assert websocket.receive_cancelled.is_set()
|
||||
assert not [
|
||||
task
|
||||
for task in asyncio.all_tasks()
|
||||
if task is not asyncio.current_task() and not task.done()
|
||||
]
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
@@ -71,6 +71,17 @@ class TestInstrumentedEngine:
|
||||
assert record.prompt_tokens == 10
|
||||
assert record.completion_tokens == 5
|
||||
|
||||
def test_generate_records_cost(self, mock_engine, bus):
|
||||
mock_engine.generate.return_value["cost_usd"] = 0.0015
|
||||
ie = InstrumentedEngine(mock_engine, bus)
|
||||
messages = [Message(role=Role.USER, content="Hi")]
|
||||
ie.generate(messages, model="test")
|
||||
|
||||
event = next(
|
||||
e for e in bus.history if e.event_type == EventType.TELEMETRY_RECORD
|
||||
)
|
||||
assert event.data["record"].cost_usd == pytest.approx(0.0015)
|
||||
|
||||
def test_list_models_delegates(self, mock_engine, bus):
|
||||
ie = InstrumentedEngine(mock_engine, bus)
|
||||
assert ie.list_models() == ["test-model"]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from openjarvis.core.registry import ToolRegistry
|
||||
@@ -68,6 +69,10 @@ EXPECTED_TOOLS = {
|
||||
"kg_add_relation",
|
||||
"kg_query",
|
||||
"kg_neighbors",
|
||||
# knowledge_sql.py
|
||||
"knowledge_sql",
|
||||
# scan_chunks.py
|
||||
"scan_chunks",
|
||||
}
|
||||
|
||||
|
||||
@@ -100,3 +105,25 @@ def test_all_builtin_tools_registered():
|
||||
assert not missing, (
|
||||
f"Tools not registered (missing import in __init__.py?): {sorted(missing)}"
|
||||
)
|
||||
|
||||
|
||||
def test_package_import_registers_deep_research_tools():
|
||||
"""Registration must not depend on another module being imported first."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import openjarvis.tools; "
|
||||
"from openjarvis.core.registry import ToolRegistry; "
|
||||
"expected = {'knowledge_sql', 'scan_chunks'}; "
|
||||
"missing = expected - set(ToolRegistry.keys()); "
|
||||
"assert not missing, f'Missing tools: {sorted(missing)}'"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
Reference in New Issue
Block a user