Skip to content

Commit 089efc1

Browse files
authored
refactor: consolidate on_schema_mismatch into reprompt config (#493)
* refactor(config): move on_schema_mismatch + strict_schema into RepromptConfig Remove on_schema_mismatch and strict_schema from ActionConfig and DefaultsConfig. Add on_schema_mismatch: Literal['reject', 'reprompt'] | None to RepromptConfig. Schema conformance checking is now declared inside reprompt: block only. * refactor(processing): _resolve_schema_mismatch_mode reads reprompt config Read on_schema_mismatch from reprompt dict instead of top-level key. Remove ON_SCHEMA_MISMATCH_KEY and STRICT_SCHEMA_KEY imports from helpers.py and factory.py. Update warning messages to reference new config location. * refactor(validation): granularity validator uses reprompt.on_schema_mismatch Remove old top-level on_schema_mismatch check. Add check that reprompt.on_schema_mismatch: reprompt/reject requires a schema defined. * chore: remove ON_SCHEMA_MISMATCH_KEY + STRICT_SCHEMA_KEY constants Both constants are dead — no production code references them after the config model and processing changes. * test: migrate on_schema_mismatch fixtures to reprompt config Update all test fixtures from top-level on_schema_mismatch/strict_schema to reprompt: {on_schema_mismatch: reject/reprompt}. Remove tests for warn mode and strict_schema alias. 189 tests pass. * docs: update output-validation + reprompt-patterns for new config shape Move on_schema_mismatch inside reprompt: block in all examples. Remove warn mode and strict_schema references. * refactor(test): extract shared _make_schema_config helper De-duplicate _make_config which was copied verbatim across TestValidateSchemaSkip and TestRepromptFallbackWarning. * chore: add changie breaking change entry for reprompt consolidation * fix: changie timestamp to valid ISO-8601 * fix: add isinstance guard for non-dict reprompt + extra=forbid on RepromptConfig Granularity validator now uses isinstance(reprompt_raw, dict) instead of truthy-or to prevent AttributeError when reprompt is a non-dict value. RepromptConfig gains extra='forbid' to catch typos like on_schema_mistmatch. * fix: improve reprompt CLI output clarity - Client parse warnings downgraded to debug (reprompt loop owns the messaging) - Remove redundant 'Validation failed' after 'Invalid JSON' (one message per event) - Add 'Retrying with feedback (attempt N/M)' before each retry - Only log 'Reprompt passed on attempt N/M' when it took >1 attempt - 'Schema validation failed' only shown for schema errors, not parse errors * fix: include expected schema fields in JSON parse error feedback When the model returns empty/invalid JSON, the reprompt feedback now includes the expected field names from the schema. This guides the model on WHAT to produce, not just 'return JSON'. Example feedback: Your previous response was not valid JSON. Return only a JSON object... Expected JSON fields: summary, exam_density, key_topics * fix: forceful JSON parse feedback with expected field names When the model returns empty/invalid JSON, the reprompt feedback is now assertive and includes the exact fields expected from the schema. Example: CRITICAL: Your previous response was empty or not valid JSON. This is a strict JSON pipeline — every response MUST be a single JSON object. No markdown. No code fences. No explanation. You MUST return a JSON object with these fields: summary, exam_density Example structure: { "summary": "...", "exam_density": "...", } * docs: fix dangling reprompt blocks missing on_schema_mismatch Every reprompt: example now includes on_schema_mismatch: reprompt or validation: to match actual framework behavior. A bare reprompt block without either does not create a RepromptService. Also added on_schema_mismatch, use_self_reflection, use_llm_critique, and critique_after_attempt to the reprompt-patterns field reference table.
1 parent 84be3b8 commit 089efc1

18 files changed

Lines changed: 261 additions & 276 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Breaking Change
2+
body: "Remove top-level on_schema_mismatch and strict_schema config keys. Schema conformance is now declared inside reprompt: {on_schema_mismatch: reject/reprompt}."
3+
time: 2026-05-04T14:33:00.000000Z

agent_actions/config/schema.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,17 @@ class RetryConfig(BaseModel):
8989
class RepromptConfig(BaseModel):
9090
"""Configuration for reprompt behavior on validation failures.
9191
92-
``validation`` is optional when an external validator is provided
93-
(e.g. via ``on_schema_mismatch: reprompt``).
92+
``validation`` is optional when ``on_schema_mismatch`` is set — the schema
93+
itself serves as the validator.
9494
"""
9595

96+
model_config = ConfigDict(extra="forbid")
97+
9698
validation: str | None = Field(default=None, description="Name of validation UDF function")
99+
on_schema_mismatch: Literal["reject", "reprompt"] | None = Field(
100+
default=None,
101+
description="Schema conformance check: reject (hard fail) or reprompt (retry on mismatch)",
102+
)
97103
max_attempts: int = Field(
98104
default=2,
99105
ge=1,
@@ -187,12 +193,6 @@ class ActionConfig(BaseModel):
187193
reprompt: RepromptConfig | None = Field(
188194
default=None, description="Reprompt configuration for validation failures"
189195
)
190-
strict_schema: bool | None = Field(
191-
default=None, description="Enable strict schema validation (reject on mismatch)"
192-
)
193-
on_schema_mismatch: Literal["warn", "reprompt", "reject"] | None = Field(
194-
default=None, description="Schema mismatch mode: warn, reprompt, or reject"
195-
)
196196
idempotency_key: str | None = Field(default=None, description="Idempotency key template")
197197
prompt: str | None = Field(default=None, description="Prompt template or reference")
198198
dependencies: list[str] = Field(
@@ -361,10 +361,6 @@ class DefaultsConfig(BaseModel):
361361
)
362362
constraints: Any | None = Field(default=None, description="Default constraints")
363363
retry: RetryConfig | None = Field(default=None, description="Default retry configuration")
364-
strict_schema: bool | None = Field(default=None, description="Default strict schema flag")
365-
on_schema_mismatch: Literal["warn", "reprompt", "reject"] | None = Field(
366-
default=None, description="Default schema mismatch mode"
367-
)
368364

369365
# --- Expander-consumed keys ---
370366
context_scope: dict[str, Any] | None = Field(default=None, description="Default ctx scope")

agent_actions/llm/providers/ollama/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def _call_ollama_json(
170170
parsed = json.loads(content)
171171
return [parsed] if isinstance(parsed, dict) else [{"response": parsed}]
172172
except json.JSONDecodeError as e:
173-
logger.warning(
173+
logger.debug(
174174
"%s/%s returned invalid JSON: %s",
175175
vendor_slug,
176176
model,

agent_actions/llm/providers/openai/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,15 +127,15 @@ def call_json(
127127
request_id=request_id,
128128
)
129129
)
130-
logger.warning(
130+
logger.debug(
131131
"Empty response content from OpenAI API, model=%s",
132132
model_name,
133133
)
134134
return [{"raw_response": "", "_parse_error": "Empty response from API"}]
135135
try:
136136
response_data: dict[str, Any] | list[dict[str, Any]] = json.loads(response_content)
137137
except json.JSONDecodeError as e:
138-
logger.warning(
138+
logger.debug(
139139
"Failed to parse JSON from OpenAI response: %s (snippet: %.200s)",
140140
e,
141141
response_content,

agent_actions/processing/helpers.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any
77

88
from agent_actions.errors import SchemaValidationError
9-
from agent_actions.utils.constants import ON_SCHEMA_MISMATCH_KEY, SCHEMA_KEY, STRICT_SCHEMA_KEY
9+
from agent_actions.utils.constants import SCHEMA_KEY
1010
from agent_actions.utils.transformation import PassthroughTransformer
1111

1212
logger = logging.getLogger(__name__)
@@ -70,20 +70,14 @@ def run_dynamic_agent(
7070

7171

7272
def _resolve_schema_mismatch_mode(agent_config: dict[str, Any]) -> str:
73-
"""Resolve on_schema_mismatch to 'warn', 'reprompt', or 'reject'."""
74-
explicit = agent_config.get(ON_SCHEMA_MISMATCH_KEY)
75-
if explicit in ("warn", "reprompt", "reject"):
76-
return str(explicit)
77-
78-
if explicit is not None:
79-
logger.warning(
80-
"Unrecognized on_schema_mismatch value '%s', defaulting to 'warn'",
81-
explicit,
82-
)
83-
84-
if agent_config.get(STRICT_SCHEMA_KEY, False):
85-
return "reject"
73+
"""Resolve schema mismatch mode from reprompt.on_schema_mismatch.
8674
75+
Returns ``"reject"``, ``"reprompt"``, or ``"warn"`` (internal signal for
76+
no enforcement).
77+
"""
78+
reprompt = agent_config.get("reprompt")
79+
if isinstance(reprompt, dict) and reprompt.get("on_schema_mismatch"):
80+
return str(reprompt["on_schema_mismatch"])
8781
return "warn"
8882

8983

@@ -96,9 +90,9 @@ def _validate_llm_output_schema(
9690
) -> Any:
9791
"""Validate LLM output against expected schema if defined.
9892
99-
Returns the response unchanged. When ``on_schema_mismatch`` is "reprompt"
100-
and ``skip_schema_validation`` is True, validation is deferred to the
101-
outer reprompt loop.
93+
Returns the response unchanged. When ``reprompt.on_schema_mismatch`` is
94+
"reprompt" and ``skip_schema_validation`` is True, validation is deferred
95+
to the outer reprompt loop.
10296
10397
Raises:
10498
SchemaValidationError: If on_schema_mismatch="reject" and validation fails.
@@ -108,9 +102,8 @@ def _validate_llm_output_schema(
108102
mismatch_mode = _resolve_schema_mismatch_mode(agent_config)
109103
if mismatch_mode in ("reject", "reprompt"):
110104
logger.warning(
111-
"Action '%s': on_schema_mismatch is '%s' but no schema is defined — "
112-
"schema validation will be skipped. Define a schema or set "
113-
"on_schema_mismatch to 'warn'.",
105+
"Action '%s': reprompt.on_schema_mismatch is '%s' but no schema is "
106+
"defined — schema validation will be skipped.",
114107
agent_name,
115108
mismatch_mode,
116109
)
@@ -141,8 +134,8 @@ def _validate_llm_output_schema(
141134
if not report.is_compliant:
142135
if strict_mode:
143136
hint = (
144-
"Enable strict_schema: false to allow schema mismatches, "
145-
"or update the prompt to match expected schema"
137+
"Remove reprompt.on_schema_mismatch: reject to allow schema "
138+
"mismatches, or update the prompt to match expected schema"
146139
)
147140
if report.namespace_hint:
148141
hint = f"{hint}. {report.namespace_hint}"

agent_actions/processing/invocation/factory.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def _build_validator(agent_config: dict[str, Any]) -> ResponseValidator | None:
7777
SchemaValidator,
7878
UdfValidator,
7979
)
80-
from agent_actions.utils.constants import SCHEMA_KEY, STRICT_SCHEMA_KEY
80+
from agent_actions.utils.constants import SCHEMA_KEY
8181

8282
validators: list[ResponseValidator] = []
8383

@@ -92,8 +92,7 @@ def _build_validator(agent_config: dict[str, Any]) -> ResponseValidator | None:
9292
mode = _resolve_schema_mismatch_mode(agent_config)
9393
if mode == "reprompt":
9494
action_name = agent_config.get("name", "unknown")
95-
strict = agent_config.get(STRICT_SCHEMA_KEY, False)
96-
validators.append(SchemaValidator(schema, action_name, strict_mode=strict))
95+
validators.append(SchemaValidator(schema, action_name))
9796

9897
if not validators:
9998
return None

agent_actions/processing/recovery/reprompt.py

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,46 @@
1919
)
2020
from .retry import RetryExhaustedException
2121

22-
_JSON_PARSE_FEEDBACK = (
23-
"Your previous response was not valid JSON. "
24-
"Return only a JSON object — no markdown, no explanation, no code fences."
25-
)
22+
23+
def _extract_field_names(schema: dict) -> list[str]:
24+
"""Extract field names from any schema format."""
25+
# fields-style (agent-actions format)
26+
fields = schema.get("fields")
27+
if isinstance(fields, list):
28+
return [f.get("id") or f.get("name", "") for f in fields if isinstance(f, dict)]
29+
# properties-style (JSON Schema format)
30+
props = schema.get("properties")
31+
if isinstance(props, dict):
32+
return list(props.keys())
33+
# Inline schema: keys are field names directly
34+
if all(isinstance(v, str) for v in schema.values()):
35+
return list(schema.keys())
36+
return []
37+
38+
39+
def _build_json_parse_feedback(validator: Any) -> str:
40+
"""Build forceful JSON parse error feedback with expected schema fields."""
41+
schema = getattr(validator, "_schema", None)
42+
field_names = _extract_field_names(schema) if isinstance(schema, dict) else []
43+
44+
if field_names:
45+
fields_str = ", ".join(field_names)
46+
return (
47+
"CRITICAL: Your previous response was empty or not valid JSON. "
48+
"This is a strict JSON pipeline — every response MUST be a single "
49+
"JSON object. No markdown. No code fences. No explanation. No "
50+
"preamble. Just the raw JSON object.\n\n"
51+
f"You MUST return a JSON object with these fields: {fields_str}\n\n"
52+
"Example structure:\n"
53+
"{\n" + "".join(f' "{name}": "...",\n' for name in field_names) + "}"
54+
)
55+
56+
return (
57+
"CRITICAL: Your previous response was empty or not valid JSON. "
58+
"This is a strict JSON pipeline — every response MUST be a single "
59+
"JSON object. No markdown. No code fences. No explanation. No "
60+
"preamble. Just the raw JSON object."
61+
)
2662

2763

2864
def _get_parse_error(response: Any) -> str | None:
@@ -206,7 +242,7 @@ def execute(
206242
if parse_error is not None:
207243
is_valid = False
208244
logger.warning(
209-
"[%s] Provider returned invalid JSON on attempt %d/%d: %s",
245+
"[%s] Invalid JSON on attempt %d/%d %s",
210246
context,
211247
attempts,
212248
self.max_attempts,
@@ -216,12 +252,13 @@ def execute(
216252
is_valid = safe_validate(self._validator.validate, response, context=context)
217253

218254
if is_valid:
219-
logger.info(
220-
"[%s] Validation passed on attempt %d/%d",
221-
context,
222-
attempts,
223-
self.max_attempts,
224-
)
255+
if attempts > 1:
256+
logger.info(
257+
"[%s] Reprompt passed on attempt %d/%d",
258+
context,
259+
attempts,
260+
self.max_attempts,
261+
)
225262
return RepromptResult(
226263
response=response,
227264
executed=True,
@@ -231,18 +268,26 @@ def execute(
231268
exhausted=False,
232269
)
233270

234-
logger.warning(
235-
"[%s] Validation failed on attempt %d/%d",
236-
context,
237-
attempts,
238-
self.max_attempts,
239-
)
271+
if parse_error is None:
272+
logger.warning(
273+
"[%s] Schema validation failed on attempt %d/%d",
274+
context,
275+
attempts,
276+
self.max_attempts,
277+
)
240278

241279
if attempts >= self.max_attempts:
242280
break
243281

282+
logger.info(
283+
"[%s] Retrying with feedback (attempt %d/%d)",
284+
context,
285+
attempts + 1,
286+
self.max_attempts,
287+
)
288+
244289
if parse_error is not None:
245-
feedback = _JSON_PARSE_FEEDBACK
290+
feedback = _build_json_parse_feedback(self._validator)
246291
else:
247292
feedback = build_validation_feedback(
248293
response, self._validator.feedback_message, strategies=self._strategies

agent_actions/skills/agac-agent-skills/references/reprompt-patterns.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,28 @@ How to configure automatic retry with feedback when LLM output fails validation.
2828
2929
| Field | Type | Default | Description |
3030
|-------|------|---------|-------------|
31+
| `on_schema_mismatch` | string | None | `"reprompt"` (retry on schema fail) or `"reject"` (hard fail) |
3132
| `validation` | string | None | Name of `@reprompt_validation` function |
3233
| `max_attempts` | int | 2 | Total attempts including first try (1-10) |
3334
| `on_exhausted` | string | `"return_last"` | What to do when all attempts fail |
35+
| `use_self_reflection` | bool | false | Add self-analysis prompt before retry |
36+
| `use_llm_critique` | bool | false | Use a second LLM call to critique failures |
37+
| `critique_after_attempt` | int | 2 | Critique starts on this attempt number |
3438

3539
**`on_exhausted` options:**
3640
- `"return_last"` — Accept the last response even though it failed validation. Downstream actions receive potentially invalid data.
3741
- `"raise"` — Raise a `RuntimeError`, failing the action. Use this when invalid data is worse than no data.
3842

3943
## Schema-Based Reprompt (No Custom UDF)
4044

41-
For simple schema validation without writing Python, use `on_schema_mismatch`:
45+
For simple schema validation without writing Python, set `on_schema_mismatch` inside the `reprompt` block:
4246

4347
```yaml
4448
- name: classify_issue
4549
schema: issue_classification
4650
json_mode: true
47-
on_schema_mismatch: reprompt # "warn" | "reprompt" | "reject"
4851
reprompt:
52+
on_schema_mismatch: reprompt # "reprompt" | "reject"
4953
max_attempts: 3
5054
```
5155

@@ -101,13 +105,13 @@ tools/
101105
102106
## Composed Validators
103107
104-
When both `validation` (custom UDF) and `on_schema_mismatch: reprompt` are configured, validators are composed — the schema check runs first, then the custom UDF. Fails on the first failure.
108+
When both `validation` (custom UDF) and `on_schema_mismatch: reprompt` are configured in the same `reprompt` block, validators are composed — the schema check runs first, then the custom UDF. Fails on the first failure.
105109
106110
```yaml
107111
- name: generate_catalog_entry
108112
schema: catalog_entry
109-
on_schema_mismatch: reprompt # Layer 1: schema check
110113
reprompt:
114+
on_schema_mismatch: reprompt # Layer 1: schema check
111115
validation: "check_valid_bisac" # Layer 2: custom business logic
112116
max_attempts: 3
113117
```
@@ -189,8 +193,8 @@ def check_genre_classification(response: dict) -> bool:
189193
entities: array[string]! # Required array of strings
190194
confidence: number! # Required number
191195
json_mode: true
192-
on_schema_mismatch: reprompt
193196
reprompt:
197+
on_schema_mismatch: reprompt
194198
max_attempts: 2
195199
on_exhausted: raise # Fail if schema still violated
196200
```

agent_actions/utils/constants.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
PROMPT_KEY = "prompt"
1313
SCHEMA_NAME_KEY = "schema_name"
1414
SCHEMA_KEY = "schema"
15-
STRICT_SCHEMA_KEY = "strict_schema"
16-
ON_SCHEMA_MISMATCH_KEY = "on_schema_mismatch"
1715
CHUNK_CONFIG_KEY = "chunk_config"
1816

1917
# Reserved agent/action names that cannot be used in workflows.

agent_actions/validation/action_validators/granularity_output_field_validator.py

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from agent_actions.utils.constants import (
55
HITL_FILE_GRANULARITY_ERROR,
66
JSON_MODE_KEY,
7-
ON_SCHEMA_MISMATCH_KEY,
87
SCHEMA_KEY,
98
SCHEMA_NAME_KEY,
109
)
@@ -56,32 +55,19 @@ def validate(self, context) -> ActionEntryValidationResult:
5655
if json_mode:
5756
errors.append(f"{desc} 'output_field' can only be used when 'json_mode' is false.")
5857

59-
on_mismatch_raw = normalized_entry.get(ON_SCHEMA_MISMATCH_KEY)
60-
if isinstance(on_mismatch_raw, str):
61-
on_mismatch = on_mismatch_raw.lower()
62-
else:
63-
on_mismatch = None
64-
65-
if on_mismatch in ("reject", "reprompt"):
58+
reprompt_raw = normalized_entry.get("reprompt")
59+
reprompt_cfg = reprompt_raw if isinstance(reprompt_raw, dict) else {}
60+
schema_mismatch_mode = reprompt_cfg.get("on_schema_mismatch")
61+
if schema_mismatch_mode in ("reprompt", "reject"):
6662
has_schema = bool(
6763
normalized_entry.get(SCHEMA_KEY) or normalized_entry.get(SCHEMA_NAME_KEY)
6864
)
6965
if not has_schema:
7066
errors.append(
71-
f"{desc} 'on_schema_mismatch: {on_mismatch}' requires a schema "
72-
"to validate against. Define 'schema' or 'schema_name', "
73-
"or change on_schema_mismatch to 'warn'."
67+
f"{desc} reprompt.on_schema_mismatch: {schema_mismatch_mode} requires "
68+
"a schema to validate against. Define 'schema' or 'schema_name'."
7469
)
7570

76-
if on_mismatch == "reprompt":
77-
reprompt = normalized_entry.get("reprompt")
78-
if not reprompt:
79-
errors.append(
80-
f"{desc} 'on_schema_mismatch: reprompt' requires a 'reprompt' "
81-
"configuration block. Add reprompt: {{validation: your_udf_name}} "
82-
"or change on_schema_mismatch to 'warn' or 'reject'."
83-
)
84-
8571
if errors:
8672
return ActionEntryValidationResult.with_errors(errors)
8773

0 commit comments

Comments
 (0)