Skip to content

Commit 12b32f1

Browse files
author
AgentPatterns
committed
feat(examples/python): add self-critique-agent runnable example
1 parent 32ca214 commit 12b32f1

File tree

2 files changed

+82
-6
lines changed

2 files changed

+82
-6
lines changed

examples/agent-patterns/self-critique-agent/python/gateway.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def __init__(self, reason: str):
1616

1717
@dataclass(frozen=True)
1818
class Budget:
19-
max_seconds: int = 30
19+
max_seconds: int = 120
2020
max_draft_chars: int = 900
2121
max_risks: int = 5
2222
max_required_changes: int = 5
@@ -184,11 +184,45 @@ def _is_enforceable_required_change(item: str) -> bool:
184184

185185

186186
def _contains_normalized_phrase(*, text: str, phrase: str) -> bool:
187-
normalized_text = _normalize_space(text).lower()
188-
normalized_phrase = _normalize_space(phrase).lower()
187+
# Compare using token-like normalization so punctuation differences
188+
# (e.g. trailing dots/commas) do not cause false negatives.
189+
normalized_text = re.sub(r"[^a-z0-9% ]+", " ", _normalize_space(text).lower())
190+
normalized_phrase = re.sub(r"[^a-z0-9% ]+", " ", _normalize_space(phrase).lower())
191+
normalized_text = " ".join(normalized_text.split())
192+
normalized_phrase = " ".join(normalized_phrase.split())
189193
return normalized_phrase in normalized_text
190194

191195

196+
def _remove_phrase_occurrences(*, text: str, phrase: str) -> str:
197+
cleaned = text
198+
normalized_phrase = _normalize_space(phrase).strip()
199+
if not normalized_phrase:
200+
return cleaned
201+
202+
variants = {normalized_phrase, normalized_phrase.rstrip(".!?")}
203+
for variant in variants:
204+
if not variant:
205+
continue
206+
cleaned = re.sub(re.escape(variant), "", cleaned, flags=re.IGNORECASE)
207+
208+
cleaned = re.sub(r"\s+\.", ".", cleaned)
209+
cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
210+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
211+
return cleaned.strip()
212+
213+
214+
def _append_phrase_sentence(*, text: str, phrase: str) -> str:
215+
sentence = _normalize_space(phrase).strip()
216+
if not sentence:
217+
return text
218+
219+
out = text.rstrip()
220+
if out and out[-1] not in ".!?":
221+
out += "."
222+
separator = "\n\n" if "\n\n" in out else " "
223+
return (out + separator + sentence).strip()
224+
225+
192226

193227
def validate_draft(draft: Any, *, max_chars: int) -> str:
194228
if not isinstance(draft, str) or not draft.strip():
@@ -308,6 +342,28 @@ def enforce_execution_decision(self, decision: str) -> None:
308342
if decision not in self.allow_execution_decisions:
309343
raise StopRun(f"critique_decision_denied_execution:{decision}")
310344

345+
def apply_required_changes_fallback(self, *, text: str, required_changes: list[str]) -> str:
346+
"""
347+
Deterministic fallback for enforceable required changes:
348+
remove MUST_REMOVE/REMOVE phrases and append missing MUST_INCLUDE/ADD phrases.
349+
"""
350+
candidate = (text or "").strip()
351+
if not candidate:
352+
return candidate
353+
354+
phrase_rules = _extract_required_change_rules(required_changes)
355+
must_include = phrase_rules["must_include"]
356+
must_remove = phrase_rules["must_remove"]
357+
358+
for phrase in must_remove:
359+
candidate = _remove_phrase_occurrences(text=candidate, phrase=phrase)
360+
361+
for phrase in must_include:
362+
if not _contains_normalized_phrase(text=candidate, phrase=phrase):
363+
candidate = _append_phrase_sentence(text=candidate, phrase=phrase)
364+
365+
return candidate.strip()
366+
311367
def validate_revision(
312368
self,
313369
*,

examples/agent-patterns/self-critique-agent/python/main.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
INCIDENT_CONTEXT = build_incident_context(report_date="2026-03-06", region="US")
1818

1919
BUDGET = Budget(
20-
max_seconds=30,
20+
max_seconds=120,
2121
max_draft_chars=900,
2222
max_risks=5,
2323
max_required_changes=5,
@@ -175,7 +175,8 @@ def stopped(stop_reason: str, *, phase: str, **extra: Any) -> dict[str, Any]:
175175
revise_attempts = 0
176176
revise_retried = False
177177
revised_payload: dict[str, Any] | None = None
178-
for attempt in range(1, 3):
178+
last_revised_candidate = draft
179+
for attempt in range(1, 4):
179180
if (time.monotonic() - started) > BUDGET.max_seconds:
180181
return stopped("max_seconds", phase="revise")
181182

@@ -189,6 +190,7 @@ def stopped(stop_reason: str, *, phase: str, **extra: Any) -> dict[str, Any]:
189190
required_changes=critique["required_changes"],
190191
strict_mode=strict_mode,
191192
)
193+
last_revised_candidate = revised_raw
192194
revised_payload = gateway.validate_revision(
193195
original=draft,
194196
revised=revised_raw,
@@ -203,9 +205,27 @@ def stopped(stop_reason: str, *, phase: str, **extra: Any) -> dict[str, Any]:
203205
except LLMEmpty:
204206
return stopped("llm_empty", phase="revise")
205207
except StopRun as exc:
206-
if exc.reason == "patch_violation:required_changes_not_applied" and attempt < 2:
208+
if exc.reason == "patch_violation:required_changes_not_applied" and attempt < 3:
207209
revise_retried = True
208210
continue
211+
if exc.reason == "patch_violation:required_changes_not_applied":
212+
# Final fallback: enforce required phrase edits deterministically.
213+
try:
214+
fallback_revised = gateway.apply_required_changes_fallback(
215+
text=last_revised_candidate,
216+
required_changes=critique["required_changes"],
217+
)
218+
revised_payload = gateway.validate_revision(
219+
original=draft,
220+
revised=fallback_revised,
221+
context=incident_context,
222+
required_changes=critique["required_changes"],
223+
)
224+
revise_attempts = attempt + 1
225+
revise_retried = True
226+
break
227+
except StopRun as fallback_exc:
228+
return stopped(fallback_exc.reason, phase="revise")
209229
return stopped(exc.reason, phase="revise")
210230

211231
if revised_payload is None:

0 commit comments

Comments
 (0)