livepilot 1.17.1 → 1.17.3

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,15 @@ async def commit_preview_variant(
270
270
  if not ps:
271
271
  return {"error": f"Preview set {set_id} not found"}
272
272
 
273
- chosen = engine.commit_variant(ps, variant_id)
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
274
282
  if not chosen:
275
283
  available = [v.variant_id for v in ps.variants]
276
284
  return {
@@ -278,8 +286,58 @@ async def commit_preview_variant(
278
286
  "available_variants": available,
279
287
  }
280
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
+ # ── P1#2 fix (v1.17.3): execute BEFORE flipping state ──
331
+ # Prior behavior: engine.commit_variant() ran here, BEFORE execution.
332
+ # If every step then failed, the returned payload correctly said
333
+ # committed=False / status='failed' — but preview_set.status was
334
+ # already "committed" and Wonder lifecycle advance fired regardless.
335
+ # Response and stored state contradicted each other.
336
+ #
337
+ # New flow: we already have `chosen` from the resolution block above.
338
+ # Execute the plan first, count successes, THEN flip state only when
339
+ # at least one step actually applied. Zero successes = honest no-op.
281
340
  result = {
282
- "committed": True,
283
341
  "variant_id": chosen.variant_id,
284
342
  "label": chosen.label,
285
343
  "intent": chosen.intent,
@@ -289,57 +347,55 @@ async def commit_preview_variant(
289
347
  }
290
348
 
291
349
  # ── 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
- )
350
+ from ..runtime.execution_router import execute_plan_steps_async
351
+ plan = chosen.compiled_plan
352
+ steps = plan if isinstance(plan, list) else plan.get("steps", []) or []
353
+ ableton = _get_ableton(ctx)
354
+ bridge = ctx.lifespan_context.get("m4l")
355
+ mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
356
+
357
+ exec_results = await execute_plan_steps_async(
358
+ steps,
359
+ ableton=ableton,
360
+ bridge=bridge,
361
+ mcp_registry=mcp_registry,
362
+ ctx=ctx,
363
+ stop_on_failure=False,
364
+ )
365
+ log = [
366
+ {
367
+ "tool": r.tool,
368
+ "backend": r.backend,
369
+ "ok": r.ok,
370
+ **({"result": r.result} if r.ok else {"error": r.error}),
371
+ }
372
+ for r in exec_results
373
+ ]
374
+ steps_ok = sum(1 for r in exec_results if r.ok)
375
+ steps_failed = len(exec_results) - steps_ok
376
+
377
+ result["execution_log"] = log
378
+ result["steps_ok"] = steps_ok
379
+ result["steps_failed"] = steps_failed
380
+
381
+ # ── P1#2: only flip preview-set state when at least one step succeeded ──
382
+ if steps_failed == 0 and steps_ok > 0:
383
+ result["status"] = "committed"
384
+ result["committed"] = True
385
+ engine.commit_variant(ps, variant_id)
386
+ elif steps_ok > 0:
387
+ result["status"] = "committed_with_errors"
388
+ result["committed"] = True # partial but real commit
389
+ engine.commit_variant(ps, variant_id)
302
390
  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", {})
391
+ # Every step failed — do NOT flip preview-set state, do NOT advance
392
+ # Wonder. The response already reflects the failure; the stored
393
+ # state must agree.
394
+ result["status"] = "failed"
395
+ result["committed"] = False
396
+ return result
309
397
 
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
341
-
342
- # Wonder lifecycle hooks
398
+ # Wonder lifecycle hooks — only reached when steps_ok > 0.
343
399
  ws = _find_wonder_session_by_preview(set_id)
344
400
  if ws:
345
401
  ws.selected_variant_id = variant_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,
@@ -130,11 +179,19 @@ def get_session_kernel(
130
179
  if analyzer_ok:
131
180
  analyzer_fresh = spectral.get("spectrum") is not None
132
181
 
182
+ # P2#3 (v1.17.3): probe web + flucoma the same way get_capability_state
183
+ # does, and propagate through. Without this the kernel's capability view
184
+ # lies to orchestration planners.
185
+ web_ok = _probe_web()
186
+ flucoma_ok = _probe_flucoma()
187
+
133
188
  state = build_capability_state(
134
189
  session_ok=session_ok,
135
190
  analyzer_ok=analyzer_ok,
136
191
  analyzer_fresh=analyzer_fresh,
137
192
  memory_ok=True,
193
+ web_ok=web_ok,
194
+ flucoma_ok=flucoma_ok,
138
195
  )
139
196
 
140
197
  # Optional subcomponents — degrade gracefully, but reach into the SAME
@@ -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
 
@@ -19,12 +19,16 @@ from .models import TimbralFingerprint
19
19
 
20
20
  # ── Band-based brightness / warmth mapping ──────────────────────────────
21
21
  #
22
- # The M4L analyzer returns an 8-band spectrum by default. When a full
23
- # spectrum dict is passed, we look for these band keys in order. If the
24
- # raw {freq: magnitude} shape is passed instead, we fall back to a coarser
25
- # low/mid/high split.
22
+ # Two upstream producers feed this extractor with different band schemas:
23
+ # 1. get_master_spectrum (M4L analyzer) — v1.16+: 9 bands (sub_low,
24
+ # sub, low, low_mid, mid, high_mid, high, presence, air);
25
+ # pre-v1.16: 8 bands (no sub_low).
26
+ # 2. analyze_spectrum_offline — 8 bands with legacy names
27
+ # (sub, low, low_mid, mid, high_mid, high, very_high, ultra).
28
+ # We index the union of both name sets below; `_band_energy` uses dict.get
29
+ # so missing bands simply return 0 without complaint.
26
30
 
27
- _BANDS = ("sub", "low", "low_mid", "mid", "high_mid", "high", "very_high", "ultra")
31
+ _BANDS = ("sub_low", "sub", "low", "low_mid", "mid", "high_mid", "high", "presence", "air", "very_high", "ultra")
28
32
 
29
33
 
30
34
  def _band_energy(spectrum: Optional[dict], band: str) -> float:
@@ -55,9 +59,11 @@ def extract_timbre_fingerprint(
55
59
  Inputs are all optional — the function degrades gracefully when only
56
60
  some dimensions are measurable.
57
61
 
58
- spectrum: either {sub, low, low_mid, mid, high_mid, high, very_high, ultra}
59
- or {"bands": {...}} the 8-band shape returned by get_master_spectrum /
60
- analyze_spectrum_offline. Missing bands default to 0.
62
+ spectrum: either the 9-band shape from get_master_spectrum
63
+ ({sub_low, sub, low, low_mid, mid, high_mid, high, presence, air}),
64
+ the legacy 8-band shape from analyze_spectrum_offline
65
+ ({sub, low, low_mid, mid, high_mid, high, very_high, ultra}),
66
+ or {"bands": {...}} wrapping either. Missing bands default to 0.
61
67
  loudness: {"rms": float, "peak": float, "lufs": float, "lra": float} —
62
68
  output shape from analyze_loudness.
63
69
  spectral_shape: FluCoMa descriptors when available — {"centroid", "flatness",
@@ -35,6 +35,12 @@ from .taste import (
35
35
  compute_taste_fit,
36
36
  get_taste_profile,
37
37
  )
38
+ from .iteration import (
39
+ iterate_toward_goal_engine,
40
+ iterate_toward_goal_engine_async,
41
+ IterationResult,
42
+ IterationStep,
43
+ )
38
44
 
39
45
  __all__ = [
40
46
  "QUALITY_DIMENSIONS", "MEASURABLE_PROXIES",
@@ -49,4 +55,8 @@ __all__ = [
49
55
  "analyze_outcome_history",
50
56
  "compute_taste_fit",
51
57
  "get_taste_profile",
58
+ "iterate_toward_goal_engine",
59
+ "iterate_toward_goal_engine_async",
60
+ "IterationResult",
61
+ "IterationStep",
52
62
  ]