Lightweight validation at agent boundaries. Know what broke and where.
Project description
handoff-guard
Validation for LLM agents that retries with feedback.
The Problem
When an LLM agent returns bad output, you get a generic error and no recovery path:
ValidationError: 1 validation error for State
field required (type=value_error.missing)
Which node? Which field? What was passed? Can the agent fix it?
The Solution
from handoff import guard, retry, parse_json
from pydantic import BaseModel, Field
class WriterOutput(BaseModel):
draft: str = Field(min_length=100)
word_count: int = Field(ge=50)
tone: str
title: str
@guard(output=WriterOutput, node_name="writer", max_attempts=3)
def writer_agent(state: dict) -> dict:
prompt = "Write a JSON response with: draft, word_count, tone, title."
if retry.is_retry:
prompt += f"\n\nYour previous attempt failed:\n{retry.feedback()}"
response = call_llm(prompt)
return parse_json(response)
When validation fails, the agent retries with feedback about what went wrong. After all attempts are exhausted:
HandoffViolation in 'writer' (attempt 3/3):
Contract: output
Field: draft
Expected: String should have at least 100 characters
Suggestion: Increase the length of 'draft'
History: 3 failed attempts
Quick Start
pip install handoff-guard
# See retry-with-feedback in action (no API key needed)
python -m examples.llm_demo.run_demo
# Run with real LLM calls
export OPENROUTER_API_KEY=your_key
python -m examples.llm_demo.run_demo --pipeline --api
Features
- Retry with feedback — Failed outputs are fed back to the agent as context
- Know which node failed — No more guessing from stack traces
- Know which field failed — Exact path to the problem
- Get fix suggestions — Actionable error messages
parse_json— Strips code fences, conversational wrappers, handles BOM, repairs malformed JSON (trailing commas, single quotes, unquoted keys, missing braces, comments), raisesParseErrorwith actionable line/column info- Framework agnostic — Works with LangGraph, CrewAI, or plain Python
- Lightweight — Just Pydantic, no Docker, no telemetry servers
API
@guard decorator
@guard(
input=InputSchema, # Pydantic model for input validation
output=OutputSchema, # Pydantic model for output validation
node_name="my_node", # Identifies the node in errors (default: function name)
max_attempts=3, # Retry up to 3 times (default: 1, no retry)
retry_on=("validation", "parse"), # What errors trigger retry (default)
on_fail="raise", # "raise" | "return_none" | "return_input" | callable
input_param="state", # Name of the input arg to validate (default: "state")
)
retry proxy
Access retry state inside any guarded function:
from handoff import retry
retry.is_retry # True if attempt > 1
retry.attempt # Current attempt number
retry.max_attempts # Total allowed attempts
retry.remaining # Attempts left
retry.is_final_attempt
retry.feedback() # Formatted string describing last error, or None
retry.last_error # Diagnostic object, or None
retry.history # List of AttemptRecord objects
parse_json
from handoff import parse_json
# Handles code fences
data = parse_json('```json\n{"key": "value"}\n```')
# Returns: {"key": "value"}
# Handles LLM chattiness
data = parse_json('Sure! Here\'s the JSON:\n{"key": "value"}\nLet me know if you need anything!')
# Returns: {"key": "value"}
# Repairs malformed JSON
data = parse_json('{"a": 1,}') # trailing comma
data = parse_json("{'a': 1}") # single quotes
data = parse_json('{a: 1}') # unquoted keys
data = parse_json('{"a": 1') # missing brace
data = parse_json('{"a": 1 // comment}') # JS comments
# Raises ParseError on failure (retryable by @guard)
# ParseError includes detailed context:
# - Line/column location
# - Context snippet with pointer
# - Suggested fix
# - Input preview for debugging
HandoffViolation
Raised when all retry attempts are exhausted:
from handoff import HandoffViolation
try:
result = my_agent(state)
except HandoffViolation as e:
print(e.node_name) # "writer"
print(e.total_attempts) # 3
print(e.history) # List of AttemptRecord with diagnostics
print(e.to_dict()) # Serializable for logging
Handle Failures
@guard(output=Schema, on_fail="raise") # Raise exception (default)
@guard(output=Schema, on_fail="return_none") # Return None on failure
@guard(output=Schema, on_fail="return_input") # Return input unchanged
@guard(output=Schema, on_fail=my_handler) # Custom handler
Examples
| Demo | What it shows |
|---|---|
examples/llm_demo |
Retry-with-feedback: writer fails, gets feedback, self-corrects |
examples/rag_demo |
Multi-stage pipeline validation + hallucinated citation detection |
Both demos support --api for real LLM calls and run with mock data by default.
With LangGraph
from handoff.langgraph import guarded_node
from pydantic import BaseModel, Field
class RouterOutput(BaseModel):
next_agent: str = Field(pattern="^(writer|reviewer|done)$")
messages: list
@guarded_node(output=RouterOutput)
def router(state: dict) -> dict:
return {
"next_agent": "writer",
"messages": state["messages"]
}
Why not just use Pydantic directly?
You should! Handoff uses Pydantic under the hood.
The difference:
| Pydantic alone | Handoff |
|---|---|
ValidationError: 1 validation error |
HandoffViolation in 'router_node' |
| Generic stack trace | Exact node + field + suggestion |
| You wire up validation manually | One decorator |
| No retry | Automatic retry with feedback |
| Errors are for developers | Errors are actionable for agents |
Roadmap
- Invariant contracts (input/output relationships)
- CrewAI adapter
- Retry with feedback loop
- VS Code extension for violation inspection
Contributing
Contributions welcome! Please open an issue first to discuss what you'd like to change.
License
MIT
Built for developers who are tired of debugging agent handoffs.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file handoff_guard-0.2.0.tar.gz.
File metadata
- Download URL: handoff_guard-0.2.0.tar.gz
- Upload date:
- Size: 28.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.28 {"installer":{"name":"uv","version":"0.9.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ebfd64b66a57966ab4ab23871673257bfae07dfdfb5fb0039f0316a7a5bc777b
|
|
| MD5 |
a0162834dba3d5588128b033d1d74442
|
|
| BLAKE2b-256 |
c894898fcc9f7f39038c0868102ea81a5d7a7138cc35c628a913766a659c856c
|
File details
Details for the file handoff_guard-0.2.0-py3-none-any.whl.
File metadata
- Download URL: handoff_guard-0.2.0-py3-none-any.whl
- Upload date:
- Size: 15.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.9.28 {"installer":{"name":"uv","version":"0.9.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
01730c6859d8cc5a133c87af14eec2f1d6377f9784bc05eba48a94c3ad8d7e1a
|
|
| MD5 |
d245362734e20ac95bb8b875e213498a
|
|
| BLAKE2b-256 |
ab8d2702507d7f3539160edc2843a6076e79d61dac3868ff81a6a840b5d95054
|