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.
Metadata
Release files for handoff-guard 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| handoff_guard-0.2.1.tar.gz | 30.4 kB | Without provenance | Details for handoff_guard-0.2.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| handoff_guard-0.2.1-py3-none-any.whl | Python 3 | none | any | Details for handoff_guard-0.2.1-py3-none-any.whl |
Total release size: 46.9 kB
Release files / handoff_guard-0.2.1.tar.gz
| Download URL | handoff_guard-0.2.1.tar.gz |
|---|---|
| Size | 30.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f91900b116881663b182434b39c877b0975ef2709e1df08335833971be43224d
|
|
BLAKE2b-256 checksum How to use checksums |
57fe08a3f888a41d0ee999749108c0f6e98b712d05895d4e270a7237f8ecff41
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Release files / handoff_guard-0.2.1-py3-none-any.whl
| Download URL | handoff_guard-0.2.1-py3-none-any.whl |
|---|---|
| Size | 16.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
12d39ed6350d7219162ea807a76b45556dff618df7e25eb3fb4b39e9dc5af49a
|
|
BLAKE2b-256 checksum How to use checksums |
2712a9ace9b300f921b18fbd40b645e1a41c55df1adf9b63823fff65f338141f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Security for handoff-guard 0.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.