Skip to main content

postflight

tests PyPI Python versions License

Turn-level failure detection for tool-calling agents

postflight reads agent traces you already emit and returns coded findings for failures that span a whole turn: a tool that declined three steps before the reply contradicting it, the same read issued eight times, a cache that never warmed. It calls no model and has no dependencies.


One turn of four steps: a generation that plans, a search tool, a notification tool returning {"sent": false}, and a reply saying "I've let them know." A bracket labelled UNVERIFIED_CLAIM spans the last two. Below, each step is scored on its own and every one passes.

The failure is a relationship between steps. Scored one at a time, which is what an observation-scoped evaluator does, every step here passes.


The taxonomy

Code What it means Why it matters
UNVERIFIED_CLAIM The reply asserts a write no successful tool backs up. The only one a user experiences as a lie. They were told something happened that did not happen.
TOOL_ERROR A tool raised; the framework wrapped it. The visible half of tool failure, usually already in your dashboards.
TOOL_REFUSAL 1 A tool ran fine and declined in its own result body, with no error flag. The dangerous half. Every guard that asks "did the tool run" is satisfied, so a false confirmation ships.
REPEATED_TOOL The same tool called 3+ times in one turn. The model is searching for an argument it was never given. A context gap, not a model failure.
TOOL_STORM 8+ tool calls in one turn. Same cause, worse. Cost and latency both.
EMPTY_REPLY 2 No text where somebody was owed one. On a 1:1 channel, the "it just didn't respond" bug.
GATE_FILTERED 3 A turn a relevance gate dropped without doing work. Information, not a fault. Silence is the design. Watch the count for a gate that has started swallowing real traffic.
SLOW_TURN Wall clock over the threshold. Usually a storm with a human waiting.
NO_CACHE_HIT A prompt big enough to cache that read nothing from cache. Caching is a prefix match, so one volatile byte early in the system prompt drops the discount on every turn.

The codes are the stable interface. Filter on them, chart them, page on them. Renaming one is a breaking change.


Install

pip install postflight

No dependencies. Python 3.11+.


Use

The CLI is what a cron job or a CI step wants. Exit status is 1 when something faulted, 0 otherwise:

python -m postflight --langfuse --hours 24
python -m postflight --otel spans.jsonl

From Python:

from postflight import Config, faults, run_all
from postflight.adapters.langfuse import LangfuseAdapter, LangfuseClient

client = LangfuseClient(host, public_key, secret_key)
turns = LangfuseAdapter().turns(client.observations(hours=24))

for turn_id, findings in run_all(turns, Config()).items():
    for finding in faults(findings):
        print(turn_id, finding.code, finding.message)

run_all returns every finding, including Severity.INFO ones like GATE_FILTERED. Wrap it in faults() for anything a human reads first.


Output

Run against the fixture, a support agent built to fail this way and captured through OpenInference:

$ python -m postflight --otel tests/fixtures/openinference_support_turn.jsonl
  0x4069cd953e   TOOL_REFUSAL       1 tool call(s) declined in-body

1 turns, 1 flagged
  TOOL_REFUSAL       1

Not all detectors are live on this data:
  GATE_FILTERED: INERT - no quiet_kinds configured, so nothing is silent by design
  NO_CACHE_HIT: INERT - no generation reports cache usage, and unknown is not treated as zero

Each finding carries a detail dict, which is the part you act on:

{"calls": [{"tool": "send_notification",
            "result": "{'sent': False, 'reason': 'channel unavailable'}"}]}

The closing block comes from coverage(), and it is worth reading before the findings. A detector whose input is missing does not error, it just never fires, and an empty column looks the same as a clean agent. coverage() reports which detectors could not have fired on this data, so a zero elsewhere means something.

Expect SLOW_TURN and GATE_FILTERED to dominate any real window and expect the rare rows to carry the weight. Sort by severity, not by count.


The Turn contract

Detectors never see your trace format. They read Turn, so an adapter is a function from whatever you have to an ordered sequence of steps:

from postflight.model import Generation, ToolCall, Turn

Turn(
    id="…",
    # the surface: which agent, which channel
    kind="chat.turn",
    # ordered, because the sequence is the signal
    steps=(
        Generation(text="", input_tokens=900, model="…"),
        ToolCall(name="search", result={"count": 0}),
        Generation(text="I couldn't find it.", input_tokens=1200, model="…"),
    ),
)

A turn is a sequence, not a tree. Flattening is what lets a detector see a tool that declined several steps before the reply contradicting it.

Adapters ship for Langfuse and for OpenTelemetry / OpenInference. Copy postflight/adapters/otel.py, which handles flattened attributes, a span tree, and two timestamp encodings, so most of the awkward cases are already worked out. Read writing an adapter first; its three notes are each a mistake already made once.


Configuring

Thresholds, claim vocabulary, which surfaces owe a reply, and what each detector needs in order to fire at all: docs/configuring.md.


Status

Alpha. Detector codes are the public interface; thresholds, default vocabularies, and added detectors are not.

SemVer, with the 0.x convention the spec leaves undefined made explicit: while the major is 0, a minor bump may break the API and a patch may not. Release notes are on the Releases page.

Issues and PRs welcome. No response SLA.

Apache 2.0. See LICENSE.

  1. Detects an in-body decline in a shape you have told it about. The default is a success flag set to false. If your tools say no some other way, see configuring. ↩

  2. Reports at INFO until you set conversational_kinds, since unconfigured it cannot tell a silent channel from a batch job that returns a document. ↩

  3. Never fires until you set quiet_kinds. Nothing is a gate by default. ↩

Metadata

Release files for postflight 0.1.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 postflight 0.1.1
File Size Uploaded
postflight-0.1.1.tar.gz 60.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for postflight 0.1.1
File Interpreter ABI Platform
postflight-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 95.9 kB

Release files / postflight-0.1.1.tar.gz

Download URL postflight-0.1.1.tar.gz
Size 60.8 kB
Tags Source
SHA-256 checksum
How to use checksums
5c242bed432a9b05b782ffa8ac9b19ad40f95070f79f004bce57f4f24f3d4ead
BLAKE2b-256 checksum
How to use checksums
4302433b5a7a8324ce9694023425cc59f7b92c47820a0430954277781fa980ca
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 17, 2026.

Transparency log

Release files / postflight-0.1.1-py3-none-any.whl

Download URL postflight-0.1.1-py3-none-any.whl
Size 35.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
225544038c16dab13d9e24480071e306e5ab02c89c4c91ecccff14d6393d19e7
BLAKE2b-256 checksum
How to use checksums
3ccec50348bb91d97659a7c42b1fb513dae74065ba3021a2358b3ae7bf40a2d0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 17, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

2 release files

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