Skip to main content

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.

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

handoff_guard-0.2.1.tar.gz (30.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

handoff_guard-0.2.1-py3-none-any.whl (16.5 kB view details)

Uploaded Python 3

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

Hashes for handoff_guard-0.2.1.tar.gz
Algorithm Hash digest
SHA256 f91900b116881663b182434b39c877b0975ef2709e1df08335833971be43224d
MD5 c7b264aea3d3db20631053c7ebbb0a81
BLAKE2b-256 57fe08a3f888a41d0ee999749108c0f6e98b712d05895d4e270a7237f8ecff41

See more details on using hashes here.

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

Hashes for handoff_guard-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 12d39ed6350d7219162ea807a76b45556dff618df7e25eb3fb4b39e9dc5af49a
MD5 3afba13d9f9e6bee82ff9b96f7d98923
BLAKE2b-256 2712a9ace9b300f921b18fbd40b645e1a41c55df1adf9b63823fff65f338141f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page