Consolidate contributor PRs #340 + #341 + #301 + #347 + #202 (#367)

Consolidates five contributor PRs into one bundle to avoid overlapping issues (notably #340/#341 both touching mcp/transport.py + agent_cmd.py + executor.py). Original authorship preserved on every commit. See PR #367 for full per-author breakdown.

Credits: @Dilligaf371 (#340, #341), @eddierichter-amd (#301), @kriptoburak (#347), @bootcrowns (#202).
This commit is contained in:
Jon Saad-Falcon
2026-05-20 13:37:46 -07:00
committed by GitHub
13 changed files with 158 additions and 47 deletions
+5 -5
View File
@@ -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.
+8
View File
@@ -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:
+24
View File
@@ -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:
+20 -2
View File
@@ -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"]
+8 -5
View File
@@ -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
@@ -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():
+4
View File
@@ -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
]
+23 -5
View File
@@ -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:
+25
View File
@@ -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:
+14
View File
@@ -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."""
+1 -1
View File
@@ -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),
]
+18 -7
View File
@@ -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={
+7 -21
View File
@@ -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