diff --git a/services/hermes/plugins/cluster-read/__init__.py b/services/hermes/plugins/cluster-read/__init__.py index 7758f195..921de89b 100644 --- a/services/hermes/plugins/cluster-read/__init__.py +++ b/services/hermes/plugins/cluster-read/__init__.py @@ -119,6 +119,11 @@ def _strip_noise(payload: Any) -> Any: if isinstance(annotations, dict): for key in [k for k in annotations if "last-applied" in k]: annotations.pop(key, None) + status = payload.get("status") + if isinstance(status, dict) and isinstance(status.get("images"), list): + # Node .status.images is a huge blob of every cached image; it + # never helps the model and routinely overflows the size cap. + status["images"] = f"[{len(status['images'])} cached images omitted]" for value in payload.values(): _strip_noise(value) elif isinstance(payload, list): @@ -149,17 +154,20 @@ def _handle_cluster_read(args: dict[str, Any] | None = None, **kwargs: Any) -> s return json.dumps({"error": f"cluster API returned {error.code}", "detail": detail[:400]}) except Exception as error: # noqa: BLE001 - tool result, never an exception into the loop return json.dumps({"error": f"cluster API unavailable: {type(error).__name__}"}) - truncated = len(raw) > MAX_RESPONSE_BYTES + over_wire = len(raw) > MAX_RESPONSE_BYTES + if over_wire: + # A truncated wire body is not parseable JSON; hand back the readable + # prefix rather than failing, so a large list still yields something. + return json.dumps({"truncated": True, "reason": "response exceeded size cap; narrow with namespace/name/label_selector", + "partial": raw[: MAX_RESPONSE_BYTES - 96].decode("utf-8", "replace")}) try: - payload = _strip_noise(json.loads(raw[:MAX_RESPONSE_BYTES] if truncated else raw)) + payload = _strip_noise(json.loads(raw)) except ValueError: return json.dumps({"error": "cluster API returned non-JSON data"}) body = json.dumps(payload, separators=(",", ":")) if len(body) > MAX_RESPONSE_BYTES: - body = body[:MAX_RESPONSE_BYTES] - truncated = True - if truncated: - return json.dumps({"truncated": True, "partial": body[: MAX_RESPONSE_BYTES - 64]}) + return json.dumps({"truncated": True, "reason": "result exceeded size cap after cleanup; narrow the query", + "partial": body[: MAX_RESPONSE_BYTES - 96]}) return body diff --git a/testing/tests/test_hermes_cluster_read_plugin.py b/testing/tests/test_hermes_cluster_read_plugin.py index bad7dde0..dc62ba59 100644 --- a/testing/tests/test_hermes_cluster_read_plugin.py +++ b/testing/tests/test_hermes_cluster_read_plugin.py @@ -86,7 +86,17 @@ def test_handler_is_get_only_and_bounded(monkeypatch, tmp_path): monkeypatch.setattr(mod.urllib.request, "urlopen", huge_open) out = json.loads(mod._handle_cluster_read({"resource": "pods"})) - assert out.get("truncated") is True or out.get("error") + assert out.get("truncated") is True and "partial" in out + + def node_open(request, timeout=None, context=None): + payload = {"kind": "NodeList", "items": [{"metadata": {"name": "n"}, + "status": {"images": [{"names": ["x"], "sizeBytes": 1} for _ in range(500)]}}]} + return FakeResponse(json.dumps(payload).encode()) + + monkeypatch.setattr(mod.urllib.request, "urlopen", node_open) + out = json.loads(mod._handle_cluster_read({"resource": "nodes"})) + assert out["kind"] == "NodeList" + assert "cached images omitted" in json.dumps(out) def test_errors_never_raise(monkeypatch, tmp_path):