Skip to content
Merged
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
11 changes: 10 additions & 1 deletion docs/production-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,18 @@ per-persona error strings, so the banner names the actual upstream failure
|---|---|
| `0` | Run completed and is valid. |
| `1` | Startup/config error (bad flags, missing files, refused flag combos). |
| `2` | Run completed but **invalid** (`run_invalid: true`): failure rate over threshold, total failure, cost gate tripped, SIGINT, missing-input refusals, or synthesis failure. |
| `2` | Run completed but **invalid** (`run_invalid: true`): failure rate over threshold, total failure, cost gate tripped, SIGINT, or missing-input refusals. |
| `3` | `--strict` violation: any panelist-question error at all. |

A **synthesis-stage failure does not invalidate the run**: when every
panelist response completed, the envelope keeps them, sets
`synthesis: null` (or the fallback synthesis payload), carries a
structured top-level `synthesis_error` plus a `synthesis_failed: ...`
warning, and exits `0`. Recover the missing synthesis cheaply with
`synthpanel panel synthesize <result-id>` instead of re-running the
panel. (`panel synthesize` itself still exits `2` on failure — synthesis
is that command's sole deliverable.)

**Every abort path still emits valid partial JSON.** With
`--output-format json`, a cost-gate halt, SIGINT, or total failure does not
produce truncated garbage — it produces a complete, parseable envelope with
Expand Down
5 changes: 3 additions & 2 deletions src/synth_panel/_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,8 +786,9 @@ def run_panel_sync(
synthesis_dict: dict[str, Any] | None = None
# sp-avmm: synthesis failures surface as a ``synthesis_error`` key on
# ``synthesis_dict``. Callers (MCP server / SDK) detect this and lift
# it to ``run_invalid=True`` + top-level ``synthesis_error`` on the
# result envelope.
# it to a top-level ``synthesis_error`` + warning on the result
# envelope. It does not invalidate the run — panelist responses are
# complete; consumers can re-synthesize the saved result.
# GH#576: mirror the CLI's cost-gate discipline — a halted run is a
# deliberately-truncated partial, so spending more budget synthesizing
# it would produce an untrustworthy result. Skip synthesis entirely.
Expand Down
30 changes: 23 additions & 7 deletions src/synth_panel/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2715,7 +2715,6 @@ def _on_complete(pr: PanelistResult) -> None:
),
diagnostic=overflow,
)
run_invalid = True
else:
try:
if resolved_strategy == STRATEGY_MAP_REDUCE:
Expand Down Expand Up @@ -2767,7 +2766,6 @@ def _on_complete(pr: PanelistResult) -> None:
),
diagnostic=exc.diagnostic,
)
run_invalid = True
except Exception as exc:
# sp-rcvr: synthesis recovery ladder — classify the failure,
# retry transients once, fall back to map-reduce on (suspected)
Expand Down Expand Up @@ -2828,14 +2826,12 @@ def _on_complete(pr: PanelistResult) -> None:
suggested_fix=suggested,
diagnostic=diagnostic,
)
run_invalid = True

# sy-549: a synthesis that returned a *fallback* result (judge exhausted
# its schema-adherence retries, or returned a partial schema) is NOT a
# healthy synthesis. Previously such a result was persisted as if it
# succeeded, hiding the failure behind mostly-empty fields. Surface a
# loud warning + a machine-detectable error payload and mark the run
# invalid, mirroring the sp-avmm hard-fail on synthesis API errors.
# loud warning + a machine-detectable error payload.
Comment on lines 2833 to +2834

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report fallback synthesis as failed in text output

When the judge returns is_fallback=True in the default text-output path, this branch now permits exit 0 but leaves synthesis_dict truthy. The run summary later at lines 3172-3173 therefore prints Synthesis: completed even though this branch just reported it as incomplete; with the invalid-run banner and nonzero exit removed, the final status is especially likely to mislead users. Select the text status using synthesis_error_payload or is_fallback before treating a synthesis dictionary as completed.

Useful? React with 👍 / 👎.

if synthesis_result is not None and getattr(synthesis_result, "is_fallback", False):
synth_err = synthesis_result.error or "synthesis judge did not return a valid structured result"
logger.warning("synthesis produced a fallback result: %s", synth_err)
Expand All @@ -2850,7 +2846,6 @@ def _on_complete(pr: PanelistResult) -> None:
"result with `synthpanel panel synthesize <id> --synthesis-model ...`."
),
)
run_invalid = True

if synthesis_result:
total_usage = panelist_usage + synthesis_result.usage
Expand Down Expand Up @@ -3313,7 +3308,19 @@ def _cli_panelist_formatter(pr: PanelistResult, panel_model: str) -> dict[str, A
if synthesis_error_payload is not None:
# sp-avmm: top-level synthesis_error so JSON/NDJSON consumers
# see the failure without walking into the synthesis dict.
# A synthesis-stage failure does NOT invalidate the run — the
# panelist responses above are complete and usable. Degrade to
# synthesis: null/fallback + a warning; recovery is a cheap
# re-synthesize of the saved result, not a full re-run.
extra["synthesis_error"] = synthesis_error_payload
warnings_list = extra.setdefault("warnings", [])
if isinstance(warnings_list, list):
warnings_list.append(
"synthesis_failed: "
f"{synthesis_error_payload.get('message', 'synthesis failed')}"
" — panelist responses are complete; recover with"
" `synthpanel panel synthesize <result-id>`"
)
if missing_input_invalid:
# sp-bjt4: surface as a top-level warning so MCP / CI consumers
# see the condition even if they don't special-case
Expand Down Expand Up @@ -3702,7 +3709,6 @@ def _handle_panel_run_multi_round(
"Rerun with a higher-quality --synthesis-model."
),
)
run_invalid = True

# ── Cost / usage aggregation ────────────────────────────────────────
panelist_usage = ZERO_USAGE
Expand Down Expand Up @@ -3940,7 +3946,17 @@ def _handle_panel_run_multi_round(
extra["total_failure"] = total_failure
extra["abort_reason"] = _classify_total_failure_abort_reason(total_failure)
if synthesis_error_payload is not None:
# Synthesis-stage failure degrades gracefully: the per-round
# panelist responses are complete, so surface the payload plus
# a warning instead of invalidating the run.
extra["synthesis_error"] = synthesis_error_payload
warnings_list = extra.setdefault("warnings", [])
if isinstance(warnings_list, list):
warnings_list.append(
"synthesis_failed: "
f"{synthesis_error_payload.get('message', 'synthesis failed')}"
" — panelist responses are complete"
)
if missing_input_invalid:
warnings_list = extra.setdefault("warnings", [])
if isinstance(warnings_list, list):
Expand Down
14 changes: 7 additions & 7 deletions src/synth_panel/cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,14 +329,14 @@ class ModelPricing:
)

# Meta Llama 3.3 70B Instruct (OpenRouter: ``meta-llama/llama-3.3-70b-instruct``).
# OR feed (2026-05-10): $0.10/M input, $0.32/M output. Prior rates
# ($0.23/$0.40) overstated cost ~2x — OR's blended provider rate has
# dropped as more low-cost providers came online.
# OR feed (2026-07-19): $0.13/M input, $0.40/M output. The 2026-05-10
# snapshot ($0.10/$0.32) understated provider-reported cost ~30% on live
# runs — OR's blended provider rate moved back up.
LLAMA_3_3_70B_PRICING = ModelPricing(
input_cost_per_million=0.10,
output_cost_per_million=0.32,
cache_creation_cost_per_million=0.10,
cache_read_cost_per_million=0.10,
input_cost_per_million=0.13,
output_cost_per_million=0.40,
cache_creation_cost_per_million=0.13,
cache_read_cost_per_million=0.13,
)

DEFAULT_PRICING = SONNET_PRICING
Expand Down
12 changes: 9 additions & 3 deletions src/synth_panel/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1568,11 +1568,17 @@ async def _run_panel_async(

# sp-avmm: synthesis failure must surface loudly at the envelope
# top-level. Without this, MCP callers see synthesis: {synthesis_error:
# …} buried inside the result and cannot gate on run validity without
# inspecting the nested payload.
# …} buried inside the result and cannot spot the failure without
# inspecting the nested payload. It does NOT set run_invalid — the
# panelist responses are complete and usable; degrade to a warning so
# callers can re-synthesize instead of discarding the run.
if synthesis_dict and isinstance(synthesis_dict.get("synthesis_error"), dict):
result["run_invalid"] = True
result["synthesis_error"] = synthesis_dict["synthesis_error"]
result["warnings"].append(
"synthesis_failed: "
f"{synthesis_dict['synthesis_error'].get('message', 'synthesis failed')}"
" — panelist responses are complete"
)

# GH#576: cost gate tripped mid-run — the orchestrator cancelled the
# pending panelists and ``result_dicts`` is a valid completed prefix.
Expand Down
28 changes: 21 additions & 7 deletions src/synth_panel/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,10 @@ class PollResult(_DictLikeMixin):
total_usage: dict[str, Any]
total_cost: str
metadata: dict[str, Any] | None = None
# sp-avmm: synthesis failure surfaced at the top-level so consumers can
# gate on validity without walking into the nested synthesis dict.
# sp-avmm: synthesis failure surfaced at the top-level (synthesis_error)
# so consumers see it without walking into the nested synthesis dict.
# run_invalid stays False for synthesis-stage failures — panelist
# responses are complete; it flags panelist-level invalidity only.
run_invalid: bool = False
synthesis_error: dict[str, Any] | None = None
# sy-4yd: deterministic structured-response rollup (vote counts,
Expand Down Expand Up @@ -311,7 +313,8 @@ class PanelResult(_DictLikeMixin):
terminal_round: str | None = None
results: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] | None = None
# sp-avmm: synthesis failure markers, mirroring PollResult.
# sp-avmm: synthesis failure markers, mirroring PollResult. run_invalid
# is not set by synthesis-stage failures (see PollResult).
run_invalid: bool = False
synthesis_error: dict[str, Any] | None = None
# sy-4yd: deterministic structured-response rollup. See PollResult
Expand Down Expand Up @@ -543,13 +546,21 @@ def _build_panel_result_from_single_round(
# otherwise the single ``model`` is the only candidate.
cost_warnings = build_cost_fallback_warnings(contributing_models if contributing_models is not None else [model])
# sp-avmm: carry synthesis failure markers onto the top-level result
# so MCP/CI consumers can branch on run validity without walking into
# the nested synthesis envelope.
# so MCP/CI consumers can spot the failure without walking into the
# nested synthesis envelope. A synthesis-stage failure does NOT set
# run_invalid — the panelist responses are complete and usable, so
# degrade to a warning + synthesis_error instead of invalidating.
synthesis_error = synthesis_dict.get("synthesis_error") if isinstance(synthesis_dict, dict) else None
# sp-g59o: surface synthesis-level heuristic warnings (e.g. degenerate
# structured output) at the top level so MCP consumers don't have to
# walk into the nested synthesis dict to find them.
synthesis_warnings = list(synthesis_dict.get("warnings") or []) if isinstance(synthesis_dict, dict) else []
if isinstance(synthesis_error, dict):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle fallback results in SDK and MCP envelopes

When run_panel_sync returns a normal SynthesisResult(is_fallback=True) after exhausting schema retries, its dictionary contains is_fallback and error but no nested synthesis_error. Consequently this new warning block never runs and the SDK returns neither a top-level synthesis_error nor synthesis_failed warning; _run_panel_async in the MCP server uses the same nested-key-only test. CLI explicitly converts this case into a structured synthesis_fallback error, so derive the same payload from is_fallback here and in MCP to avoid presenting an incomplete synthesis as healthy to programmatic callers.

Useful? React with 👍 / 👎.

synthesis_warnings.append(
"synthesis_failed: "
f"{synthesis_error.get('message', 'synthesis failed')}"
" — panelist responses are complete"
)
poll_summary_payload = _compute_poll_summary_payload(
result_dicts=result_dicts,
questions=questions,
Expand Down Expand Up @@ -577,7 +588,7 @@ def _build_panel_result_from_single_round(
terminal_round=None,
results=result_dicts,
metadata=metadata,
run_invalid=bool(synthesis_error),
run_invalid=False,
synthesis_error=synthesis_error if isinstance(synthesis_error, dict) else None,
poll_summary=poll_summary_payload,
)
Expand Down Expand Up @@ -896,7 +907,10 @@ def quick_poll(
total_usage=total_usage.to_dict(),
total_cost=total_cost.format_usd(),
metadata=metadata,
run_invalid=bool(synthesis_error),
# A synthesis-stage failure does not invalidate the poll — the
# panelist responses are complete; synthesis_error carries the
# failure detail for consumers that want to re-synthesize.
run_invalid=False,
synthesis_error=synthesis_error if isinstance(synthesis_error, dict) else None,
poll_summary=poll_summary_payload,
)
Expand Down
24 changes: 22 additions & 2 deletions src/synth_panel/structured/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@

from __future__ import annotations

from typing import Annotated
import re
from typing import Annotated, Any

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator


class RankedItem(BaseModel):
Expand Down Expand Up @@ -82,6 +83,11 @@ class AnnotatedChoice(BaseModel):
attachment_id: str = ""


# Leading bullet / enumeration markers stripped when coercing a
# newline-bulleted string into list items: "-", "*", "•", "–", "1.", "1)".
_BULLET_PREFIX_RE = re.compile(r"^\s*(?:[-*•–]|\d+[.)])\s+")


class PartialSummary(BaseModel):
"""Per-question map-phase synthesis partial (v1.0.3 P2).

Expand All @@ -91,6 +97,12 @@ class PartialSummary(BaseModel):
single map call surfaces as a :class:`pydantic.ValidationError`
instead of silently propagating empty themes through to the final
synthesis.

List fields tolerate a string value by splitting it into items
(newline-bulleted lists like ``"\\n- A\\n- B"`` are the observed
provider drift). The single-pass synthesis path never re-validates
these fields, so a string slips through it unchanged — the map
boundary must not be stricter than single-pass for the same drift.
"""

summary: str
Expand All @@ -100,6 +112,14 @@ class PartialSummary(BaseModel):
surprises: list[str]
recommendation: str

@field_validator("themes", "agreements", "disagreements", "surprises", mode="before")
@classmethod
def _coerce_string_to_list(cls, v: Any) -> Any:
if not isinstance(v, str):
return v
items = [_BULLET_PREFIX_RE.sub("", line).strip() for line in v.splitlines()]
return [item for item in items if item]


MODEL_REGISTRY: dict[str, type[BaseModel]] = {
"ranking": Ranking,
Expand Down
28 changes: 17 additions & 11 deletions src/synth_panel/synthesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -1421,24 +1421,30 @@ def _run_one_map(idx: int) -> tuple[int, SynthesisResult, dict[str, Any]]:
is_fallback=True,
)
try:
typed_partials.append(
PartialSummary.model_validate(
{
"summary": res.summary,
"themes": res.themes,
"agreements": res.agreements,
"disagreements": res.disagreements,
"surprises": res.surprises,
"recommendation": res.recommendation,
}
)
partial = PartialSummary.model_validate(
{
"summary": res.summary,
"themes": res.themes,
"agreements": res.agreements,
"disagreements": res.disagreements,
"surprises": res.surprises,
"recommendation": res.recommendation,
}
)
except _PydanticValidationError as ve:
raise MapPhaseFailure(
f"map-reduce question {i} partial failed typed validation: {ve}",
question_index=i,
validation_error=ve,
) from ve
typed_partials.append(partial)
# Write the coerced list fields back so the reduce stage and the
# per_question rollup see lists even when the map model emitted
# newline-bulleted strings (the drift PartialSummary tolerates).
res.themes = partial.themes
res.agreements = partial.agreements
res.disagreements = partial.disagreements
res.surprises = partial.surprises

# Reduce phase
synthetic_panelists = _build_synthetic_reduce_panelists(completed_maps, questions)
Expand Down
Loading
Loading