Skip to main content

Lightweight validation at agent boundaries. Know what broke and where.

Project description

handoff-guard

Validation for LLM agents that retries with feedback.

PyPI version License: MIT

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, handles BOM, raises ParseError on failure
  • 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
)

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

data = parse_json('```json\n{"key": "value"}\n```')
# Returns: {"key": "value"}
# Raises ParseError on failure (retryable by @guard)

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


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.1.0.tar.gz (26.3 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.1.0-py3-none-any.whl (13.1 kB view details)

Uploaded Python 3

File details

Details for the file handoff_guard-0.1.0.tar.gz.

File metadata

  • Download URL: handoff_guard-0.1.0.tar.gz
  • Upload date:
  • Size: 26.3 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.1.0.tar.gz
Algorithm Hash digest
SHA256 9b279208579813711e967f1b96339cc869736c8d085aaf274beda63b6f6b865c
MD5 9ecb3d5f1a588485cbbd3e35dc82e071
BLAKE2b-256 3cbcf0d94262d16f48495b37d5fecd77a4197559c4dcafd1ba82bad19b6ed5f8

See more details on using hashes here.

File details

Details for the file handoff_guard-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: handoff_guard-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 13.1 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 64ab5933fae14cd7300be1b795b0d8621e541a09057ff389f950f9f19720a7e5
MD5 e3bb7c5ba157a61ca84ee8cc85142f2c
BLAKE2b-256 6e20b5f7cf14b6b432de8cf640fb24156411a09f73eeb0eb9d9bba70f933d384

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