Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions tests/test_cluster_worker_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,134 @@ async def test_heartbeat_updates_kv_quant(self):
mgr.heartbeat("w1", kv_cache_quant_support=["fp16", "turboquant-k3v2"])
result = mgr.kv_quant_union()
assert "turboquant-k3v2" in result


# ---------------------------------------------------------------------------
# WorkerInfo.available_models field — sidecar-manifest-derived field
# ---------------------------------------------------------------------------

class TestWorkerInfoAvailableModels:
def test_default_is_empty_list(self):
w = WorkerInfo(name="w", url="http://localhost:9000")
assert w.available_models == []

def test_custom_value_stored(self):
models = [{"model_id": "qwen3.6-35b-a3b", "status": "available"}]
w = WorkerInfo(name="w", url="http://localhost:9000", available_models=models)
assert w.available_models == models

def test_serialises_via_asdict(self):
models = [
{"model_id": "qwen3.6-35b-a3b", "capability": "llm-chat", "status": "available"},
{"model_id": "qwen3-embedding-8b", "capability": "embedding", "status": "loaded"},
]
w = WorkerInfo(name="w", url="http://localhost:9000", available_models=models)
d = asdict(w)
assert "available_models" in d
assert d["available_models"] == models

def test_roundtrip_default(self):
w = WorkerInfo(name="w", url="http://localhost:9000")
d = asdict(w)
w2 = WorkerInfo(**{k: v for k, v in d.items()})
assert w2.available_models == []

def test_roundtrip_custom(self):
models = [{"model_id": "test-model", "status": "available"}]
w = WorkerInfo(name="w", url="http://localhost:9000", available_models=models)
d = asdict(w)
w2 = WorkerInfo(**{k: v for k, v in d.items()})
assert w2.available_models == models


# ---------------------------------------------------------------------------
# ClusterManager.heartbeat — available_models derivation from backends
# ---------------------------------------------------------------------------

class TestHeartbeatAvailableModels:
async def _register(self, mgr: ClusterManager, name: str) -> None:
w = WorkerInfo(name=name, url=f"http://localhost:900{name[-1]}")
await mgr.register_worker(w)

def test_heartbeat_derives_available_models_from_backends(self):
mgr = ClusterManager()
w = WorkerInfo(name="w1", url="http://localhost:9000")
mgr._workers["w1"] = w

backends = [
{
"name": "llama-cpp:9090",
"type": "llama-cpp",
"url": "http://localhost:9090",
"capabilities": ["llm-chat"],
"models": [{"name": "qwen35-9b"}],
"status": "ok",
"available_models": [
{"model_id": "qwen3.6-35b-a3b", "capability": "llm-chat", "status": "available"},
{"model_id": "harmonic-hermes-9b", "capability": "llm-chat", "status": "available"},
],
},
]
mgr.heartbeat("w1", backends=backends)
assert len(mgr._workers["w1"].available_models) == 2
assert mgr._workers["w1"].available_models[0]["model_id"] == "qwen3.6-35b-a3b"

def test_heartbeat_no_available_models_in_backends(self):
"""Backends without available_models should leave the field empty."""
mgr = ClusterManager()
w = WorkerInfo(name="w1", url="http://localhost:9000")
mgr._workers["w1"] = w

backends = [
{
"name": "ollama:11434",
"type": "ollama",
"url": "http://localhost:11434",
"models": [{"name": "llama3"}],
"status": "ok",
},
]
mgr.heartbeat("w1", backends=backends)
assert mgr._workers["w1"].available_models == []

def test_heartbeat_deduplicates_across_backends(self):
"""Same model_id appearing in multiple backends is deduplicated."""
mgr = ClusterManager()
w = WorkerInfo(name="w1", url="http://localhost:9000")
mgr._workers["w1"] = w

backends = [
{
"name": "llama-cpp:9090",
"type": "llama-cpp",
"url": "http://localhost:9090",
"available_models": [
{"model_id": "shared-model", "status": "available"},
],
},
{
"name": "llama-cpp:9091",
"type": "llama-cpp",
"url": "http://localhost:9091",
"available_models": [
{"model_id": "shared-model", "status": "available"},
{"model_id": "unique-model", "status": "available"},
],
},
]
mgr.heartbeat("w1", backends=backends)
# shared-model should appear only once
model_ids = [m["model_id"] for m in mgr._workers["w1"].available_models]
assert len(model_ids) == 2
assert model_ids == ["shared-model", "unique-model"]

def test_heartbeat_available_models_is_additive(self):
"""When heartbeat omits backends, existing available_models is untouched."""
mgr = ClusterManager()
models = [{"model_id": "existing", "status": "available"}]
w = WorkerInfo(name="w1", url="http://localhost:9000", available_models=models)
mgr._workers["w1"] = w

# Heartbeat without backends — available_models unchanged
mgr.heartbeat("w1", load=0.5)
assert mgr._workers["w1"].available_models == models
173 changes: 173 additions & 0 deletions tests/test_skald_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Tests for tinyagentos.worker.skald_manifest — sidecar manifest parser."""
from __future__ import annotations

import json
import os
from pathlib import Path
from unittest.mock import patch

import pytest

from tinyagentos.worker.skald_manifest import (
DEFAULT_MANIFEST_PATH,
SOFTWARE_TO_BACKEND_TYPE,
load_manifest,
)


# ---------------------------------------------------------------------------
# SOFTWARE_TO_BACKEND_TYPE mapping
# ---------------------------------------------------------------------------

class TestSoftwareToBackendType:
def test_llamacpp_maps_to_llama_cpp(self):
assert SOFTWARE_TO_BACKEND_TYPE["llamacpp"] == "llama-cpp"

def test_embed_maps_to_llama_cpp(self):
assert SOFTWARE_TO_BACKEND_TYPE["embed"] == "llama-cpp"

def test_kokoro_maps_to_kokoro(self):
assert SOFTWARE_TO_BACKEND_TYPE["kokoro"] == "kokoro"

def test_whisper_maps_to_whisper(self):
assert SOFTWARE_TO_BACKEND_TYPE["whisper"] == "whisper"

def test_unknown_software_raises_keyerror(self):
with pytest.raises(KeyError):
_ = SOFTWARE_TO_BACKEND_TYPE["nonexistent-software"]


# ---------------------------------------------------------------------------
# load_manifest — happy paths
# ---------------------------------------------------------------------------

MANIFEST_FIXTURE = {
"resource_id": "gtx1080",
"models": [
{
"model_id": "qwen3.6-35b-a3b",
"capability": "llm-chat",
"software": "llamacpp",
"port": 9090,
"vram_required_gb": 7.2,
"health_url": "http://127.0.0.1:9090/health",
},
{
"model_id": "qwen3-embedding-8b",
"capability": "embedding",
"software": "embed",
"port": 8081,
"vram_required_gb": 4.5,
"health_url": "http://127.0.0.1:8081/health",
},
{
"model_id": "whisper-large-v3",
"capability": "stt",
"software": "whisper",
"port": 0,
"vram_required_gb": 1.5,
"health_url": "",
},
],
}


class TestLoadManifestHappy:
def test_loads_valid_manifest_file(self, tmp_path: Path):
manifest_path = tmp_path / "skald-models.json"
manifest_path.write_text(json.dumps(MANIFEST_FIXTURE))

result = load_manifest(str(manifest_path))
assert result["resource_id"] == "gtx1080"
assert len(result["models"]) == 3
assert result["models"][0]["model_id"] == "qwen3.6-35b-a3b"

def test_returns_model_fields(self, tmp_path: Path):
manifest_path = tmp_path / "skald-models.json"
manifest_path.write_text(json.dumps(MANIFEST_FIXTURE))

result = load_manifest(str(manifest_path))
m0 = result["models"][0]
assert m0["capability"] == "llm-chat"
assert m0["software"] == "llamacpp"
assert m0["port"] == 9090
assert m0["vram_required_gb"] == 7.2
assert m0["health_url"] == "http://127.0.0.1:9090/health"

def test_env_var_overrides_path(self, tmp_path: Path, monkeypatch):
manifest_path = tmp_path / "custom-manifest.json"
manifest_path.write_text(json.dumps(MANIFEST_FIXTURE))
monkeypatch.setenv("TAOS_SKALD_MANIFEST", str(manifest_path))

result = load_manifest()
assert result["resource_id"] == "gtx1080"


# ---------------------------------------------------------------------------
# load_manifest — missing file
# ---------------------------------------------------------------------------

class TestLoadManifestAbsent:
def test_missing_file_returns_empty_manifest(self, tmp_path: Path):
nonexistent = tmp_path / "does-not-exist.json"
result = load_manifest(str(nonexistent))
assert result == {"resource_id": "", "models": []}

def test_default_path_absent_returns_empty(self, monkeypatch):
# Ensure env var is unset and default path doesn't exist
monkeypatch.delenv("TAOS_SKALD_MANIFEST", raising=False)
# Patch Path.exists to return False for the default path
with patch.object(Path, "exists", return_value=False):
result = load_manifest()
assert result == {"resource_id": "", "models": []}


# ---------------------------------------------------------------------------
# load_manifest — edge cases
# ---------------------------------------------------------------------------

class TestLoadManifestEdgeCases:
def test_empty_models_list(self, tmp_path: Path):
manifest = {"resource_id": "gtx1080", "models": []}
manifest_path = tmp_path / "empty.json"
manifest_path.write_text(json.dumps(manifest))

result = load_manifest(str(manifest_path))
assert result["resource_id"] == "gtx1080"
assert result["models"] == []

def test_no_resource_id(self, tmp_path: Path):
manifest = {"models": [{"model_id": "test"}]}
manifest_path = tmp_path / "no-resource.json"
manifest_path.write_text(json.dumps(manifest))

result = load_manifest(str(manifest_path))
# JSON parsing succeeds; missing keys aren't added
assert "resource_id" not in result # it won't be added by load_manifest
assert len(result["models"]) == 1

def test_invalid_json_raises(self, tmp_path: Path):
manifest_path = tmp_path / "bad.json"
manifest_path.write_text("not valid json {{{")

with pytest.raises(json.JSONDecodeError):
load_manifest(str(manifest_path))

def test_none_path_falls_back_to_env(self, tmp_path: Path, monkeypatch):
manifest_path = tmp_path / "from-env.json"
manifest_path.write_text(json.dumps(MANIFEST_FIXTURE))
monkeypatch.setenv("TAOS_SKALD_MANIFEST", str(manifest_path))

result = load_manifest(None)
assert result["resource_id"] == "gtx1080"

def test_no_models_key_returns_empty_models(self, tmp_path: Path):
"""A valid JSON doc without a 'models' key — treated as empty."""
manifest = {"resource_id": "bare"}
manifest_path = tmp_path / "bare.json"
manifest_path.write_text(json.dumps(manifest))

result = load_manifest(str(manifest_path))
assert result["resource_id"] == "bare"
# load_manifest doesn't add missing keys, consumer code checks .get("models")
assert "models" not in result
12 changes: 12 additions & 0 deletions tinyagentos/cluster/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,18 @@ def heartbeat(
if name_m and name_m not in flat_models:
flat_models.append(name_m)
worker.models = flat_models
# Derive available_models from the live backend catalog.
# Each backend may carry a sidecar-manifest-enriched
# available_models list; flatten across all backends.
flat_available: list[dict] = []
seen_ids: set[str] = set()
for b in backends:
for m in b.get("available_models") or []:
model_id = m.get("model_id") or ""
if model_id and model_id not in seen_ids:
seen_ids.add(model_id)
flat_available.append(m)
worker.available_models = flat_available
if capabilities is not None:
worker.capabilities = list(capabilities)
if kv_cache_quant_support is not None:
Expand Down
1 change: 1 addition & 0 deletions tinyagentos/cluster/worker_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class WorkerInfo:
hardware: dict = field(default_factory=dict) # From hardware detection
backends: list[dict] = field(default_factory=list) # Available inference backends
models: list[str] = field(default_factory=list) # Currently loaded models
available_models: list[dict] = field(default_factory=list) # Models this worker CAN load (from Skald sidecar manifest)
capabilities: list[str] = field(default_factory=list) # embed, chat, rerank, image-gen, tts, etc
status: str = "online" # online | offline | busy
last_heartbeat: float = 0
Expand Down
36 changes: 36 additions & 0 deletions tinyagentos/worker/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,42 @@ async def detect_backends(self) -> list[dict]:
# its cluster-level kv_cache_quant_support advertisement.
"kv_quant_support": kv_quant,
})

# Enrich each backend with available models from the Skald sidecar
# manifest. The manifest declares which models this machine *can*
# run (per the GPU catalog), independently of what is currently
# loaded. The controller then sees both "loaded" and "available"
# states so the cluster-wide view reflects total capacity.
from tinyagentos.worker.skald_manifest import (
load_manifest,
SOFTWARE_TO_BACKEND_TYPE,
)

manifest = load_manifest()
if manifest.get("models"):
for backend in backends:
backend_type = backend["type"]
probed_names = {
m.get("name", "") for m in backend.get("models", [])
}
available = []
for m in manifest["models"]:
if SOFTWARE_TO_BACKEND_TYPE.get(m.get("software", "")) != backend_type:
continue
model_id = m["model_id"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing model_id crashes probe

Medium Severity

Manifest enrichment uses m["model_id"] without a fallback. A valid JSON manifest with a model object missing model_id raises KeyError inside detect_backends, breaking registration and heartbeats until the file is fixed.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d58e6e. Configure here.

status = "loaded" if model_id in probed_names else "available"
available.append({
"model_id": model_id,
"capability": m.get("capability", ""),
"software": m.get("software", ""),
"port": m.get("port", 0),
"vram_required_gb": m.get("vram_required_gb", 0.0),
"health_url": m.get("health_url", ""),
"status": status,
})
if available:
backend["available_models"] = available

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manifest ignores backend port

Medium Severity

Sidecar entries are attached to every probed backend whose type matches SOFTWARE_TO_BACKEND_TYPE, without comparing manifest port/health_url to the backend URL. Multiple llama-cpp instances or Skald services on non-default ports can get the wrong models, ports, and health URLs on the cluster view.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7d58e6e. Configure here.


Comment on lines +133 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Manifest enrichment can crash register() on malformed/racy sidecar data.

This block has no exception handling, and both indexing (m["model_id"]) and file parsing failures propagate uncaught. heartbeat() (line 434) wraps its entire backend probe in try/except and safely returns 0 on failure, but register() (line 378) calls detect_backends() directly with no protection, and run()'s registration branch (lines 474-479) doesn't guard it either. A malformed or transiently-rewritten manifest file (e.g. JSONDecodeError, a missing model_id key, or a non-dict JSON root) would propagate an unhandled exception out of register(), potentially breaking the worker's registration loop entirely.

🛡️ Proposed fix: contain manifest failures locally
-        from tinyagentos.worker.skald_manifest import (
-            load_manifest,
-            SOFTWARE_TO_BACKEND_TYPE,
-        )
-
-        manifest = load_manifest()
-        if manifest.get("models"):
+        from tinyagentos.worker.skald_manifest import (
+            load_manifest,
+            SOFTWARE_TO_BACKEND_TYPE,
+        )
+
+        try:
+            manifest = load_manifest()
+        except Exception:
+            manifest = {}
+
+        if isinstance(manifest, dict) and manifest.get("models"):
             for backend in backends:
                 backend_type = backend["type"]
                 probed_names = {
                     m.get("name", "") for m in backend.get("models", [])
                 }
                 available = []
                 for m in manifest["models"]:
                     if SOFTWARE_TO_BACKEND_TYPE.get(m.get("software", "")) != backend_type:
                         continue
-                    model_id = m["model_id"]
+                    model_id = m.get("model_id")
+                    if not model_id:
+                        continue
                     status = "loaded" if model_id in probed_names else "available"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from tinyagentos.worker.skald_manifest import (
load_manifest,
SOFTWARE_TO_BACKEND_TYPE,
)
manifest = load_manifest()
if manifest.get("models"):
for backend in backends:
backend_type = backend["type"]
probed_names = {
m.get("name", "") for m in backend.get("models", [])
}
available = []
for m in manifest["models"]:
if SOFTWARE_TO_BACKEND_TYPE.get(m.get("software", "")) != backend_type:
continue
model_id = m["model_id"]
status = "loaded" if model_id in probed_names else "available"
available.append({
"model_id": model_id,
"capability": m.get("capability", ""),
"software": m.get("software", ""),
"port": m.get("port", 0),
"vram_required_gb": m.get("vram_required_gb", 0.0),
"health_url": m.get("health_url", ""),
"status": status,
})
if available:
backend["available_models"] = available
from tinyagentos.worker.skald_manifest import (
load_manifest,
SOFTWARE_TO_BACKEND_TYPE,
)
try:
manifest = load_manifest()
except Exception:
manifest = {}
if isinstance(manifest, dict) and manifest.get("models"):
for backend in backends:
backend_type = backend["type"]
probed_names = {
m.get("name", "") for m in backend.get("models", [])
}
available = []
for m in manifest["models"]:
if SOFTWARE_TO_BACKEND_TYPE.get(m.get("software", "")) != backend_type:
continue
model_id = m.get("model_id")
if not model_id:
continue
status = "loaded" if model_id in probed_names else "available"
available.append({
"model_id": model_id,
"capability": m.get("capability", ""),
"software": m.get("software", ""),
"port": m.get("port", 0),
"vram_required_gb": m.get("vram_required_gb", 0.0),
"health_url": m.get("health_url", ""),
"status": status,
})
if available:
backend["available_models"] = available
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/worker/agent.py` around lines 133 - 162, The manifest enrichment
in detect_backends() can throw and break register() when sidecar data is
malformed or mid-write. Add local exception handling around load_manifest() and
the per-model access in this block, and make missing or invalid manifest entries
fail soft by skipping enrichment instead of propagating. Keep the existing
backend loop and available_models shaping intact, but ensure register() and the
run() registration path stay resilient even if manifest parsing or model_id
lookup fails.

return backends

async def _probe_kv_quant(
Expand Down
Loading
Loading