Skip to content

Commit 8edac69

Browse files
awesome-proclaude
andcommitted
feat: verifier retry loop (v0.3)
Add Pillar 3 — the self-healing verifier loop. After an agent returns, GuardLoop runs a fail-fast chain of verifiers (sync or async callables); on rejection it appends the feedback to RunContext.retry_feedback and re-invokes the agent, bounded by VerifierConfig.max_retries. All attempts share one BudgetController and one asyncio.timeout, so a verifier loop cannot bypass any guardrail. - new module guardloop.verifier: VerifierResult, VerifierContext, VerifierConfig, Verifier alias, VerifierChain, EVENT_* constants, and built-in factories non_empty / matches_regex / is_json_object - GuardLoop(verifiers=..., verifier_config=...) and add_verifier() - RunResult.verification_passed / verification_attempts / verification_feedback - RunContext.retry_feedback / attempt - exceptions VerificationFailed (strict mode) and VerifierExecutionError - OTel verifier_run spans plus agent_run verification attrs/events - opt-in VerifierConfig(raise_on_failure=True); default keeps the last output with success=False / terminated_reason="verification_failed" - bump to 0.3.0, start CHANGELOG.md, point Changelog URL at it - tests/test_verifier.py (22 tests), examples/verifier_retry_loop.py - README and docs/{design,roadmap,project-overview}.md updated Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 241bd99 commit 8edac69

16 files changed

Lines changed: 1533 additions & 99 deletions

CHANGELOG.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Changelog
2+
3+
All notable changes to GuardLoop are documented here. The format is based on
4+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5+
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (pre-1.0:
6+
minor releases may include breaking changes).
7+
8+
## [0.3.0] - 2026-05-10
9+
10+
### Added
11+
12+
- **Verifier retry loop (Pillar 3 / self-healing).** After an agent finishes,
13+
GuardLoop can run a chain of verifiers against the output; on rejection it
14+
appends the verifier's feedback to `RunContext.retry_feedback` and re-invokes
15+
the agent, bounded by `VerifierConfig.max_retries`. All attempts share the
16+
same budget (cost / tokens / time / tool calls) and the run's single
17+
`asyncio.timeout`, so a verifier loop cannot bypass any guardrail.
18+
- New module `guardloop.verifier` with public exports: `Verifier` (callable
19+
type alias — sync or async, returning `VerifierResult`, `bool`, or `None`),
20+
`VerifierResult`, `VerifierContext`, `VerifierConfig`, and `VerifierChain`.
21+
- Built-in rule-based verifier factories: `non_empty()`, `matches_regex(...)`,
22+
`is_json_object(required_keys=...)`.
23+
- `GuardLoop(verifiers=[...], verifier_config=VerifierConfig(...))` constructor
24+
parameters and `GuardLoop.add_verifier(fn)`.
25+
- `RunResult` fields: `verification_passed: bool | None`,
26+
`verification_attempts: int`, `verification_feedback: list[str]`.
27+
- `RunContext.retry_feedback: list[str]` and `RunContext.attempt: int`.
28+
- New exceptions `VerificationFailed` (`terminated_reason="verification_failed"`,
29+
raised only in strict mode) and `VerifierExecutionError`
30+
(`terminated_reason="verifier_error"`, raised when a verifier itself throws).
31+
- OpenTelemetry: `verifier_run <name>` child spans, `agent_run` attributes
32+
`guardloop.verification.passed` / `guardloop.verification.attempts`, and
33+
`guardloop.verification.failed` / `.retrying` / `.exhausted` span events.
34+
- No-key demo `examples/verifier_retry_loop.py`.
35+
36+
### Changed
37+
38+
- When verification ultimately fails (retries exhausted), `RunResult.success`
39+
is `False` with `terminated_reason="verification_failed"`, but `output` still
40+
holds the last attempt's text — consistent with how budget/timeout stops
41+
report. Set `VerifierConfig(raise_on_failure=True)` for strict behavior
42+
(surfaces a `VerificationFailed` with `output=None` and details in
43+
`metadata`).
44+
- `pyproject.toml`: `Changelog` URL now points at this file.
45+
46+
## [0.2.0] - 2026
47+
48+
### Added
49+
50+
- Per-tool circuit breakers with `closed` / `open` / `half_open` states, a
51+
global default policy plus per-tool overrides, breaker state that persists on
52+
the `GuardLoop` instance across runs, and `runtime.circuit_breaker_snapshots()`
53+
/ `runtime.reset_circuit_breakers()`.
54+
- `ctx.call_tool(...)` / `ctx.wrap_tool(...)` route tool calls through the
55+
breaker before the tool-call budget is incremented.
56+
- `CircuitBreakerOpen` exception and circuit-breaker OpenTelemetry attributes
57+
on tool spans.
58+
- No-key demo `examples/tool_circuit_breaker.py`.
59+
60+
## [0.1.0] - 2026
61+
62+
### Added
63+
64+
- Async runtime wrapper: `GuardLoop.run(agent, ...)` returns a structured
65+
`RunResult`; controlled stops become `success=False` with a
66+
`terminated_reason` instead of raised exceptions.
67+
- Hard budget caps for cost (`Decimal`), tokens, wall-clock time, and tool
68+
calls, enforced pre-flight before each LLM request.
69+
- Direct wrappers for `AsyncOpenAI.responses.create` and
70+
`AsyncAnthropic.messages.create` with usage accounting and pricing.
71+
- OpenTelemetry spans for agent runs, LLM calls, and tool calls (core depends
72+
only on `opentelemetry-api`; exporters via the `otel` extra).
73+
- Public exception hierarchy: `GuardLoopError`, `BudgetExceeded`,
74+
`TokenLimitExceeded`, `ToolCallLimitExceeded`, `TimeLimitExceeded`,
75+
`ModelPricingMissing`, `TokenLimitMissing`; `AgentRuntime` / `AgentRuntimeError`
76+
compatibility aliases.
77+
- No-key demo `examples/runaway_cost_prevention.py`; packaged and published to
78+
PyPI via GitHub Actions OIDC Trusted Publishing.
79+
80+
[0.3.0]: https://github.com/awesome-pro/guardloop/releases/tag/v0.3.0
81+
[0.2.0]: https://github.com/awesome-pro/guardloop/releases/tag/v0.2.0
82+
[0.1.0]: https://github.com/awesome-pro/guardloop/releases/tag/v0.1.0

README.md

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
GuardLoop is a production runtime guardrail for AI agents. It wraps model
44
clients and tools with hard budget caps, timeout control, tool-call limits, and
5-
per-tool circuit breakers, with OpenTelemetry traces for every protected call.
6-
Runaway agent loops can be stopped before they burn through money, and flaky
7-
tools can be cut off before an agent retries them into a bigger incident.
5+
per-tool circuit breakers, re-runs an agent against verifiers until the output
6+
passes, and emits OpenTelemetry traces for every protected call. Runaway agent
7+
loops can be stopped before they burn through money, flaky tools can be cut off
8+
before an agent retries them into a bigger incident, and confidently-wrong
9+
answers get a second pass.
810

9-
The v0.2 focus is intentionally sharp: **runtime guardrails for async Python
10-
agents** using direct OpenAI and Anthropic wrappers plus protected tool calls.
11+
The v0.3 focus is intentionally sharp: **runtime guardrails for async Python
12+
agents** — direct OpenAI and Anthropic wrappers, protected tool calls, per-tool
13+
circuit breakers, and a verify-fix-retry loop.
1114

1215
```python
1316
from guardloop import (
@@ -16,6 +19,8 @@ from guardloop import (
1619
CircuitBreakerConfig,
1720
CircuitBreakerPolicy,
1821
RunContext,
22+
VerifierConfig,
23+
is_json_object,
1924
)
2025

2126
runtime = GuardLoop(
@@ -31,13 +36,18 @@ runtime = GuardLoop(
3136
recovery_timeout_seconds=30,
3237
)
3338
),
39+
verifiers=[is_json_object(required_keys=["answer"])],
40+
verifier_config=VerifierConfig(max_retries=2),
3441
)
3542

3643

3744
async def agent(ctx: RunContext, prompt: str) -> str:
45+
instructions = prompt
46+
if ctx.retry_feedback:
47+
instructions += "\n\nFix the previous attempt: " + "; ".join(ctx.retry_feedback)
3848
response = await ctx.openai.responses.create(
3949
model="gpt-5.2",
40-
input=prompt,
50+
input=instructions,
4151
max_output_tokens=300,
4252
)
4353
return str(response.output_text)
@@ -58,13 +68,51 @@ flowchart LR
5868
U["User code"] --> R["GuardLoop"]
5969
R --> B["BudgetController"]
6070
R --> CB["CircuitBreakerRegistry"]
71+
R --> V["VerifierChain"]
6172
R --> T["OpenTelemetry spans"]
6273
R --> C["RunContext"]
6374
C --> O["Wrapped OpenAI client"]
6475
C --> A["Wrapped Anthropic client"]
6576
C --> W["Wrapped tools"]
77+
V -. "feedback on retry" .-> C
6678
```
6779

80+
## Verifier Retry Loop
81+
82+
Agents can return confidently wrong answers. Attach verifiers — plain callables,
83+
sync or async — and GuardLoop runs them after the agent finishes. On rejection
84+
it feeds the verifier's feedback into `ctx.retry_feedback` and re-invokes the
85+
agent, up to `VerifierConfig.max_retries` times. Every attempt shares the same
86+
budget and the run's timeout, so the retry loop can never spend past a cap.
87+
88+
```python
89+
from guardloop import GuardLoop, RunContext, VerifierConfig, VerifierContext, VerifierResult
90+
91+
92+
def no_todo(output: object, ctx: VerifierContext) -> VerifierResult:
93+
if "TODO" in str(output):
94+
return VerifierResult(passed=False, feedback="Replace the TODO placeholder.")
95+
return VerifierResult(passed=True)
96+
97+
98+
runtime = GuardLoop(verifiers=[no_todo], verifier_config=VerifierConfig(max_retries=2))
99+
100+
101+
async def agent(ctx: RunContext, task: str) -> str:
102+
# On a retry, ctx.retry_feedback holds the verifier's complaints — read it.
103+
...
104+
105+
106+
result = await runtime.run(agent, "draft the release notes")
107+
print(result.verification_passed, result.verification_attempts, result.verification_feedback)
108+
```
109+
110+
Built-in rule-based verifiers ship in `guardloop`: `non_empty()`,
111+
`matches_regex(...)`, `is_json_object(required_keys=...)`. By default an output
112+
that fails every retry comes back as `success=False` with
113+
`terminated_reason="verification_failed"` but with `output` still populated;
114+
set `VerifierConfig(raise_on_failure=True)` for a hard stop.
115+
68116
## Project Guide
69117

70118
For a deeper walkthrough of what has been implemented, how the code is
@@ -113,6 +161,15 @@ uv run python examples/tool_circuit_breaker.py
113161
This demo uses a failing fake tool. GuardLoop allows the first failures,
114162
opens the circuit breaker, then rejects the next call without invoking the tool.
115163

164+
```bash
165+
uv run python examples/verifier_retry_loop.py
166+
```
167+
168+
This demo's agent first returns a bad answer (a `TODO` placeholder, then
169+
malformed JSON). A verifier chain rejects it with feedback, the agent reads
170+
`ctx.retry_feedback` and self-corrects, and the run ends with
171+
`verification_passed: true` after three attempts.
172+
116173
## Live Provider Smoke Tests
117174

118175
```bash
@@ -135,20 +192,27 @@ uv run ruff format --check .
135192
uv run pyright
136193
```
137194

138-
## v0.2 Scope
195+
## v0.3 Scope
139196

140197
- Async Python runtime with `src/` package layout.
141198
- Hard caps for cost, tokens, time, and tool calls.
142-
- Per-tool circuit breakers with closed, open, and half-open states.
143-
- Global default breaker policy plus per-tool overrides.
144-
- Direct wrappers for `AsyncOpenAI.responses.create`.
145-
- Direct wrappers for `AsyncAnthropic.messages.create`.
146-
- OpenTelemetry spans for agent runs, LLM calls, and tools.
199+
- Per-tool circuit breakers with closed, open, and half-open states; global
200+
default breaker policy plus per-tool overrides.
201+
- Verify-fix-retry loop: sync or async output verifiers, fail-fast chains,
202+
built-in rule-based verifiers, feedback into `ctx.retry_feedback`, and an
203+
opt-in strict mode — all attempts share one budget and the run timeout.
204+
- Direct wrappers for `AsyncOpenAI.responses.create` and
205+
`AsyncAnthropic.messages.create`.
206+
- OpenTelemetry spans for agent runs, LLM calls, tools, and verifiers.
147207
- Fake-client tests and demos that do not require API keys.
148208

149209
## Roadmap
150210

151-
- v0.2: per-tool circuit breakers.
152-
- v0.3: verifier/self-healing retry loop.
211+
- v0.2: per-tool circuit breakers.
212+
- v0.3: verify-fix-retry loop.
153213
- v0.4: LangGraph and OpenAI Agents SDK adapters.
154-
- v0.5: Jaeger/Phoenix trace screenshots, blog post, and GitHub release.
214+
- v0.5: Jaeger/Phoenix trace screenshots, demo video, and blog post.
215+
- v0.6: persistent breaker state, YAML/TOML policy, multi-model pricing, loop detection.
216+
- v1.0: stable API, changelog, docs site, release checklist.
217+
218+
See [docs/roadmap.md](docs/roadmap.md) for details.

docs/design.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# GuardLoop v0.2 Design
1+
# GuardLoop Design
22

33
GuardLoop is a wrapper, not an agent framework. A user passes an async agent
44
callable to `runtime.run()`. The runtime creates a `RunContext` containing
@@ -40,9 +40,38 @@ rejections do not count as tool failures.
4040
Built-in prices are defaults, not truth forever. Callers can pass
4141
`ModelPricing` entries to override or add models as providers update pricing.
4242

43+
## Verifier Retry Loop
44+
45+
Verifiers are stateless callables (sync or async) that judge an agent's output.
46+
A `VerifierChain` runs them in order, fail-fast: the first failing verdict wins.
47+
Anything not a `VerifierResult` is normalized (`True`/`None` -> passed,
48+
`False` -> failed). If a verifier itself raises, that is a verifier bug, not the
49+
agent's: the runtime surfaces it as `VerifierExecutionError`
50+
(`terminated_reason="verifier_error"`) and does not retry.
51+
52+
The runtime owns the loop, not the agent. One `BudgetController` and one
53+
`RunContext` flow through every attempt; the only mutation between attempts is
54+
appending the failing verifier's feedback to `ctx.retry_feedback` (and bumping
55+
`ctx.attempt`). The agent is re-invoked with the same `*args`/`**kwargs` and is
56+
expected to read `ctx.retry_feedback` if it wants to self-correct. Because the
57+
budget is shared and the whole loop sits inside the run's single
58+
`asyncio.timeout()`, a verifier loop can never spend past a cap or outlive the
59+
time limit.
60+
61+
When retries are exhausted: by default the runtime returns
62+
`RunResult(success=False, terminated_reason="verification_failed",
63+
verification_passed=False)` with `output` still set to the last attempt — the
64+
agent produced an answer, it just isn't trusted. With
65+
`VerifierConfig(raise_on_failure=True)` the runtime instead surfaces a
66+
`VerificationFailed` (same `terminated_reason`, `output=None`, attempt count and
67+
feedback in `metadata`).
68+
4369
## Telemetry
4470

4571
Provider wrappers emit OpenTelemetry spans through a small conventions module.
4672
This keeps GenAI semantic convention names isolated while the standard evolves.
4773
Tool spans also include circuit breaker state, failure count, and whether a
48-
call was blocked.
74+
call was blocked. Each verifier runs in a `verifier_run <name>` child span; the
75+
root `agent_run` span carries `guardloop.verification.passed` /
76+
`guardloop.verification.attempts` plus `guardloop.verification.failed`,
77+
`.retrying`, and `.exhausted` events.

0 commit comments

Comments
 (0)