From 4c37aa83dfa7890aca1ba604a01199b041fa6feb Mon Sep 17 00:00:00 2001 From: Gilles Ceyssat Date: Tue, 12 May 2026 07:53:56 +0400 Subject: [PATCH 01/10] feat(executor): pull SystemBuilder MCP tools into agent's tool list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentExecutor builds agents from a template's `tools` whitelist by resolving each name against the static `ToolRegistry`. External MCP tools (discovered at SystemBuilder.build() time via _discover_external_mcp) were never picked up — they exist on system.tool_executor._tools but the per-agent build path never read from there. Result: any agent whose template declared MCP tool names by name got 0 of them at runtime, falling back to natives only. The agent's system prompt could still mention the tools, but the model could not call them (only shell_exec or other native fallbacks were available). This adds a fallback after the ToolRegistry loop: for any tool name not resolved from the registry, look it up in system.tool_executor._tools and append the already-instantiated MCP adapter. Native tools still take precedence (same name, the registry hit wins). Verified by configuring a CyberStrikeAI stdio MCP server (78 tools). Before this patch: agent built with 4 tools. After: 4 native + 78 MCP. --- src/openjarvis/agents/executor.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/openjarvis/agents/executor.py b/src/openjarvis/agents/executor.py index 8d6be76f..cc60c6a7 100644 --- a/src/openjarvis/agents/executor.py +++ b/src/openjarvis/agents/executor.py @@ -317,6 +317,21 @@ class AgentExecutor: tool_instances.append(tool) except Exception: logger.warning("Failed to instantiate tool %s", tname) + + # Pull tools already discovered by SystemBuilder (e.g. external MCP + # adapters) that aren't in the static ToolRegistry. Without this, + # agents declaring MCP-discovered tools in their template would + # silently fall back to natives only. + if self._system is not None and getattr(self._system, "tool_executor", None) is not None: + mcp_pool = getattr(self._system.tool_executor, "_tools", {}) or {} + existing = {t.spec.name for t in tool_instances} + for tname in tool_names: + if tname in existing: + continue + pooled = mcp_pool.get(tname) + if pooled is not None: + tool_instances.append(pooled) + if tool_instances: logger.info( "Agent %s: resolved %d/%d tools", @@ -332,6 +347,12 @@ class AgentExecutor: agent_kwargs["system_prompt"] = sys_prompt if getattr(agent_cls, "accepts_tools", False) and tool_instances: agent_kwargs["tools"] = tool_instances + # Propagate confirmation policy from the AgentExecutor down to the + # agent's own ToolExecutor. Set by CLI paths like `jarvis agents ask` + # so non-interactive runs can auto-approve tool execution. + if getattr(self, "_confirm_callback", None) is not None: + agent_kwargs["interactive"] = True + agent_kwargs["confirm_callback"] = self._confirm_callback try: agent_instance = agent_cls(engine, model, **agent_kwargs) except TypeError: From 333cb9a37094ff50db6dfa61aeac1ffa35171856 Mon Sep 17 00:00:00 2001 From: Gilles Ceyssat Date: Tue, 12 May 2026 07:53:56 +0400 Subject: [PATCH 02/10] feat(cli): wire confirm_callback for `jarvis agents ask` (with --yes/--no-yes) `jarvis agents ask` is a non-interactive CLI path, so the AgentExecutor's ToolExecutor never had a confirm_callback wired. Tools whose ToolSpec sets requires_confirmation=True (shell_exec, git_*, ...) returned "requires confirmation but no confirmation callback is available" and the agent relayed that back as natural language, never executing. This adds a --yes/--no-yes flag (default --yes): - --yes: auto-approve (lambda _prompt: True). Suited for CLI runs where the operator already authorized the engagement scope. - --no-yes: prompt on TTY via click.confirm. The callback is set on the AgentExecutor itself; executor.py:_invoke_agent reads it and forwards it to the constructed agent through agent_kwargs (both `interactive=True` and `confirm_callback=...`). --- src/openjarvis/cli/agent_cmd.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/openjarvis/cli/agent_cmd.py b/src/openjarvis/cli/agent_cmd.py index db24144c..31724ef5 100644 --- a/src/openjarvis/cli/agent_cmd.py +++ b/src/openjarvis/cli/agent_cmd.py @@ -702,16 +702,34 @@ def errors(): @agent.command("ask") @click.argument("agent_id") @click.argument("message") -def ask(agent_id, message): +@click.option( + "--yes/--no-yes", + "auto_approve", + default=True, + help="Auto-approve tool execution that would otherwise need confirmation. " + "Default: on (suits non-interactive CLI use). Pass --no-yes to require a " + "TTY prompt for tools whose ToolSpec sets requires_confirmation=True.", +) +def ask(agent_id, message, auto_approve): """Ask an agent a question (immediate response).""" manager = _get_manager() agent_id = _resolve_agent_id(manager, agent_id) manager.send_message(agent_id, message, mode="immediate") click.echo("Asking agent...") - _, executor, _ = _get_scheduler_and_executor() + _, executor, system = _get_scheduler_and_executor() if executor is None: click.echo("Executor not available", err=True) raise SystemExit(1) + # Wire a confirmation callback so the agent's own ToolExecutor can actually + # run tools whose ToolSpec sets requires_confirmation=True (e.g. shell_exec, + # git_*). `executor` is the AgentExecutor; the callback is read in + # _invoke_agent and propagated to the constructed agent via agent_kwargs. + if auto_approve: + executor._confirm_callback = lambda _prompt: True + else: + executor._confirm_callback = ( + lambda prompt: click.confirm(f"\n{prompt}", default=False) + ) executor.execute_tick(agent_id) msgs = manager.list_messages(agent_id) responses = [m for m in msgs if m["direction"] == "agent_to_user"] From 36482b1a27814965c8a7118c63c1cad411be7cb2 Mon Sep 17 00:00:00 2001 From: Gilles Ceyssat Date: Tue, 12 May 2026 08:12:03 +0400 Subject: [PATCH 03/10] =?UTF-8?q?fix(mcp):=20bump=20default=20tool=20timeo?= =?UTF-8?q?ut=2030s=20=E2=86=92=20600s=20for=20MCP-discovered=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCPClient.list_tools constructs ToolSpec without a timeout_seconds override, so MCP tools inherit the dataclass default of 30s. Most MCP servers in practice wrap long-running tools (pentest scanners, build runners, search agents, …) that comfortably take longer than 30s. The symptom is misleading — the ToolExecutor reports the timeout, and the LLM relays it as "command execution timed out", with no hint that the cause is the OpenJarvis client side and not the MCP server's actual policy. (CyberStrikeAI, for example, allows 30 *minutes* on its side via tool_timeout_minutes.) Bump the default to 600s (10 min) — still bounded, but enough for the typical long-running tool. Individual MCP servers can shorten via their own timeout policy if they care. --- src/openjarvis/mcp/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/openjarvis/mcp/client.py b/src/openjarvis/mcp/client.py index c37a04a0..5b495922 100644 --- a/src/openjarvis/mcp/client.py +++ b/src/openjarvis/mcp/client.py @@ -84,11 +84,15 @@ class MCPClient: """ response = self._send("tools/list") tools = response.result.get("tools", []) + # MCP tools often wrap long-running pentest/scan commands. The default + # ToolSpec timeout (30s) kills them mid-scan. Bump to 600s — individual + # MCP servers can shorten via their own protocol if needed. return [ ToolSpec( name=t["name"], description=t.get("description", ""), parameters=t.get("inputSchema", {}), + timeout_seconds=600.0, ) for t in tools ] From a65cd12ea204545559a2eb10f90a1ec496953b5b Mon Sep 17 00:00:00 2001 From: Eddie Richter Date: Sat, 2 May 2026 19:02:27 -0600 Subject: [PATCH 04/10] lemonade: update default host and recommended model --- docs/architecture/engine.md | 10 ++++---- src/openjarvis/core/config.py | 13 ++++++---- .../engine/openai_compat_engines.py | 2 +- tests/core/test_config.py | 25 +++++++++++++++++++ tests/core/test_recommend_model.py | 14 +++++++++++ tests/engine/test_engine_model_matrix.py | 2 +- tests/engine/test_lemonade.py | 22 ++++++++++------ 7 files changed, 69 insertions(+), 19 deletions(-) diff --git a/docs/architecture/engine.md b/docs/architecture/engine.md index 0be515d0..7fad6232 100644 --- a/docs/architecture/engine.md +++ b/docs/architecture/engine.md @@ -116,7 +116,7 @@ All providers produce the same output format consumed by agents: | **LM Studio** | `lmstudio` | OpenAI-compatible | 1234 | No (GPU optional) | Desktop GUI, easy model management | | **Exo** | `exo` | OpenAI-compatible | 52415 | No (distributed) | Distributed inference across heterogeneous devices | | **Nexa** | `nexa` | OpenAI-compatible | 18181 | No (CPU/GPU) | On-device inference with GGUF models | -| **Lemonade** | `lemonade` | OpenAI-compatible | 8000 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs | +| **Lemonade** | `lemonade` | OpenAI-compatible | 13305 | AMD GPU/NPU | AMD consumer GPUs (RDNA), Ryzen AI NPUs | | **Uzu** | `uzu` | OpenAI-compatible | 8000 | Varies | Uzu inference runtime | | **Apple FM** | `apple_fm` | OpenAI-compatible | 8079 | Apple Silicon | Apple Foundation Model on-device inference | | **LiteLLM** | `litellm` | OpenAI-compatible | — | No | Unified proxy to 100+ LLM providers | @@ -135,7 +135,7 @@ The Ollama backend communicates via Ollama's native HTTP API at `/api/chat` and The vLLM backend uses the OpenAI-compatible `/v1/chat/completions` API. It is recommended for datacenter GPUs (NVIDIA A100, H100, L40, A10, A30 and AMD MI300, MI325, MI350, MI355). -- **Default host:** `http://localhost:8000` +- **Default host:** `http://localhost:13305` - **Health check:** `GET /v1/models` - **Tool fallback:** If the server returns HTTP 400 when tools are included, the engine automatically retries without tools @@ -204,7 +204,7 @@ The Nexa backend connects to the Nexa SDK on-device inference server via a FastA The Lemonade backend connects to the [Lemonade](https://lemonade-server.ai/) inference server, which is optimized for AMD consumer GPUs (RDNA architecture) and Ryzen AI Neural Processing Units (NPUs). It uses the OpenAI-compatible `/v1/chat/completions` API. -- **Default host:** `http://localhost:8000` +- **Default host:** `http://localhost:13305` - **Health check:** `GET /v1/models` - **Install:** Visit [lemonade-server.ai](https://lemonade-server.ai/) for platform-specific installation instructions - **Best for:** Ryzen AI GPUs and NPUs, and AMD-based desktop and laptop systems @@ -357,7 +357,7 @@ host = "http://localhost:30000" # binary_path = "" # [engine.lemonade] -# host = "http://localhost:8000" +# host = "http://localhost:13305" ``` The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settings: @@ -370,7 +370,7 @@ The `EngineConfig` dataclass and its per-engine sub-dataclasses map these settin | `SGLangEngineConfig` | `host` | `http://localhost:30000` | SGLang server URL | | `LlamaCppEngineConfig` | `host` | `http://localhost:8080` | llama.cpp server URL | | `LlamaCppEngineConfig` | `binary_path` | `""` | Path to llama.cpp binary (for managed mode) | -| `LemonadeEngineConfig` | `host` | `http://localhost:8000` | Lemonade server URL | +| `LemonadeEngineConfig` | `host` | `http://localhost:13305` | Lemonade server URL | !!! note "Backward compatibility" The old flat field names `ollama_host`, `vllm_host`, `llamacpp_host`, `llamacpp_path`, `sglang_host`, and `lemonade_host` under `[engine]` are still accepted as backward-compatible properties on `EngineConfig`. New configurations should use the nested sub-section format. diff --git a/src/openjarvis/core/config.py b/src/openjarvis/core/config.py index a00f48cd..c7f6bb7d 100644 --- a/src/openjarvis/core/config.py +++ b/src/openjarvis/core/config.py @@ -280,14 +280,14 @@ _MODEL_TIERS = [ (64, "qwen3.5:27b"), ] _MODEL_TIER_FALLBACK = "qwen3.5:27b" +_LEMONADE_DEFAULT_MODEL = "Qwen3.6-35B-A3B-GGUF" def recommend_model(hw: HardwareInfo, engine: str) -> str: - """Suggest the best Qwen3.5 model that fits the detected hardware. + """Suggest a default model for the selected engine and hardware. - Uses an explicit tier table mapping available memory to model size. - Falls back to scanning the full catalog if the tiered model is not - compatible with the selected engine. + For Lemonade, prefer the validated Qwen3.6 35B A3B GGUF default. + For other local engines, use the generic Qwen3.5 tier mapping. """ from openjarvis.intelligence.model_catalog import BUILTIN_MODELS @@ -295,6 +295,9 @@ def recommend_model(hw: HardwareInfo, engine: str) -> str: if available_gb <= 0: return "" + if engine == "lemonade": + return _LEMONADE_DEFAULT_MODEL + # Build a lookup for quick engine-compatibility checks catalog = {spec.model_id: spec for spec in BUILTIN_MODELS} @@ -422,7 +425,7 @@ class GemmaCppEngineConfig: class LemonadeEngineConfig: """Per-engine config for Lemonade.""" - host: str = "http://localhost:8000" + host: str = "http://localhost:13305" @dataclass diff --git a/src/openjarvis/engine/openai_compat_engines.py b/src/openjarvis/engine/openai_compat_engines.py index 95ec1200..6d191f3d 100644 --- a/src/openjarvis/engine/openai_compat_engines.py +++ b/src/openjarvis/engine/openai_compat_engines.py @@ -13,7 +13,7 @@ _ENGINES = { "nexa": ("NexaEngine", "http://localhost:18181", "/v1"), "uzu": ("UzuEngine", "http://localhost:8000", ""), "apple_fm": ("AppleFmEngine", "http://localhost:8079", "/v1"), - "lemonade": ("LemonadeEngine", "http://localhost:8000", "/v1"), + "lemonade": ("LemonadeEngine", "http://localhost:13305", "/v1"), } for _key, (_cls_name, _default_host, _api_prefix) in _ENGINES.items(): diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 864e843e..337f671f 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -37,10 +37,12 @@ class TestDefaults: assert ec.vllm.host == "http://localhost:8000" assert ec.sglang.host == "http://localhost:30000" assert ec.llamacpp.host == "http://localhost:8080" + assert ec.lemonade.host == "http://localhost:13305" assert ec.llamacpp.binary_path == "" # Backward-compat properties still work assert ec.ollama_host == "" assert ec.vllm_host == "http://localhost:8000" + assert ec.lemonade_host == "http://localhost:13305" class TestRecommendEngine: @@ -93,6 +95,17 @@ class TestTomlLoading: assert cfg.engine.default == "vllm" assert cfg.memory.default_backend == "faiss" + def test_loads_nested_lemonade_host_override(self, tmp_path: Path) -> None: + toml_file = tmp_path / "config.toml" + toml_file.write_text( + '[engine]\ndefault = "lemonade"\n\n' + '[engine.lemonade]\nhost = "http://custom-lemonade:19000"\n' + ) + cfg = load_config(toml_file) + assert cfg.engine.default == "lemonade" + assert cfg.engine.lemonade.host == "http://custom-lemonade:19000" + assert cfg.engine.lemonade_host == "http://custom-lemonade:19000" + class TestGenerateToml: def test_contains_engine_section(self) -> None: @@ -213,6 +226,7 @@ class TestNestedEngineConfig: assert ec.vllm.host == "http://localhost:8000" assert ec.sglang.host == "http://localhost:30000" assert ec.llamacpp.host == "http://localhost:8080" + assert ec.lemonade.host == "http://localhost:13305" assert ec.llamacpp.binary_path == "" def test_backward_compat_setter(self) -> None: @@ -250,6 +264,17 @@ class TestNestedEngineConfig: assert cfg.engine.ollama.host == "http://old:11434" assert cfg.engine.vllm.host == "http://old:8000" + def test_loads_old_flat_lemonade_host(self, tmp_path: Path) -> None: + toml_file = tmp_path / "config.toml" + toml_file.write_text( + '[engine]\ndefault = "lemonade"\n' + 'lemonade_host = "http://legacy-lemonade:19191"\n' + ) + cfg = load_config(toml_file) + assert cfg.engine.default == "lemonade" + assert cfg.engine.lemonade.host == "http://legacy-lemonade:19191" + assert cfg.engine.lemonade_host == "http://legacy-lemonade:19191" + class TestNestedLearningConfig: def test_defaults(self) -> None: diff --git a/tests/core/test_recommend_model.py b/tests/core/test_recommend_model.py index 77b0be89..b8c95f8d 100644 --- a/tests/core/test_recommend_model.py +++ b/tests/core/test_recommend_model.py @@ -86,6 +86,20 @@ class TestRecommendModelGpu: # available = 288 GB → tier fallback qwen3.5:27b, valid for vllm assert result == "qwen3.5:27b" + def test_amd_lemonade_picks_qwen36_35b_a3b(self) -> None: + hw = HardwareInfo( + platform="linux", + ram_gb=64.0, + gpu=GpuInfo( + vendor="amd", + name="Radeon RX 7900 XTX", + vram_gb=24.0, + count=1, + ), + ) + result = recommend_model(hw, "lemonade") + assert result == "Qwen3.6-35B-A3B-GGUF" + class TestRecommendModelEdgeCases: """Edge cases.""" diff --git a/tests/engine/test_engine_model_matrix.py b/tests/engine/test_engine_model_matrix.py index 9547ff0b..36888a41 100644 --- a/tests/engine/test_engine_model_matrix.py +++ b/tests/engine/test_engine_model_matrix.py @@ -32,7 +32,7 @@ _OPENAI_COMPAT_ENGINES = [ ("nexa", "http://testhost:18181", NexaEngine), ("uzu", "http://testhost:8000", UzuEngine), ("apple_fm", "http://testhost:8079", AppleFmEngine), - ("lemonade", "http://testhost:8000", LemonadeEngine), + ("lemonade", "http://testhost:13305", LemonadeEngine), ] diff --git a/tests/engine/test_lemonade.py b/tests/engine/test_lemonade.py index d5bc0e37..9291f361 100644 --- a/tests/engine/test_lemonade.py +++ b/tests/engine/test_lemonade.py @@ -19,7 +19,7 @@ from openjarvis.engine.openai_compat_engines import LemonadeEngine @pytest.fixture() def engine() -> LemonadeEngine: EngineRegistry.register_value("lemonade", LemonadeEngine) - return LemonadeEngine(host="http://testhost:8000") + return LemonadeEngine(host="http://testhost:13305") class TestLemonadeEngineBasics: @@ -27,7 +27,7 @@ class TestLemonadeEngineBasics: assert LemonadeEngine.engine_id == "lemonade" def test_default_host(self) -> None: - assert LemonadeEngine._default_host == "http://localhost:8000" + assert LemonadeEngine._default_host == "http://localhost:13305" def test_api_prefix(self) -> None: assert LemonadeEngine._api_prefix == "/v1" @@ -36,11 +36,19 @@ class TestLemonadeEngineBasics: EngineRegistry.register_value("lemonade", LemonadeEngine) assert EngineRegistry.get("lemonade") is LemonadeEngine + def test_env_var_overrides_default_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("LEMONADE_HOST", "http://env-lemonade:17777") + engine = LemonadeEngine() + try: + assert engine._host == "http://env-lemonade:17777" + finally: + engine.close() + class TestLemonadeGenerate: def test_generate_uses_v1_prefix(self, engine: LemonadeEngine) -> None: with respx.mock: - respx.post("http://testhost:8000/v1/chat/completions").mock( + respx.post("http://testhost:13305/v1/chat/completions").mock( return_value=httpx.Response( 200, json={ @@ -67,7 +75,7 @@ class TestLemonadeGenerate: def test_generate_connection_error(self, engine: LemonadeEngine) -> None: with respx.mock: - respx.post("http://testhost:8000/v1/chat/completions").mock( + respx.post("http://testhost:13305/v1/chat/completions").mock( side_effect=httpx.ConnectError("refused") ) with pytest.raises(EngineConnectionError): @@ -80,14 +88,14 @@ class TestLemonadeGenerate: class TestLemonadeHealth: def test_health_true(self, engine: LemonadeEngine) -> None: with respx.mock: - respx.get("http://testhost:8000/v1/models").mock( + respx.get("http://testhost:13305/v1/models").mock( return_value=httpx.Response(200, json={"data": []}) ) assert engine.health() is True def test_health_false(self, engine: LemonadeEngine) -> None: with respx.mock: - respx.get("http://testhost:8000/v1/models").mock( + respx.get("http://testhost:13305/v1/models").mock( side_effect=httpx.ConnectError("refused") ) assert engine.health() is False @@ -96,7 +104,7 @@ class TestLemonadeHealth: class TestLemonadeListModels: def test_list_models_uses_v1_prefix(self, engine: LemonadeEngine) -> None: with respx.mock: - respx.get("http://testhost:8000/v1/models").mock( + respx.get("http://testhost:13305/v1/models").mock( return_value=httpx.Response( 200, json={ From 86c42e4e4a702a7a9ed120174aade08e27e319a6 Mon Sep 17 00:00:00 2001 From: Eddie Richter Date: Sat, 2 May 2026 19:16:31 -0600 Subject: [PATCH 05/10] tests: wrap lemonade host override signature --- tests/engine/test_lemonade.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/engine/test_lemonade.py b/tests/engine/test_lemonade.py index 9291f361..385ed25d 100644 --- a/tests/engine/test_lemonade.py +++ b/tests/engine/test_lemonade.py @@ -36,7 +36,10 @@ class TestLemonadeEngineBasics: EngineRegistry.register_value("lemonade", LemonadeEngine) assert EngineRegistry.get("lemonade") is LemonadeEngine - def test_env_var_overrides_default_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_env_var_overrides_default_host( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: monkeypatch.setenv("LEMONADE_HOST", "http://env-lemonade:17777") engine = LemonadeEngine() try: From 1d37528637fb5aec61f7e4364d94da51ace247c8 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Fri, 15 May 2026 22:38:58 +0300 Subject: [PATCH 06/10] docs: add Hermes Tweet skill install example --- docs/user-guide/skills.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/user-guide/skills.md b/docs/user-guide/skills.md index 05717262..9786fdc4 100644 --- a/docs/user-guide/skills.md +++ b/docs/user-guide/skills.md @@ -148,6 +148,14 @@ jarvis skill sync openclaw --search "web3|crypto" jarvis skill install github:user/repo/path/to/skill --url https://github.com/user/repo ``` +For example, install the Hermes Tweet skill when you want an agent to search +Twitter/X, read tweet replies, monitor tweets, export followers, and run +gated post, reply, or DM workflows: + +```bash +jarvis skill install github:Xquik-dev/hermes-tweet/skills/hermes-tweet --url https://github.com/Xquik-dev/hermes-tweet +``` + ### Config-Driven Auto Import Add sources to `~/.openjarvis/config.toml` for automatic syncing: From 5f685a2e3b740a521fde3148d0e03adcdf565d19 Mon Sep 17 00:00:00 2001 From: Abhinav Cherukuru Date: Mon, 6 Apr 2026 17:20:24 -0400 Subject: [PATCH 07/10] feat(telemetry): expand GPU_SPECS with Intel Arc, Jetson Orin, and Snapdragon entries Adds hardware specs for Intel Arc B580/B570, NVIDIA Jetson Orin NX/AGX Orin, and Qualcomm Snapdragon X Elite/Plus to the GPU_SPECS database in telemetry/gpu_monitor.py. Specs sourced from official vendor datasheets: - Intel Arc B580: 196 TFLOPS FP16, 456 GB/s, 190W TDP - Intel Arc B570: 136 TFLOPS FP16, 380 GB/s, 150W TDP - Jetson Orin NX 16GB: 50 TFLOPS FP16, 102 GB/s, 25W TDP - Jetson Orin NX 8GB: 25 TFLOPS FP16, 68 GB/s, 15W TDP - Jetson AGX Orin: 108 TFLOPS FP16, 204 GB/s, 60W TDP - Snapdragon X Elite (Adreno X1-85): 4.6 TFLOPS FP16, 136 GB/s, 80W SoC TDP - Snapdragon X Plus: 3.8 TFLOPS FP16, 136 GB/s, 80W SoC TDP Closes Workstream 5 roadmap item: GPU specs database expansion. --- src/openjarvis/telemetry/gpu_monitor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/openjarvis/telemetry/gpu_monitor.py b/src/openjarvis/telemetry/gpu_monitor.py index 6690b941..a044ca40 100644 --- a/src/openjarvis/telemetry/gpu_monitor.py +++ b/src/openjarvis/telemetry/gpu_monitor.py @@ -50,6 +50,16 @@ GPU_SPECS: Dict[str, GpuHardwareSpec] = { # Apple Silicon "M4 Max": GpuHardwareSpec(tflops_fp16=53, bandwidth_gb_s=546, tdp_watts=40), "M2 Ultra": GpuHardwareSpec(tflops_fp16=27, bandwidth_gb_s=800, tdp_watts=60), + # Intel Arc + "Arc B580": GpuHardwareSpec(tflops_fp16=196, bandwidth_gb_s=456, tdp_watts=190), + "Arc B570": GpuHardwareSpec(tflops_fp16=136, bandwidth_gb_s=380, tdp_watts=150), + # NVIDIA Jetson + "Jetson Orin NX 16GB": GpuHardwareSpec(tflops_fp16=50, bandwidth_gb_s=102, tdp_watts=25), + "Jetson Orin NX 8GB": GpuHardwareSpec(tflops_fp16=25, bandwidth_gb_s=68, tdp_watts=15), + "Jetson AGX Orin": GpuHardwareSpec(tflops_fp16=108, bandwidth_gb_s=204, tdp_watts=60), + # Qualcomm + "Snapdragon X Elite": GpuHardwareSpec(tflops_fp16=4.6, bandwidth_gb_s=136, tdp_watts=80), + "Snapdragon X Plus": GpuHardwareSpec(tflops_fp16=3.8, bandwidth_gb_s=136, tdp_watts=80), } From ef267abcc1c5913d1f856144f2350220ad882cd6 Mon Sep 17 00:00:00 2001 From: Abhinav Cherukuru Date: Tue, 14 Apr 2026 16:09:20 -0400 Subject: [PATCH 08/10] fix: remove extra whitespace in Arc B580 entry --- src/openjarvis/telemetry/gpu_monitor.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/openjarvis/telemetry/gpu_monitor.py b/src/openjarvis/telemetry/gpu_monitor.py index a044ca40..82dbfbb1 100644 --- a/src/openjarvis/telemetry/gpu_monitor.py +++ b/src/openjarvis/telemetry/gpu_monitor.py @@ -13,7 +13,6 @@ logger = logging.getLogger(__name__) try: import pynvml - _PYNVML_AVAILABLE = True except ImportError: _PYNVML_AVAILABLE = False @@ -23,7 +22,6 @@ except ImportError: # Hardware spec database # --------------------------------------------------------------------------- - @dataclass(frozen=True) class GpuHardwareSpec: """Peak theoretical capabilities for a known GPU model.""" @@ -50,7 +48,7 @@ GPU_SPECS: Dict[str, GpuHardwareSpec] = { # Apple Silicon "M4 Max": GpuHardwareSpec(tflops_fp16=53, bandwidth_gb_s=546, tdp_watts=40), "M2 Ultra": GpuHardwareSpec(tflops_fp16=27, bandwidth_gb_s=800, tdp_watts=60), - # Intel Arc + # Intel Arc "Arc B580": GpuHardwareSpec(tflops_fp16=196, bandwidth_gb_s=456, tdp_watts=190), "Arc B570": GpuHardwareSpec(tflops_fp16=136, bandwidth_gb_s=380, tdp_watts=150), # NVIDIA Jetson @@ -80,7 +78,6 @@ def lookup_gpu_spec(name: str) -> Optional[GpuHardwareSpec]: # Snapshot & aggregated sample # --------------------------------------------------------------------------- - @dataclass class GpuSnapshot: """A single point-in-time reading from one GPU device.""" @@ -113,7 +110,6 @@ class GpuSample: # Monitor # --------------------------------------------------------------------------- - class GpuMonitor: """Background GPU poller using pynvml. @@ -229,6 +225,7 @@ class GpuMonitor: mean_util = sum(s.utilization_pct for s in tick_snaps) / len(tick_snaps) total_mem = sum(s.memory_used_gb for s in tick_snaps) mean_temp = sum(s.temperature_c for s in tick_snaps) / len(tick_snaps) + tick_powers.append(total_power) tick_utils.append(mean_util) tick_mems.append(total_mem) @@ -266,7 +263,6 @@ class GpuMonitor: :class:`GpuSample` without starting a background thread. """ result = GpuSample() - if not self._initialized or self._device_count == 0: t_start = time.monotonic() yield result @@ -286,11 +282,13 @@ class GpuMonitor: t_start = time.monotonic() thread.start() + try: yield result finally: stop_event.set() thread.join(timeout=2.0) + wall = time.monotonic() - t_start with lock: From 9c9c883f24ac6f8495d3ce1ef00d46d18f15ec43 Mon Sep 17 00:00:00 2001 From: Abhinav Cherukuru Date: Tue, 14 Apr 2026 16:11:04 -0400 Subject: [PATCH 09/10] test: update test_all_specs_present with 7 new GPUs --- tests/telemetry/test_gpu_monitor.py | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/tests/telemetry/test_gpu_monitor.py b/tests/telemetry/test_gpu_monitor.py index e4b0f1dc..40aa2b1a 100644 --- a/tests/telemetry/test_gpu_monitor.py +++ b/tests/telemetry/test_gpu_monitor.py @@ -10,11 +10,11 @@ from unittest.mock import MagicMock, patch import pytest + # --------------------------------------------------------------------------- # Helpers: build a fake pynvml module # --------------------------------------------------------------------------- - @dataclass class _FakeUtilization: gpu: int = 80 @@ -60,7 +60,6 @@ def _snap(power=300, util=80, mem=12, temp=65, dev=0): # Tests: GpuHardwareSpec lookup # --------------------------------------------------------------------------- - class TestGpuHardwareSpec: def test_lookup_exact_key(self): from openjarvis.telemetry.gpu_monitor import lookup_gpu_spec @@ -107,6 +106,13 @@ class TestGpuHardwareSpec: "MI250X", "M4 Max", "M2 Ultra", + "Arc B580", + "Arc B570", + "Jetson Orin NX 16GB", + "Jetson Orin NX 8GB", + "Jetson AGX Orin", + "Snapdragon X Elite", + "Snapdragon X Plus", } assert set(GPU_SPECS.keys()) == expected @@ -122,7 +128,6 @@ class TestGpuHardwareSpec: # Tests: energy integration math (trapezoidal rule) # --------------------------------------------------------------------------- - class TestEnergyIntegration: def test_constant_power(self): """Constant 300W for 10 seconds = 3000 J.""" @@ -130,7 +135,6 @@ class TestEnergyIntegration: snapshots = [[_snap(power=300)] for _ in range(11)] timestamps = [float(i) for i in range(11)] - sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=10.0) assert sample.energy_joules == pytest.approx(3000.0, rel=1e-6) assert sample.mean_power_watts == pytest.approx(300.0, rel=1e-6) @@ -146,7 +150,6 @@ class TestEnergyIntegration: [_snap(power=100.0 * i, util=50, mem=8, temp=60)] for i in range(5) ] timestamps = [float(i) for i in range(5)] - sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=4.0) assert sample.energy_joules == pytest.approx(800.0, rel=1e-6) assert sample.mean_power_watts == pytest.approx(200.0, rel=1e-6) @@ -167,7 +170,6 @@ class TestEnergyIntegration: snapshots = [[_snap(power=250, util=90, mem=16, temp=70)]] timestamps = [0.0] - sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=0.05) assert sample.energy_joules == 0.0 assert sample.num_snapshots == 1 @@ -178,7 +180,6 @@ class TestEnergyIntegration: # Tests: GpuSample aggregation # --------------------------------------------------------------------------- - class TestGpuSampleAggregation: def test_peak_values(self): from openjarvis.telemetry.gpu_monitor import GpuMonitor @@ -189,7 +190,6 @@ class TestGpuSampleAggregation: [_snap(power=300, util=70, mem=15, temp=65)], ] timestamps = [0.0, 1.0, 2.0] - sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=2.0) assert sample.peak_power_watts == pytest.approx(400.0) assert sample.peak_utilization_pct == pytest.approx(95.0) @@ -205,7 +205,6 @@ class TestGpuSampleAggregation: [_snap(power=300, util=80, mem=16, temp=70)], ] timestamps = [0.0, 1.0, 2.0] - sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=2.0) assert sample.mean_power_watts == pytest.approx(200.0) assert sample.mean_utilization_pct == pytest.approx(60.0) @@ -217,7 +216,6 @@ class TestGpuSampleAggregation: # Tests: Multi-GPU aggregation # --------------------------------------------------------------------------- - class TestMultiGpu: def test_multi_device_power_sum(self): """Power summed across devices; util/temp averaged.""" @@ -229,7 +227,6 @@ class TestMultiGpu: ] snapshots = [tick, tick] timestamps = [0.0, 1.0] - sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=1.0) assert sample.energy_joules == pytest.approx(500.0) assert sample.mean_power_watts == pytest.approx(500.0) @@ -252,7 +249,6 @@ class TestMultiGpu: ], ] timestamps = [0.0, 2.0] - sample = GpuMonitor._aggregate(snapshots, timestamps, wall_duration=2.0) # Tick powers: 200, 600 => 0.5*(200+600)*2 = 800 assert sample.energy_joules == pytest.approx(800.0) @@ -263,7 +259,6 @@ class TestMultiGpu: # Tests: context manager flow (with mocked pynvml) # --------------------------------------------------------------------------- - class TestContextManager: def test_sample_context_manager(self): """sample() starts/stops polling and populates result.""" @@ -280,10 +275,8 @@ class TestContextManager: monitor._initialized = True monitor._device_count = 1 monitor._handles = ["handle-0"] - with monitor.sample() as result: time.sleep(0.1) - assert result.duration_seconds > 0 assert result.num_snapshots > 0 assert result.mean_power_watts > 0 @@ -301,10 +294,8 @@ class TestContextManager: monitor._handles = [] monitor._device_count = 0 monitor._initialized = False - with monitor.sample() as result: pass - assert result.num_snapshots == 0 assert result.energy_joules == 0.0 assert result.duration_seconds >= 0 @@ -314,7 +305,6 @@ class TestContextManager: # Tests: available() # --------------------------------------------------------------------------- - class TestAvailable: def test_available_false_when_pynvml_missing(self): """available() returns False when pynvml not importable.""" @@ -330,7 +320,6 @@ class TestAvailable: def test_available_true_with_fake_pynvml(self): """available() returns True when pynvml can init.""" fake_pynvml = _make_fake_pynvml() - with patch.dict(sys.modules, {"pynvml": fake_pynvml}): import openjarvis.telemetry.gpu_monitor as mod @@ -348,7 +337,6 @@ class TestAvailable: """available() returns False when nvmlInit raises.""" fake_pynvml = _make_fake_pynvml() fake_pynvml.nvmlInit.side_effect = RuntimeError("no driver") - with patch.dict(sys.modules, {"pynvml": fake_pynvml}): import openjarvis.telemetry.gpu_monitor as mod @@ -365,7 +353,6 @@ class TestAvailable: # Tests: dataclass defaults # --------------------------------------------------------------------------- - class TestDataclasses: def test_gpu_snapshot_defaults(self): from openjarvis.telemetry.gpu_monitor import GpuSnapshot From e087773b3688f0ecbc4a454ef86f91e28497d380 Mon Sep 17 00:00:00 2001 From: krypticmouse Date: Wed, 20 May 2026 20:22:44 +0000 Subject: [PATCH 10/10] style: ruff line-length + import fixes for #340 and #202 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the cherry-picks from @Dilligaf371's #340 and @bootcrowns's #202. Both fail ruff's E501 (88-char line limit) on main: - ``src/openjarvis/agents/executor.py:325`` (from #340) — the ``self._system is not None and getattr(..., "tool_executor", None) is not None`` guard was 101 chars. Wrapped the condition. - ``src/openjarvis/telemetry/gpu_monitor.py:55-60`` (from #202) — five new GPU_SPECS entries (Jetson Orin NX 16GB/8GB, AGX Orin, Snapdragon X Elite/Plus) were 90-93 chars. Wrapped each GpuHardwareSpec constructor call onto its own block. - ``tests/telemetry/test_gpu_monitor.py`` (from #202) — removed a spurious blank line between imports and the first ``#`` comment block, picked up by ``ruff check --fix`` (rule I001). No behavior change — pure formatting. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/openjarvis/agents/executor.py | 5 ++++- src/openjarvis/telemetry/gpu_monitor.py | 20 +++++++++++++++----- tests/telemetry/test_gpu_monitor.py | 1 - 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/openjarvis/agents/executor.py b/src/openjarvis/agents/executor.py index cc60c6a7..3feed1d3 100644 --- a/src/openjarvis/agents/executor.py +++ b/src/openjarvis/agents/executor.py @@ -322,7 +322,10 @@ class AgentExecutor: # adapters) that aren't in the static ToolRegistry. Without this, # agents declaring MCP-discovered tools in their template would # silently fall back to natives only. - if self._system is not None and getattr(self._system, "tool_executor", None) is not None: + if ( + self._system is not None + and getattr(self._system, "tool_executor", None) is not None + ): mcp_pool = getattr(self._system.tool_executor, "_tools", {}) or {} existing = {t.spec.name for t in tool_instances} for tname in tool_names: diff --git a/src/openjarvis/telemetry/gpu_monitor.py b/src/openjarvis/telemetry/gpu_monitor.py index 82dbfbb1..23927db9 100644 --- a/src/openjarvis/telemetry/gpu_monitor.py +++ b/src/openjarvis/telemetry/gpu_monitor.py @@ -52,12 +52,22 @@ GPU_SPECS: Dict[str, GpuHardwareSpec] = { "Arc B580": GpuHardwareSpec(tflops_fp16=196, bandwidth_gb_s=456, tdp_watts=190), "Arc B570": GpuHardwareSpec(tflops_fp16=136, bandwidth_gb_s=380, tdp_watts=150), # NVIDIA Jetson - "Jetson Orin NX 16GB": GpuHardwareSpec(tflops_fp16=50, bandwidth_gb_s=102, tdp_watts=25), - "Jetson Orin NX 8GB": GpuHardwareSpec(tflops_fp16=25, bandwidth_gb_s=68, tdp_watts=15), - "Jetson AGX Orin": GpuHardwareSpec(tflops_fp16=108, bandwidth_gb_s=204, tdp_watts=60), + "Jetson Orin NX 16GB": GpuHardwareSpec( + tflops_fp16=50, bandwidth_gb_s=102, tdp_watts=25 + ), + "Jetson Orin NX 8GB": GpuHardwareSpec( + tflops_fp16=25, bandwidth_gb_s=68, tdp_watts=15 + ), + "Jetson AGX Orin": GpuHardwareSpec( + tflops_fp16=108, bandwidth_gb_s=204, tdp_watts=60 + ), # Qualcomm - "Snapdragon X Elite": GpuHardwareSpec(tflops_fp16=4.6, bandwidth_gb_s=136, tdp_watts=80), - "Snapdragon X Plus": GpuHardwareSpec(tflops_fp16=3.8, bandwidth_gb_s=136, tdp_watts=80), + "Snapdragon X Elite": GpuHardwareSpec( + tflops_fp16=4.6, bandwidth_gb_s=136, tdp_watts=80 + ), + "Snapdragon X Plus": GpuHardwareSpec( + tflops_fp16=3.8, bandwidth_gb_s=136, tdp_watts=80 + ), } diff --git a/tests/telemetry/test_gpu_monitor.py b/tests/telemetry/test_gpu_monitor.py index 40aa2b1a..9ba6dfc5 100644 --- a/tests/telemetry/test_gpu_monitor.py +++ b/tests/telemetry/test_gpu_monitor.py @@ -10,7 +10,6 @@ from unittest.mock import MagicMock, patch import pytest - # --------------------------------------------------------------------------- # Helpers: build a fake pynvml module # ---------------------------------------------------------------------------