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
18 changed files with 614 additions and 39 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."""
+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
@@ -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="")