livepilot 1.17.0 → 1.17.2

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.
@@ -270,7 +270,68 @@ async def commit_preview_variant(
270
270
  if not ps:
271
271
  return {"error": f"Preview set {set_id} not found"}
272
272
 
273
+ # Resolve the chosen variant WITHOUT mutating state yet. We have to
274
+ # short-circuit analytical-only / blocked picks BEFORE engine.commit_variant
275
+ # runs, otherwise `preview_set.status` gets flipped to "committed" and
276
+ # sibling variants get discarded even though nothing executed.
277
+ chosen = None
278
+ for v in ps.variants:
279
+ if v.variant_id == variant_id:
280
+ chosen = v
281
+ break
282
+ if not chosen:
283
+ available = [v.variant_id for v in ps.variants]
284
+ return {
285
+ "error": f"Variant {variant_id} not found in set {set_id}",
286
+ "available_variants": available,
287
+ }
288
+
289
+ # ── Truth-gap guard: refuse to "commit" a variant that can't execute ──
290
+ # If the variant was flagged blocked/failed upstream or lacks a
291
+ # compiled plan, the old code still marked preview_set.status='committed'
292
+ # and returned committed=False as a silent contradiction. Close that
293
+ # gap: return an honest no-op and leave state untouched so the caller
294
+ # can pick a different variant.
295
+ plan = chosen.compiled_plan
296
+ plan_is_empty = (
297
+ plan is None
298
+ or (isinstance(plan, list) and len(plan) == 0)
299
+ or (isinstance(plan, dict) and len(plan.get("steps") or []) == 0)
300
+ )
301
+ blocked = chosen.status in {"blocked", "failed"}
302
+ if plan_is_empty or blocked:
303
+ reason = "blocked" if blocked and plan_is_empty is False else "analytical_only"
304
+ return {
305
+ "committed": False,
306
+ "status": "analytical_only" if reason == "analytical_only" else "blocked",
307
+ "reason": reason,
308
+ "preview_set_id": set_id,
309
+ "variant_id": chosen.variant_id,
310
+ "label": chosen.label,
311
+ "intent": chosen.intent,
312
+ "move_id": chosen.move_id,
313
+ "identity_effect": chosen.identity_effect,
314
+ "what_preserved": chosen.what_preserved,
315
+ "message": (
316
+ "chose analytical variant; no session changes applied"
317
+ if reason == "analytical_only"
318
+ else "variant is blocked; no session changes applied"
319
+ ),
320
+ "note": (
321
+ "Variant has no compiled plan (analytical-only). Preview set "
322
+ "was left in its pre-commit state so you can pick a different "
323
+ "variant."
324
+ if reason == "analytical_only"
325
+ else "Variant is blocked/failed. Preview set was left in its "
326
+ "pre-commit state so you can pick a different variant."
327
+ ),
328
+ }
329
+
330
+ # Only now do we flip state — the chosen variant has an executable plan.
273
331
  chosen = engine.commit_variant(ps, variant_id)
332
+ # engine.commit_variant cannot return None here (we already verified
333
+ # the variant_id exists), but keep the defensive check for the type
334
+ # checker.
274
335
  if not chosen:
275
336
  available = [v.variant_id for v in ps.variants]
276
337
  return {
@@ -289,55 +350,44 @@ async def commit_preview_variant(
289
350
  }
290
351
 
291
352
  # ── v1.10.3: actually execute the compiled plan ──
292
- # If there's no compiled plan, the variant is analytical-only — record
293
- # the choice and return honestly instead of pretending it was applied.
294
- if not chosen.compiled_plan:
295
- result["committed"] = False
296
- result["status"] = "analytical_only"
297
- result["note"] = (
298
- "Variant has no compiled plan (analytical-only). Preview set "
299
- "marked the choice but no session changes were made. Use an "
300
- "executable variant if you want the commit to apply changes."
301
- )
353
+ from ..runtime.execution_router import execute_plan_steps_async
354
+ plan = chosen.compiled_plan
355
+ steps = plan if isinstance(plan, list) else plan.get("steps", []) or []
356
+ ableton = _get_ableton(ctx)
357
+ bridge = ctx.lifespan_context.get("m4l")
358
+ mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
359
+
360
+ exec_results = await execute_plan_steps_async(
361
+ steps,
362
+ ableton=ableton,
363
+ bridge=bridge,
364
+ mcp_registry=mcp_registry,
365
+ ctx=ctx,
366
+ stop_on_failure=False,
367
+ )
368
+ log = [
369
+ {
370
+ "tool": r.tool,
371
+ "backend": r.backend,
372
+ "ok": r.ok,
373
+ **({"result": r.result} if r.ok else {"error": r.error}),
374
+ }
375
+ for r in exec_results
376
+ ]
377
+ steps_ok = sum(1 for r in exec_results if r.ok)
378
+ steps_failed = len(exec_results) - steps_ok
379
+
380
+ result["execution_log"] = log
381
+ result["steps_ok"] = steps_ok
382
+ result["steps_failed"] = steps_failed
383
+
384
+ if steps_failed == 0 and steps_ok > 0:
385
+ result["status"] = "committed"
386
+ elif steps_ok > 0:
387
+ result["status"] = "committed_with_errors"
302
388
  else:
303
- from ..runtime.execution_router import execute_plan_steps_async
304
- plan = chosen.compiled_plan
305
- steps = plan if isinstance(plan, list) else plan.get("steps", []) or []
306
- ableton = _get_ableton(ctx)
307
- bridge = ctx.lifespan_context.get("m4l")
308
- mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
309
-
310
- exec_results = await execute_plan_steps_async(
311
- steps,
312
- ableton=ableton,
313
- bridge=bridge,
314
- mcp_registry=mcp_registry,
315
- ctx=ctx,
316
- stop_on_failure=False,
317
- )
318
- log = [
319
- {
320
- "tool": r.tool,
321
- "backend": r.backend,
322
- "ok": r.ok,
323
- **({"result": r.result} if r.ok else {"error": r.error}),
324
- }
325
- for r in exec_results
326
- ]
327
- steps_ok = sum(1 for r in exec_results if r.ok)
328
- steps_failed = len(exec_results) - steps_ok
329
-
330
- result["execution_log"] = log
331
- result["steps_ok"] = steps_ok
332
- result["steps_failed"] = steps_failed
333
-
334
- if steps_failed == 0 and steps_ok > 0:
335
- result["status"] = "committed"
336
- elif steps_ok > 0:
337
- result["status"] = "committed_with_errors"
338
- else:
339
- result["status"] = "failed"
340
- result["committed"] = False
389
+ result["status"] = "failed"
390
+ result["committed"] = False
341
391
 
342
392
  # Wonder lifecycle hooks
343
393
  ws = _find_wonder_session_by_preview(set_id)
@@ -155,6 +155,9 @@ def build_capability_state(
155
155
  )
156
156
 
157
157
  # ── web ──────────────────────────────────────────────────────────
158
+ # Server-side outbound HTTP capability. True when the MCP host can
159
+ # reach an arbitrary public URL. Does NOT imply curated research
160
+ # corpora are installed — see the ``research`` domain below.
158
161
  web_reasons: list[str] = []
159
162
  if not web_ok:
160
163
  web_reasons.append("web_unavailable")
@@ -166,6 +169,21 @@ def build_capability_state(
166
169
  reasons=web_reasons,
167
170
  )
168
171
 
172
+ # ── flucoma ──────────────────────────────────────────────────────
173
+ # Optional dependency (the ``flucoma`` Python package). Emitted
174
+ # unconditionally so consumers can distinguish "probed and missing"
175
+ # from "probe not run yet".
176
+ flucoma_reasons: list[str] = []
177
+ if not flucoma_ok:
178
+ flucoma_reasons.append("flucoma_not_installed")
179
+ domains["flucoma"] = CapabilityDomain(
180
+ name="flucoma",
181
+ available=flucoma_ok,
182
+ confidence=0.9 if flucoma_ok else 0.0,
183
+ mode="available" if flucoma_ok else "unavailable",
184
+ reasons=flucoma_reasons,
185
+ )
186
+
169
187
  # ── research (composite) ────────────────────────────────────────
170
188
  research_reasons: list[str] = []
171
189
  research_sources = 0
@@ -0,0 +1,62 @@
1
+ """Explicit degradation signalling for engines that fall back to synthesized data.
2
+
3
+ Before PR-B, several engines silently substituted defaults when a data
4
+ source failed — ``song_brain`` injected ``tempo=120.0, track_count=0``
5
+ on session-fetch failure, and ``preview_studio`` compiled variants
6
+ against an empty-but-valid kernel when the caller didn't supply one.
7
+ Downstream consumers had no way to tell synthesized data from real
8
+ data, so polished outputs were returned as if they were real.
9
+
10
+ ``DegradationInfo`` is the shared payload engines attach to their
11
+ responses whenever they substitute fallback values. Consumers can
12
+ inspect ``is_degraded``, ``reasons``, and ``substituted_fields`` to
13
+ decide whether to trust the response or re-try the operation.
14
+
15
+ Usage::
16
+
17
+ from mcp_server.runtime.degradation import DegradationInfo
18
+
19
+ deg = DegradationInfo()
20
+ try:
21
+ data = fetch_real_data()
22
+ except Exception:
23
+ data = FALLBACK_DATA
24
+ deg = DegradationInfo(
25
+ is_degraded=True,
26
+ reasons=["data_source_unreachable"],
27
+ substituted_fields=["tempo", "track_count"],
28
+ )
29
+ return {..., "degradation": deg.to_dict()}
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from dataclasses import dataclass, field
35
+
36
+
37
+ @dataclass
38
+ class DegradationInfo:
39
+ """A structured signal that an engine substituted fallback data.
40
+
41
+ Attributes:
42
+ is_degraded: True when any field in the response was substituted
43
+ with a synthesized/default value. False means the response
44
+ is fully backed by real data sources.
45
+ reasons: Short machine-readable tokens describing why degradation
46
+ happened (e.g., ``"session_fetch_failed"``,
47
+ ``"empty_kernel_fallback"``). Intentionally open-ended — the
48
+ set grows as new fallback paths get flagged.
49
+ substituted_fields: Names of top-level response fields whose
50
+ values came from the fallback path, not the real source.
51
+ """
52
+
53
+ is_degraded: bool = False
54
+ reasons: list[str] = field(default_factory=list)
55
+ substituted_fields: list[str] = field(default_factory=list)
56
+
57
+ def to_dict(self) -> dict:
58
+ return {
59
+ "is_degraded": self.is_degraded,
60
+ "reasons": list(self.reasons),
61
+ "substituted_fields": list(self.substituted_fields),
62
+ }
@@ -7,6 +7,9 @@ Tools:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import importlib.util
11
+ import logging
12
+ import urllib.request
10
13
  from typing import Optional
11
14
 
12
15
  from fastmcp import Context
@@ -14,13 +17,55 @@ from fastmcp import Context
14
17
  from ..server import mcp
15
18
  from ..memory.technique_store import TechniqueStore
16
19
  from .capability_state import build_capability_state
17
- import logging
18
20
 
19
21
  logger = logging.getLogger(__name__)
20
22
 
21
23
  _memory_store = TechniqueStore()
22
24
 
23
25
 
26
+ # ── Capability probes ──────────────────────────────────────────────────
27
+ #
28
+ # These helpers are module-level so tests can monkeypatch them directly.
29
+
30
+
31
+ def _probe_web(timeout: float = 0.5) -> bool:
32
+ """Server-side outbound HTTP probe.
33
+
34
+ True when the MCP host can reach an arbitrary public URL. Does NOT
35
+ imply curated research corpora are installed — see the ``research``
36
+ domain for that.
37
+
38
+ Implementation: a ``timeout``-second HEAD request to
39
+ ``https://api.github.com`` using stdlib ``urllib.request``. Any
40
+ exception (DNS failure, TLS error, socket timeout, proxy block,
41
+ non-2xx response) collapses to False so the probe is safe to call
42
+ from any code path.
43
+ """
44
+ req = urllib.request.Request("https://api.github.com", method="HEAD")
45
+ try:
46
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
47
+ status = getattr(resp, "status", None)
48
+ return status is not None and 200 <= status < 400
49
+ except Exception as exc: # noqa: BLE001 — swallow everything to False
50
+ logger.debug("_probe_web failed: %s", exc)
51
+ return False
52
+
53
+
54
+ def _probe_flucoma() -> bool:
55
+ """Check whether the ``flucoma`` Python package is importable.
56
+
57
+ Uses ``importlib.util.find_spec`` so no import side-effects fire
58
+ (matching the pattern already used for optional capability probes
59
+ elsewhere in the codebase). Returns False if the package is missing
60
+ or if the spec lookup itself raises.
61
+ """
62
+ try:
63
+ return importlib.util.find_spec("flucoma") is not None
64
+ except Exception as exc: # noqa: BLE001
65
+ logger.debug("_probe_flucoma failed: %s", exc)
66
+ return False
67
+
68
+
24
69
  @mcp.tool()
25
70
  def get_capability_state(ctx: Context) -> dict:
26
71
  """Probe the runtime and return a capability state snapshot.
@@ -59,9 +104,13 @@ def get_capability_state(ctx: Context) -> dict:
59
104
  logger.debug("get_capability_state failed: %s", exc)
60
105
  memory_ok = False
61
106
 
62
- # ── Web / FluCoMa not probed live, default to False ───────────
63
- web_ok = False
64
- flucoma_ok = False
107
+ # ── Web — actually probe outbound HTTP egress ───────────────────
108
+ # Scoped to server-side outbound HTTP reachability; does NOT imply
109
+ # a curated research corpus is installed (see ``research`` domain).
110
+ web_ok = _probe_web()
111
+
112
+ # ── FluCoMa — optional import via find_spec (no side effects) ───
113
+ flucoma_ok = _probe_flucoma()
65
114
 
66
115
  state = build_capability_state(
67
116
  session_ok=session_ok,
@@ -259,14 +259,10 @@ async def search_samples(
259
259
  # Splice search — prefer gRPC online catalog when available, fall back
260
260
  # to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
261
261
  if source in (None, "splice"):
262
- grpc_client = None
263
- try:
264
- grpc_client = ctx.lifespan_context.get("splice_client")
265
- except AttributeError:
266
- grpc_client = None
262
+ grpc_client = await _ensure_splice_client_connected(ctx)
267
263
 
268
264
  used_grpc = False
269
- if grpc_client is not None and getattr(grpc_client, "connected", False):
265
+ if grpc_client is not None:
270
266
  try:
271
267
  grpc_result = await grpc_client.search_samples(
272
268
  query=query,
@@ -712,6 +708,43 @@ _SPLICE_USER_LIB_DEST = "~/Music/Ableton/User Library/Samples/Splice"
712
708
  _SPLICE_PREVIEW_CACHE = "~/Library/Caches/LivePilot/splice_previews"
713
709
 
714
710
 
711
+ def _get_splice_client_from_context(ctx: Context):
712
+ """Return the shared Splice client from lifespan context when present."""
713
+ try:
714
+ return ctx.lifespan_context.get("splice_client")
715
+ except AttributeError:
716
+ return None
717
+
718
+
719
+ async def _ensure_splice_client_connected(ctx: Context):
720
+ """Reconnect the shared Splice client on demand.
721
+
722
+ The MCP server creates one long-lived client during startup. If that
723
+ first handshake races or Splice launches later, the old behavior kept
724
+ every tool stuck in a disconnected state until the whole MCP server
725
+ restarted. Re-check here so tool results reflect current desktop state.
726
+ """
727
+ client = _get_splice_client_from_context(ctx)
728
+ if client is None:
729
+ return None
730
+ if getattr(client, "connected", False):
731
+ return client
732
+
733
+ connect = getattr(client, "connect", None)
734
+ if connect is None:
735
+ return None
736
+
737
+ try:
738
+ await connect()
739
+ except Exception as exc:
740
+ logger.debug("Splice reconnect failed: %s", exc)
741
+ return None
742
+
743
+ if getattr(client, "connected", False):
744
+ return client
745
+ return None
746
+
747
+
715
748
  @mcp.tool()
716
749
  async def get_splice_credits(ctx: Context) -> dict:
717
750
  """Get the user's current Splice plan, credits, and daily sample quota.
@@ -745,15 +778,10 @@ async def get_splice_credits(ctx: Context) -> dict:
745
778
  from ..splice_client.models import PlanKind
746
779
  from ..splice_client.quota import get_tracker
747
780
 
748
- client = None
749
- try:
750
- client = ctx.lifespan_context.get("splice_client")
751
- except AttributeError:
752
- pass
753
-
754
781
  quota_summary = get_tracker().summary()
782
+ client = await _ensure_splice_client_connected(ctx)
755
783
 
756
- if client is None or not getattr(client, "connected", False):
784
+ if client is None:
757
785
  return {
758
786
  "connected": False,
759
787
  "username": "",
@@ -857,13 +885,8 @@ async def splice_catalog_hunt(
857
885
  Each sample entry contains `file_hash` which you can pass to
858
886
  `splice_download_sample` to trigger a download.
859
887
  """
860
- client = None
861
- try:
862
- client = ctx.lifespan_context.get("splice_client")
863
- except AttributeError:
864
- pass
865
-
866
- if client is None or not getattr(client, "connected", False):
888
+ client = await _ensure_splice_client_connected(ctx)
889
+ if client is None:
867
890
  return {
868
891
  "connected": False,
869
892
  "error": "Splice gRPC not connected",
@@ -970,13 +993,8 @@ async def splice_download_sample(
970
993
  """
971
994
  import shutil
972
995
 
973
- client = None
974
- try:
975
- client = ctx.lifespan_context.get("splice_client")
976
- except AttributeError:
977
- pass
978
-
979
- if client is None or not getattr(client, "connected", False):
996
+ client = await _ensure_splice_client_connected(ctx)
997
+ if client is None:
980
998
  return {
981
999
  "ok": False,
982
1000
  "error": "Splice gRPC not connected",
@@ -1096,13 +1114,8 @@ async def splice_preview_sample(
1096
1114
  import urllib.request
1097
1115
  import urllib.error
1098
1116
 
1099
- client = None
1100
- try:
1101
- client = ctx.lifespan_context.get("splice_client")
1102
- except AttributeError:
1103
- pass
1104
-
1105
- if client is None or not getattr(client, "connected", False):
1117
+ client = await _ensure_splice_client_connected(ctx)
1118
+ if client is None:
1106
1119
  return {"ok": False, "error": "Splice gRPC not connected"}
1107
1120
 
1108
1121
  # Two-stage lookup: SampleInfo is the fast path but only returns
@@ -1184,14 +1197,10 @@ async def splice_preview_sample(
1184
1197
  # ────────────────────────────────────────────────────────────────────────
1185
1198
 
1186
1199
 
1187
- def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
1200
+ async def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
1188
1201
  """Fetch the Splice client from context, or return an error dict."""
1189
- client = None
1190
- try:
1191
- client = ctx.lifespan_context.get("splice_client")
1192
- except AttributeError:
1193
- pass
1194
- if client is None or not getattr(client, "connected", False):
1202
+ client = await _ensure_splice_client_connected(ctx)
1203
+ if client is None:
1195
1204
  return None, {"ok": False, "error": "Splice gRPC not connected"}
1196
1205
  return client, None
1197
1206
 
@@ -1215,7 +1224,7 @@ async def splice_list_collections(
1215
1224
  ],
1216
1225
  }
1217
1226
  """
1218
- client, err = _require_splice_client(ctx)
1227
+ client, err = await _require_splice_client(ctx)
1219
1228
  if err:
1220
1229
  return err
1221
1230
  total, collections = await client.list_collections(
@@ -1244,7 +1253,7 @@ async def splice_search_in_collection(
1244
1253
  `splice_catalog_hunt` — you can feed them straight into
1245
1254
  `splice_preview_sample` or `splice_download_sample`.
1246
1255
  """
1247
- client, err = _require_splice_client(ctx)
1256
+ client, err = await _require_splice_client(ctx)
1248
1257
  if err:
1249
1258
  return err
1250
1259
  total, samples = await client.collection_samples(
@@ -1271,7 +1280,7 @@ async def splice_add_to_collection(
1271
1280
  and web UI immediately. Use this to let LivePilot "save for later"
1272
1281
  items it finds during composition work.
1273
1282
  """
1274
- client, err = _require_splice_client(ctx)
1283
+ client, err = await _require_splice_client(ctx)
1275
1284
  if err:
1276
1285
  return err
1277
1286
  if not file_hashes:
@@ -1289,7 +1298,7 @@ async def splice_remove_from_collection(
1289
1298
  ctx: Context, collection_uuid: str, file_hashes: list[str],
1290
1299
  ) -> dict:
1291
1300
  """Remove one or more samples from a user Collection (server-side)."""
1292
- client, err = _require_splice_client(ctx)
1301
+ client, err = await _require_splice_client(ctx)
1293
1302
  if err:
1294
1303
  return err
1295
1304
  if not file_hashes:
@@ -1307,7 +1316,7 @@ async def splice_remove_from_collection(
1307
1316
  @mcp.tool()
1308
1317
  async def splice_create_collection(ctx: Context, name: str) -> dict:
1309
1318
  """Create a new user Collection. Returns the new UUID on success."""
1310
- client, err = _require_splice_client(ctx)
1319
+ client, err = await _require_splice_client(ctx)
1311
1320
  if err:
1312
1321
  return err
1313
1322
  name = (name or "").strip()
@@ -1347,7 +1356,7 @@ async def splice_list_presets(
1347
1356
  ],
1348
1357
  }
1349
1358
  """
1350
- client, err = _require_splice_client(ctx)
1359
+ client, err = await _require_splice_client(ctx)
1351
1360
  if err:
1352
1361
  return err
1353
1362
  total, presets = await client.list_purchased_presets(
@@ -1371,7 +1380,7 @@ async def splice_preset_info(
1371
1380
  plugin_name: str = "",
1372
1381
  ) -> dict:
1373
1382
  """Fetch metadata for a single preset (uuid, file_hash, or plugin_name)."""
1374
- client, err = _require_splice_client(ctx)
1383
+ client, err = await _require_splice_client(ctx)
1375
1384
  if err:
1376
1385
  return err
1377
1386
  if not (uuid or file_hash or plugin_name):
@@ -1394,7 +1403,7 @@ async def splice_download_preset(ctx: Context, uuid: str) -> dict:
1394
1403
  """
1395
1404
  from ..splice_client.client import CREDIT_HARD_FLOOR
1396
1405
 
1397
- client, err = _require_splice_client(ctx)
1406
+ client, err = await _require_splice_client(ctx)
1398
1407
  if err:
1399
1408
  return err
1400
1409
  if not uuid:
@@ -1437,7 +1446,7 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
1437
1446
  Useful for discovering related samples by pack, or surfacing pack-level
1438
1447
  genre/provider info that search results omit.
1439
1448
  """
1440
- client, err = _require_splice_client(ctx)
1449
+ client, err = await _require_splice_client(ctx)
1441
1450
  if err:
1442
1451
  return err
1443
1452
  if not pack_uuid:
@@ -1469,19 +1478,15 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
1469
1478
  # ────────────────────────────────────────────────────────────────────────
1470
1479
 
1471
1480
 
1472
- def _build_http_bridge(ctx: Context):
1481
+ async def _build_http_bridge(ctx: Context):
1473
1482
  """Construct the HTTPS bridge with the current gRPC client attached.
1474
1483
 
1475
1484
  Returns (bridge, err_dict). On success err_dict is None.
1476
1485
  """
1477
1486
  from ..splice_client.http_bridge import SpliceHTTPBridge
1478
1487
 
1479
- client = None
1480
- try:
1481
- client = ctx.lifespan_context.get("splice_client")
1482
- except AttributeError:
1483
- pass
1484
- if client is None or not getattr(client, "connected", False):
1488
+ client = await _ensure_splice_client_connected(ctx)
1489
+ if client is None:
1485
1490
  return None, {
1486
1491
  "ok": False,
1487
1492
  "error": "Splice gRPC not connected — session token unreachable",
@@ -1520,7 +1525,7 @@ async def splice_describe_sound(
1520
1525
  instrument/tags/pack_name/files. Use the uuid with
1521
1526
  `splice_download_sample(uuid)` to pull the audio file.
1522
1527
  """
1523
- bridge, err = _build_http_bridge(ctx)
1528
+ bridge, err = await _build_http_bridge(ctx)
1524
1529
  if err:
1525
1530
  return err
1526
1531
  from ..splice_client.http_bridge import SpliceHTTPError
@@ -1572,7 +1577,7 @@ async def splice_generate_variation(
1572
1577
  duration/tags/pack_name/files). Use the uuid of any result with
1573
1578
  `splice_download_sample()` to pull the audio.
1574
1579
  """
1575
- bridge, err = _build_http_bridge(ctx)
1580
+ bridge, err = await _build_http_bridge(ctx)
1576
1581
  if err:
1577
1582
  return err
1578
1583
  from ..splice_client.http_bridge import SpliceHTTPError
@@ -1631,12 +1636,8 @@ async def splice_http_diagnose(ctx: Context) -> dict:
1631
1636
  # diagnostic is worse than no diagnostic.
1632
1637
  session_token_available = False
1633
1638
  session_token_error = None
1634
- grpc_client = None
1635
- try:
1636
- grpc_client = ctx.lifespan_context.get("splice_client")
1637
- except AttributeError:
1638
- pass
1639
- if grpc_client is None or not getattr(grpc_client, "connected", False):
1639
+ grpc_client = await _ensure_splice_client_connected(ctx)
1640
+ if grpc_client is None:
1640
1641
  session_token_error = "Splice gRPC not connected"
1641
1642
  else:
1642
1643
  # Connection is up; confirm a token actually comes back.
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from fastmcp import Context
11
11
 
12
+ from ..runtime.degradation import DegradationInfo
12
13
  from ..server import mcp
13
14
  from . import builder
14
15
  from .models import SongBrain
@@ -55,6 +56,12 @@ def _fetch_session_data(ctx: Context) -> dict:
55
56
  - composition_analysis: from musical intelligence section inference
56
57
  - role_graph: from semantic move resolvers (track role inference)
57
58
  - recent_moves: from session-scoped action ledger
59
+
60
+ On session-fetch failure the fallback session_info shape is injected
61
+ (``tempo=120.0, track_count=0``) and a ``DegradationInfo`` is attached
62
+ under the ``_degradation`` key so callers can tell synthesized data
63
+ from real data. ``_fetch_session_data`` never raises — it always
64
+ returns a dict with the expected keys.
58
65
  """
59
66
  ableton = _get_ableton(ctx)
60
67
  data: dict = {
@@ -66,12 +73,19 @@ def _fetch_session_data(ctx: Context) -> dict:
66
73
  "role_graph": {},
67
74
  "recent_moves": [],
68
75
  }
76
+ degradation = DegradationInfo()
69
77
 
70
78
  try:
71
79
  data["session_info"] = ableton.send_command("get_session_info", {})
72
80
  except Exception as exc:
73
81
  logger.debug("_fetch_session_data failed: %s", exc)
74
82
  data["session_info"] = {"tempo": 120.0, "track_count": 0}
83
+ degradation.is_degraded = True
84
+ if "session_fetch_failed" not in degradation.reasons:
85
+ degradation.reasons.append("session_fetch_failed")
86
+ for fld in ("tempo", "track_count"):
87
+ if fld not in degradation.substituted_fields:
88
+ degradation.substituted_fields.append(fld)
75
89
 
76
90
  try:
77
91
  matrix = ableton.send_command("get_scene_matrix")
@@ -135,6 +149,10 @@ def _fetch_session_data(ctx: Context) -> dict:
135
149
  except Exception as exc:
136
150
  logger.debug("_fetch_session_data failed: %s", exc)
137
151
 
152
+ # Attach the degradation signal so build_song_brain can surface it.
153
+ # Under a reserved key (leading underscore) so it never collides with
154
+ # a real session data field.
155
+ data["_degradation"] = degradation
138
156
  return data
139
157
 
140
158
 
@@ -180,10 +198,15 @@ def build_song_brain(ctx: Context) -> dict:
180
198
  )
181
199
  _set_brain(ctx, brain)
182
200
 
201
+ # Surface the degradation payload so callers can distinguish a
202
+ # tempo=120 / track_count=0 synthesized response from a real one.
203
+ degradation = data.get("_degradation") or DegradationInfo()
204
+
183
205
  return {
184
206
  **brain.to_dict(),
185
207
  "summary": brain.summary,
186
208
  "capability": cap.to_dict(),
209
+ "degradation": degradation.to_dict(),
187
210
  }
188
211
 
189
212