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/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: diff --git a/src/openjarvis/agents/executor.py b/src/openjarvis/agents/executor.py index 8d6be76f..3feed1d3 100644 --- a/src/openjarvis/agents/executor.py +++ b/src/openjarvis/agents/executor.py @@ -317,6 +317,24 @@ 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 +350,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: 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"] 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/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 ] diff --git a/src/openjarvis/telemetry/gpu_monitor.py b/src/openjarvis/telemetry/gpu_monitor.py index 6690b941..23927db9 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,6 +48,26 @@ 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 + ), } @@ -70,7 +88,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.""" @@ -103,7 +120,6 @@ class GpuSample: # Monitor # --------------------------------------------------------------------------- - class GpuMonitor: """Background GPU poller using pynvml. @@ -219,6 +235,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) @@ -256,7 +273,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 @@ -276,11 +292,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: 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..385ed25d 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,22 @@ 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 +78,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 +91,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 +107,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={ diff --git a/tests/telemetry/test_gpu_monitor.py b/tests/telemetry/test_gpu_monitor.py index e4b0f1dc..9ba6dfc5 100644 --- a/tests/telemetry/test_gpu_monitor.py +++ b/tests/telemetry/test_gpu_monitor.py @@ -14,7 +14,6 @@ import pytest # Helpers: build a fake pynvml module # --------------------------------------------------------------------------- - @dataclass class _FakeUtilization: gpu: int = 80 @@ -60,7 +59,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 +105,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 +127,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 +134,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 +149,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 +169,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 +179,6 @@ class TestEnergyIntegration: # Tests: GpuSample aggregation # --------------------------------------------------------------------------- - class TestGpuSampleAggregation: def test_peak_values(self): from openjarvis.telemetry.gpu_monitor import GpuMonitor @@ -189,7 +189,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 +204,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 +215,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 +226,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 +248,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 +258,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 +274,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 +293,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 +304,6 @@ class TestContextManager: # Tests: available() # --------------------------------------------------------------------------- - class TestAvailable: def test_available_false_when_pynvml_missing(self): """available() returns False when pynvml not importable.""" @@ -330,7 +319,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 +336,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 +352,6 @@ class TestAvailable: # Tests: dataclass defaults # --------------------------------------------------------------------------- - class TestDataclasses: def test_gpu_snapshot_defaults(self): from openjarvis.telemetry.gpu_monitor import GpuSnapshot