Skip to content

Commit 8d68309

Browse files
Guardrails facade, middleware docs, proxy + text intent ADRs (#10) (#11)
* Update docs for guardrail middleware refactor, bump to v0.2.0 ARCHITECTURE.md and WORKFLOW.md still referenced the old runner internals (direct StepTracker usage, inline retry/error counting) instead of the middleware pattern shipped in #9. Update dependency diagram, data flow pseudocode, mermaid flowcharts, and type reference to reflect ResponseValidator / StepEnforcer / ErrorTracker composition. Also fix license (Apache→MIT) and a broken relative link in WORKFLOW.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add Guardrails facade for foreign loop integrators Two-method API wrapping ResponseValidator, StepEnforcer, and ErrorTracker: guardrails.check(response) -> CheckResult (execute/retry/step_blocked/fatal) guardrails.record(executed) -> bool (terminal reached?) Rework examples/foreign_loop.py into two parts: Part 1 uses the new Guardrails facade, Part 2 shows the granular middleware for custom control. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add ADRs for proxy and text response intent, update middleware docs ADR-012: Revised proxy design -- extract WorkflowRunner's front half (compaction, reasoning folding, message merging, serialization, validation) into reusable component. Proxy is WorkflowRunner's front half behind an HTTP server, not a reimplementation. ADR-013: Text response intent -- when model chooses text over tools. Three approaches: intentional flag on TextResponse, synthetic respond tool injection, tool_choice respect. USER_GUIDE Mode 3: Add Guardrails facade (simple two-call API) alongside existing granular middleware API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 99539cf commit 8d68309

12 files changed

Lines changed: 870 additions & 156 deletions

File tree

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ bfcl_results.jsonl
3232
Thumbs.db
3333
Desktop.ini
3434
.DS_Store
35+
36+
# External tools
37+
.opencode/
38+
opencode-panic-*.log
39+
.opencode.json

docs/ARCHITECTURE.md

Lines changed: 180 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ A reusable Python library for self-hosted LLM tool-calling and multi-step agenti
77
**Tested models:** Ministral 8B/14B (Instruct + Reasoning), Qwen3 8B/14B, Llama 3.1 8B, Mistral 7B v0.3, Mistral Nemo 12B, Claude Haiku/Sonnet/Opus
88
**Target hardware:** 12–32GB VRAM (consumer GPUs)
99
**Backends:** Ollama, llama-server (llama.cpp), Llamafile, Anthropic API (baseline)
10-
**License:** Apache 2.0
10+
**License:** MIT
1111

1212
---
1313

@@ -109,12 +109,22 @@ Downstream consumers who inspect message history (logging, debugging, eval) get
109109
│ │ │ Compact │ │ Hardware │ │
110110
│ │ │ Strategy │ │ Profile │ │
111111
│ │ └──────────────┘ └──────────────┘ │
112-
│ ┌──────▼──────┐ ┌──────────────┐ │
113-
│ │ Step │ │ Message │ │
114-
│ │ Tracker │ │ Types │ │
115-
│ └─────────────┘ └──────────────┘ │
116-
│ │ │
117-
│ ┌──────▼──────────────────────────────────────────┐ │
112+
│ ┌──────▼──────────────────────────┐ │
113+
│ │ Guardrails (middleware) │ ┌──────────────┐ │
114+
│ │ ┌──────────────────────────┐ │ │ Message │ │
115+
│ │ │ ResponseValidator │ │ │ Types │ │
116+
│ │ │ (rescue, retry, unknown) │ │ └──────────────┘ │
117+
│ │ ├──────────────────────────┤ │ │
118+
│ │ │ StepEnforcer │ │ │
119+
│ │ │ (wraps StepTracker, │ │ │
120+
│ │ │ premature escalation) │ │ │
121+
│ │ ├──────────────────────────┤ │ │
122+
│ │ │ ErrorTracker │ │ │
123+
│ │ │ (retry + tool budgets) │ │ │
124+
│ │ └──────────────────────────┘ │ │
125+
│ └─────────────┬───────────────────┘ │
126+
│ │ │
127+
│ ┌─────────────▼────────────────────────────────────┐ │
118128
│ │ LLMClient (Protocol) │ │
119129
│ │ ┌─────────────┐ ┌──────────────┐ ┌───────────┐ │ │
120130
│ │ │OllamaClient │ │LlamafileClnt │ │Anthropic- │ │ │
@@ -498,6 +508,124 @@ class StepTracker:
498508
return f"[Steps completed: {', '.join(self.completed_steps)}]"
499509

500510

511+
# ── Guardrail Middleware ──────────────────────────────────────
512+
#
513+
# Composable middleware extracted from WorkflowRunner internals.
514+
# The runner instantiates these per-run; they are also importable
515+
# for use in foreign orchestration loops (see examples/foreign_loop.py).
516+
517+
518+
@dataclass
519+
class Nudge:
520+
"""A corrective message to inject into the conversation."""
521+
role: str # Always "user"
522+
content: str # The nudge text
523+
kind: str # "retry", "unknown_tool", or "step"
524+
tier: int = 0 # Escalation tier (step nudges: 1/2/3)
525+
526+
527+
@dataclass
528+
class ValidationResult:
529+
"""Result of validating an LLM response.
530+
531+
Exactly one of ``tool_calls`` or ``nudge`` is set:
532+
- If ``needs_retry`` is False, ``tool_calls`` contains the validated calls.
533+
- If ``needs_retry`` is True, ``nudge`` contains the message to inject.
534+
"""
535+
tool_calls: list[ToolCall] | None
536+
nudge: Nudge | None
537+
needs_retry: bool
538+
539+
540+
class ResponseValidator:
541+
"""Validates LLM responses: rescues tool calls from text, checks tool names.
542+
543+
Stateless — safe to reuse across turns and sessions.
544+
545+
Args:
546+
tool_names: Valid tool names for this workflow.
547+
rescue_enabled: If True, attempt to parse tool calls from TextResponse
548+
before generating a retry nudge.
549+
"""
550+
551+
def __init__(self, tool_names: list[str], rescue_enabled: bool = True) -> None: ...
552+
553+
def validate(self, response: LLMResponse) -> ValidationResult:
554+
"""Validate an LLM response.
555+
556+
Returns ValidationResult with tool_calls on success, or a Nudge on failure.
557+
TextResponse path: try rescue_tool_call(), then retry nudge.
558+
list[ToolCall] path: check for unknown tool names → unknown_tool_nudge.
559+
"""
560+
...
561+
562+
563+
@dataclass
564+
class StepCheck:
565+
"""Result of checking tool calls against step requirements."""
566+
nudge: Nudge | None
567+
needs_nudge: bool
568+
569+
570+
class StepEnforcer:
571+
"""Tracks required steps and enforces them with escalating nudges.
572+
573+
Wraps StepTracker internally. Stateful — instantiate per session/task.
574+
575+
Args:
576+
required_steps: Tool names that must be called before the terminal tool.
577+
terminal_tool: The tool that ends the workflow.
578+
max_premature_attempts: How many premature terminal attempts before
579+
the enforcer signals exhaustion.
580+
"""
581+
582+
def __init__(self, required_steps: list[str], terminal_tool: str,
583+
max_premature_attempts: int = 3) -> None: ...
584+
585+
def check(self, tool_calls: list[ToolCall]) -> StepCheck:
586+
"""Check whether tool calls include a premature terminal call.
587+
588+
Returns StepCheck with escalating nudge (tier 1/2/3) if premature.
589+
"""
590+
...
591+
592+
def record(self, tool_name: str) -> None: ...
593+
def is_satisfied(self) -> bool: ...
594+
def pending(self) -> list[str]: ...
595+
def reset_premature(self) -> None: ...
596+
def summary_hint(self) -> str: ...
597+
598+
@property
599+
def premature_attempts(self) -> int: ...
600+
@property
601+
def premature_exhausted(self) -> bool: ...
602+
@property
603+
def completed_steps(self) -> dict[str, None]: ...
604+
605+
606+
class ErrorTracker:
607+
"""Tracks consecutive retry and tool error counts against limits.
608+
609+
Stateful — instantiate per session/task.
610+
611+
Args:
612+
max_retries: Consecutive formatting/validation failures before exhaustion.
613+
max_tool_errors: Consecutive tool execution errors before exhaustion.
614+
"""
615+
616+
def __init__(self, max_retries: int = 3, max_tool_errors: int = 2) -> None: ...
617+
618+
def record_retry(self) -> None: ...
619+
def reset_retries(self) -> None: ...
620+
def record_result(self, success: bool, is_soft_error: bool = False) -> None: ...
621+
def reset_errors(self) -> None: ...
622+
623+
@property
624+
def retries_exhausted(self) -> bool: ...
625+
@property
626+
def tool_errors_exhausted(self) -> bool: ...
627+
628+
501629
# ── Workflow Definition ────────────────────────────────────────
502630

503631

@@ -555,17 +683,21 @@ class WorkflowRunner:
555683
556684
This is the core agentic loop. It:
557685
1. Builds the initial message list (system prompt + user input)
558-
2. Sends messages to the LLM via the client (streaming or batch)
559-
3. Inspects the response — if TextResponse (malformed/refusal), retries with nudge
560-
4. Validates and executes returned tool calls (supports parallel batches)
561-
5. Manages context budget via ContextManager
562-
6. Enforces required steps via StepEnforcer
563-
7. Terminates on terminal tool or max iterations
564-
565-
Retry logic lives here, not on the client. Every LLM call — whether it
566-
returns a list[ToolCall] or TextResponse — consumes one iteration. The
567-
runner tracks attempt counts, which feed directly into eval metrics
568-
(retry rate, parse rate). When a model returns multiple tool calls in one
686+
2. Initializes guardrail middleware (ResponseValidator, StepEnforcer, ErrorTracker)
687+
3. Sends messages to the LLM via the client (streaming or batch)
688+
4. Delegates response validation to ResponseValidator (rescue, retry, unknown tool)
689+
5. Delegates step enforcement to StepEnforcer (premature terminal escalation)
690+
6. Delegates error budgets to ErrorTracker (consecutive retries and tool errors)
691+
7. Manages context budget via ContextManager
692+
8. Terminates on terminal tool or max iterations
693+
694+
The runner composes middleware internally — it instantiates
695+
ResponseValidator, StepEnforcer, and ErrorTracker per run(). These same
696+
components are importable for use in foreign orchestration loops (see
697+
examples/foreign_loop.py and ADR-011).
698+
699+
Every LLM call — whether it returns a list[ToolCall] or TextResponse —
700+
consumes one iteration. When a model returns multiple tool calls in one
569701
response, the runner executes all of them in the same iteration, emitting
570702
one TOOL_CALL message (with N ToolCallInfo entries) and N TOOL_RESULT
571703
messages.
@@ -738,7 +870,10 @@ WorkflowRunner.run()
738870
│ [Message(SYSTEM, rendered_prompt, meta=SYSTEM_PROMPT)]
739871
│ [Message(USER, user_input, meta=USER_INPUT)]
740872
741-
├─ 2. Initialize StepTracker(required_steps)
873+
├─ 2. Initialize guardrail middleware
874+
│ validator = ResponseValidator(tool_names, rescue_enabled)
875+
│ step_enforcer = StepEnforcer(required_steps, terminal_tool)
876+
│ error_tracker = ErrorTracker(max_retries, max_tool_errors)
742877
743878
└─ 3. Loop (up to max_iterations):
744879
@@ -758,45 +893,45 @@ WorkflowRunner.run()
758893
│ → forward chunks to on_chunk callback
759894
│ → collect FINAL chunk (raise StreamError if missing)
760895
761-
├─ 3c. Response is TextResponse? (malformed / refusal)
896+
├─ 3c. ResponseValidator.validate(response)
762897
│ │
763-
│ ├─ Try rescue_tool_call() — extract valid list[ToolCall] from free text
764-
│ │ (prompt-injected JSON or rehearsal syntax). Skip if rescue_enabled=False.
765-
│ ├─ Rescued? → use as ToolCall, continue to 3d
766-
│ ├─ Not rescued: increment consecutive_retries counter
767-
│ ├─ consecutive_retries > max_retries_per_step → raise ToolCallError
768-
│ └─ Append TEXT_RESPONSE message + retry nudge, continue to next iteration
769-
│ (consumes one iteration from max_iterations budget)
898+
│ ├─ TextResponse → try rescue_tool_call() (skip if rescue_enabled=False)
899+
│ │ ├─ Rescued? → ValidationResult(tool_calls=rescued, needs_retry=False)
900+
│ │ └─ Not rescued → ValidationResult(nudge=retry_nudge, needs_retry=True)
901+
│ ├─ list[ToolCall] with unknown tool → ValidationResult(nudge=unknown_tool_nudge, needs_retry=True)
902+
│ └─ list[ToolCall] all known → ValidationResult(tool_calls=..., needs_retry=False)
770903
771-
├─ 3d. Got list[ToolCall] (1 or more tool calls)
904+
│ If needs_retry:
905+
│ ├─ error_tracker.record_retry()
906+
│ ├─ error_tracker.retries_exhausted → raise ToolCallError
907+
│ └─ Emit assistant content + nudge message, continue to next iteration
772908
773-
├─ 3e. Validate ALL tool names exist in workflow.tools
774-
│ │
775-
│ ├─ Any unknown → increment consecutive_retries, append unknown_tool_nudge
776-
│ │ (lists available tools), continue to next iteration
777-
│ └─ All known → reset consecutive_retries to 0, continue to 3f
909+
├─ 3d. Valid tool calls — error_tracker.reset_retries()
778910
779-
├─ 3f. Check for terminal tool in the batch
911+
├─ 3e. StepEnforcer.check(tool_calls)
780912
│ │
781-
│ ├─ Terminal present + StepTracker NOT satisfied → escalating step nudge
782-
│ │ (tier 1/2/3), premature_terminal_attempts > 3 → raise StepEnforcementError
783-
│ └─ No terminal, or terminal + satisfied → continue to 3g
913+
│ ├─ Terminal present + steps NOT satisfied → StepCheck(nudge=step_nudge, needs_nudge=True)
914+
│ │ ├─ step_enforcer.premature_exhausted → raise StepEnforcementError
915+
│ │ └─ Emit tool call + escalating step nudge (tier 1/2/3), continue
916+
│ └─ No premature terminal → StepCheck(needs_nudge=False), continue to 3f
784917
785-
├─ 3g. Execute ALL tool calls in the batch sequentially
918+
├─ 3f. Execute ALL tool calls in the batch sequentially
786919
│ │
787920
│ Emit one TOOL_CALL message with N ToolCallInfo entries, then
788921
│ execute each tool and emit N individual TOOL_RESULT messages.
789922
│ │
790-
│ ├─ Tool raises → append [ToolError] as TOOL_RESULT, track last_error
923+
│ ├─ ToolResolutionError → feed back to model, don't count error,
924+
│ │ don't record step
925+
│ ├─ Other exception → feed back as [ToolError], track last_error
791926
│ │ batch_had_error flag set, continue to next tool in batch
792-
│ ├─ Success → StepTracker.record(), append result as TOOL_RESULT
927+
│ ├─ Success → step_enforcer.record(), append result as TOOL_RESULT
793928
│ └─ Terminal tool in batch + succeeded → stash result for return
794929
795-
├─ 3h. Post-batch bookkeeping:
930+
├─ 3g. Post-batch bookkeeping:
796931
│ │
797-
│ ├─ batch_had_error → increment consecutive_tool_errors
798-
│ │ consecutive_tool_errors > max_tool_errors → raise ToolExecutionError
799-
│ ├─ No errors → reset consecutive_tool_errors and premature_terminal_attempts
932+
│ ├─ batch_had_error → error_tracker.record_result(success=False)
933+
│ │ error_tracker.tool_errors_exhausted → raise ToolExecutionError
934+
│ ├─ No errors → error_tracker.reset_errors(), step_enforcer.reset_premature()
800935
│ └─ Terminal tool succeeded → return terminal result
801936
│ │
802937
│ Messages appended per iteration:
@@ -808,7 +943,7 @@ WorkflowRunner.run()
808943
│ REASONING messages are folded into the following TOOL_CALL message's
809944
│ content field on the wire, keeping the internal list separate for compaction.
810945
811-
└─ 3i. Continue loop
946+
└─ 3h. Continue loop
812947
```
813948

814949
### Message Lifecycle

docs/USER_GUIDE.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,34 @@ client = OpenAI(base_url="http://localhost:8081/v1")
4646

4747
Import forge's guardrail components directly into your own orchestration loop. You own the loop, forge provides the reliability logic.
4848

49+
**Simple API** (two calls -- covers most use cases):
50+
51+
```python
52+
from forge.guardrails import Guardrails
53+
54+
guardrails = Guardrails(
55+
tool_names=["search", "lookup", "answer"],
56+
required_steps=["search", "lookup"],
57+
terminal_tool="answer",
58+
)
59+
60+
# After each LLM response:
61+
result = guardrails.check(response)
62+
63+
if result.action in ("retry", "step_blocked"):
64+
messages.append({"role": result.nudge.role, "content": result.nudge.content})
65+
continue
66+
67+
if result.action == "fatal":
68+
raise RuntimeError(result.reason)
69+
70+
# result.action == "execute" -- run the tools, then tell forge what succeeded:
71+
execute(result.tool_calls)
72+
done = guardrails.record([tc.tool for tc in result.tool_calls])
73+
```
74+
75+
**Granular API** (individual components for custom control):
76+
4977
```python
5078
from forge.guardrails import ResponseValidator, StepEnforcer, ErrorTracker
5179

@@ -71,7 +99,7 @@ for tc in result.tool_calls:
7199
errors.record_result(success=ok)
72100
```
73101

74-
**Best for:** Framework developers embedding forge's guardrails inside a custom agent, a proprietary pipeline, or another open-source framework. For a complete runnable example, see [`examples/foreign_loop.py`](../examples/foreign_loop.py). For design rationale, see [ADR-011](decisions/011-guardrail-middleware.md).
102+
**Best for:** Framework developers embedding forge's guardrails inside a custom agent, a proprietary pipeline, or another open-source framework. For a complete runnable example showing both APIs, see [`examples/foreign_loop.py`](../examples/foreign_loop.py). For design rationale, see [ADR-011](decisions/011-guardrail-middleware.md).
75103

76104
### How they relate
77105

0 commit comments

Comments
 (0)