mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-15 09:21:56 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2e1c375aa | ||
|
|
ef28e5f84b | ||
|
|
f2483e7bf3 | ||
|
|
156d41d2f9 | ||
|
|
4b7bb936ff | ||
|
|
3c68a17ac5 | ||
|
|
0d32784ed6 | ||
|
|
20a7424883 | ||
|
|
a97c64c67b | ||
|
|
6e40d87eb5 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "189,482",
|
||||
"message": "190,252",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<!-- The desktop API URL is user-configurable, so it cannot be represented
|
||||
by Tauri's single, build-time exceptionDomain setting. Keep this
|
||||
exception scoped to WKWebView; native URLSession traffic retains ATS. -->
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; img-src 'self' data: blob:"
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' http: https: ws: wss:; img-src 'self' data: blob:"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -45,7 +45,6 @@
|
||||
"macOS": {
|
||||
"entitlements": "Entitlements.plist",
|
||||
"minimumSystemVersion": "10.15",
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": "-"
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildWsUrl } from './useAgentEvents';
|
||||
|
||||
const SETTINGS_KEY = 'openjarvis-settings';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
describe('buildWsUrl', () => {
|
||||
it('authenticates agent events with the configured API key', () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
apiUrl: 'https://jarvis.example.com:8443',
|
||||
apiKey: 'secret+/=',
|
||||
}),
|
||||
);
|
||||
|
||||
const url = new URL(buildWsUrl('agent/one'));
|
||||
|
||||
expect(url.origin).toBe('wss://jarvis.example.com:8443');
|
||||
expect(url.pathname).toBe('/v1/agents/events');
|
||||
expect(url.searchParams.get('agent_id')).toBe('agent/one');
|
||||
expect(url.searchParams.get('token')).toBe('secret+/=');
|
||||
});
|
||||
|
||||
it('normalizes a versioned API base without duplicating /v1', () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({ apiUrl: 'http://192.0.2.10:8000/v1/' }),
|
||||
);
|
||||
|
||||
expect(buildWsUrl()).toBe('ws://192.0.2.10:8000/v1/agents/events');
|
||||
});
|
||||
|
||||
it('omits the token for a keyless server', () => {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({ apiUrl: 'http://localhost:8000' }),
|
||||
);
|
||||
|
||||
const url = new URL(buildWsUrl('agent-one'));
|
||||
|
||||
expect(url.searchParams.has('token')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { getBase } from './api';
|
||||
import { getApiKey, getBase } from './api';
|
||||
|
||||
export interface AgentEvent {
|
||||
type: string;
|
||||
@@ -7,19 +7,16 @@ export interface AgentEvent {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildWsUrl(agentId?: string): string {
|
||||
export function buildWsUrl(agentId?: string): string {
|
||||
const base = getBase();
|
||||
let origin: string;
|
||||
if (base) {
|
||||
origin = base.replace(/^http/, 'ws');
|
||||
} else {
|
||||
const loc = window.location;
|
||||
origin = `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}`;
|
||||
}
|
||||
const path = '/v1/agents/events';
|
||||
return agentId
|
||||
? `${origin}${path}?agent_id=${encodeURIComponent(agentId)}`
|
||||
: `${origin}${path}`;
|
||||
const url = new URL('/v1/agents/events', base || window.location.origin);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (agentId) url.searchParams.set('agent_id', agentId);
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) url.searchParams.set('token', apiKey);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,6 +127,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
|
||||
memory_backend: Optional[Any] = None,
|
||||
interactive: bool = False,
|
||||
confirm_callback=None,
|
||||
prompt_builder: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -139,7 +140,7 @@ class MonitorOperativeAgent(ToolUsingAgent):
|
||||
max_tokens=max_tokens,
|
||||
interactive=interactive,
|
||||
confirm_callback=confirm_callback,
|
||||
prompt_builder=kwargs.get("prompt_builder"),
|
||||
prompt_builder=prompt_builder,
|
||||
)
|
||||
# Validate strategies
|
||||
if memory_extraction not in VALID_MEMORY_EXTRACTION:
|
||||
|
||||
@@ -58,6 +58,7 @@ class OperativeAgent(ToolUsingAgent):
|
||||
memory_backend: Optional[Any] = None,
|
||||
interactive: bool = False,
|
||||
confirm_callback=None,
|
||||
prompt_builder: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -70,7 +71,7 @@ class OperativeAgent(ToolUsingAgent):
|
||||
max_tokens=max_tokens,
|
||||
interactive=interactive,
|
||||
confirm_callback=confirm_callback,
|
||||
prompt_builder=kwargs.get("prompt_builder"),
|
||||
prompt_builder=prompt_builder,
|
||||
)
|
||||
self._system_prompt = system_prompt or ""
|
||||
self._operator_id = operator_id
|
||||
|
||||
@@ -13,6 +13,7 @@ Supports two modes:
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
@@ -142,12 +143,21 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
tool_call = ToolCall(
|
||||
id=f"orch_{turns}",
|
||||
name=parsed["tool"],
|
||||
arguments=parsed["input"] or "{}",
|
||||
arguments=self._normalize_structured_tool_input(
|
||||
parsed["tool"],
|
||||
parsed["input"],
|
||||
),
|
||||
)
|
||||
tool_result = self._executor.execute(tool_call)
|
||||
all_tool_results.append(tool_result)
|
||||
|
||||
observation = f"Observation: {tool_result.content}"
|
||||
if tool_result.success:
|
||||
observation = f"Observation: {tool_result.content}"
|
||||
else:
|
||||
observation = (
|
||||
f"Observation: Tool '{tool_result.tool_name}' failed: "
|
||||
f"{tool_result.content}"
|
||||
)
|
||||
messages.append(Message(role=Role.USER, content=observation))
|
||||
continue
|
||||
|
||||
@@ -162,6 +172,75 @@ class OrchestratorAgent(ToolUsingAgent):
|
||||
# Max turns exceeded
|
||||
return self._max_turns_result(all_tool_results, turns)
|
||||
|
||||
def _normalize_structured_tool_input(
|
||||
self,
|
||||
tool_name: str,
|
||||
raw_input: str,
|
||||
) -> str:
|
||||
"""Map unambiguous structured text input to a string parameter."""
|
||||
if not raw_input:
|
||||
return "{}"
|
||||
|
||||
try:
|
||||
parsed_input = json.loads(raw_input)
|
||||
except json.JSONDecodeError:
|
||||
invalid_json = True
|
||||
string_value = raw_input
|
||||
else:
|
||||
invalid_json = False
|
||||
if isinstance(parsed_input, dict):
|
||||
return raw_input
|
||||
# INPUT is a text protocol. A non-object JSON value such as 42,
|
||||
# true, null, or [1, 2] may still be the intended text for a tool's
|
||||
# string parameter. Quoted JSON strings are decoded to remove only
|
||||
# their surrounding quotes; other values retain their source text.
|
||||
string_value = parsed_input if isinstance(parsed_input, str) else raw_input
|
||||
|
||||
tool_spec = None
|
||||
for candidate in reversed(self._tools):
|
||||
candidate_spec = candidate.spec
|
||||
if candidate_spec.name == tool_name:
|
||||
tool_spec = candidate_spec
|
||||
break
|
||||
if tool_spec is None:
|
||||
return raw_input
|
||||
|
||||
parameters = tool_spec.parameters
|
||||
parameter_container_type = parameters.get("type")
|
||||
if parameter_container_type not in (None, "object"):
|
||||
return raw_input
|
||||
|
||||
properties = parameters.get("properties", {})
|
||||
required = parameters.get("required", [])
|
||||
if not isinstance(properties, dict) or not isinstance(required, list):
|
||||
return raw_input
|
||||
|
||||
if len(required) == 1 and required[0] in properties:
|
||||
parameter_name = required[0]
|
||||
elif not required and len(properties) == 1:
|
||||
parameter_name = next(iter(properties))
|
||||
else:
|
||||
return raw_input
|
||||
|
||||
parameter_schema = properties[parameter_name]
|
||||
if not isinstance(parameter_schema, dict):
|
||||
return raw_input
|
||||
parameter_type = parameter_schema.get("type")
|
||||
accepts_string = parameter_type == "string" or (
|
||||
isinstance(parameter_type, list) and "string" in parameter_type
|
||||
)
|
||||
if not accepts_string:
|
||||
return raw_input
|
||||
|
||||
allow_object_text = (
|
||||
tool_spec.metadata.get("structured_allow_object_text") is True
|
||||
)
|
||||
starts_like_object = raw_input.lstrip("\ufeff \t\r\n").startswith("{")
|
||||
if invalid_json and starts_like_object and not allow_object_text:
|
||||
return raw_input
|
||||
|
||||
return json.dumps({parameter_name: string_value})
|
||||
|
||||
@staticmethod
|
||||
def _parse_structured_response(text: str) -> dict:
|
||||
"""Parse THOUGHT/TOOL/INPUT/FINAL_ANSWER from model output."""
|
||||
|
||||
@@ -398,9 +398,8 @@ def _run_agent(
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md persona
|
||||
# files actually reach the model. Only passed to agents whose __init__
|
||||
# accepts a `prompt_builder` kwarg (BaseAgent does; agents that override
|
||||
# __init__ without forwarding it, e.g. OrchestratorAgent, opt out
|
||||
# automatically and keep their existing system-prompt machinery).
|
||||
# explicitly accepts a `prompt_builder` kwarg. Agents with specialized
|
||||
# prompt machinery opt in by naming and forwarding the parameter.
|
||||
import inspect as _inspect
|
||||
|
||||
if "prompt_builder" in _inspect.signature(agent_cls.__init__).parameters:
|
||||
|
||||
@@ -367,6 +367,27 @@ def serve(
|
||||
if getattr(agent_cls, "accepts_tools", False):
|
||||
agent_kwargs["max_turns"] = config.agent.max_turns
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md
|
||||
# reach the model on the SERVE path too. ``ask.py`` has done
|
||||
# this since the persona system landed; ``serve.py`` never did,
|
||||
# so an agent served over HTTP silently answered as a generic
|
||||
# assistant while the same agent via the CLI kept its persona.
|
||||
# Guarded so agents with specialized prompt machinery must opt
|
||||
# in by explicitly naming and forwarding the kwarg.
|
||||
import inspect as _inspect
|
||||
|
||||
if (
|
||||
"prompt_builder"
|
||||
in _inspect.signature(agent_cls.__init__).parameters
|
||||
):
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=config.agent.default_system_prompt or "",
|
||||
memory_files_config=config.memory_files,
|
||||
system_prompt_config=config.system_prompt,
|
||||
)
|
||||
|
||||
agent = agent_cls(engine, model_name, **agent_kwargs)
|
||||
# Pin MCP transports to the agent's lifetime so HTTP
|
||||
# connections don't close mid-request (#461).
|
||||
|
||||
@@ -516,6 +516,20 @@ class Jarvis:
|
||||
existing = agent_kwargs.get("tools", [])
|
||||
agent_kwargs["tools"] = digest_tools + list(existing)
|
||||
|
||||
# Wire the SystemPromptBuilder so SOUL.md / MEMORY.md / USER.md reach
|
||||
# the model — mirrors ``cli/ask.py`` and ``cli/serve.py``. Guarded so
|
||||
# agents whose ``__init__`` doesn't accept the kwarg opt out.
|
||||
import inspect as _inspect
|
||||
|
||||
if "prompt_builder" in _inspect.signature(agent_cls.__init__).parameters:
|
||||
from openjarvis.prompt.builder import SystemPromptBuilder
|
||||
|
||||
agent_kwargs["prompt_builder"] = SystemPromptBuilder(
|
||||
agent_template=self._config.agent.default_system_prompt or "",
|
||||
memory_files_config=self._config.memory_files,
|
||||
system_prompt_config=self._config.system_prompt,
|
||||
)
|
||||
|
||||
agent_obj = agent_cls(self._engine, model_name, **agent_kwargs)
|
||||
ctx = AgentContext()
|
||||
|
||||
|
||||
@@ -136,6 +136,15 @@ class ToolExecutor:
|
||||
content=f"Invalid arguments JSON: {exc}",
|
||||
success=False,
|
||||
)
|
||||
if not isinstance(params, dict):
|
||||
return ToolResult(
|
||||
tool_name=tool_call.name,
|
||||
content=(
|
||||
"Invalid arguments: expected a JSON object, "
|
||||
f"got {type(params).__name__}."
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Boundary guard: scan external tool arguments
|
||||
if self._boundary_guard is not None and not getattr(tool, "is_local", True):
|
||||
@@ -143,6 +152,15 @@ class ToolExecutor:
|
||||
tool_call = self._boundary_guard.check_outbound(tool_call)
|
||||
# Re-parse arguments after potential redaction
|
||||
params = json.loads(tool_call.arguments) if tool_call.arguments else {}
|
||||
if not isinstance(params, dict):
|
||||
return ToolResult(
|
||||
tool_name=tool_call.name,
|
||||
content=(
|
||||
"Invalid arguments: expected a JSON object, "
|
||||
f"got {type(params).__name__}."
|
||||
),
|
||||
success=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ToolResult(
|
||||
tool_name=tool_call.name,
|
||||
|
||||
@@ -56,6 +56,7 @@ class CodeInterpreterTool(BaseTool):
|
||||
"required": ["code"],
|
||||
},
|
||||
category="code",
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
|
||||
@@ -55,6 +55,7 @@ class DockerCodeInterpreterTool(BaseTool):
|
||||
},
|
||||
category="code",
|
||||
timeout_seconds=60.0,
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
|
||||
@@ -191,6 +191,7 @@ class ReplTool(BaseTool):
|
||||
"required": ["code"],
|
||||
},
|
||||
category="code",
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
|
||||
@@ -396,11 +396,10 @@ class TestPersonaFilesReachModel:
|
||||
assert "MEMORY_SENTINEL" in joined
|
||||
assert "USER_SENTINEL" in joined
|
||||
|
||||
def test_orchestrator_keeps_its_own_system_prompt(
|
||||
def test_orchestrator_accepts_persona_prompt_builder(
|
||||
self, runner, monkeypatch, tmp_path
|
||||
):
|
||||
"""OrchestratorAgent's __init__ doesn't accept ``prompt_builder``;
|
||||
the wiring must skip it silently rather than crash."""
|
||||
"""Orchestrator explicitly accepts and applies persona wiring."""
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
@@ -425,5 +424,6 @@ class TestPersonaFilesReachModel:
|
||||
):
|
||||
result = runner.invoke(cli, ["ask", "--agent", "orchestrator", "Hello"])
|
||||
|
||||
# Pass condition: doesn't crash with TypeError on prompt_builder kwarg.
|
||||
assert result.exit_code == 0, result.output
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert "ORCH_PERSONA_SENTINEL" in messages[0].content
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Regression: ``jarvis serve`` must wire the ``SystemPromptBuilder`` into the
|
||||
agent it constructs, so SOUL.md / MEMORY.md / USER.md reach the model over HTTP.
|
||||
|
||||
``cli/ask.py`` (and ``cli/chat_cmd.py`` and the managed-agent executor) have
|
||||
wired the builder since the persona system landed. The serve path never did, so
|
||||
an agent served over HTTP silently answered as a generic assistant — explicitly
|
||||
denying the persona — while the same agent via the CLI kept it. Found deploying
|
||||
a personal assistant: SOUL.md was correct on disk the whole time; no error, no
|
||||
warning.
|
||||
|
||||
This test boots ``serve`` just far enough to capture the agent handed to
|
||||
``create_app`` and asserts the builder (and thus the persona content) is
|
||||
present. It fails on the unpatched serve path: ``agent._prompt_builder`` is
|
||||
``None``, so the persona files never reach the model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
pytest.importorskip("uvicorn")
|
||||
|
||||
# ``openjarvis.cli.serve`` as a package attribute resolves to the click
|
||||
# *command* (re-exported); grab the real module to monkeypatch its globals.
|
||||
serve_mod = importlib.import_module("openjarvis.cli.serve")
|
||||
|
||||
|
||||
def _fake_engine() -> MagicMock:
|
||||
engine = MagicMock()
|
||||
engine.list_models.return_value = ["test-model"]
|
||||
engine.health.return_value = True
|
||||
engine.name = "mock"
|
||||
engine.engine_id = "mock"
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"agent_name",
|
||||
["simple", "orchestrator", "monitor_operative", "operative"],
|
||||
)
|
||||
def test_serve_wires_persona_builder_into_served_agent(
|
||||
tmp_path, monkeypatch, agent_name
|
||||
):
|
||||
"""The agent built on the serve path must carry a SystemPromptBuilder whose
|
||||
assembled prompt includes SOUL.md content (regression for the HTTP persona
|
||||
loss)."""
|
||||
from openjarvis.agents.monitor_operative import MonitorOperativeAgent
|
||||
from openjarvis.agents.operative import OperativeAgent
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
# Persona file with a unique sentinel we can grep for in the built prompt.
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("SERVE_PERSONA_SENTINEL", encoding="utf-8")
|
||||
|
||||
# conftest clears registries per-test; re-register the agent we exercise.
|
||||
agent_classes = {
|
||||
"simple": SimpleAgent,
|
||||
"orchestrator": OrchestratorAgent,
|
||||
"monitor_operative": MonitorOperativeAgent,
|
||||
"operative": OperativeAgent,
|
||||
}
|
||||
if not AgentRegistry.contains(agent_name):
|
||||
AgentRegistry.register_value(agent_name, agent_classes[agent_name])
|
||||
|
||||
config = JarvisConfig()
|
||||
config.server.host = "127.0.0.1"
|
||||
config.server.port = 8123
|
||||
config.intelligence.default_model = "test-model"
|
||||
config.memory_files.soul_path = str(soul)
|
||||
# Keep the heavy optional subsystems off so we reach create_app cleanly.
|
||||
config.telemetry.enabled = False
|
||||
config.agent_manager.enabled = False
|
||||
config.sessions.enabled = False
|
||||
config.channel.enabled = False
|
||||
config.skills.enabled = False
|
||||
config.agent.context_from_memory = False
|
||||
|
||||
engine = _fake_engine()
|
||||
monkeypatch.setattr(serve_mod, "load_config", lambda *a, **k: config)
|
||||
monkeypatch.setattr(serve_mod, "get_engine", lambda *a, **k: ("mock", engine))
|
||||
monkeypatch.setattr(serve_mod, "discover_engines", lambda *a, **k: {})
|
||||
monkeypatch.setattr(serve_mod, "discover_models", lambda *a, **k: {})
|
||||
|
||||
# setup_security returns its own context; pass the engine straight through.
|
||||
sec = MagicMock()
|
||||
sec.engine = engine
|
||||
sec.capability_policy = None
|
||||
sec.audit_logger = None
|
||||
monkeypatch.setattr("openjarvis.security.setup_security", lambda *a, **k: sec)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture_create_app(*args, **kwargs):
|
||||
captured["agent"] = kwargs.get("agent")
|
||||
return MagicMock(name="app")
|
||||
|
||||
with (
|
||||
patch("openjarvis.server.app.create_app", side_effect=_capture_create_app),
|
||||
patch("uvicorn.run", lambda *a, **k: None),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["serve", "--agent", agent_name], catch_exceptions=False
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
agent = captured.get("agent")
|
||||
assert agent is not None, (
|
||||
"serve did not construct an agent or never reached create_app; "
|
||||
f"output:\n{result.output}"
|
||||
)
|
||||
# The regression: without the fix ``agent._prompt_builder`` is None and the
|
||||
# persona files never reach the model over HTTP.
|
||||
assert agent._prompt_builder is not None, (
|
||||
f"serve constructed {agent_name} without a prompt_builder — SOUL.md / "
|
||||
"MEMORY.md / USER.md would be silently dropped on the HTTP path."
|
||||
)
|
||||
assert "SERVE_PERSONA_SENTINEL" in agent._prompt_builder.build(), (
|
||||
"prompt_builder is wired on serve, but its built prompt omits SOUL.md"
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
@@ -95,6 +95,30 @@ class TestJarvisAsk:
|
||||
assert result == "Agent response"
|
||||
j.close()
|
||||
|
||||
def test_ask_with_agent_wires_persona(self, tmp_path):
|
||||
from openjarvis.agents.simple import SimpleAgent
|
||||
from openjarvis.core.registry import AgentRegistry
|
||||
|
||||
soul = tmp_path / "SOUL.md"
|
||||
soul.write_text("SDK_PERSONA_SENTINEL", encoding="utf-8")
|
||||
|
||||
cfg = JarvisConfig()
|
||||
cfg.memory_files.soul_path = str(soul)
|
||||
cfg.memory_files.memory_path = ""
|
||||
cfg.memory_files.user_path = ""
|
||||
cfg.agent.context_from_memory = False
|
||||
|
||||
if not AgentRegistry.contains("simple"):
|
||||
AgentRegistry.register_value("simple", SimpleAgent)
|
||||
|
||||
engine = _make_engine()
|
||||
with patch("openjarvis.sdk.get_engine", return_value=("mock", engine)):
|
||||
j = Jarvis(config=cfg, model="test-model")
|
||||
j.ask("Hello", agent="simple")
|
||||
messages = engine.generate.call_args.args[0]
|
||||
assert "SDK_PERSONA_SENTINEL" in messages[0].content
|
||||
j.close()
|
||||
|
||||
def test_ask_no_engine_raises(self):
|
||||
with patch("openjarvis.sdk.get_engine", return_value=None):
|
||||
j = Jarvis(config=JarvisConfig())
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.agents.orchestrator import OrchestratorAgent
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.engine._stubs import InferenceEngine
|
||||
@@ -18,8 +20,10 @@ class _MockEngine(InferenceEngine):
|
||||
def __init__(self, responses: list[str]) -> None:
|
||||
self._responses = list(responses)
|
||||
self._call_idx = 0
|
||||
self.calls = []
|
||||
|
||||
def generate(self, messages, **kwargs) -> dict:
|
||||
self.calls.append(list(messages))
|
||||
if self._call_idx < len(self._responses):
|
||||
content = self._responses[self._call_idx]
|
||||
self._call_idx += 1
|
||||
@@ -58,6 +62,109 @@ class _MockTool(BaseTool):
|
||||
return ToolResult(tool_name="calculator", content=str(expr), success=True)
|
||||
|
||||
|
||||
class _FileReadLikeTool(BaseTool):
|
||||
"""Small test double with one required and one optional parameter."""
|
||||
|
||||
tool_id = "file_read"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="file_read",
|
||||
description="Read a file",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"max_lines": {"type": "integer"},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool_name="file_read",
|
||||
content=params.get("path", ""),
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
class _AmbiguousTool(BaseTool):
|
||||
"""Test double whose bare input cannot map to one parameter safely."""
|
||||
|
||||
tool_id = "copy"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="copy",
|
||||
description="Copy a value",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"destination": {"type": "string"},
|
||||
},
|
||||
"required": ["source", "destination"],
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(tool_name="copy", content="copied", success=True)
|
||||
|
||||
|
||||
class _CodeLikeTool(BaseTool):
|
||||
"""Test double that explicitly accepts object-prefixed source text."""
|
||||
|
||||
tool_id = "code"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="code",
|
||||
description="Execute source code",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"code": {"type": "string"}},
|
||||
"required": ["code"],
|
||||
},
|
||||
metadata={"structured_allow_object_text": True},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool_name="code",
|
||||
content=params.get("code", ""),
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
class _UnionStringTool(BaseTool):
|
||||
"""Test double with a JSON Schema union that accepts strings."""
|
||||
|
||||
tool_id = "union_file_read"
|
||||
|
||||
@property
|
||||
def spec(self) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name="union_file_read",
|
||||
description="Read a nullable path",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": ["string", "null"]}},
|
||||
"required": ["path"],
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self, **params) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool_name="union_file_read",
|
||||
content=params.get("path", ""),
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
# -- Tests -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -80,6 +187,207 @@ class TestStructuredMode:
|
||||
assert result.content == "4"
|
||||
assert result.turns == 2
|
||||
assert len(result.tool_results) == 1
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == "2+2"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_input", "expected"),
|
||||
[
|
||||
("notes/today.md", "notes/today.md"),
|
||||
('"notes/today.md"', "notes/today.md"),
|
||||
('{"path": "notes/today.md"}', "notes/today.md"),
|
||||
("42", "42"),
|
||||
("true", "true"),
|
||||
("false", "false"),
|
||||
("null", "null"),
|
||||
("1e3", "1e3"),
|
||||
('["notes/today.md"]', '["notes/today.md"]'),
|
||||
('["notes/today.md",]', '["notes/today.md",]'),
|
||||
("[draft] notes.md", "[draft] notes.md"),
|
||||
(
|
||||
"[x * 2 for x in range(3)]",
|
||||
"[x * 2 for x in range(3)]",
|
||||
),
|
||||
('"notes/today.md', '"notes/today.md'),
|
||||
('""', ""),
|
||||
('"{draft} notes.md"', "{draft} notes.md"),
|
||||
(r'"C:\\Users\\me\\notes.txt"', r"C:\Users\me\notes.txt"),
|
||||
],
|
||||
)
|
||||
def test_single_string_parameter_accepts_text_input(self, raw_input, expected):
|
||||
"""Structured text maps to the unambiguous string parameter."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: file_read\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_FileReadLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Read my notes")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_input",
|
||||
[
|
||||
'{"path": "notes/today.md",}',
|
||||
'{path: "notes/today.md"}',
|
||||
"{'path': 'notes/today.md'}",
|
||||
'{"unknown": 1, "path": "notes/today.md",}',
|
||||
r'{"pa\u0074h": "notes/today.md",}',
|
||||
'\ufeff{"path": "notes/today.md",}',
|
||||
],
|
||||
)
|
||||
def test_malformed_json_like_input_remains_an_argument_error(self, raw_input):
|
||||
"""Malformed JSON-looking text is not reclassified as a tool value."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: file_read\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_FileReadLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Read my notes")
|
||||
|
||||
assert result.tool_results[0].success is False
|
||||
assert "Invalid arguments JSON" in result.tool_results[0].content
|
||||
assert "Tool 'file_read' failed: Invalid arguments JSON" in (
|
||||
engine.calls[1][-1].content
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_input",
|
||||
[
|
||||
"{'code': value}",
|
||||
'{"code": object()}',
|
||||
"{'nested': {'value': 1}}",
|
||||
],
|
||||
)
|
||||
def test_opted_in_tool_accepts_object_prefixed_text(self, raw_input):
|
||||
"""Explicit raw-text metadata disambiguates dict-shaped source code."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: code\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_CodeLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Execute code")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == raw_input
|
||||
|
||||
def test_valid_object_remains_arguments_for_opted_in_tool(self):
|
||||
"""Raw-text metadata does not override valid JSON argument objects."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
'TOOL: code\nINPUT: {"code": "print(1)"}',
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_CodeLikeTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Execute code")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == "print(1)"
|
||||
|
||||
def test_union_string_schema_accepts_json_scalar_text(self):
|
||||
"""String unions normalize text that also parses as a JSON scalar."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
"TOOL: union_file_read\nINPUT: null",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_UnionStringTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Read the path named null")
|
||||
|
||||
assert result.tool_results[0].success is True
|
||||
assert result.tool_results[0].content == "null"
|
||||
|
||||
def test_ambiguous_json_scalar_gets_stable_object_error(self):
|
||||
"""Ambiguous valid JSON is rejected before tool dispatch."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
"TOOL: copy\nINPUT: 42",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_AmbiguousTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Copy my notes")
|
||||
|
||||
assert result.tool_results[0].success is False
|
||||
assert result.tool_results[0].content == (
|
||||
"Invalid arguments: expected a JSON object, got int."
|
||||
)
|
||||
assert "Tool 'copy' failed: Invalid arguments" in (engine.calls[1][-1].content)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_input",
|
||||
[
|
||||
"notes/today.md",
|
||||
'{"source": "notes/today.md",}',
|
||||
],
|
||||
)
|
||||
def test_multiple_required_parameters_do_not_guess_string_mapping(
|
||||
self,
|
||||
raw_input,
|
||||
):
|
||||
"""Ambiguous bare input remains invalid instead of choosing a field."""
|
||||
engine = _MockEngine(
|
||||
[
|
||||
f"TOOL: copy\nINPUT: {raw_input}",
|
||||
"FINAL_ANSWER: done",
|
||||
]
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine=engine,
|
||||
model="test",
|
||||
tools=[_AmbiguousTool()],
|
||||
mode="structured",
|
||||
)
|
||||
|
||||
result = agent.run("Copy my notes")
|
||||
|
||||
assert result.tool_results[0].success is False
|
||||
assert "Invalid arguments JSON" in result.tool_results[0].content
|
||||
|
||||
def test_direct_final_answer(self):
|
||||
"""Test that FINAL_ANSWER on first turn works."""
|
||||
|
||||
@@ -127,6 +127,10 @@ class TestCodeInterpreterTool:
|
||||
tool = CodeInterpreterTool()
|
||||
assert tool.tool_id == "code_interpreter"
|
||||
|
||||
def test_structured_object_text_opt_in(self):
|
||||
tool = CodeInterpreterTool()
|
||||
assert tool.spec.metadata["structured_allow_object_text"] is True
|
||||
|
||||
def test_registry_registration(self):
|
||||
ToolRegistry.register_value("code_interpreter", CodeInterpreterTool)
|
||||
assert ToolRegistry.contains("code_interpreter")
|
||||
|
||||
@@ -25,6 +25,7 @@ class TestDockerCodeInterpreterTool:
|
||||
assert spec.name == "code_interpreter_docker"
|
||||
assert "code" in spec.parameters["properties"]
|
||||
assert spec.category == "code"
|
||||
assert spec.metadata["structured_allow_object_text"] is True
|
||||
|
||||
def test_empty_code(self):
|
||||
from openjarvis.tools.code_interpreter_docker import (
|
||||
|
||||
@@ -17,6 +17,10 @@ class TestReplSpec:
|
||||
tool = ReplTool()
|
||||
assert tool.spec.category == "code"
|
||||
|
||||
def test_structured_object_text_opt_in(self):
|
||||
tool = ReplTool()
|
||||
assert tool.spec.metadata["structured_allow_object_text"] is True
|
||||
|
||||
def test_spec_parameters(self):
|
||||
tool = ReplTool()
|
||||
params = tool.spec.parameters
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.types import ToolCall, ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolExecutor, ToolSpec
|
||||
@@ -50,6 +54,17 @@ class _ErrorTool(BaseTool):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
class _ScalarBoundaryGuard:
|
||||
"""Test guard that rewrites outbound arguments to a JSON scalar."""
|
||||
|
||||
def check_outbound(self, tool_call: ToolCall) -> ToolCall:
|
||||
return ToolCall(
|
||||
id=tool_call.id,
|
||||
name=tool_call.name,
|
||||
arguments=json.dumps("redacted"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ToolSpec tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -134,6 +149,38 @@ class TestToolExecutor:
|
||||
assert result.success is False
|
||||
assert "Invalid arguments JSON" in result.content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("arguments", "decoded_type"),
|
||||
[
|
||||
("42", "int"),
|
||||
("true", "bool"),
|
||||
("null", "NoneType"),
|
||||
("[]", "list"),
|
||||
('"text"', "str"),
|
||||
],
|
||||
)
|
||||
def test_execute_rejects_non_object_json(self, arguments, decoded_type):
|
||||
executor = ToolExecutor([_EchoTool()])
|
||||
call = ToolCall(id="1", name="echo", arguments=arguments)
|
||||
|
||||
result = executor.execute(call)
|
||||
|
||||
assert result.success is False
|
||||
assert result.content == (
|
||||
f"Invalid arguments: expected a JSON object, got {decoded_type}."
|
||||
)
|
||||
|
||||
def test_execute_revalidates_boundary_guard_arguments(self):
|
||||
tool = _EchoTool()
|
||||
tool.is_local = False
|
||||
executor = ToolExecutor([tool], boundary_guard=_ScalarBoundaryGuard())
|
||||
call = ToolCall(id="1", name="echo", arguments='{"text":"safe"}')
|
||||
|
||||
result = executor.execute(call)
|
||||
|
||||
assert result.success is False
|
||||
assert result.content == ("Invalid arguments: expected a JSON object, got str.")
|
||||
|
||||
def test_execute_empty_arguments(self):
|
||||
executor = ToolExecutor([_EchoTool()])
|
||||
call = ToolCall(id="1", name="echo", arguments="")
|
||||
|
||||
Reference in New Issue
Block a user