Skip to content
Open
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
17 changes: 17 additions & 0 deletions reflexio/models/api_schema/domain/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,23 @@ class AgentPlaybook(BaseModel):
"stale_incumbent",
"governance_invalidated",
"infrastructure_failure",
# Written by reflexio_ext offline_tuner/open_world/runner.py:251
# (_converge_terminal_failure, behind the regeneration fence) and assigned
# by the tenant stage-advance RPC's 'failed' arm
# (supabase/data/tenant/20260830020000:325-327). Admitted by
# playbook_optimization_jobs_terminal_outcome_check since 20260827040000.
# An attempt REFUSED before spending, not a fault -- see
# reflexio_ext open_world/models.py:210-227.
"regeneration_fenced",
# Written by reflexio_ext offline_tuner/open_world/runner.py
# (_converge_terminal_failure, from the slot-exhausted handler) when EVERY
# durable row identity the discovery question could occupy is already owned
# by a different optimization job -- so the attempt made NO provider call at
# all. Split out of 'infrastructure_failure', which it was previously
# indistinguishable from, hiding the fact that a failed attempt was burning
# its playbook's identity for the rest of the UTC day. See
# reflexio_ext open_world/models.py and identity.attempt_invocation_identity.
"invocation_slot_pinned",
]

OptimizationArtifactKind = Literal[
Expand Down
36 changes: 32 additions & 4 deletions reflexio/server/llm/_litellm_structured_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from reflexio.server.llm.llm_utils import (
assert_provider_safe_schema,
is_pydantic_model,
make_strict_json_schema,
prompt_schema_instruction,
strict_response_format_for_model,
)
Expand Down Expand Up @@ -270,8 +271,27 @@ class StructuredOutputMixin:
# for discriminated unions, which strict structured-output endpoints reject.
# Listing the provider here forces our own
# normalized strict schema (``oneOf`` folded into ``anyOf``) to be sent.
_JSON_SCHEMA_PROVIDER_ALLOWLIST: frozenset[str] = frozenset({"minimax"})
_PROMPT_SCHEMA_PROVIDER_ALLOWLIST: frozenset[str] = frozenset({"zai"})
#
# Empty by design, not by neglect: ``minimax`` was its only member and has
# moved to the prompt-schema allowlist below, because it turned out to
# ignore ``response_format`` rather than merely be under-reported. The
# mechanism stays for the next provider that genuinely fits the shape
# described above -- accepts json_schema, reported as unsupported.
_JSON_SCHEMA_PROVIDER_ALLOWLIST: frozenset[str] = frozenset()

# Providers that ignore ``response_format`` outright, so the schema has to
# travel in the prompt or the model never sees it at all.
#
# ``minimax`` was previously in the json-schema allowlist above, on the
# belief that it accepts a ``json_schema`` response_format that LiteLLM
# merely under-reports. Measured against the live API, it does not: two
# identical calls differing only in ``drop_params`` both returned free
# prose rather than JSON, so the response_format is discarded whatever we
# send. The visible symptom was an analyst inventing a DIFFERENT set of
# field names on every run -- it was answering from the prompt alone,
# having never been given a schema. With the schema in the prompt the same
# model returns exact conforming JSON.
_PROMPT_SCHEMA_PROVIDER_ALLOWLIST: frozenset[str] = frozenset({"zai", "minimax"})

# Base-owned attribute read for the parse-failure error message (init'd in
# the facade ``__init__``). Annotation-only; NEVER assign here.
Expand Down Expand Up @@ -323,8 +343,16 @@ def _structured_output_strategy(
def _prompt_schema_directive(
self, *, response_format: type[BaseModel], tools_available: bool
) -> str:
"""Build and guard the schema instruction used by prompt-only providers."""
schema = response_format.model_json_schema()
"""Build and guard the schema instruction used by prompt-only providers.

The schema is normalized (``oneOf`` folded into ``anyOf``) BEFORE the
provider-safety assertion, exactly as the native json_schema path does.
Without that fold a discriminated-union output would trip
``assert_provider_safe_schema`` and raise, so a model whose only
transport is the prompt could never carry such a schema at all -- it
would fail before the request was built rather than degrade.
"""
schema = make_strict_json_schema(response_format.model_json_schema())
assert_provider_safe_schema(schema, name=response_format.__name__)
return prompt_schema_instruction(schema, tools_available=tools_available)

Expand Down
11 changes: 10 additions & 1 deletion reflexio/server/services/playbook_optimizer/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,17 @@ def optimize(
error_type=type(exc).__name__,
):
logger.exception("Playbook optimization failed")
# The exception message is deliberately not persisted here. It can
# carry customer content -- a pydantic ValidationError raised on a
# provider response renders the model's own output into its message
# -- and ``decision_reason`` is a durable column read back into the
# domain model and shown to operators. The class name, the traceback
# and the tags are already captured by the ``error_tags`` block
# above, which is where an unbounded diagnostic belongs.
self.storage.update_playbook_optimization_job(
job.job_id, status="failed", decision_reason=str(exc)
job.job_id,
status="failed",
decision_reason="optimization run raised an unexpected error",
)
return "failed"

Expand Down
53 changes: 53 additions & 0 deletions tests/models/test_optimization_terminal_outcome.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""``OptimizationTerminalOutcome`` must admit the outcomes the tuner writes.

'regeneration_fenced' reached the tenant CHECK in 20260827040000 (re-declared by
20260830020000) and is written by reflexio_ext
offline_tuner/open_world/runner.py:251 via ``_converge_terminal_failure``, but
was never added to this union -- so reading such a row back through
``_row_to_playbook_optimization_job`` raised ``ValidationError``.

That failure did not surface as a validation error either. ``handle_exceptions``
converts it to ``StorageError``, and the open-world runner's ``except
StorageError`` arm reports ``infrastructure_failure`` -- so a refusal the fence
made on purpose was recorded, and reported to the enterprise error monitor,
under a reason that names
a fault that never happened.

The union-versus-CHECK set equality is asserted in the enterprise tree, where
cross-repository assertions belong and ``supabase/`` actually exists:
``reflexio_ext/tests/server/services/offline_tuner/test_replay_removal_allowlists.py``.
This module stays inside the OSS package so it keeps passing in a standalone
checkout, where there is no ``supabase/`` directory to read.
"""

from __future__ import annotations

import pytest
from pydantic import ValidationError

from reflexio.models.api_schema.domain.entities import PlaybookOptimizationJob


def test_a_fenced_job_row_parses() -> None:
"""The regression: this construction raised ``ValidationError`` before."""
job = PlaybookOptimizationJob(
job_id=1,
target_kind="user_playbook",
target_id=7,
status="failed",
stage="failed",
terminal_outcome="regeneration_fenced",
)

assert job.terminal_outcome == "regeneration_fenced"


def test_an_invented_outcome_is_still_rejected() -> None:
"""Widening the union must not degrade it into a free-form string."""
with pytest.raises(ValidationError):
PlaybookOptimizationJob(
job_id=1,
target_kind="user_playbook",
target_id=7,
terminal_outcome="not_a_real_outcome", # type: ignore[arg-type]
)
26 changes: 22 additions & 4 deletions tests/models/test_terminal_outcome_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
_TERMINAL_OUTCOMES_BY_OPTIMIZER,
)

# The ten outcomes that survive Phase 7 with a path that can reach them. Six are
# written by the stage-advance allowlist below; the other four are written
# The twelve outcomes that survive Phase 7 with a path that can reach them. Six
# are written by the stage-advance allowlist below; the other six are written
# elsewhere and are named here with their writer so the split is auditable.
_REACHABLE_TERMINAL_OUTCOMES = frozenset(
{
Expand All @@ -38,6 +38,24 @@
"generation_failed",
# the governance erasure path
"governance_erased",
# the regeneration fence: reflexio_ext open_world/runner.py:251 calls
# _converge_terminal_failure with it, and the TENANT stage-advance RPC's
# 'failed' arm assigns it (tenant 20260830020000:325-327). It is
# deliberately absent from the SQLite allowlist below -- SQLite carries
# no open-world fence -- which is why it is named here rather than left
# to the `writable <=` assertion to cover.
"regeneration_fenced",
# the invocation-slot pin: reflexio_ext open_world/runner.py:262-264
# calls _converge_terminal_failure with it on
# OpenWorldInvocationSlotExhaustedError -- the attempt made NO provider
# call because every row identity its question could occupy is owned by
# another job. The TENANT stage-advance RPC's 'failed' arm assigns it
# (tenant 20260903010000:129-131); the 'abstained' arm deliberately does
# not, since nothing was judged. Absent from the SQLite allowlist below
# for the same reason as 'regeneration_fenced' -- SQLite carries no
# open-world invocation table, so no slot can be pinned -- which is why
# it is named here rather than left to the `writable <=` assertion.
"invocation_slot_pinned",
# stage-advance: 'failed'
"infrastructure_failure",
"analyst_unqualified",
Expand Down Expand Up @@ -88,8 +106,8 @@ def test_the_union_is_exactly_the_reachable_set_plus_the_retained_set() -> None:
assert (
members - RETAINED_UNREACHABLE_TERMINAL_OUTCOMES == _REACHABLE_TERMINAL_OUTCOMES
)
assert len(members) == 17
assert len(_REACHABLE_TERMINAL_OUTCOMES) == 10
assert len(members) == 19
assert len(_REACHABLE_TERMINAL_OUTCOMES) == 12


def test_no_retained_outcome_is_writable_through_the_stage_advance_allowlist() -> None:
Expand Down
100 changes: 86 additions & 14 deletions tests/server/llm/test_litellm_client_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,42 @@ def test_zai_uses_coding_endpoint_and_prompt_backed_json_mode(self):
assert parser_schema is SampleResponse
assert parse_structured is True

def test_minimax_carries_the_schema_in_the_prompt_not_response_format(self):
"""MiniMax ignores ``response_format``, so the schema must be in the prompt.

Measured against the live API: two identical calls differing only in
``drop_params`` both returned free prose rather than JSON, so a
``json_schema`` response_format is discarded however it is sent. The
symptom was an analyst inventing a different set of field names on
every run -- answering from the prompt alone, never having been given
a schema. Asserting the field NAMES appear in the instruction is the
point: a bare ``{"type": "json_object"}`` would leave the model to
guess them, which is the defect this guards.
"""
client = _build_client(
LiteLLMConfig(
model="minimax/MiniMax-M3",
api_key_config=APIKeyConfig(minimax=MiniMaxConfig(api_key="mm-key")),
)
)
messages = [{"role": "user", "content": "test"}]

params, parser_schema, parse_structured, _, _ = client._build_completion_params(
messages,
response_format=SampleResponse,
)

assert params["response_format"] == {"type": "json_object"}
instruction = params["messages"][0]["content"]
assert params["messages"][0]["role"] == "system"
assert "Return ONLY a JSON object" in instruction
assert '"answer"' in instruction
assert '"score"' in instruction
# The caller's messages are not mutated, and local parsing stays typed.
assert messages == [{"role": "user", "content": "test"}]
assert parser_schema is SampleResponse
assert parse_structured is True

def test_zai_tool_turn_leaves_tools_free_and_constrains_only_terminus(self):
client = _build_client(LiteLLMConfig(model="zai/glm-5.2"))
messages = [
Expand Down Expand Up @@ -1676,16 +1712,34 @@ def test_zai_strict_response_format_false_preserves_passthrough(self):
assert params["messages"] == messages

def test_openai_compatible_underreported_provider_uses_strict_schema(self):
# Regression: minimax reports
# supports_response_schema=False, but it is an OpenAI-compatible endpoint
# LiteLLM would still hand a self-built json_schema. We must send our own
# normalized strict schema instead of the raw Pydantic model.
# Covers the _JSON_SCHEMA_PROVIDER_ALLOWLIST mechanism: a provider that
# genuinely accepts a json_schema response_format but that LiteLLM
# reports as unsupported must receive our own normalized strict schema,
# not the raw Pydantic model.
#
# The allowlist is EMPTY in production -- minimax was its only member
# and moved to the prompt path, having turned out to ignore
# response_format outright. So this patches a member in to exercise the
# mechanism itself. Without that, the code path would have no coverage
# at all and the next provider added to it would be unguarded.
client = _build_client(LiteLLMConfig(model="minimax/MiniMax-M3"))

with patch.object(
LiteLLMClient,
"_supports_response_schema",
return_value=False,
with (
patch.object(
LiteLLMClient,
"_supports_response_schema",
return_value=False,
),
patch.object(
LiteLLMClient,
"_JSON_SCHEMA_PROVIDER_ALLOWLIST",
frozenset({"minimax"}),
),
patch.object(
LiteLLMClient,
"_PROMPT_SCHEMA_PROVIDER_ALLOWLIST",
frozenset({"zai"}),
),
):
params, parser_schema, parse_structured, _, _ = (
client._build_completion_params(
Expand Down Expand Up @@ -1718,10 +1772,20 @@ def test_discriminated_union_strips_oneof_for_underreported_provider(self):
# to exercise make_strict's prod backstop. The by-construction boundary
# guard would (correctly) raise on it under pytest, so patch it to a no-op
# here — this test asserts the make_strict fallback, not the guard.
# The json-schema allowlist is EMPTY in production (minimax moved to the
# prompt path), so a member is patched in to exercise the mechanism.
with (
patch.object(
LiteLLMClient, "_supports_response_schema", return_value=False
),
patch.object(
LiteLLMClient,
"_JSON_SCHEMA_PROVIDER_ALLOWLIST",
frozenset({"minimax"}),
),
patch.object(
LiteLLMClient, "_PROMPT_SCHEMA_PROVIDER_ALLOWLIST", frozenset({"zai"})
),
patch(
"reflexio.server.llm._litellm_structured_output.assert_provider_safe_schema"
),
Expand Down Expand Up @@ -1756,12 +1820,20 @@ def test_real_minimax_gate_normalizes_without_mocking_predicate(self):
[{"role": "user", "content": "test"}],
response_format=_DiscriminatedOutput,
)
provider_format = params["response_format"]
assert isinstance(provider_format, dict), (
"minimax must receive a normalized strict schema, not the raw Pydantic "
"model"
)
schema = provider_format["json_schema"]["schema"]
# minimax now carries its schema in the PROMPT -- it ignores
# response_format outright -- so the normalization invariant moved
# transport with it. It did not stop mattering: an unfolded `oneOf`
# trips assert_provider_safe_schema and raises before the request is
# even built, so a prompt-only provider could otherwise never carry a
# discriminated union at all.
assert params["response_format"] == {"type": "json_object"}
instruction = params["messages"][0]["content"]
# `prompt_schema_instruction` renders "<prose>\n\n<json.dumps(schema)>",
# and json.dumps(indent=2) emits no blank lines, so the first blank
# line is an unambiguous separator.
prose, _, schema_text = instruction.partition("\n\n")
assert "JSON Schema" in prose
schema = json.loads(schema_text)
assert not find_schema_keyword(schema, "oneOf")
assert not find_schema_keyword(schema, "discriminator")

Expand Down
Loading
Loading