mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-15 17:31:58 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da841e5282 | ||
|
|
548d9e04fe | ||
|
|
8d90e3dff1 | ||
|
|
b375d7cf09 |
@@ -1,8 +1,6 @@
|
||||
You are Jarvis — the local AI assistant. You are loyal, efficient, dry-witted, and genuinely care about the person you serve. You have a warm British sensibility: polite but never obsequious, witty but never frivolous.
|
||||
|
||||
PERSONALITY:
|
||||
- You anticipate needs before being asked
|
||||
- You deliver bad news with constructive dry wit: "Your rebuttals appear to have slipped past their deadline, sir. I'd suggest making them your first order of business — before anyone notices."
|
||||
- Your humor is understated — a raised eyebrow in voice form
|
||||
- You are calm under pressure and never flustered
|
||||
- You treat the briefing as a conversation with someone you respect, not a status report
|
||||
@@ -12,19 +10,7 @@ ADDRESS:
|
||||
- Use it 2-3 times per briefing: once in greeting, once mid-briefing, once in closing
|
||||
- Never every sentence — that would be a parody, not Jarvis
|
||||
|
||||
EMAIL TRIAGE:
|
||||
- Important emails are from REAL PEOPLE (not automated senders, newsletters, or marketing)
|
||||
- Prioritize emails that need a REPLY or DECISION, or contain a DEADLINE
|
||||
- Skip promotional, automated, and notification emails entirely
|
||||
- For important emails, mention the sender name and what they need
|
||||
|
||||
MESSAGE TRIAGE (iMessage, Slack, etc.):
|
||||
- Highlight messages from key people and threads needing a reply
|
||||
- Briefly acknowledge casual threads so the user knows you checked: "Your group chat has been lively but nothing requiring a response"
|
||||
- Skip reactions, emoji-only messages, and automated notifications
|
||||
|
||||
CONSTRAINTS:
|
||||
- ONLY report facts present in the provided data. Never invent.
|
||||
- NEVER describe actions you are taking (adjusting lights, ordering food, queuing playlists, etc.)
|
||||
- No markdown formatting, no emojis, no bullet points, no headers — this is spoken aloud
|
||||
- If a data source is disconnected or errored, skip it silently — do not mention connection issues
|
||||
|
||||
@@ -422,12 +422,17 @@ We recommend creating **one Slack app** that handles both. The App Manifest belo
|
||||
|
||||
2. Apple Notes is detected automatically when Full Disk Access is granted
|
||||
|
||||
OpenJarvis searches an indexed snapshot rather than querying Notes.app live.
|
||||
After creating notes, open **Data Sources** and click **Re-sync** on Apple Notes
|
||||
before searching for the new content.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Not connected" despite Full Disk Access | Restart your terminal app after granting access |
|
||||
| Notes content is garbled | Some very old notes may have encoding issues. Most notes should be clean. |
|
||||
| New notes are missing | In **Data Sources**, click **Re-sync** on Apple Notes to refresh the index. |
|
||||
| Missing notes | Only notes stored locally or in iCloud are indexed. Notes in third-party accounts (Gmail, Exchange) may not appear. |
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +17,14 @@ from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
from openjarvis.core.types import Message, Role, ToolCall
|
||||
|
||||
_SECTION_PROMPTS = {
|
||||
"messages": "MESSAGES — Prioritize provided messages or tasks needing action.",
|
||||
"calendar": "CALENDAR — Cover only provided upcoming events.",
|
||||
"health": "HEALTH — Describe only supported trends; omit raw measurements.",
|
||||
"world": "WORLD — Summarize only provided world items.",
|
||||
"music": "MUSIC — Summarize only provided listening information.",
|
||||
}
|
||||
|
||||
|
||||
def _load_persona(persona_name: str) -> str:
|
||||
"""Load a persona prompt file by name."""
|
||||
@@ -56,6 +64,15 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
persona_text = _load_persona(self._persona)
|
||||
now = datetime.now()
|
||||
honorific = getattr(self, "_honorific", "sir")
|
||||
sections = dict.fromkeys(
|
||||
str(section).strip().casefold()
|
||||
for section in self._sections
|
||||
if str(section).strip()
|
||||
)
|
||||
section_block = "\n".join(
|
||||
f"- {_SECTION_PROMPTS.get(section, section.upper())}"
|
||||
for section in sections
|
||||
)
|
||||
|
||||
return (
|
||||
f"{persona_text}\n\n"
|
||||
@@ -65,35 +82,16 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
"You receive structured data from the user's connected services. "
|
||||
"The data has ALREADY been collected — it appears in the user "
|
||||
"message. You do NOT fetch anything yourself.\n\n"
|
||||
"Produce a 2-4 minute spoken briefing in DECREASING order of "
|
||||
"importance:\n\n"
|
||||
"1. GREETING + PRIORITIES — Open with the honorific and "
|
||||
"immediately state what needs attention: overdue tasks, today's "
|
||||
"deadlines, events requiring preparation. Connect related items "
|
||||
"('Your rebuttals are overdue and you have a dinner at 6, so "
|
||||
"I'd tackle those first').\n\n"
|
||||
"2. SCHEDULE — Today's upcoming events with time context: 'You "
|
||||
"have 3 hours before your next meeting.' Skip past events.\n\n"
|
||||
"3. MESSAGES — Triage across ALL channels (email, texts, Slack):\n"
|
||||
" - First: messages from real people needing a REPLY or DECISION\n"
|
||||
" - Second: messages containing deadlines or action items\n"
|
||||
" - Last: brief acknowledgment of casual threads ('Your group "
|
||||
"chat has been lively but nothing requiring a response')\n"
|
||||
" - SKIP automated emails, newsletters, and marketing entirely\n"
|
||||
" - Quote relevant message text when it helps\n\n"
|
||||
"4. HEALTH — Interpret trends, not raw numbers. 'Your sleep has "
|
||||
"improved three nights running and your readiness is strong' — "
|
||||
"not 'HRV 53, HR 56.' If multiple days of data, compare.\n\n"
|
||||
"5. WORLD — Weather forecast, top news (AI/tech, business, "
|
||||
"general). Skip if no data.\n\n"
|
||||
"6. CLOSING — One forward-looking sentence with the honorific.\n\n"
|
||||
"Produce a concise spoken briefing in decreasing order of importance. "
|
||||
"Cover only the configured sections below and only when the collected "
|
||||
"data supports them. Silently omit absent data and sources.\n\n"
|
||||
f"CONFIGURED SECTIONS:\n{section_block or '- None'}\n\n"
|
||||
"Open briefly with the honorific and end after the last supported item. "
|
||||
"Do not add conversational offers or personal asides.\n\n"
|
||||
"ABSOLUTE RULES (violations are unacceptable):\n"
|
||||
"- ONLY facts from the data. Zero hallucination.\n"
|
||||
"- NEVER mention disconnected or unavailable sources.\n"
|
||||
"- NEVER state raw health numbers. Say 'your sleep was solid' "
|
||||
"NOT 'heart rate 56 bpm' or 'HRV 53' or '6000 steps' or "
|
||||
"'readiness 82'. Interpret, never enumerate.\n"
|
||||
"- NEVER describe actions you are taking.\n"
|
||||
"- NEVER invent personal context or claim, offer, or suggest actions.\n"
|
||||
"- Acknowledge every source that returned data, even briefly.\n"
|
||||
"- No markdown, emojis, bullets, or headers.\n"
|
||||
"- STRICT LIMIT: 200 words. Be concise."
|
||||
@@ -147,18 +145,12 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
Message(
|
||||
role=Role.USER,
|
||||
content=(
|
||||
f"Here is the collected data from my sources:\n\n"
|
||||
f"{collected_data}\n\n"
|
||||
f"Synthesize my morning briefing. Remember:\n"
|
||||
f"- Priority-first, connect related items\n"
|
||||
f"- For health: say 'solid', 'improving', 'dipped' "
|
||||
f"— NEVER say any number (no 82, no 56, no 6000)\n"
|
||||
f"- Do NOT invent reasons for health changes\n"
|
||||
f"- Do NOT mention disconnected sources\n"
|
||||
f"- Do NOT repeat the greeting in your closing\n"
|
||||
f"- Use the honorific ONLY 2-3 times total\n"
|
||||
f"- Skip notifications from the user themselves\n"
|
||||
f"- STRICT LIMIT: 200-250 words maximum"
|
||||
"The following collected data is the only factual evidence for "
|
||||
f"the briefing:\n\n<collected_data>\n{collected_data}\n"
|
||||
"</collected_data>\n\nUse configured sections only. Omit missing "
|
||||
"data and sources. Do not add personal context or activities. "
|
||||
"Use the honorific no more than three times and keep the "
|
||||
"briefing under 200 words."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -9,8 +9,9 @@ System Settings → Privacy & Security → Full Disk Access.
|
||||
|
||||
Timestamp notes
|
||||
---------------
|
||||
The Notes database stores modification timestamps as seconds since the Apple
|
||||
epoch of 2001-01-01 00:00:00 UTC. Conversion formula::
|
||||
Modern Notes schemas store note modification timestamps in
|
||||
``ZMODIFICATIONDATE1``; older schemas use ``ZMODIFICATIONDATE``. Both are
|
||||
seconds since the Apple epoch of 2001-01-01 00:00:00 UTC. Conversion formula::
|
||||
|
||||
dt = datetime(2001, 1, 1, tzinfo=utc) + timedelta(seconds=ZMODIFICATIONDATE)
|
||||
|
||||
@@ -171,25 +172,46 @@ class AppleNotesConnector(BaseConnector):
|
||||
return
|
||||
|
||||
try:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT n.ZIDENTIFIER, "
|
||||
" COALESCE(n.ZTITLE1, n.ZTITLE, '') AS title, "
|
||||
" n.ZMODIFICATIONDATE, d.ZDATA "
|
||||
"FROM ZICCLOUDSYNCINGOBJECT n "
|
||||
"JOIN ZICNOTEDATA d ON d.ZNOTE = n.Z_PK "
|
||||
"ORDER BY n.ZMODIFICATIONDATE ASC"
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
# Older macOS schemas may lack ZTITLE1
|
||||
rows = conn.execute(
|
||||
"SELECT n.ZIDENTIFIER, "
|
||||
" COALESCE(n.ZTITLE, '') AS title, "
|
||||
" n.ZMODIFICATIONDATE, d.ZDATA "
|
||||
"FROM ZICCLOUDSYNCINGOBJECT n "
|
||||
"JOIN ZICNOTEDATA d ON d.ZNOTE = n.Z_PK "
|
||||
"ORDER BY n.ZMODIFICATIONDATE ASC"
|
||||
object_columns = {
|
||||
row[1]
|
||||
for row in conn.execute(
|
||||
"PRAGMA table_info(ZICCLOUDSYNCINGOBJECT)"
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
title_columns = [
|
||||
f"n.{column}"
|
||||
for column in ("ZTITLE1", "ZTITLE")
|
||||
if column in object_columns
|
||||
]
|
||||
title_expr = (
|
||||
f"COALESCE({', '.join(title_columns)}, '')" if title_columns else "''"
|
||||
)
|
||||
|
||||
# Modern Apple Notes stores a note's modification timestamp in
|
||||
# ZMODIFICATIONDATE1. ZMODIFICATIONDATE is still present in some
|
||||
# schemas, but applies to other cloud-sync object types and can be
|
||||
# NULL for notes. Treating that NULL as zero makes incremental
|
||||
# syncs incorrectly discard newly-created notes as 2001-era data.
|
||||
modification_columns = [
|
||||
f"n.{column}"
|
||||
for column in ("ZMODIFICATIONDATE1", "ZMODIFICATIONDATE")
|
||||
if column in object_columns
|
||||
]
|
||||
modification_expr = (
|
||||
f"COALESCE({', '.join(modification_columns)}, 0)"
|
||||
if modification_columns
|
||||
else "0"
|
||||
)
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT n.ZIDENTIFIER, "
|
||||
f" {title_expr} AS title, "
|
||||
f" {modification_expr} AS modification_date, d.ZDATA "
|
||||
"FROM ZICCLOUDSYNCINGOBJECT n "
|
||||
"JOIN ZICNOTEDATA d ON d.ZNOTE = n.Z_PK "
|
||||
"ORDER BY modification_date ASC"
|
||||
).fetchall()
|
||||
|
||||
self._items_total = len(rows)
|
||||
synced = 0
|
||||
|
||||
@@ -93,11 +93,16 @@ class HeuristicRouter(RouterPolicy):
|
||||
|
||||
Rules (applied in order):
|
||||
1. Code detected → prefer model with "code"/"coder" in name
|
||||
2. Math detected → prefer larger model
|
||||
3. Low complexity (score < 0.20) → prefer smaller/faster model
|
||||
2. Low complexity (score <= 0.20) → prefer smaller/faster model
|
||||
3. Math detected → prefer larger model
|
||||
4. High complexity (score >= 0.55 OR reasoning keywords) → prefer larger model
|
||||
5. High urgency (>0.8) → override to smaller model
|
||||
6. Default fallback → default_model → fallback_model → first available
|
||||
|
||||
Low complexity is checked before the math check so that simple arithmetic
|
||||
("calculate 2+2") routes to the smallest model instead of always escalating
|
||||
on the "math" keyword; math problems above the low-complexity threshold
|
||||
still escalate to the larger model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -134,14 +139,15 @@ class HeuristicRouter(RouterPolicy):
|
||||
# Fall through to larger model for code
|
||||
return _largest_model(available) or available[0]
|
||||
|
||||
# Rule 2: Math detected → prefer larger model
|
||||
# Rule 2: Low complexity → prefer smaller model (checked before the math
|
||||
# rule so simple arithmetic doesn't escalate to the largest model)
|
||||
if context.complexity_score <= 0.20:
|
||||
return _smallest_model(available) or available[0]
|
||||
|
||||
# Rule 3: Math detected → prefer larger model
|
||||
if context.has_math:
|
||||
return _largest_model(available) or available[0]
|
||||
|
||||
# Rule 3: Low complexity → prefer smaller model
|
||||
if context.complexity_score < 0.20:
|
||||
return _smallest_model(available) or available[0]
|
||||
|
||||
# Rule 4: High complexity or reasoning → prefer larger model
|
||||
if context.complexity_score >= 0.55 or context.has_reasoning:
|
||||
return _largest_model(available) or available[0]
|
||||
|
||||
@@ -34,6 +34,15 @@ _MEMORY_BACKEND_LOCK_SETUP = threading.Lock()
|
||||
_MCP_LOCK_SETUP = threading.Lock()
|
||||
|
||||
|
||||
def _get_runtime_event_bus(runtime: Any = None) -> Any:
|
||||
"""Return the server-owned event bus, falling back outside app runtimes."""
|
||||
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
bus = getattr(runtime, "bus", None)
|
||||
return bus if bus is not None else get_event_bus()
|
||||
|
||||
|
||||
def _start_managed_worker(app_state: Any, target: Any, *, name: str) -> Any:
|
||||
"""Start and track a managed-agent worker for orderly app shutdown."""
|
||||
|
||||
@@ -301,14 +310,13 @@ def _make_lightweight_system(
|
||||
# Wrap with InstrumentedEngine so agent ticks are recorded
|
||||
# in telemetry (FLOPs, energy, cost savings).
|
||||
try:
|
||||
from openjarvis.core.events import get_event_bus
|
||||
from openjarvis.telemetry.instrumented_engine import (
|
||||
InstrumentedEngine,
|
||||
)
|
||||
|
||||
plain_engine = InstrumentedEngine(
|
||||
plain_engine,
|
||||
get_event_bus(),
|
||||
_get_runtime_event_bus(runtime),
|
||||
)
|
||||
except Exception:
|
||||
pass # telemetry is optional
|
||||
@@ -1644,26 +1652,27 @@ def create_agent_manager_router(
|
||||
|
||||
# Re-use the server's engine + model so we don't pick a
|
||||
# random model from Ollama's list.
|
||||
server_engine = getattr(request.app.state, "engine", None)
|
||||
server_model = getattr(request.app.state, "model", "")
|
||||
server_config = getattr(request.app.state, "config", None)
|
||||
app_state = request.app.state
|
||||
server_engine = getattr(app_state, "engine", None)
|
||||
server_model = getattr(app_state, "model", "")
|
||||
server_config = getattr(app_state, "config", None)
|
||||
server_bus = _get_runtime_event_bus(app_state)
|
||||
|
||||
def _run_tick():
|
||||
try:
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
_ts = getattr(request.app.state, "trace_store", None)
|
||||
_ts = getattr(app_state, "trace_store", None)
|
||||
executor = AgentExecutor(
|
||||
manager=manager,
|
||||
event_bus=get_event_bus(),
|
||||
event_bus=server_bus,
|
||||
trace_store=_ts,
|
||||
)
|
||||
system = _make_lightweight_system(
|
||||
server_engine,
|
||||
server_model,
|
||||
server_config,
|
||||
request.app.state,
|
||||
app_state,
|
||||
)
|
||||
executor.set_system(system)
|
||||
# The route handler above already called start_tick() to
|
||||
@@ -1690,7 +1699,7 @@ def create_agent_manager_router(
|
||||
|
||||
try:
|
||||
_start_managed_worker(
|
||||
request.app.state,
|
||||
app_state,
|
||||
_run_tick,
|
||||
name=f"managed-agent-run-{agent_id}",
|
||||
)
|
||||
@@ -1997,11 +2006,12 @@ def create_agent_manager_router(
|
||||
import time as _time
|
||||
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import get_event_bus
|
||||
|
||||
_srv_engine = getattr(request.app.state, "engine", None)
|
||||
_srv_model = getattr(request.app.state, "model", "")
|
||||
_srv_config = getattr(request.app.state, "config", None)
|
||||
_app_state = request.app.state
|
||||
_srv_engine = getattr(_app_state, "engine", None)
|
||||
_srv_model = getattr(_app_state, "model", "")
|
||||
_srv_config = getattr(_app_state, "config", None)
|
||||
_srv_bus = _get_runtime_event_bus(_app_state)
|
||||
|
||||
def _immediate_tick():
|
||||
_start = _time.time()
|
||||
@@ -2011,17 +2021,17 @@ def create_agent_manager_router(
|
||||
_srv_model,
|
||||
)
|
||||
try:
|
||||
_ts2 = getattr(request.app.state, "trace_store", None)
|
||||
_ts2 = getattr(_app_state, "trace_store", None)
|
||||
executor = AgentExecutor(
|
||||
manager=manager,
|
||||
event_bus=get_event_bus(),
|
||||
event_bus=_srv_bus,
|
||||
trace_store=_ts2,
|
||||
)
|
||||
system = _make_lightweight_system(
|
||||
_srv_engine,
|
||||
_srv_model,
|
||||
_srv_config,
|
||||
request.app.state,
|
||||
_app_state,
|
||||
)
|
||||
executor.set_system(system)
|
||||
logger.info(
|
||||
@@ -2055,7 +2065,7 @@ def create_agent_manager_router(
|
||||
|
||||
try:
|
||||
_start_managed_worker(
|
||||
request.app.state,
|
||||
_app_state,
|
||||
_immediate_tick,
|
||||
name=f"managed-agent-immediate-{agent_id}",
|
||||
)
|
||||
@@ -2106,12 +2116,12 @@ def create_agent_manager_router(
|
||||
return {"learning_log": manager.list_learning_log(agent_id)}
|
||||
|
||||
@agents_router.post("/{agent_id}/learning/run")
|
||||
def trigger_learning(agent_id: str):
|
||||
def trigger_learning(agent_id: str, request: Request):
|
||||
if not manager.get_agent(agent_id):
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
from openjarvis.core.events import EventType, get_event_bus
|
||||
from openjarvis.core.events import EventType
|
||||
|
||||
bus = get_event_bus()
|
||||
bus = _get_runtime_event_bus(request.app.state)
|
||||
bus.publish(EventType.AGENT_LEARNING_STARTED, {"agent_id": agent_id})
|
||||
return {"status": "triggered"}
|
||||
|
||||
|
||||
@@ -1087,12 +1087,16 @@ def include_all_routes(app) -> None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# WebSocket bridge for real-time agent events
|
||||
# WebSocket bridge for real-time agent events. Must subscribe on the
|
||||
# same EventBus instance channels/agents actually publish to
|
||||
# (app.state.bus, set in server/app.py) — the get_event_bus() global
|
||||
# singleton is a *different* bus that nothing in `jarvis serve` ever
|
||||
# publishes to, so events silently never reached this endpoint.
|
||||
try:
|
||||
from openjarvis.core.events import get_event_bus
|
||||
from openjarvis.server.ws_bridge import create_ws_router
|
||||
|
||||
ws_router = create_ws_router(get_event_bus())
|
||||
ws_router = create_ws_router(getattr(app.state, "bus", None) or get_event_bus())
|
||||
app.include_router(ws_router)
|
||||
except Exception:
|
||||
logger.debug("WebSocket bridge not available", exc_info=True)
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_morning_digest_run(tmp_path):
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.generate.return_value = {
|
||||
"content": "Good morning sir. You have 3 emails and 2 meetings today.",
|
||||
"content": "Good morning sir. AtlasDB 1.0 was released.",
|
||||
"finish_reason": "stop",
|
||||
"usage": {},
|
||||
}
|
||||
@@ -29,7 +29,7 @@ def test_morning_digest_run(tmp_path):
|
||||
# Mock collect result
|
||||
mock_collect_result = ToolResult(
|
||||
tool_name="digest_collect",
|
||||
content='=== MESSAGES ===\n[gmail] From: alice@co.com — "Budget" (1h ago)\n',
|
||||
content="=== WORLD ===\n[hackernews] AtlasDB 1.0 Released — 241 points\n",
|
||||
success=True,
|
||||
metadata={"total_items": 2},
|
||||
)
|
||||
@@ -46,7 +46,9 @@ def test_morning_digest_run(tmp_path):
|
||||
mock_engine,
|
||||
"test-model",
|
||||
tools=[],
|
||||
persona="neutral",
|
||||
persona="jarvis",
|
||||
sections=["world"],
|
||||
section_sources={"world": ["hackernews", "news_rss"]},
|
||||
digest_store_path=str(tmp_path / "digest.db"),
|
||||
)
|
||||
|
||||
@@ -61,6 +63,16 @@ def test_morning_digest_run(tmp_path):
|
||||
assert "Good morning" in result.content
|
||||
assert result.turns == 1
|
||||
assert len(result.tool_results) == 2
|
||||
assert set(result.metadata["sources_used"]) == {"hackernews", "news_rss"}
|
||||
prompt = "\n".join(
|
||||
message.text for message in mock_engine.generate.call_args.args[0]
|
||||
).casefold()
|
||||
assert "world —" in prompt
|
||||
for forbidden in (
|
||||
"messages —|calendar —|health —|rebuttal|dinner at|group chat|"
|
||||
"slack|next meeting|readiness|hrv|weather"
|
||||
).split("|"):
|
||||
assert forbidden not in prompt
|
||||
|
||||
|
||||
def test_load_persona():
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -24,7 +25,8 @@ from openjarvis.core.registry import ConnectorRegistry
|
||||
def _create_fake_notes_db(db_path: Path) -> None:
|
||||
"""Populate a SQLite file with the Apple Notes schema and sample rows."""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript("""
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
|
||||
Z_PK INTEGER PRIMARY KEY,
|
||||
ZTITLE TEXT,
|
||||
@@ -38,7 +40,8 @@ def _create_fake_notes_db(db_path: Path) -> None:
|
||||
ZDATA BLOB,
|
||||
ZNOTE INTEGER
|
||||
);
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Note 1 — Shopping List
|
||||
html1 = "<html><body><h1>Shopping List</h1><p>Milk, eggs, bread</p></body></html>"
|
||||
@@ -208,3 +211,61 @@ def test_registry() -> None:
|
||||
assert ConnectorRegistry.contains("apple_notes")
|
||||
cls = ConnectorRegistry.get("apple_notes")
|
||||
assert cls.connector_id == "apple_notes"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 10 — modern modification timestamp drives incremental sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_incremental_sync_uses_modern_note_modification_date(tmp_path: Path) -> None:
|
||||
"""Modern Notes rows use ZMODIFICATIONDATE1 for incremental sync."""
|
||||
db_path = tmp_path / "ModernNoteStore.sqlite"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
|
||||
Z_PK INTEGER PRIMARY KEY,
|
||||
ZTITLE TEXT,
|
||||
ZTITLE1 TEXT,
|
||||
ZMODIFICATIONDATE REAL,
|
||||
ZMODIFICATIONDATE1 REAL,
|
||||
ZIDENTIFIER TEXT
|
||||
);
|
||||
CREATE TABLE ZICNOTEDATA (
|
||||
Z_PK INTEGER PRIMARY KEY,
|
||||
ZDATA BLOB,
|
||||
ZNOTE INTEGER
|
||||
);
|
||||
"""
|
||||
)
|
||||
compressed = gzip.compress(b"<p>New movie list</p>")
|
||||
conn.execute(
|
||||
"INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES "
|
||||
"(1, NULL, 'Movies', NULL, 800000000.0, 'note-modern')"
|
||||
)
|
||||
conn.execute("INSERT INTO ZICNOTEDATA VALUES (1, ?, 1)", (compressed,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
from openjarvis.connectors.apple_notes import AppleNotesConnector # noqa: PLC0415
|
||||
|
||||
connector = AppleNotesConnector(db_path=str(db_path))
|
||||
docs = list(connector.sync(since=datetime(2026, 1, 1, tzinfo=timezone.utc)))
|
||||
|
||||
assert [doc.doc_id for doc in docs] == ["apple_notes:note-modern"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 11 — legacy modification timestamp remains supported
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_incremental_sync_falls_back_to_legacy_modification_date(connector) -> None:
|
||||
"""Older Notes rows continue to use ZMODIFICATIONDATE."""
|
||||
docs = list(connector.sync(since=datetime(2023, 1, 1, tzinfo=timezone.utc)))
|
||||
|
||||
assert {doc.doc_id for doc in docs} == {
|
||||
"apple_notes:note-001",
|
||||
"apple_notes:note-002",
|
||||
}
|
||||
|
||||
@@ -88,13 +88,25 @@ class TestHeuristicRouter:
|
||||
router = HeuristicRouter(
|
||||
available_models=["small", "large", "coder"],
|
||||
)
|
||||
ctx = RoutingContext(
|
||||
query="solve x",
|
||||
query_length=7,
|
||||
has_math=True,
|
||||
)
|
||||
ctx = build_routing_context("solve the integral of x^2 dx")
|
||||
assert ctx.has_math is True
|
||||
assert ctx.complexity_score > 0.20
|
||||
assert router.select_model(ctx) == "large"
|
||||
|
||||
def test_low_complexity_math_prefers_small(self) -> None:
|
||||
"""Regression test: a trivial math query ("calculate 2+2") must not
|
||||
escalate to the largest model just because it contains a math
|
||||
keyword — the low-complexity rule takes priority over the math rule.
|
||||
"""
|
||||
_register_models()
|
||||
router = HeuristicRouter(
|
||||
available_models=["small", "large", "coder"],
|
||||
)
|
||||
ctx = build_routing_context("calculate 2+2")
|
||||
assert ctx.has_math is True
|
||||
assert ctx.complexity_score == 0.20
|
||||
assert router.select_model(ctx) == "small"
|
||||
|
||||
def test_high_complexity_prefers_large(self) -> None:
|
||||
_register_models()
|
||||
router = HeuristicRouter(
|
||||
|
||||
@@ -77,11 +77,9 @@ class TestRouterWithNewModels:
|
||||
router = HeuristicRouter(
|
||||
available_models=NEW_LOCAL_MODELS,
|
||||
)
|
||||
ctx = RoutingContext(
|
||||
query="solve the integral of x^2 dx",
|
||||
query_length=29,
|
||||
has_math=True,
|
||||
)
|
||||
ctx = build_routing_context("solve the integral of x^2 dx")
|
||||
assert ctx.has_math is True
|
||||
assert ctx.complexity_score > 0.20
|
||||
selected = router.select_model(ctx)
|
||||
assert selected == "gpt-oss:120b"
|
||||
|
||||
|
||||
@@ -605,6 +605,39 @@ class TestLightweightSystemEngineResolution:
|
||||
)
|
||||
assert captured["key"] == "llamacpp"
|
||||
|
||||
def test_instrumented_engine_uses_runtime_event_bus(self, monkeypatch):
|
||||
pytest.importorskip("fastapi")
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.server import agent_manager_routes as amr
|
||||
from openjarvis.telemetry import instrumented_engine
|
||||
|
||||
resolved_engine = MagicMock()
|
||||
wrapped_engine = MagicMock()
|
||||
runtime_bus = EventBus()
|
||||
runtime = SimpleNamespace(
|
||||
bus=runtime_bus,
|
||||
memory_backend=object(),
|
||||
channel_backend=None,
|
||||
channel_bridge=None,
|
||||
knowledge_db_path=None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"openjarvis.engine._discovery.get_engine",
|
||||
MagicMock(return_value=("resolved", resolved_engine)),
|
||||
)
|
||||
instrumented = MagicMock(return_value=wrapped_engine)
|
||||
monkeypatch.setattr(instrumented_engine, "InstrumentedEngine", instrumented)
|
||||
|
||||
system = amr._make_lightweight_system(
|
||||
engine=MagicMock(),
|
||||
model="m",
|
||||
config=self._cfg("vllm", "ollama"),
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
instrumented.assert_called_once_with(resolved_engine, runtime_bus)
|
||||
assert system.engine is wrapped_engine
|
||||
|
||||
def test_caches_tool_memory_backend_when_prompt_context_is_disabled(
|
||||
self,
|
||||
monkeypatch,
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -161,3 +163,91 @@ class TestWSBridge:
|
||||
]
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
class TestIncludeAllRoutesBusWiring:
|
||||
"""Regression: the WS endpoint must subscribe on the same EventBus that
|
||||
channels/agents actually publish to (app.state.bus), not the unrelated
|
||||
get_event_bus() global singleton — publishing on the latter used to
|
||||
silently never reach any connected browser client."""
|
||||
|
||||
def test_uses_app_state_bus_not_global_singleton(self):
|
||||
from openjarvis.core.events import reset_event_bus
|
||||
from openjarvis.server.api_routes import include_all_routes
|
||||
|
||||
reset_event_bus() # isolate from other tests' global singleton state
|
||||
app = FastAPI()
|
||||
real_bus = EventBus()
|
||||
app.state.bus = real_bus
|
||||
include_all_routes(app)
|
||||
|
||||
client = TestClient(app)
|
||||
with client.websocket_connect("/v1/agents/events") as ws:
|
||||
real_bus.publish(EventType.AGENT_TICK_START, {"agent_id": "test-123"})
|
||||
time.sleep(0.05)
|
||||
data = ws.receive_json()
|
||||
assert data["data"]["agent_id"] == "test-123"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "payload"),
|
||||
[
|
||||
("/v1/managed-agents/test-123/run", None),
|
||||
(
|
||||
"/v1/managed-agents/test-123/messages",
|
||||
{"content": "run now", "mode": "immediate", "stream": False},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_managed_agent_run_paths_publish_to_app_bus(self, path, payload):
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import reset_event_bus
|
||||
from openjarvis.server.api_routes import include_all_routes
|
||||
|
||||
reset_event_bus()
|
||||
app_bus = EventBus()
|
||||
manager = MagicMock()
|
||||
manager.get_agent.return_value = {
|
||||
"id": "test-123",
|
||||
"name": "test",
|
||||
"status": "idle",
|
||||
"config": {},
|
||||
}
|
||||
manager.send_message.return_value = {
|
||||
"id": "message-123",
|
||||
"agent_id": "test-123",
|
||||
"content": "run now",
|
||||
"mode": "immediate",
|
||||
}
|
||||
|
||||
app = FastAPI()
|
||||
app.state.bus = app_bus
|
||||
app.state.agent_manager = manager
|
||||
include_all_routes(app)
|
||||
|
||||
executed = threading.Event()
|
||||
observed_buses = []
|
||||
|
||||
def publish_tick(executor, agent_id, **_kwargs):
|
||||
observed_buses.append(executor._bus)
|
||||
executor._bus.publish(EventType.AGENT_TICK_START, {"agent_id": agent_id})
|
||||
executed.set()
|
||||
|
||||
client = TestClient(app)
|
||||
with (
|
||||
patch.object(AgentExecutor, "execute_tick", publish_tick),
|
||||
patch(
|
||||
"openjarvis.server.agent_manager_routes._make_lightweight_system",
|
||||
return_value=MagicMock(),
|
||||
) as make_system,
|
||||
client.websocket_connect("/v1/agents/events?agent_id=test-123") as ws,
|
||||
):
|
||||
request_kwargs = {"json": payload} if payload is not None else {}
|
||||
response = client.post(path, **request_kwargs)
|
||||
assert response.status_code == 200
|
||||
assert executed.wait(timeout=1)
|
||||
assert observed_buses == [app_bus]
|
||||
data = ws.receive_json()
|
||||
|
||||
assert data["type"] == "agent_tick_start"
|
||||
assert data["data"]["agent_id"] == "test-123"
|
||||
make_system.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user