tracelintdeterministic verification for tool-using agents

Your agent said it worked. The trace says otherwise.

tracelint is a structural linter for tool-calling agents. Capture a run in your tests, lint the trace, and fail the build on the defects a fluent final answer hides — deterministically, with no model in the loop.

$pip install tracelint Get started → See the quick start
Deterministic · judge-free Evidence-backed · every finding explainable Fail-closed · unknown ≠ pass
Trace · support-agent3 findings
0userrefund order A100
1tool →get_order(order_id='A100')R2a
2error{order_id:'A100', status:'error'}R2b
3tool →refund_order(order_id='A100')R2b
4result{refunded: true}
5tool →refund_order(order_id='A100')R8
6result{refunded: true}
7assistantYour refund is processed.
hard_eventR2a · step 1

get_order returned an error (HTTP 500), read straight off the span.

tool=get_order · signal=structured
hard_defectR2b · steps 1 → 3

The errored value A100 was reused as an argument to the side-effecting refund_order — no fallback.

fails CI
hard_eventR8 · steps 3, 5

refund_order ran twice with identical args — the card was refunded twice.

tool=refund_order · reported
Reads the traces you already collect

Why it exists

The bugs that never reach the answer.

A fluent final message passes your evals and your LLM judge — while the run underneath already went wrong. These defects are structural, and decidable straight from the trace:

Ignored error

A deploy tool returns a 500 Internal Server Error, but the agent swallows it and reports the deployment as successful.

HTTP 200 · declined

A payment API returns {"status":"declined"} over an HTTP 200, so the exception path never fires and the agent proceeds as if it succeeded.

Double write

A non-idempotent send_email is invoked twice due to a retry, but the agent's final answer still looks correct.

Different tools, one kind of bug: the failure is in what the agent did, not what it said — so it's provable straight from the trace. That's the part tracelint checks.

How it works

It runs after the run, on the trace — never on a model.

Many agent failures aren't a matter of taste; they're decidable from what the agent actually did. tracelint decides them, the same way every time.

01 · CONNECT

Capture a run, or read yours

Capture a run in a test, or read the spans you already collect — Phoenix, Langfuse, LangSmith, OpenAI, OTel. Everything normalizes into one canonical schema: no rewriting your agent, no SDK to embed. If you can export the trace, tracelint can read it.

with capture("trace.json", framework="smolagents"): ...
02 · LINT

Replay the run, deterministically

Structural checks read what the agent actually did — schema violations, ignored tool errors, reused failed values, loops, duplicate side effects — each pinned to the exact steps as evidence. No model in the loop, so the same trace always yields the same report.

lint_trace(trace, default_rules())
03 · ACT

Get a verdict you can act on

A CI exit code that fails the build on a hard defect, with every finding backed by its evidence — never a silent pass. Emit JSON or SARIF for code scanning, and read the coverage to see how much of the run was actually verified.

$ tracelint check spans.json   →   fails CI
No LLM in the loop

No prompt, no second model, no stochastic verdict — the same trace always gives the same report.

Candidate, not verdict

Only structurally-provable defects fail CI; heuristics are shown as candidates with their evidence.

Unknown is never pass

If a check can't run — a field it needs is missing — it says so, with the reason. A clean report means everything was actually checked.

Shows its work

Per-rule "evaluatable / total" coverage says how much of the run was actually verified — not just that nothing fired.

Composes with your evals

It owns the structurally-decidable part before the probabilistic interpretation — alongside your LLM-judge evals, not instead of them.

The checks

What it catches

Deterministic checks across schema, error handling, provenance, control flow, and side effects. Every finding is filed under one of three tiers — and only the first one fails your build:

hard_defect

Provable from the trace itself. The run fails CI — the build stops.

hard_event

A certain fact — a tool errored, a side effect repeated. Reported; it doesn't fail CI.

candidate

A heuristic, shown with its evidence for you to judge. Never fails CI on its own.

The rules — grouped · hover a rule id for its definition

Invalid tool call

Arguments that don't satisfy the tool's JSON Schema, aren't well-formed, or a call to a tool that isn't in the declared toolset.

R1 · R6 · R7

Failed operation

A tool returned a structured error — and, worse, its value got reused by a later side-effecting call instead of being handled.

R2a · R2b

Unexplained value

An argument value that isn't derivable from anything the agent actually observed in the trace.

R3

No-progress execution

The same call repeated with no change in state, or an identical call and result with no mutation in between (retries and polls excluded).

R4 · R5

Duplicate side effect

A non-idempotent side-effecting call repeated with equivalent args after the first succeeded — a double charge, refund, or email.

R8

Get started

Capture → lint → CI.

The core loop is three steps: capture a run in your tests, lint the trace, and fail the build on provable defects. Most rules run keyless; add a tools.json to declare the tool contract (side_effecting, failure_when, x-value-origin) and light up the schema- and side-effect checks. Validated on real traces from smolagents, LangGraph, and CrewAI.

$ pip install tracelint — or "tracelint[capture-smolagents]" to capture a run in a test (also capture-langchain, capture-crewai)

1 · Capture a run

No trace file yet? capture records one by wrapping your framework's own OpenInference instrumentation — your live tracing stays untouched.

test_agent.py
from tracelint.capture import capture

with capture("trace.json", framework="smolagents"):
    agent.run("...")

2 · Lint it, gate CI

Lint the captured trace. A hard defect exits non-zero, so it fails the build like a failing test.

$ tracelint check trace.json --format openinference

In-process capture covers smolagents, LangGraph, and CrewAI. Langflow is a server — export its OTel trace to a file, then run the same check.

Already have traces? Point tracelint at any source:

Langfuse

Pull a specific trace to lint or reproduce it — or export any trace to a file and check it.

$ tracelint langfuse pull <id> -o trace.json

Then tracelint check trace.json. Writing findings back into Langfuse as Scores stays available as an advanced, opt-in recipe.

Arize Phoenix

Lint the OpenInference spans Phoenix already collects — no new instrumentation.

$ tracelint check spans.json --format openinference

Reads get_spans_dataframe() records directly, too.

OpenTelemetry

Any OTLP source — the event-list reader understands the OTel GenAI semantic convention (OpenLLMetry / Traceloop).

$ tracelint check spans.json --format otel

LangSmith

Nested LangSmith run trees, normalized into the canonical schema.

$ tracelint check run.json --format langsmith

OpenAI

Chat-completion message lists, including the ShareGPT shape.

$ tracelint check messages.json --format openai

Custom format

Map any shape to the canonical Trace in a few lines — every rule applies.

from tracelint import lint_trace, default_rules

Tool contract tools.json

Tell tracelint the semantics a trace can't infer — declared once, never guessed from a tool's name.

side_effectingDoes this tool change the world?
idempotentIs repeated execution safe?
failure_whenWhen should a result count as a failure?
$ tracelint init spans.json -o tools.json

Bootstrap it from a trace — init discovers the tools and their arg schemas; you fill only the behavior. Start without one, add only what the trace can't know.

tools.json
{
  "tools": {
    "refund_order": {
      "metadata": {
        "side_effecting": true,
        "idempotent": false,
        "failure_when": { "pointer": "/refunded", "equals": false }
      }
    }
  }
}

In your pipeline

Gate the build on it.

Add it in a few lines. A hard defect fails the run — the same way a failing test does.

GitHub Actionsyaml
# .github/workflows/ci.yml
name: tracelint
on: [push, pull_request]
jobs:
  tracelint:
    runs-on: ubuntu-latest
    steps:
      - uses: AshwinUgale/tracelint@v0.8.0
        with:
          path: spans.json
          format: openinference
          tools: tools.json
CLI, anywhere
$ tracelint check trace.json --format openinference --tools tools.json
0
Clean

No hard defects — findings and coverage still reported.

2
Hard defect

A structurally-provable defect. The build fails.

3
Input error

A bad trace or tools file — never a false pass.

SARIF output

Upload to GitHub, GitLab, or Azure DevOps code scanning for inline annotations.