mlspace-plugin 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. mlspace_mcp/__init__.py +3 -0
  2. mlspace_mcp/__main__.py +125 -0
  3. mlspace_mcp/_status.py +18 -0
  4. mlspace_mcp/auth.py +109 -0
  5. mlspace_mcp/bodyspec.py +184 -0
  6. mlspace_mcp/client.py +170 -0
  7. mlspace_mcp/client_setup.py +235 -0
  8. mlspace_mcp/config.py +283 -0
  9. mlspace_mcp/errors.py +241 -0
  10. mlspace_mcp/formatting.py +502 -0
  11. mlspace_mcp/init_cli.py +356 -0
  12. mlspace_mcp/job_hooks.py +242 -0
  13. mlspace_mcp/jsonc.py +90 -0
  14. mlspace_mcp/overview.py +421 -0
  15. mlspace_mcp/registry.py +632 -0
  16. mlspace_mcp/resource_refs.py +188 -0
  17. mlspace_mcp/server.py +190 -0
  18. mlspace_mcp/setup_ui.py +103 -0
  19. mlspace_mcp/skills/mlspace-call-deployed-model-predict/SKILL.md +36 -0
  20. mlspace_mcp/skills/mlspace-deploy-inference-service/SKILL.md +42 -0
  21. mlspace_mcp/skills/mlspace-diagnose-data-transfer/SKILL.md +41 -0
  22. mlspace_mcp/skills/mlspace-diagnose-failed-training-job/SKILL.md +64 -0
  23. mlspace_mcp/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +44 -0
  24. mlspace_mcp/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +40 -0
  25. mlspace_mcp/skills/mlspace-discover-launchable-resources/SKILL.md +45 -0
  26. mlspace_mcp/skills/mlspace-launch-training-job/SKILL.md +66 -0
  27. mlspace_mcp/skills/mlspace-list-running-jobs/SKILL.md +43 -0
  28. mlspace_mcp/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +44 -0
  29. mlspace_mcp/skills/mlspace-stage-s3-to-nfs/SKILL.md +45 -0
  30. mlspace_mcp/skills/mlspace-transfer-compute-node/SKILL.md +63 -0
  31. mlspace_mcp/spec/body_schemas.json +7506 -0
  32. mlspace_mcp/spec/openapi.json +28347 -0
  33. mlspace_mcp/tls.py +46 -0
  34. mlspace_mcp/tools/__init__.py +1 -0
  35. mlspace_mcp/tools/allocations.py +92 -0
  36. mlspace_mcp/tools/async_inference.py +77 -0
  37. mlspace_mcp/tools/build_image.py +76 -0
  38. mlspace_mcp/tools/dalle.py +52 -0
  39. mlspace_mcp/tools/data_transfer.py +260 -0
  40. mlspace_mcp/tools/docker_registry.py +156 -0
  41. mlspace_mcp/tools/inference.py +88 -0
  42. mlspace_mcp/tools/jobs.py +155 -0
  43. mlspace_mcp/tools/notebooks.py +174 -0
  44. mlspace_mcp/tools/queues.py +202 -0
  45. mlspace_mcp/tools/resources.py +86 -0
  46. mlspace_mcp/tools/tensorboards.py +112 -0
  47. mlspace_mcp/tools/workspaces.py +92 -0
  48. mlspace_mcp/workspace_context.py +107 -0
  49. mlspace_plugin-0.2.1.dist-info/METADATA +427 -0
  50. mlspace_plugin-0.2.1.dist-info/RECORD +53 -0
  51. mlspace_plugin-0.2.1.dist-info/WHEEL +4 -0
  52. mlspace_plugin-0.2.1.dist-info/entry_points.txt +2 -0
  53. mlspace_plugin-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,502 @@
1
+ """Response shaping for tool output.
2
+
3
+ v1 policy (deliberately minimal — see DESIGN.md §formatting):
4
+ - default ``json``: compact structured passthrough with an explicit, testable
5
+ noise-key drop list (k8s manifest cruft) — NOT a universal recursive markdown
6
+ renderer, which mangles the manifests many GET endpoints return.
7
+ - long strings (base64 images, embedded blobs) are elided with a size note.
8
+ - arrays are capped to ``list_cap`` rendered rows with a "… N more" footer.
9
+ - caller-supplied ``redact_keys`` are masked (credential-returning reads).
10
+ - ``kind="log"`` returns the last ``log_tail`` lines with a truncation header.
11
+ - ``kind="catalog"`` losslessly de-duplicates the configs cross-product: the
12
+ same heavy image objects are re-listed under every instance_type, so we hoist
13
+ them into a per-region ``image_catalog`` and leave light ``name:tag`` refs in
14
+ place (the exact per-instance image mapping is preserved). Fail-open on shape.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from typing import Any
22
+
23
+ from ._status import JOB_STATUS_CANON
24
+
25
+ # Keys dropped from API JSON to cut Kubernetes/manifest noise.
26
+ # Deliberately NOT including the generic name "annotations": dropping it globally
27
+ # at every depth would silently delete legitimate user/model metadata fields. The
28
+ # bulky k8s annotation blob it carries is the last-applied-config, dropped by name.
29
+ NOISE_KEYS = frozenset(
30
+ {
31
+ "managedFields",
32
+ "resourceVersion",
33
+ "ownerReferences",
34
+ "last-applied-configuration",
35
+ "kubectl.kubernetes.io/last-applied-configuration",
36
+ }
37
+ )
38
+
39
+ _STR_ELIDE_LEN = 4096
40
+ _REDACTED = "<redacted>"
41
+
42
+ # Advisory attached to each catalog region: the listed instance_types are the
43
+ # region's full catalog, NOT what the caller's allocation actually grants. Worded
44
+ # without any ": " so it can't trip the compact-catalog separator assertions.
45
+ _ALLOCATION_NOTE = (
46
+ "instance_types here is the region's full catalog and is NOT filtered by your "
47
+ "allocation. A catalog-valid type can still be rejected at submit with a 400 "
48
+ "«not available in the allocation <tariff>»; the per-allocation availability "
49
+ "endpoint (mlspace_resources instance_types) may return 405 on PAYG — so the "
50
+ "only reliable check is to try a run."
51
+ )
52
+
53
+ # Marker that replaces a repo list element's heavy recent_image.extra_attrs (~39%
54
+ # of the response). Elision, not a silent drop: it names what was omitted and where
55
+ # to get the full value (get_image).
56
+ _EXTRA_ATTRS_MARKER = "<omitted in list — call get_image for the full docker config>"
57
+
58
+
59
+ def _elide_str(value: str) -> str:
60
+ if len(value) > _STR_ELIDE_LEN:
61
+ return f"<{len(value)} chars elided>"
62
+ return value
63
+
64
+
65
+ def _mask_secret(value: Any) -> Any:
66
+ """Uniform masking for a redacted value: keep type-shape signal, drop content."""
67
+ if isinstance(value, str):
68
+ return f"<redacted, {len(value)} chars>"
69
+ if value is None:
70
+ return None
71
+ return _REDACTED
72
+
73
+
74
+ # Heuristic secret-key detection for the dry-run preview body, where a write domain
75
+ # may not declare exact `redact_result_keys` (e.g. a data-transfer connector body can
76
+ # carry a password / security_key). Matched by NAME substring — over-redacting a
77
+ # preview is safe; leaking a credential into tool output/traces is not.
78
+ _SECRET_NEEDLES = (
79
+ "password", "passwd", "secret", "token", "credential",
80
+ "private_key", "security_key", "access_key", "api_key", "apikey",
81
+ )
82
+
83
+
84
+ def _looks_secret(key: Any) -> bool:
85
+ return isinstance(key, str) and any(n in key.lower() for n in _SECRET_NEEDLES)
86
+
87
+
88
+ def redact_secrets(obj: Any) -> Any:
89
+ """Recursively mask values under secret-looking keys (by key NAME). Pure and
90
+ fail-safe: non-dict/list inputs pass through. Used for the dry-run preview body."""
91
+ if isinstance(obj, dict):
92
+ return {
93
+ k: _mask_secret(v) if _looks_secret(k) else redact_secrets(v)
94
+ for k, v in obj.items()
95
+ }
96
+ if isinstance(obj, list):
97
+ return [redact_secrets(v) for v in obj]
98
+ return obj
99
+
100
+
101
+ def _clean(obj: Any, redact_keys: frozenset[str], list_cap: int) -> Any:
102
+ """Recursively drop noise keys, redact secrets, elide blobs, cap arrays."""
103
+ if isinstance(obj, dict):
104
+ out: dict[str, Any] = {}
105
+ for key, value in obj.items():
106
+ if key in NOISE_KEYS:
107
+ continue
108
+ if key in redact_keys:
109
+ out[key] = _mask_secret(value)
110
+ continue
111
+ out[key] = _clean(value, redact_keys, list_cap)
112
+ return out
113
+ if isinstance(obj, list):
114
+ cleaned = [_clean(v, redact_keys, list_cap) for v in obj[:list_cap]]
115
+ if len(obj) > list_cap:
116
+ cleaned.append(f"… {len(obj) - list_cap} more item(s) omitted (use offset/limit to page)")
117
+ return cleaned
118
+ if isinstance(obj, str):
119
+ return _elide_str(obj)
120
+ return obj
121
+
122
+
123
+ def _image_ref(img: Any) -> str | None:
124
+ """Stable ``name:tag`` identity for a configs image object, or None if the
125
+ object isn't shaped like an image (no ``name``) — caller then leaves it inline."""
126
+ if not isinstance(img, dict) or "name" not in img:
127
+ return None
128
+ name = img.get("name")
129
+ tags = img.get("tags")
130
+ if isinstance(tags, list):
131
+ tag_str = ",".join(str(t) for t in tags)
132
+ elif tags is None:
133
+ tag_str = ""
134
+ else:
135
+ tag_str = str(tags)
136
+ return f"{name}:{tag_str}"
137
+
138
+
139
+ def _shrink_region(region: Any) -> Any:
140
+ """Hoist a region's repeated image objects into a deduped ``image_catalog``,
141
+ replacing each instance_type's ``images`` with light refs. Fail-open: any
142
+ sub-structure that isn't the expected shape is passed through untouched."""
143
+ if not isinstance(region, dict):
144
+ return region
145
+ instances = region.get("instances_types")
146
+ if not isinstance(instances, list):
147
+ return region
148
+
149
+ catalog: dict[str, Any] = {}
150
+ new_instances: list[Any] = []
151
+ for inst in instances:
152
+ if not isinstance(inst, dict) or not isinstance(inst.get("images"), list):
153
+ new_instances.append(inst)
154
+ continue
155
+ refs: list[Any] = []
156
+ for img in inst["images"]:
157
+ ref = _image_ref(img)
158
+ if ref is None:
159
+ # not image-shaped — keep inline so nothing is lost
160
+ refs.append(img)
161
+ continue
162
+ existing = catalog.get(ref)
163
+ if existing is None:
164
+ catalog[ref] = img
165
+ elif existing != img:
166
+ # same name:tag but different object — disambiguate to stay lossless
167
+ n = 2
168
+ ref = f"{ref}#{n}"
169
+ while ref in catalog and catalog[ref] != img:
170
+ n += 1
171
+ ref = f"{ref.rsplit('#', 1)[0]}#{n}"
172
+ catalog.setdefault(ref, img)
173
+ refs.append(ref)
174
+ new_inst = dict(inst)
175
+ new_inst["images"] = refs
176
+ new_instances.append(new_inst)
177
+
178
+ new_region = dict(region)
179
+ new_region["instances_types"] = new_instances
180
+ # don't clobber a (hypothetical) pre-existing region key of the same name
181
+ key = "image_catalog" if "image_catalog" not in new_region else "_image_catalog"
182
+ new_region[key] = catalog
183
+ # Only on the matched shape with a NON-EMPTY catalog: warn that this list is the
184
+ # region's full catalog, not the caller's allocation grant (a region with no
185
+ # instances_types returned early above and stays untouched).
186
+ if instances:
187
+ new_region.setdefault("_allocation_note", _ALLOCATION_NOTE)
188
+ return new_region
189
+
190
+
191
+ def _region_matches(region: Any, wanted: str) -> bool:
192
+ """True if a region's ``key`` or ``name`` equals ``wanted`` (case-insensitive)."""
193
+ if not isinstance(region, dict):
194
+ return False
195
+ target = wanted.casefold()
196
+ for field_name in ("key", "name"):
197
+ value = region.get(field_name)
198
+ if isinstance(value, str) and value.casefold() == target:
199
+ return True
200
+ return False
201
+
202
+
203
+ def _shrink_configs(data: Any, region: str | None = None) -> Any:
204
+ """Losslessly shrink a configs-shaped dict (``{"regions": [...]}``). Returns
205
+ the input untouched if it isn't the expected shape (fail-open).
206
+
207
+ If ``region`` is given, keep only the region whose ``key``/``name`` matches it
208
+ (case-insensitive). If nothing matches, keep all regions (fail-open). The
209
+ image-dedup is still applied to whichever region(s) are kept."""
210
+ if not isinstance(data, dict) or not isinstance(data.get("regions"), list):
211
+ return data
212
+ regions = data["regions"]
213
+ if region:
214
+ filtered = [r for r in regions if _region_matches(r, region)]
215
+ if filtered: # fail-open: empty match → keep all
216
+ regions = filtered
217
+ out = dict(data)
218
+ out["regions"] = [_shrink_region(r) for r in regions]
219
+ return out
220
+
221
+
222
+ def _to_json(obj: Any) -> str:
223
+ return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
224
+
225
+
226
+ def _to_json_compact(obj: Any) -> str:
227
+ return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=str)
228
+
229
+
230
+ def _to_markdown(obj: Any) -> str:
231
+ """Thin v1 markdown: a JSON code fence (no lossy recursive rendering)."""
232
+ return "```json\n" + _to_json(obj) + "\n```"
233
+
234
+
235
+ def _normalise_job(data: Any) -> Any:
236
+ """Type a jobs `get` response: flag the created→'job not found' race and the
237
+ failed+empty-error trap, and add a canonical status. Strictly fail-open and
238
+ additive — original ``status``/``error_code``/``error_message`` are untouched,
239
+ only ``_wrapper_note``/``status_canonical`` (leading-underscore / unused keys)
240
+ are added.
241
+
242
+ ``_wrapper_note`` names BOTH indistinguishable causes and picks neither, so the
243
+ wrapper translates the response shape without inventing a reason. Overlaps the
244
+ mlspace-diagnose-failed-training-job skill (which also says error_code/error_message are deprecated —
245
+ ignore) ON PURPOSE: this is the inline defense for a caller who went straight to
246
+ get without loading the skill."""
247
+ if not isinstance(data, dict):
248
+ return data
249
+ out = dict(data)
250
+ raw = str(data.get("status", ""))
251
+ status = raw.casefold()
252
+ if status == "job not found":
253
+ out["_wrapper_note"] = (
254
+ "status='job not found' under HTTP 200 has two indistinguishable causes "
255
+ "and this body picks NEITHER. (1) The job was created <~2-3s ago and has "
256
+ "not propagated yet — retry the get in a few seconds BEFORE concluding "
257
+ "anything. (2) The name is wrong, or the job does not exist / was pruned by "
258
+ "retention. The API returns an identical body in both cases, so this 200 is "
259
+ "NOT confirmation that creation failed — do not create a duplicate."
260
+ )
261
+ elif (
262
+ status == "failed"
263
+ and data.get("error_code") in (0, None)
264
+ and data.get("error_message") in ("", None)
265
+ ):
266
+ out["_wrapper_note"] = (
267
+ "error_code/error_message on GetJobStatusResponse are declared deprecated "
268
+ "constants (const 0 and const empty-string) — they are empty BY CONTRACT on "
269
+ "a failure, not by accident. The real reason is only in the logs. Call "
270
+ "action=logs (job_name + region, tail~100); the wrapper returns them in "
271
+ "chronological order with the cause at the end."
272
+ )
273
+ # Stable canonical spelling for get(lowercase) vs list(Capitalized) equality
274
+ # checks. Absent for 'job not found' and any unknown value (not in the enum).
275
+ canon = JOB_STATUS_CANON.get(status)
276
+ if canon is not None:
277
+ out["status_canonical"] = canon
278
+ return out
279
+
280
+
281
+ def _shrink_repos(data: Any) -> Any:
282
+ """Elide each repo list element's heavy ``recent_image.extra_attrs`` (~39% of the
283
+ response) with a pointer marker. Fail-open: a non-list input, or any element not
284
+ shaped like a repo, passes through untouched; an EMPTY ``extra_attrs`` ({}) is
285
+ left as-is (nothing to omit). Does NOT mutate the input.
286
+
287
+ Scope is strictly the repos LIST. list_images (also an array of Image-Output with
288
+ extra_attrs) is deliberately OUT of scope — surfacing/rewriting it collides with
289
+ the pagination entity-splitting bug (08#4) — and get_image (kind='item') returns
290
+ extra_attrs untouched, which is exactly where the full config is wanted."""
291
+ if not isinstance(data, list):
292
+ return data
293
+ out: list[Any] = []
294
+ for repo in data:
295
+ if not isinstance(repo, dict):
296
+ out.append(repo)
297
+ continue
298
+ recent = repo.get("recent_image")
299
+ if (
300
+ isinstance(recent, dict)
301
+ and isinstance(recent.get("extra_attrs"), dict)
302
+ and recent["extra_attrs"]
303
+ ):
304
+ new_recent = dict(recent)
305
+ new_recent["extra_attrs"] = _EXTRA_ATTRS_MARKER
306
+ new_repo = dict(repo)
307
+ new_repo["recent_image"] = new_recent
308
+ out.append(new_repo)
309
+ else:
310
+ out.append(repo)
311
+ return out
312
+
313
+
314
+ def _listish_is_empty(d: Any) -> bool:
315
+ """True for a dict that has at least one list value and whose EVERY list value is
316
+ empty. Fail-open: anything that isn't such a dict returns False."""
317
+ if not isinstance(d, dict):
318
+ return False
319
+ lists = [v for v in d.values() if isinstance(v, list)]
320
+ return bool(lists) and all(len(v) == 0 for v in lists)
321
+
322
+
323
+ def _hoist_manifest_name(item: Any) -> Any:
324
+ """If ``item`` is a k8s-manifest-shaped dict (has ``kind`` and a dict
325
+ ``metadata`` with a str ``name``) and lacks a top-level ``name``, add
326
+ ``name`` = ``metadata.name``. Strictly additive and fail-open: nothing is
327
+ removed or renamed; non-manifests are returned unchanged."""
328
+ if isinstance(item, dict) and "kind" in item and "name" not in item:
329
+ meta = item.get("metadata")
330
+ if isinstance(meta, dict) and isinstance(meta.get("name"), str):
331
+ return {**item, "name": meta["name"]}
332
+ return item
333
+
334
+
335
+ def _hoist_manifest_names(cleaned: Any) -> Any:
336
+ """Apply :func:`_hoist_manifest_name` to a bare top-level list, or to each element
337
+ inside every list value of a dict. Other shapes pass through untouched."""
338
+ if isinstance(cleaned, list):
339
+ return [_hoist_manifest_name(x) for x in cleaned]
340
+ if isinstance(cleaned, dict):
341
+ return {
342
+ k: [_hoist_manifest_name(x) for x in v] if isinstance(v, list) else v
343
+ for k, v in cleaned.items()
344
+ }
345
+ return cleaned
346
+
347
+
348
+ # --- log normalisation -------------------------------------------------------
349
+ # The jobs `logs` endpoint is not chronological when `tail` is passed: it returns
350
+ # the newest N lines in REVERSE order, which inverts Python tracebacks (the
351
+ # ValueError lands above its own frames) and makes a top-down reader name the
352
+ # wrong root cause. Verified live 2026-08-03 with a controlled marker script.
353
+ _TS_RE = re.compile(r"^\s*(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})")
354
+
355
+ # The same endpoint encodes state as English prose inside an HTTP 200 body.
356
+ # Matching these is unavoidable; doing it once here beats every caller guessing.
357
+ _LOG_SENTINELS: tuple[tuple[str, str], ...] = (
358
+ (
359
+ "No such job",
360
+ "logs_expired: the platform no longer retains logs for this job (they age out "
361
+ "roughly 3 months after completion). The job itself still exists — `get` and "
362
+ "`get_params` work. This is NOT a missing job.",
363
+ ),
364
+ (
365
+ "in queue. Try later",
366
+ "job_pending: the job is still queued and has produced no output yet. "
367
+ "Poll `get` until status leaves `pending`, then re-read the logs.",
368
+ ),
369
+ )
370
+
371
+
372
+ def _log_lines_chronological(lines: list[str]) -> tuple[list[str], bool]:
373
+ """Return ``lines`` oldest-first plus whether we had to reverse them.
374
+
375
+ Only timestamped lines vote, and only a clear majority flips the order, so a
376
+ log with no timestamps (or a mixed banner) is passed through untouched."""
377
+ stamps = [(i, m.group(1)) for i, line in enumerate(lines) if (m := _TS_RE.match(line))]
378
+ if len(stamps) < 3:
379
+ return lines, False
380
+ pairs = list(zip(stamps, stamps[1:], strict=False))
381
+ ascending = sum(1 for (_, a), (_, b) in pairs if b >= a)
382
+ descending = sum(1 for (_, a), (_, b) in pairs if b <= a)
383
+ if descending > ascending:
384
+ return list(reversed(lines)), True
385
+ return lines, False
386
+
387
+
388
+ # A leading RFC3339/ISO-ish timestamp (verbose logs prepend one), stripped so the
389
+ # extracted cause line reads cleanly.
390
+ _TS_PREFIX = re.compile(
391
+ r"^\s*\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?Z?\]?\s*"
392
+ )
393
+
394
+
395
+ def _strip_leading_timestamp(line: str) -> str:
396
+ return _TS_PREFIX.sub("", line, count=1)
397
+
398
+
399
+ def _log_notes(lines: list[str]) -> list[str]:
400
+ """Scan the FULL chronological log for an mpirun/ORTE launch-failure banner and,
401
+ if found, return a single ``[likely_cause: ...]`` note. Scanning the full body
402
+ BEFORE any tail truncation is the point: the note survives even when the banner
403
+ is swept out of the tail. Strictly limited to the mpirun/ORTE family — ordinary
404
+ Python tracebacks are left alone (the chronological order already puts their
405
+ exception line at the end, and a generic parse risks mis-attribution)."""
406
+ for i, line in enumerate(lines):
407
+ low = line.lower()
408
+ if "mpirun was unable to launch" in low or "orte was unable to" in low:
409
+ cause = _strip_leading_timestamp(line).strip()
410
+ parts = [cause]
411
+ # the immediate continuation ("… or execute an executable:")
412
+ if i + 1 < len(lines) and "executable:" in lines[i + 1].lower():
413
+ parts.append(_strip_leading_timestamp(lines[i + 1]).strip())
414
+ # and the concrete "Executable: <path>" line a couple of lines down — the
415
+ # actual missing binary, which makes the cause complete instead of generic.
416
+ for j in range(i + 1, min(i + 6, len(lines))):
417
+ stripped = _strip_leading_timestamp(lines[j]).strip()
418
+ if stripped.lower().startswith("executable:"):
419
+ if stripped not in parts:
420
+ parts.append(stripped)
421
+ break
422
+ return ["[likely_cause: " + " | ".join(parts) + "]"]
423
+ return []
424
+
425
+
426
+ def _format_log(data: Any, log_tail: int, redact_keys: frozenset[str], list_cap: int) -> str:
427
+ if data is None:
428
+ return "(no log output)"
429
+ if isinstance(data, (dict, list)):
430
+ # some log endpoints wrap lines in JSON; clean (redact/elide/cap) then emit
431
+ return _to_json(_clean(data, redact_keys, list_cap))
432
+ text = str(data)
433
+ notes: list[str] = []
434
+ for needle, explanation in _LOG_SENTINELS:
435
+ if needle in text:
436
+ notes.append(f"[{explanation}]")
437
+ lines, reversed_ = _log_lines_chronological(text.splitlines())
438
+ if reversed_:
439
+ notes.append(
440
+ "[note: the API returned these lines newest-first; reordered to "
441
+ "chronological so causes precede effects]"
442
+ )
443
+ # Extract a typed cause note from the FULL chronological body BEFORE the tail
444
+ # truncation below, so the note survives even if the banner is swept out of it.
445
+ notes += _log_notes(lines)
446
+ if len(lines) > log_tail:
447
+ # keep the END of the chronological log — the failure lives there
448
+ lines = [f"[showing last {log_tail} of {len(lines)} lines]"] + lines[-log_tail:]
449
+ return "\n".join(notes + lines) if notes else "\n".join(lines)
450
+
451
+
452
+ def format_response(
453
+ data: Any,
454
+ response_format: str = "json",
455
+ *,
456
+ kind: str = "item",
457
+ redact_keys: tuple[str, ...] = (),
458
+ list_cap: int = 200,
459
+ log_tail: int = 200,
460
+ catalog_region: str | None = None,
461
+ ) -> str:
462
+ """Render an API result for a tool reply.
463
+
464
+ ``catalog_region`` (kind=="catalog" only) optionally filters the configs
465
+ cross-product to a single region client-side; ``None`` keeps all regions."""
466
+ if kind == "log":
467
+ return _format_log(data, log_tail, frozenset(redact_keys), list_cap)
468
+ if kind == "catalog":
469
+ data = _shrink_configs(data, catalog_region)
470
+ elif kind == "job":
471
+ data = _normalise_job(data)
472
+ elif kind == "registry_repos":
473
+ data = _shrink_repos(data)
474
+ if data is None or data == "":
475
+ return "(empty response — the operation succeeded with no body)"
476
+ cleaned = _clean(data, frozenset(redact_keys), list_cap)
477
+ if kind == "list" and isinstance(cleaned, (dict, list)):
478
+ # surface an addressable name on manifest-shaped list items (metadata.name)
479
+ cleaned = _hoist_manifest_names(cleaned)
480
+ # flag an empty list that carries no completeness signal (real absence vs a
481
+ # filter/region/permission narrowing). Skip lists that DO signal a total
482
+ # (failed/total/count) so we don't wrongly claim "no signal".
483
+ if (
484
+ isinstance(cleaned, dict)
485
+ and _listish_is_empty(cleaned)
486
+ and not (set(cleaned) & {"failed", "total", "count"})
487
+ and "_scope_note" not in cleaned
488
+ ):
489
+ cleaned["_scope_note"] = (
490
+ "The source returned empty and gives no completeness signal — this may "
491
+ "be a real absence OR a narrowing by filter/region/permissions, not "
492
+ "proof that nothing exists. Check region/status/search."
493
+ )
494
+ if isinstance(cleaned, (dict, list)):
495
+ if response_format == "markdown":
496
+ return _to_markdown(cleaned)
497
+ # the catalog is large even after dedup → emit compact JSON
498
+ if kind == "catalog":
499
+ return _to_json_compact(cleaned)
500
+ return _to_json(cleaned)
501
+ # scalar (str/int/bool/float)
502
+ return str(cleaned)