Skip to main content

handoff-guard

Pydantic contracts for LLM agents: validate, retry with feedback, get actionable errors.

PyPI version License: MIT Python 3.10+

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), raises ParseError with actionable line/column info. Use detailed=True to detect truncation and repair status
  • Framework agnostic — Works with LangGraph or plain Python
  • Async supported — Works with async def functions (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)

Source distribution for handoff-guard 0.2.1
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)

Table of built distributions (wheels) for handoff-guard 0.2.1
File Interpreter ABI Platform Actions
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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page