Lightweight validation at agent boundaries. Know what broke and where.
Project description
handoff-guard
Pydantic contracts for LLM agents: validate, retry with feedback, get actionable errors.
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 # PyPI: handoff-guard
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) # your LLM call here
return parse_json(response) # @guard validates dict against WriterOutput, returns dict as-is
If valid, the function returns normally. If invalid, it retries with feedback. After all attempts 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
Requires Python 3.10+ and Pydantic v2.
from handoff import guard, retry, parse_json # PyPI: handoff-guard
To run demos, clone the repo:
git clone https://github.com/acartag7/handoff-guard && cd handoff-guard
pip install -e ".[dev]"
python -m examples.llm_demo.run_demo # no API key needed
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. Usedetailed=Trueto detect truncation and repair status- Framework agnostic — Works with LangGraph or plain Python
- Async supported — Works with
async deffunctions (context-local retry state) - Lightweight — Pydantic + json-repair, no Docker, no telemetry
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 # ParseResult is the return type when detailed=True
# 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
# Detailed mode: detect truncation and repair
result = parse_json('{"draft": "long text...', detailed=True)
result.data # the parsed dict/list
result.truncated # True if unmatched braces (e.g., token limit or stream cutoff)
result.repaired # True if JSON had syntax errors that were auto-fixed
# 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-guard 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 |
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.1.tar.gz.
File metadata
- Download URL: handoff_guard-0.2.1.tar.gz
- Upload date:
- Size: 30.4 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 |
f91900b116881663b182434b39c877b0975ef2709e1df08335833971be43224d
|
|
| MD5 |
c7b264aea3d3db20631053c7ebbb0a81
|
|
| BLAKE2b-256 |
57fe08a3f888a41d0ee999749108c0f6e98b712d05895d4e270a7237f8ecff41
|
File details
Details for the file handoff_guard-0.2.1-py3-none-any.whl.
File metadata
- Download URL: handoff_guard-0.2.1-py3-none-any.whl
- Upload date:
- Size: 16.5 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 |
12d39ed6350d7219162ea807a76b45556dff618df7e25eb3fb4b39e9dc5af49a
|
|
| MD5 |
3afba13d9f9e6bee82ff9b96f7d98923
|
|
| BLAKE2b-256 |
2712a9ace9b300f921b18fbd40b645e1a41c55df1adf9b63823fff65f338141f
|