Compare commits

..
Author SHA1 Message Date
Ari f2483e7bf3 fix(frontend): move MessageBubble hooks above the early return
The three useMemo calls ran after the isUser early return, so they
were skipped entirely for user messages, violating React's rules of
hooks. Caught by the react-hooks/rules-of-hooks lint rule added last
commit. Pure reordering, no logic change; tsc and the full vitest
suite (38/38) still pass.
2026-08-14 17:05:00 -07:00
Elliot Slusky 4b7bb936ff fix(desktop): complete remote server support
Allow plaintext user-configured API hosts in the macOS webview and authenticate remote agent event WebSockets with the configured API key. Add regression coverage for both paths.
2026-08-14 16:29:59 -07:00
Haotian Zheng 3c68a17ac5 fix(desktop): allow remote API connections 2026-08-14 16:29:59 -07:00
Elliot Slusky 0d32784ed6 fix(agents): harden structured tool input normalization 2026-08-14 16:29:01 -07:00
Haotian Zheng 20a7424883 fix(agents): normalize structured string tool inputs 2026-08-14 16:29:01 -07:00
github-actions[bot] a97c64c67b chore: update clone traffic data [skip ci] 2026-08-14 07:19:51 +00:00
Jon Saad-Falcon 64333651d1 Delete tools/pearl-reference-oracle directory 2026-08-13 19:27:37 -07:00
Elliot Slusky 9bee016c82 fix(server): preserve grounded agent stream content (#736)
* fix(server): preserve grounded agent stream content

* test(server): cover active grounded stream path

* fix(server): retain agent stream bridge
2026-08-13 18:15:36 -07:00
23 changed files with 727 additions and 346 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "189,482",
"message": "190,252",
"color": "green",
"namedLogo": "git"
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 189482,
"last_updated": "2026-08-13T07:22:13Z",
"total_clones": 190252,
"last_updated": "2026-08-14T07:19:51Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -140,6 +140,7 @@
"2026-08-09": 1076,
"2026-08-10": 1060,
"2026-08-11": 2182,
"2026-08-12": 641
"2026-08-12": 641,
"2026-08-13": 770
}
}
+14
View File
@@ -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>
+1 -2
View File
@@ -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": "-"
+18 -18
View File
@@ -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) */}
+66
View File
@@ -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);
});
});
+10 -13
View File
@@ -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();
}
/**
+81 -2
View File
@@ -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."""
+8 -55
View File
@@ -246,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
+18
View File
@@ -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,
+1
View File
@@ -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:
+1
View File
@@ -191,6 +191,7 @@ class ReplTool(BaseTool):
"required": ["code"],
},
category="code",
metadata={"structured_allow_object_text": True},
)
def execute(self, **params: Any) -> ToolResult:
@@ -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
+41
View File
@@ -847,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)
+64 -2
View File
@@ -1,6 +1,68 @@
import json
"""Regression tests for streaming completed agent responses."""
from openjarvis.server.stream_bridge import AgentStreamBridge
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():
@@ -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."""
+4
View File
@@ -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 (
+4
View File
@@ -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
+47
View File
@@ -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="")
-111
View File
@@ -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.
-139
View File
@@ -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()