All notable changes to tracelint are documented here. This project adheres to Semantic Versioning (pre-1.0: minor versions may introduce additive features; the public API is not yet frozen).
Two additions, neither of which judges the agent differently.
- Record quality:
tracelint doctornow checks whether the traces are complete enough to trust, for example whether results, outcomes, arguments and reasoning are recorded, or whether subagent runs were exported. - Inspect Scout: tracelint runs as Inspect Scout scanners, reaching every trace source Scout imports, with a new reader for Inspect's chat messages.
Compatibility:
-
tracelint checkand every rule give the same results as on 0.13: no rule or existing reader changed. -
doctorprints a new record-quality section. When one input file can't be read,doctornow lists it and checks the rest instead of stopping with exit 3; it exits 3 only when nothing is readable. -
doctorstays advisory unless you pass the new--fail-under. -
The Scout extra needs Python 3.11+, but the core does not.
-
New: record quality in
tracelint doctor. Rules judge what the agent did; these checks judge whether the trace records it. They are format-neutral (tracelint.record) and never count against the agent.- Measures: results recorded, outcome recorded (a result says whether it succeeded), arguments recorded, and reasoning recorded (heuristic: placeholder or empty assistant turns excluded).
- Per trace: runs that end on an unanswered call, subagent runs that were launched but not exported (heuristic: by tool name), duplicate call ids, redacted values, and empty traces.
doctornow lists unreadable files and keeps going.--group-by dir|modelbreaks the measures down;--json PATHwrites the per-trace checks.--fail-under NAME=PERCENTexits 1 when a measure is below the threshold. Without it,doctorstays advisory.- On the Terminal-Bench 2.0 leaderboard (10,541 runs): 67.1% of tool calls had a recorded result, 3.5% of results said whether they succeeded, and 456 runs launched subagents whose runs aren't in the export. See docs/record-quality.md.
-
New: Inspect Scout scanners.
pip install "tracelint[scout]"(Python 3.11+) registers tracelint's rules as Inspect Scout scanners under thetracelintnamespace, soscout scan tracelint/tracelint -T ./logsruns them on Inspect eval logs and on everything Scout imports (Phoenix, LangSmith, Logfire, Weave, MLflow, Claude Code, Harbor). No model is called.tracelintruns the rules that need no contract (R2a, R4, R5, R6) and returns one result per rule. With-S tools=tools.json(fromtracelint initorinit --from-mcp) it also runs R1, R7, R8, R9, R10, R12, and R13 when the contract declaresoff_limits.-S rules=picks rules, and-S min_tier=drops lower tiers.tool_errors,tool_loops,redundant_callsandmalformed_argumentsrun one rule each.record_qualityruns the record-quality checks behinddoctoron each transcript. It judges the record, not the agent. Its value is the record's issue codes (results_missing,ends_unanswered, …),[]when there are none. The two heuristic checks, placeholder turns and missing subagents, are marked as heuristic. A subagent Scout kept as an agent span counts as recorded.- A result's value is the finding count. It is
Nonewhen the rule could not check the transcript, never a silent zero. Each finding cites its messages ([M5], linked inscout view), and the metadata carries the full evidence. - Scanned through Scout, 18 Terminal-Bench 2.0 trajectories got the same findings, tier by tier, as
tracelint check --format atifon the same files. R13 through Scout flagged the same 12 of 25 integrity-pilot runs as the ATIF path, the 7 known requests among them.record_qualityreported the same issue codes as the ATIF path on all 43 of those trajectories. - See docs/integrations/inspect-scout.md.
-
New: a reader for Inspect's chat messages (
from_inspect_messages,inspect_trace), the model every Scout transcript uses. It reads server-side tool uses (a provider's web search) as calls and takes arguments Inspect could not parse as R6 evidence. A tool counts as failed only on a structured signal: the message'sToolCallError, theerroron the call'stoolevent (where Scout's importers record failures), anerrorfield in the result, or Harbor's[error] tool reported failureline.
R13 sees more of what a run reached, and handles any trace size. It now also reports an exposure:
a declared source turning up in a call's result without the call naming it. Exposures are a
lower-tier hard_event, measured against an independent audit of the Terminal-Bench 3 and 4
leaderboards. Its URL scan, quadratic on long runs of letters in 0.12, is now linear. Nothing changes
for a contract without off_limits. With one, request findings and their ignore and baseline keys
are unchanged. Exposures arrive as new findings that don't fail CI unless you pass
--fail-on hard_event.
- New: R13 reports exposure to an off-limits source. Search engines index a benchmark's public
task repository, so a search that quotes a task's own parameter names can return links to its
solution without naming the benchmark. When a call's arguments named no declared source but its
result contains a URL naming one, R13 now reports a
hard_eventwithsignalresult. The evidence lists the matching URLs and the call's search query. A request stays ahard_defect, and a call with a request finding gets no exposure finding as well. An exposure doesn't fail CI unless you pass--fail-on hard_event. Results are read for URLs only, never for the bare name.- On the Terminal-Bench 3 and 4 leaderboards, checked against the independent audit in terminal-bench#2218, requests caught 49 of its 74 confirmed-use trials from the published trajectories. Adding exposures catches 56. Of the 88 audited trials flagged by a request or an exposure, 56 (64%) had confirmed use, against 49 of 61 (80%) for requests alone.
- Outside an audited set, most exposures are incidental. On the Terminal-Bench 2.0 leaderboard, exposures added 21 runs to the 7 that requests flagged. Most were searches doing the task's own research whose results included the benchmark's page for that task.
- Opt-in as before: a contract without
off_limitsgets nothing new. Ignores and CI baselines keep exposures apart from requests (the signal is part of the key).
- Fixed: R13 is linear in argument size. The URL pattern let every position in a long run of
letters with no
://(a base64 blob or a minified file being written) scan to the end of the run, so R13 took about 30 s on a 40 KB argument and hours on a megabyte. A scheme is now at most 32 characters and a string without://is skipped: the same calls are flagged, and a megabyte now takes well under a second. (A URL glued to the end of a longer run of letters keeps only 32 of them inmatched.)
Benchmark integrity. R13 flags a run that requested, or searched for, a source its contract rules
out, such as a benchmark's published solutions, its task repository and mirrors, or its leaderboard
pages. A leaderboard can check what each submitted run reached, and an RL pipeline can drop runs
that fetched their answers before they become training data. Nothing else changes: a contract
without off_limits gets the same results as on 0.11.
- New: R13, off-limits source. A benchmark publishes what an agent must not use: each task's
solution and tests, the task repository and its mirrors, the leaderboard's own pages. A contract
can now declare those sources beside
tools("off_limits": {"sources": ["tbench.ai", "terminal-bench"], "reason": "..."}), and R13 flags a call that requests one by URL (a fetch tool, acurlin a shell command) or searches for one (an argument namedquery,q,queries, …), as ahard_defect. The evidence says what the call got back (outcome), and a URL the prompt itself gave is marked a possible false positive.- A source matches as a whole token, in any case, with
-,_and a space alike; a URL is percent-decoded first, so a search engine URL counts as a search. - A source's name anywhere else in a call is not a request. On the Terminal-Bench 2.0
leaderboard runs it appears in 308 runs' arguments for ordinary reasons (a task whose input is
the string
"terminal-bench", calendar headers, canary strings), and R13 flags none of them. - Opt-in: a contract without
off_limitsgets nothing new. A malformedoff_limitsis an input error (exit 3). Atools.jsoncan hold only the policy; that registry no longer reads as "no registry" insidelint_trace, the DeepEval/promptfoo scorer andlangfuse check.
- A source matches as a whole token, in any case, with
Contracts, CI adoption, and precision measured on real agent runs.
- Contracts: four rules check what a tool's contract declares: R9 (preconditions), R10 (result
contract), R11 (contract drift) and R12 (unresolved side effect).
tracelint init --from-mcpwrites a contract from an MCPtools/listresponse. - CI adoption: a project config,
--fail-on, ignores with reasons, and a baseline with a coverage ratchet, so a project whose traces already have findings can adopt tracelint without a red build.tracelint doctorexplains why rules abstained and names the fix. - Inputs and integrations: Harbor ATIF trajectories (
--format atif), OTel GenAI tool definitions, a DeepEval metric and a promptfoo assertion. - Precision: studies on ~10,500 Terminal-Bench 2.0 runs and on SWE-bench trajectories drove fixes to R2a, R3 and R4, so those rules report far fewer findings on existing traces.
The one change you may need to act on is marked Behavior change: JSON output's findings now
holds only the active findings.
-
R2a precision: an error is what a tool reports, not an error word anywhere. A hand audit of R2a on real agent runs (
experiments/swebench/, R2a re-audit) found two fixable sources of false errors, now gone:- The text heuristic (a candidate) matched an error word anywhere in the output, so a viewed
file's own source (
raise ValueError) read as a failure — 40 of its 45 audited findings. It now counts a line that starts with an error report:ERROR:,fatal:,ValueError: …, a traceback,bash: x: command not found,make: ***,<tool_use_error>(optionally after a log tag such as[rank0]:). On a held-out sample its precision is 93%; on 10k terminal-agent runs it also now catches shell errors it used to miss (command not found,No such file or directory,Permission denied). It no longer scans the output of a Ctrl-C the agent sent. - Non-zero exits that aren't failures: a search that found nothing (
grep/rgexit 1,xargs grep123, no output) and a Ctrl-C the agent sent (130) — every one of the 21 audited exit-code false errors.tracelint.signatures.nonzero_exit_conventionnames them; the ATIF adapter reads such an exit as success, and R2a no longer flags an empty search result as a possible error. On a held-out sample, exit-code errors are 93% real errors or failures the agent was looking for (failing tests, a reproduced bug).
- The text heuristic (a candidate) matched an error word anywhere in the output, so a viewed
file's own source (
-
R4 precision: a loop is now the same call returning the identical result. On 10,541 real agent runs (the Terminal-Bench 2.0 study in
experiments/terminal_bench/), R4 flagged 2,622 loops and a hand audit found 15 agents genuinely stuck. Three causes, all fixed:- R4 compared repeats by a coarse result class, in which any non-empty output was the same
ok— so a growing build log or training progress read as "no change" (55% of its loops). It now compares the results themselves, exactly. - A missing result counted as identical to another (26%): an earlier call in a batch that
returns one observation (Terminus), or a server-side tool such as OpenAI's
web_search. R4 and R5 now treat a missing result as unknown and disclose those calls as not checked (cause: result_unrecorded), as they already did for unknown arguments. - Polling wasn't recognised unless declared in
tools.json(18%). A call that waits on something running — empty keystrokes or input, asleep/waitcommand, a tool named for waiting — repeats by design; it is now flagged only when the trace ended while it was still waiting with nothing new.
On that corpus R4 now flags 31 loops instead of 2,622: 19 identical repeats (17 an agent stuck on review — e.g.
make× 494 with no makefile; 2 an unchangedtailof a log, effectively a poll) and 12 polls still waiting when the run ended. On the SWE-bench corpus, whose loops were already identical repeats, it keeps 28 of the 29 loop runs. R4 stays a review-only candidate. - R4 compared repeats by a coarse result class, in which any non-empty output was the same
-
Fix: a tool printing a Python value JSON can't hold no longer fails the check. Result content that is a Python literal (
str(dict)output) is parsed into the structure it encodes, but a printed value with tuple or mixed-type keys — e.g. a color counter's{(0, 0, 0): 287982}— became a dict nothing downstream could serialize, so the rules crashed andtracelint checkexited 3 on a valid run (found on a real Terminal-Bench trajectory). Such a value now stays as text; ordinarystr(dict)output is still parsed. Trying tool output as a Python literal also no longer prints the parser'sSyntaxWarnings (e.g. for a printed"C:\<dir>") to stderr. -
New: Harbor ATIF trajectories (
--format atif). tracelint reads the Agent Trajectory Interchange Format that Harbor writes for every run (agent/trajectory.json) — one reader for every agent Harbor runs (Claude Code, Codex, Gemini CLI, OpenHands, Terminus, …), ATIF v1.2–v1.8. Embedded subagents are linted as their own runs; steps copied in after a context summary are context, not replayed as new calls; the one terminal screen Terminus records per batch of calls is paired with the batch's last call. ATIF has no result-status field, so a failed tool is read only from the structured signals producers write (extra.is_error,extra.tool_result_is_error, Harbor's[error] tool reported failureline,extra.status, an exit code of 0 for success) — any other non-zero exit code stays unknown, since a command can exit non-zero on a healthy run (see R2a precision, above, for the search and Ctrl-C conventions read as success). Tool definitions feedtracelint init; library users getfrom_atif_trajectory,from_atif_trajectories,atif_tools_to_registry, andlint_atif_trajectory. An ATIF file read as native (the default) is now an input error naming--format atif, where before it failed with an unrelated "unknown step type". Validated on real trajectories from 21 harnesses on the public Terminal-Bench 2.0 leaderboard; seedocs/integrations/atif.md. -
Docs: a precision study on real agent trajectories.
docs/experiments/swe-bench-precision.mdwrites up a precision / over-fire probe on 200 real, verified-correct agent trajectories (the work that motivated the R3 fix below): 0/200 false CI failures, the loop rule holds on retries (1/200), and a<returncode>-grounded analysis of where the keyless candidate heuristics are noisy on shell/stdout traces. Includes reproducible scripts (docs/experiments/swe-bench/). -
R3 precision: free-form text arguments are no longer flagged. R3 (hallucinated argument) checked every scalar string, including free-form text a model composes rather than derives — a shell command, a search query, a sentence — which its provenance model can't judge, so it flagged nearly every such argument. It now skips a string of more than one token on the keyless candidate path (a discrete datum — id, number, filename, code — is a single token), unless the field is annotated
x-value-origin: "provided"(the opt-inhard_defectpath, unchanged). On 200 real agent trajectories this cut R3's candidate findings ~88% (5,779 → 684) with no change to its discrete-value catches. (Investigated alongside R2a, which was found already correct — it trusts an explicitOKstatus before its string heuristic — so a trace's structured success signal just needs to reach the adapter'sResultStatus; no rule change there.) -
New: promptfoo assertion. Run tracelint's checks as a promptfoo
type: pythonassertion — a test case passes iff its run's trace passes the rules, same gate astracelint check(fail_on), judge-free. Point promptfoo attracelint.integrations.promptfooand configuretools/rules/fmt/fail_onin the assertion's YAMLconfig; the trace is found viaconfig.trace, avarsentry (tracelint_trace), or anoutputthat is a trace-file path. promptfoo is a Node tool that only calls the function, so there's no extra to install. A missing or unreadable trace fails that case with a recorded reason rather than erroring the eval. (The scoring core now lives intracelint.integrations.scoring, shared with the DeepEval metric.) -
New: DeepEval metric (
pip install "tracelint[deepeval]"). Run tracelint's deterministic checks as a DeepEval metric, so an existing DeepEval suite gets a judge-free structural check beside its other metrics.TracelintMetricpasses a test case iff the run's trace passes the rules, with the same gate astracelint check(fail_on). The trace is bound to the metric (trace=) or carried on the test case'sadditional_metadata; a missing or unreadable trace fails that case with a recorded reason instead of crashing the run. The scoring core (score_trace) is dependency-free and the SDK is imported lazily, so importingtracelint.integrations.deepevalnever requires DeepEval. First of the §3.6 distribution wrappers. See docs/integrations/deepeval.md. -
SARIF results anchor to the finding's trace step. A result's
region.startLinewas a constant line 1; it is now the line of the trace file where the finding's first step can be located by its source token (an OTel/Phoenixspan_id, a Langfuseobservation_id, or acall_id), so a GitHub code-scanning annotation lands on the offending record instead of the top of the file. Best-effort and fail-safe: a step that can't be located unambiguously (a span merged in from another file, a repeatedcall_idin a multi-trace.jsonl) stays at line 1, and the exactstep_indicesremain in the result'sproperties. Completes the §3.7 report-readability work. -
Per-rule help pages. Every rule a finding names now has an entry in the new
docs/rules.mdreference — what it checks, its tier, and the honest moves to resolve it — each under a stable anchor. The SARIFhelpUrinow links a GitHub code-scanning alert straight to its rule's anchor (previously every rule pointed at the repository root), and the text report ends with a pointer to the page whenever it names a rule. A test keeps the page, the shipped rule set, and the help links from drifting apart. -
OTel-GenAI ingestion: read
gen_ai.tool.definitions. The OTel adapter already read tool schemas from a TOOL span'stool.parametersand from OpenInferencellm.tools.*.json_schema; it now also reads the OTel GenAI semantic-conventiongen_ai.tool.definitionslist (each{name, parameters}/inputSchema), whether the attribute lands as a JSON string or a list. Those schemas feed each call's schema, poweringtracelint initand R11 (contract drift), so a GenAI-native instrumentation lights up R1 / R3 / R11 from the trace alone. (MCP tool calls already arrive asexecute_toolTOOL spans, andinvoke_agentis already handled — closing the ingestion half of §3.5.) -
New:
tracelint doctor. A trace where rules abstain used to show a wall of suppressions;doctorturns that into the one sentence that matters. It lints the trace(s) and reports why each rule couldn't run, grouped by cause: tool content not recorded (your instrumentation isn't capturing tool arguments/results — it names the capture setting for the format), a missingtools.json(runtracelint init), or simply not applicable (no gap). It adds a coverage summary (evaluatable / total per rule, across all traces) and is advisory — it never gates (always exits 0).tracelint doctor <traces> [--format F] [--tools T]. -
New: R12, unresolved side effect. The side-effect-integrity arc's last core rule, and the case R2 misses: not "the agent ignored an error" but "the agent never found out." R12 flags a side-effecting tool whose last call has no recorded outcome, or failed with nothing after it succeeding. It is a
candidate, fail-closed (the ROADMAP's explicit tier call): a missing result could be a truncated capture as easily as a real hang, so R12 discloses a signal for review and never asserts a defect — and marks it a possible false positive when the call sits at the very end of the trace, dropping that caution when the run clearly continued past it. Opt-in: only side-effecting tools, judged per tool on its last call, so a later success recovers an earlier failure. Independent read-back confirmation (a declaredconfirmed_by) is the deeper, deferred half. -
New: R11, contract drift. Some traces carry each tool's run-time schema inline (OTel / OpenInference tool definitions, an OpenAI
toolsblock, onToolCall.schema). R11 compares that against the committedtools.jsonstructurally — property names, each property'stype,required, the top-leveltype, ignoring descriptions/titles/order — and flags a real difference as contract drift: the committed contract has gone stale, so R1 is validating against a schema that no longer matches the tool. Like R10 it is ahard_event(certain, but about the contract rather than the agent's run) — shown but not failing CI unless you opt in with--fail-on hard_event. Opt-in and quiet: it fires only where a tool carries both an inline and a committed schema and both declare properties, so native traces and bare placeholders stay silent. This completes the MCP-native-contracts group (init --from-mcp, R10, R11). -
New: R10, the result contract. A tool can declare an
output_schema(MCP'soutputSchemamaps straight to it, andtracelint init --from-mcpnow captures it). R10 validates each recorded successful result against it — the mirror of R1 on the arguments. A violation is tool output drift and ahard_event: the output is the tool's, not the agent's own defect (unlike R1'shard_defect), so it is shown but does not fail CI unless you opt in with--fail-on hard_event. Opt-in: a tool with nooutput_schemais silent (no finding, suppression or coverage). Error results are skipped (R2's domain); a success whose content wasn't recorded is disclosed as not-checked. -
New:
tracelint init --from-mcp. Point it at a saved MCPtools/listresponse and it writes a startertools.json:inputSchemabecomes each tool's argument schema, and the server'sreadOnlyHint/idempotentHintannotations becomeside_effecting/idempotent. Because those annotations are advisory (the MCP spec says clients must not rely on them) andside_effectingdrives the hard-defect rules, they are applied only when the server states them explicitly — a tool with no hint is left with a_todoto classify, not a guessed default, so a server can't manufacture a false red. Accepts the JSON-RPC envelope, a bare{"tools": ...}, or a plain list. -
Call/result pairing is no longer O(n^2) on long traces.
Trace.call_for,result_forandpairsscanned the step list on every lookup, so the rules that pair calls with results — R2a, R2b, R4, R5 — grew quadratically with trace length. A trace now builds acall_idindex once and looks up by bisect, bringing those rules back to roughly linear: on an 8,000-call trace R2a drops from ~0.6s to ~0.04s and R2b/R4/R5 from ~0.4s each to ~0.1s, with identical findings. (R3's provenance substring scan is a separate, narrower cost — it only bites a trace that is both very long and hallucination-heavy, since a derivable value short-circuits — and is left for a dedicated change.) -
The pytest fixture and
tracelint langfuse checknow honour the project config. Both read[tool.tracelint]/tracelint.tomlfor rules, tools,fail_onand ignores, so atrace_capturetest or a checked Langfuse trace gates exactly astracelint checkdoes in CI — no silent drift between what CI enforces and what a test or alangfuse checkaccepts. (A baseline is not applied in either: it accepts findings in committed trace files, so pairtracelint langfuse pullwithtracelint check --baselinefor that.) -
Suppressions now say which fix they need. A rule that abstains was always disclosed, but every suppression read the same and the report always suggested
tracelint init— even wheninitcould not help. Each suppression now carries a category and the report groups them: missing a tools.json fact (runinit), not recorded in the trace (enable tool-content capture), and not applicable (too few calls, no results — nothing to check). The closing line suggestsinitonly when a contract is actually missing, and the category is in the JSON assuppressed_category. -
Clearer reports (output readability). The text report now leads with the trace file path instead of only the run id (keyless native traces no longer print a bare
: 0 finding(s)), a run over several files gets a one-line cross-file summary header, and a short tier legend explains the[hard_defect]/[hard_event]/[candidate]labels. -
Behavior change: JSON output no longer folds suppressions or ignored findings into
findings.findingsis now the active findings only, so its count matches the text report; suppressions (not checked) and ignored (accepted) findings are disclosed in their ownsuppressionsandignoredarrays, and each report carries itssourcefile path. -
New: R9, declared preconditions. R2b proves an agent acted on a failed result only when a value flows from the failure into the action, so the audit's deploy after an
UNSTABLEpipeline was a hard defect when the agent copied the build id but exited 0 when it deployed"latest"or a version number, and a refund after a failed lookup of an id the user gave is only an R2b candidate. A tool can now declare what must succeed before it runs:"requires": [{"tool": "get_order", "same": ["order_id"]}].- The latest returned call of the required tool decides: a retry that passes satisfies it, a
later failure un-satisfies it, and a call still in flight (fired in parallel, not waited for)
doesn't count.
samescopes it to one entity (refunding order B needs B's lookup);"succeeded": falseasks only that the call returned. - A violation is a
hard_defect, whether the required call failed or never ran. Success is read like R2a reads it; when the trace can't show it (no error, nofailure_whento read), or the arguments that scope it weren't recorded, the check is disclosed as not run, with the fix named. - Opt-in: a trace whose tools declare no
requiresgets nothing new in its report. A malformedrequiresentry is an input error (exit 3). The contract view lists it, andtracelint initsuggests it in the_todoof a tool that was called after others. - Checks are indexed per trace: 8,000 calls with a precondition each take ~0.04 s.
- The latest returned call of the required tool decides: a retry that passes satisfies it, a
later failure un-satisfies it, and a call still in flight (fired in parallel, not waited for)
doesn't count.
-
New: a CI baseline with a coverage ratchet (the second half of the CI contract). Adopting tracelint on traces that already had findings meant a red build until every one was fixed, or turning rules off.
--update-baselinerecords what the traces show (--baseline FILE, orbaselinein the config); later runs accept up to that many of each finding and fail on anything beyond it. A finding is matched by its trace file and its rule, kind, tools, fields and signal, never by step position, value or run id, so a re-run of the agent whose steps shift still matches. A recorded finding also accepts the same finding at a lower tier, never a higher one.- Only findings at the gate (
fail_on, a hard defect by default) are recorded. Accepted ones stay in the report as ignored, "in the baseline"; ones that no longer occur are noted. - Coverage ratchet: the baseline records which tools each rule could not check and which
rules checked something. A run fails (exit 1) when a rule can no longer check a tool it could,
or a rule that checked something now checks nothing: content capture switched off, schemas
deleted.
ratchet = falseor--no-ratchetturns it off; a trace file the baseline never saw has nothing to compare. - Paths are stored relative to the baseline file, and updating from some traces keeps the entries for the others. A missing or invalid baseline is an input error (exit 3).
- R2b's evidence names every side-effecting tool the failure reached (
side_effecting_uses), so a baselined failure that starts feeding a new action is a new finding.
-
New: a project config file,
--fail-on, and ignores with reasons (the first half of the CI contract). Every setting had to be repeated on each CI command, nothing below a hard defect could fail a build, and the only way to accept a known finding was to turn its rule off.[tool.tracelint]inpyproject.toml, or atracelint.toml, setsformat,tools(relative to the file),rulesandfail_on. The nearest file wins, searched up to the repository root;--config FILEnames one; flags override it. The GitHub Action'sformatinput now defaults to the config's.fail_on = "hard_event"(or--fail-on hard_event) also fails the run on hard events, and"candidate"on candidates too, with exit 1; a hard defect still exits 2. A candidate that fails the run is always shown in the text report.[[tool.tracelint.ignore]]accepts one rule's findings, optionally narrowed to atool, an argumentfieldor a tracepathglob, and requires areason. An ignored finding stays in the report (text: an "ignored" section with the reason; JSON:ignored_reason) but no longer counts toward the exit code. An ignore that matches nothing prints a warning.- A misspelled key, an unknown rule or tier, an ignore without a reason, or invalid TOML is an input error (exit 3), so a typo can't quietly loosen the gate.
- Python 3.10 reads the config with
tomli(added as a dependency there); 3.11+ usestomllib.
A correctness release. An audit ran 0.9.0 on real framework traces and through every documented CI path, and found false passes (input linted as nothing, failures read as success) and false failures (missing data treated as evidence, value overlap read as dataflow). This release fixes them, so results change on existing traces; the change you may need to act on is marked Behavior change under R2b.
- The GitHub Action installs the version you pin.
uses: AshwinUgale/tracelint@v0.10.0installed the latest tracelint on PyPI whatever the tag, so a pinned workflow changed when a release came out. It now installs the code at the pinned ref;version:still installs a named PyPI release. CI now runs the action itself. - Docs: the README rule table lists R8 and R6's candidate tier; the library example reads any format with
load_source(Trace.loadreads only native traces, so it failed on a captured one); the README's links work on PyPI; the Action and pre-commit examples pin v0.10.0 (pre-commit still said v0.4.1); the Langfuse guides lead withtracelint langfuse pull; CONTRIBUTING describes the current layout and what CI checks;--ruleshelp shows real rule ids (R2a, notR2); the roadmap lists declared preconditions. - Packaging: a
py.typedmarker; classifiers for Python 3.13 and 3.14 (CI now runs 3.10 to 3.14); the license as an SPDX expression (MIT) instead of the whole license text; tracelint.com as the homepage. - Fix: R3 reads a value's digits from one number, compares numbers by value, and no longer
slows down with the square of the trace.
- Missed defects. R3 joined every digit in a text, so a fabricated
ORD-58213"derived" from a result with a total of 58 and a quantity of 213, even on a field annotatedprovided; two observed numbers could also be run together (58 and 213 as 58213). A value's digits must now come from one number in the text (a phone number written with separators still counts), and two numbers are never joined without a separator. - False failures. The same number written differently was underivable:
1200.0against$1,200, a float4906.0against4906,12in "order 12 items". On aprovidedfield that was a hard defect on a correct call. Numbers now compare by value. - Cost. R3 rebuilt the provenance graph for every call and compared every pair of observed
values for each argument. One 1,000-row tool result with two free-text arguments took about a
minute, and so did a run of 1,000 small calls. One graph is now grown through the trace, with
its values indexed: both take a fraction of a second.
ProvenanceGraph.observe(step)adds a step to a graph.
- Missed defects. R3 joined every digit in a text, so a fabricated
- Fix: R2b follows the data, so handling an error no longer fails CI. R2b read any later call
that shared a value with a failed result as using it, and a failed result often echoes its inputs.
On real LangGraph runs, retrying a timed-out cancellation with the same id, and emailing a receipt
alongside a fraud check that failed, were hard defects (exit 2); so was charging the backup card
the user named after a decline. Now a value counts only when nothing else the agent observed
supplied it, give or take case and separators (
A-100isA100), and retries and recoveries of the failed tool are skipped. Another failed result is not a source (a retry that fails again repeats the value), and neither is a call the value was passed to.- Missed defects. Only the first call sharing a value was examined, so a logging call hid the side-effecting transfer after it (a candidate); it is now a hard defect. Each misuse is reported once, against the first failure that supplied it.
- Behavior change. A side-effecting call after a failed lookup that uses only an id the user
gave (
get_order(A100)fails, thenrefund_order(A100)) is no longer a hard defect: the refund used none of the failure's data, and R2b reports the failure as not retried (a candidate). Whether a refund needed that lookup to succeed is a precondition, not dataflow; declared preconditions are planned. When a call the value was passed to returned it (a lookup that may have confirmed it, or a log that echoed it), the use is a candidate naming that call. - The Phoenix, Langfuse and Traceloop examples and notebooks planted the old shape. Their failed
get_ordernow returns a cached payment method that the refund uses, so they still exit 2, for a reason R2b can prove.
- Fix: one rule for reading a tool's result, in every adapter (the result half of #37). Each
adapter unwrapped results, and decided what counts as an error, on its own, and they had drifted:
- Missed defects. A declined charge followed by shipping failed CI from OTel and an OpenAI
dict, but passed from an OpenAI JSON-string result, Langfuse, and LangSmith (including a real
LangSmith trace): the
failure_whenpointer never reached the result. LangChain'sToolMessage(nested in OpenInference, flat in Langfuse and LangSmith, inside LangSmith's{"output": ...}) is now unwrapped everywhere, and a JSON-string result is parsed. - False failures.
"error": falseor""counted as an error, and so did a status code in the result's body (a link checker'sstatus_code: 404), failing CI on correct runs. Now an error is the span's or run's own error status, aToolMessagewithstatus: "error", or a non-emptyerrorfield in the result. Astatus,http_statusorstatus_codeinside the result is the tool's data: R2a shows a failure-looking value as a candidate that namesfailure_when, which makes it a fact. LangSmith and Langfuse no longer read astatusinside the result as structured (OTel and OpenAI never did); LangSmith's run-levelstatusstill is. - Crash. An
http_statusrecorded as a string ("404") raised aTypeErrorand exited 3; it is read as a number.
- Missed defects. A declined charge followed by shipping failed CI from OTel and an OpenAI
dict, but passed from an OpenAI JSON-string result, Langfuse, and LangSmith (including a real
LangSmith trace): the
- Fix:
captureno longer breaks your own tracing, and never passes on an empty capture. If the framework was already instrumented (a Phoenix or Langfuse setup, as smolagents' telemetry docs show), capture recorded 0 spans, and itsuninstrument()on exit removed the shared instrumentor, so your own backend got nothing from every later run. The 0.8.0 note said existing tracing was left untouched; it was not. Now capture never uninstruments what it did not instrument, and records from the global tracer provider (whichphoenix.otel.register()sets). Verified on the real smolagents and LangChain instrumentors: with a global provider the run is captured and your tracing keeps working; with a provider capture can't reach it raises and names the fix. A capture that records nothing raises instead of writing[], which linted as a clean run; an exception from the agent itself still propagates unchanged. - Fix: the
trace_capturefixture lints every run in the block (.reports, one per run;.reportstill works for one run). It used to fail with "uselint_otel_traces", a call only the fixture could make. A capture with nothing to lint now fails the test instead of passing. - Fix: usage errors exit 3, not 2. argparse's own code, 2, is the one tracelint reserves for a hard defect, so a mistyped flag in a CI step read as "defect found".
- Fix: no crash printing text the console can't encode. Redirected output on Windows uses the
locale code page, and a report echoes the trace's own text (CJK, emoji): a clean run exited 3
with a
'charmap' codecerror. Such characters are now escaped (\u6771). pip install "tracelint[capture]"is now named when the OpenTelemetry SDK is missing, instead of a rawModuleNotFoundError. The integration guides' snippets read traces as UTF-8.- Fix: the wrong
--format, or an empty file, is an input error instead of a clean pass. The CLI's default format (and the GitHub Action's) isnative. Read as native, a span file became one empty "trace" per span and exited 0 having checked nothing; the same happened for every other format mismatch, and for an empty file ([], an empty.jsonl). Across 12 test inputs (a trace in each format, empty files, a CHAIN-only export) and the five formats, all 60 combinations exited 0 on 0.9, though only the 7 matching pairs linted anything. Now a file in which nothing reads as the requested format exits 3, naming the format it looks like (it looks like OpenInference / OTel spans: use --format openinference). Native input must be native traces (objects with astepslist; a bad.jsonlline is named), and a right-format file with no tool calls or messages is reported as "nothing to lint".load_sourceraises the sameValueError;checkchecks each file after merging runs split across files. - Fix:
.jsonlspan files are linted as runs, not as one trace per line. Exporters write one span (or one export batch) per line, as Phoenix'sto_json(..., lines=True)and OTel file exporters do. Every CI path in the docs (the GitHub Action example, the pre-commit hook, the plaintracelint check traces/*.jsonlcommand) linted each line as its own trace, so a defect spanning steps could never be seen. On a real Phoenix JSONL export of 10 failed-release runs, 0.9 reported 110 one-span "runs", exited 0, and missed all 10 defects. With--format openinference/otel, a.jsonlfile is now read as one span collection and regrouped by trace id.checkalso merges a run whose spans are split across files (a rotating exporter, a glob over batches) and reports it under the first file; a span seen twice is kept once. Lines that each hold a whole run and carry no trace ids still lint one run per line. - Fix: OpenAI
.jsonland ShareGPT datasets. A.jsonlfile of single messages is one conversation, as the docs said; 0.9 made each message its own trace, so tool results never met their calls. ShareGPT datasets ({"conversations": [...]},from/valuemessages) loaded zero traces and exited 0 with no output; they now lint one conversation each. - Fix: lossy argument records no longer fail CI — the arguments are unknown, not
{}. Instrumentation often records a tool call's arguments lossily: as a bare value (LangChain single-input and LCEL tools), redacted (OPENINFERENCE_HIDE_INPUTS), positionally with no parameter names ({"args": ["A100"], "kwargs": {}}from smolagents, Langfuse@observe, LangSmith), or not at all (OTel GenAI without content capture). The adapters turned these into{}or a made-up object, so R1 reported every required field missing, R6 called the record malformed JSON, and R8 compared made-up values: exit 2 on valid runs. Where the trace has the real arguments (thetool_callthe model emitted), they are recovered; otherwise the call carriesToolCall.args_unavailablewith the reason. R1 suppresses such a call with that reason, and R2b, R3, R4, R5 and R8 disclose the calls they could not check, so an unknown never reads as a clean pass. - Changed: R6 is a hard defect only for text the model emitted. Broken JSON in a tool's own
record, with no model call in the trace to confirm it, is now an R6
candidate: an exporter truncating a long input (a coding agent'swrite_filecontent, say) leaves the same text, and that failed CI on every such run. - Fix: one argument normalizer for every adapter (
adapters/_common.py, the argument part of #37). OpenInference/OTel, Langfuse and LangSmith now read the same record the same way. A positional argument object keeps its names, and a real parameter namedargs(run_command(command, args)) is no longer mistaken for a call envelope. - Fix: a false R8 duplicate from mispaired calls. LangChain records some TOOL inputs as bare
values and others as objects, and recovery paired a later bare span with an earlier call's
arguments — a "duplicate SMS" that never happened. Each TOOL span now claims its own model
tool_call: by id when the trace records one, else in order. Model calls with empty or malformed arguments are kept in the pairing instead of dropped. - Fix: OTel GenAI tool content.
execute_toolspans'gen_ai.tool.call.argumentsandgen_ai.tool.call.resultare read; when content capture is off, the reason names the opt-in (OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT). - Native JSON now round-trips a call's
args_unavailableand discoveredschema. - Regression fixtures: real lossy records in
tests/fixtures/lossy_args/(LCEL string tool, LangGraph with hidden inputs and outputs, hidden inputs only, optional arguments, a LangSmith LCEL run), generated offline with a scripted model.
Found by running a real LangGraph 1.2 agent (openinference-instrumentation-langchain 0.1.76,
gpt-4o-mini) through a local Arize Phoenix and linting the result — a release agent that deployed a
Jenkins UNSTABLE build to production. Both real traces are now regression fixtures
(examples/traces/langgraph_phoenix_trace.json, langgraph_capture_trace.json).
- Fix: a false CI failure on real Phoenix exports. Phoenix's span dataframe returns message
attributes (
llm.input_messages,llm.output_messages,llm.tools) unflattened into lists of relative-keyed objects; the OTel adapter only read the flat dotted form. It lost the user's request (a false R3 candidate) and, on LangChain's lossy single-argument TOOL inputs, could not recover the real arguments from the LLM span — a false R6 hard defect, exit 2, on a valid call. The adapter now rebuilds the flat OpenInference keys from nested attributes (also handles numpy arrays from an in-memory dataframe). - Fix: "acted on a failed result" was missed for LangChain / LangGraph. Their instrumentation
records a tool's output as a serialized
ToolMessage({"type": "tool", "data": {"content": ...}}), so afailure_whenpointer never matched and R2a/R2b could not see the failure. The envelope is now unwrapped to the tool's real result, and aToolMessagestatus: "error"counts as a structured tool error. - Fix: a multi-run Phoenix export was linted as one merged trace. Trace-id grouping now reads the
dataframe's flat
context.trace_idcolumn, sotracelint checksplits it into one report per run. - New:
lint_otel_traces(spans)— one report per trace, the shape a Phoenix project export actually has.lint_otel_trace/from_otel_spansnow raise on spans from several traces instead of silently merging them (a merged "trace" manufactures false loops and redundant calls). - Docs: the Phoenix, Langflow, and README snippets use
phoenix.client.Client().spans .get_spans_dataframe(...)withlint_otel_traces.
tracelint.capture— record an agent run to a lintable trace. The primary path assumed you already had a trace file; nowwith capture("trace.json", framework="smolagents"): agent.run(...)captures one by wrapping the framework's stock OpenInference instrumentor against a local OTel provider whose only exporter writes the flat OpenInference span shape — so the file lints with--format openinferenceexactly like a hand-exported one, and any tracing you already run is left untouched. Covers the in-process frameworks (smolagents, LangGraph / LangChain, CrewAI) via opt-in extrastracelint[capture]/[capture-smolagents]/[capture-langchain]/[capture-crewai]; Langflow uses an OTel-to-file recipe. The OTel SDK and instrumentors are imported lazily, so importing tracelint never requires them.tracelint langfuse pull <trace-id> [-o file]. Fetch a Langfuse trace straight to a file (as native tracelint JSON) sopull→checkcomposes and the file doubles as a saved fixture. Read-only; reuses the existingLANGFUSE_*auth and turns a vendor/network/auth failure into a clean exit3that names the env vars to check but never echoes their values. Default output is<trace-id>.json.- Write-back demoted.
pullis now the featured Langfuse verb;langfuse check --write-backis repositioned as an advanced recipe — labeled "(advanced)" in--helpand moved out of the primary README path. It still works: repositioned, not removed. trace_capturepytest fixture. Opt in withpytest_plugins = ["tracelint.pytest_plugin"]inconftest.py, then a test captures an agent run and lints it inline — a hard defect fails the test (candidates never do);assert_clean=Falseexposes the report on the handle's.reportto assert on yourself. A thin wrapper overcapture+lint_otel_trace; no new dependency.- Dev-tool / CI-first repositioning. README and VISION now lead with the capture → lint → CI core
loop and the three-tier platform model (core depends on no platform; read convenience; write-back
demoted and frozen). OpenTelemetry / OpenInference is presented as a format, not a platform.
Per-framework "capture a trace in a test" recipes were added to the integration one-pagers, and
docs/DIRECTION.mdrecords the change.
- First-run hardening. A malformed or unfamiliar trace shape now degrades to a clear error and
exit
3, never a Python traceback: the native loader validates thatstepsis a list, each step is an object, and atool_call'sargsis an object (with a specific message for each), and the CLI has a last-resort handler that turns any unforeseen error into a "please report it" message pointing at the issue tracker. A clean run with suppressed rules now prints an explicit "no structural issues found" line (and points totracelint init) instead of only a bare count. tracelint init— schema inference + inline TODOs. When the trace declares no schema for a tool,initnow infers an object schema from the argument values actually observed (dropping a type when it varies across calls; never guessingrequired), marked with a$comment. Each tool entry carries a_todolist naming exactly what to fill in (schema review +side_effecting/idempotent/failure_when), and the file has a top-level_comment; both are ignored on load, so the draft still round-trips through--tools.
-
tracelint init— bootstrap atools.jsonfrom a trace. The #1 onboarding step was writing a contract by hand; nowtracelint init spans.json --format openinference -o tools.jsonreads a trace, discovers the tools called, and fills in each tool's argument schema from the telemetry (OpenInferencetool.parameters/llm.tools.*.tool.json_schema), leaving the behavior it can't infer (side_effecting/idempotent/failure_when) as explicitnullplaceholders and printing the review TODOs. The result is a valid contract that round-trips through--tools. Framework-internal control tools (e.g.final_answer) are skipped, matching R7.ToolCallgained an optionalschemafield (discovery-only — the rules still validate against the operator'stools.json, not this), populated by the OTel/OpenInference adapter. -
HTML report is now a trace view.
tracelint check --html report.htmlrenders each linted run as the agent's step timeline (each step that a finding touches is marked inline with its rule + tier) beside finding cards (tier, rule, summary, evidence) and a verification coverage bar — instead of a bare findings table.render_htmlgained atraces=parameter (pass alongsidereports=); without it the compact table still renders. Still a single self-contained file — inline CSS, no scripts, no external resources. -
Langfuse integration —
tracelint langfuse check --trace <id>. tracelint now runs inside the platform you already use: it fetches a trace from Langfuse, lints it, and (with--write-back) writes the verdict back as Scores — a trace-leveltracelint.passed(BOOLEAN) andtracelint.hard_defects(NUMERIC), plus each certain finding (hard_defect/hard_event) attached to the exact offending observation with the evidence in the comment. Read-only by default (prints the score plan); candidates are review-only and never written. Scores are keyed by stable finding fingerprints, so a re-run updates in place instead of duplicating. Newintegrations/layer, kept separate from the pure adapters; needspip install "tracelint[langfuse]"(v3 SDK). Reframes the pitch: add deterministic structural checks to your Langfuse traces. -
Source identity on canonical steps (
SourceRef): adapters can now record where a step came from in its origin platform —provider+trace_id/span_id/observation_id— so an integration can attach a finding back to the exact offending record (a Langfuse observation, an OTel/Phoenix span). Optional and absent by default; the Langfuse adapter populates it today. The rule engine never reads it, keeping provider knowledge out of the deterministic core. Groundwork for observability-platform write-back. -
Stable finding fingerprints (
tracelint.identity.finding_fingerprint): a deterministic id from a finding's rule, kind, scope, and evidence locations — independent of output format. SARIF'spartialFingerprintsnow derive from it, and it will key idempotent write-back (update, not duplicate, on re-run). -
SARIF output for GitHub code scanning (
tracelint check --sarif out.sarif, and asarif:input on the GitHub Action). Emits a SARIF 2.1.0 log so findings appear in the repo's Security → Code scanning tab and as inline PR annotations. Tiers map to SARIF levels (hard_defect→error,hard_event→warning,candidate→note); suppressions are not results; each result carries stablepartialFingerprintsand the tracestep_indices. The file is written before the exit-2gate, so anif: always()upload-sarifstep runs even on a defect. Library entry point:tracelint.to_sarif(reports, tool_version=..., uris=...). (#13) -
New rule R8 — duplicate side effect: flags a non-idempotent side-effecting tool called again with equivalent arguments when the first call did not fail (the double-charge).
hard_eventwhen the first call succeeded,candidatewhen its outcome is unknown; a repeat after a genuine failure is a legitimate retry and is never flagged. Uses only the existingside_effecting/idempotentmetadata, and reports an event, so it never fails CI on its own. -
Tool Contracts:
ToolContractpresents a tool's declared metadata as one coherent view —args(schema),effects(side_effecting/idempotent/ …),failure(failure_when), andprovenance(x-value-origin) — viaregistry.contract_for(name)/registry.contracts(), with.describe()and.to_dict(). Presentation only: no new keys and no behaviour change (it reads the sameToolSpecthe rules already use).FailurePredicate.summary()renders a failure contract statically. Seedocs/tool-contracts.md. -
Verification coverage: each report now carries a per-rule
coverage— how many units a rule could actually evaluate vs. abstain on (e.g.R1 1/2 tool calls,R2a 1/2 tool results), shown in the text report andto_dict(). Rules opt in viaRule.coverage(); R1 (schema availability) and R2a (structurally-classifiable results) report today, and a whole-rule suppression reads as0 / total. This makes "what portion of this run was actually verifiable?" a number you can watch — the reason a clean report is trustworthy, not merely empty. -
failure_whenis now tri-state (fixes #34). A declared value predicate distinguishes MATCH (declared failure), NO_MATCH (field present, not a failure value — a clean pass), and UNKNOWN (the pointed-to field is absent, so the predicate cannot be evaluated). On a side-effecting tool an UNKNOWN result is disclosed as a suppression ("cannot verify it did not fail"), never a silent clean pass — so an API that drops the field no longer sails through the contract written to catch its failure. A new"optional": truepredicate key opts a legitimately-absent field back into a clean pass.FailurePredicate.matches()is unchanged (MATCH-only), so existing rules are not affected. -
LangSmith adapter:
from_langsmith_runand--format langsmithnormalize nested LangSmith run trees into canonical traces, preserving structured tool errors for R2. Robustness fixes: integerexecution_ordernow sorts numerically (not lexically), positional-only tool args are preserved instead of dropped to{}, and a run-level numeric HTTP status is read as an error. -
Fault-injection experiment harness (
run_experiment/render_experiment): runs an agent at baseline and under injected faults,runstimes each, and reports recovery rate, incorrect-continuation rate (the agent claimed success while the oracle failed), and tracelint-flagged rate — each with a Wilson interval. Unlike the scriptedscorecard --demo, it's built to run a real agent (seeexamples/fault_experiment.py) so the numbers are observed, not authored. -
New
DENIEDfault type: a transport success (HTTP 200, status OK) carrying a{"status": "declined"}body — invisible to structured-error detection, flagged only when the tool declares afailure_whenpredicate. The experiment prints the before/after across that declaration. -
Adapter conformance suite (
tests/conformance/): per-adapter fixtures that pin normalization only — raw provider payload → exact canonical steps, no rules — for OpenAI (standard chat + ShareGPT), OTel/OpenInference (Phoenix top-levelspan_kind+ GenAIexecute_toolsemconv), Langfuse, and LangSmith. A regression guard against the trace-misreading bugs that are worse than a missing rule.
- Fix (found by linting real Phoenix agent traces): the OpenInference adapter no longer crashes on
get_spans_dataframe()records whoseeventscell is a numpy array (array or []raised "truth value is ambiguous"). - Fix (same): tool arguments serialized as a Python
str(dict)/repr(single-quoted keys, common in real instrumentation) are now parsed vialiteral_evalinstead of being reported as malformed arguments (R6, a hard_defect). This was a false CI-failing finding on every such call — and the unparsed empty args also faked identical calls, producing false R4 loops. Both classes are gone. - The OpenTelemetry event-list reader now understands both OTel conventions: OpenInference
and the OTel GenAI semantic convention (OpenLLMetry / Traceloop). A GenAI span is read via
gen_ai.operation.name(execute_tool→ tool call,chat→ LLM), withgen_ai.tool.name, plaininput/output, and provenance seeded fromgen_ai.input.messages. One reader now covers most observability-platform exports, not just Arize/OpenInference. - The message-list reader (
from_openai_messages/--format openai) now reads the common real-world variants without a bespoke adapter: the ShareGPTfrom/valueshape (withhuman/gptroles), arole+textshape (some trajectory dumps), and typed-block content ([{"type":"text","text":...}], the Anthropic / newer-OpenAI / SWE-bench form). Typed blocks are flattened to text for messages and text tool-results, while a structured tool-result payload (a dict or data list) is preserved so R2 /failure_whencan still read it.
metadata.failure_whengainscontains(substring) andmatches(regex) modes, andpointermay be""(the whole result) — so a tool that reports failure as free text (the common MCP"Error: ..."string over a 200) can declare that contract structurally, not just tools with a/statusfield. The declaration lives in the operator'stools.json, so it works for third-party tools whose authors declare nothing.- R2a's exception-text heuristic now also matches
failed/failure(not justerror/exception/ tracebacks / HTTP 4xx-5xx), still at the candidate tier. Both raised in review.
- CI on-ramp: a composite GitHub Action (
uses: AshwinUgale/tracelint@v0.4.1) and a pre-commit hook (.pre-commit-hooks.yaml), plus an "Add to CI" guide in the README, so a build can gate ontracelint checkin a few lines. No library changes.
- Feature: tools can declare
metadata.failure_when— a JSON-pointer failure predicate ({"pointer": "/status", "in": ["declined", "failed"]}) — so a domain failure returned as a transport success (HTTP 200 with{"status": "declined"}) is caught structurally by R2, feeding R2a (hard event) and R2b (hard defect on reuse into a side-effecting call). A side-effecting tool with no predicate and an unclassifiable result is now suppressed with a reason instead of passing silently. Raised in review. - R5 now discloses when a tool absent from the registry ran between two identical calls (its side-effect status is unverifiable) rather than silently assuming it was inert.
- Fix: the OpenTelemetry/OpenInference adapter now seeds the opening user/system turn from
llm.input_messages(the OpenInference field holding what the model was asked). Without it, provenance had no record of the user's request and reported every string argument as an underivable value — a wall of false R3 candidates on real traces. - Fix: read the span status from the shapes a real export uses — the OTel SDK's nested
{"status": {"status_code": "ERROR"}}and OTLP-JSON's{"code": "STATUS_CODE_ERROR"}/ numeric2— not only a flatstatus_code. A real SDK-exported tool error was missed when its failure wasn't also echoed in the output payload. Both found by linting genuinely SDK-exported spans.
- Fix: the OpenTelemetry/OpenInference adapter now reads the shape a real Phoenix user gets from
px.Client().get_spans_dataframe().to_dict("records")— required columns at top level and attributes flattened intoattributes.*columns. Before this, a dataframe-record span was recognized by kind but read with empty args and no output. Verified against a real Phoenix trace.
- Fix: the OpenTelemetry/OpenInference adapter now recognizes Arize Phoenix's own trace export,
which records the span kind as a top-level
span_kindfield rather than theopeninference.span.kindattribute. Before this, a real Phoenix export was reduced to zero tool calls and every rule silently suppressed. Found by runningcheck --format openinferenceagainst a real Phoenix trace.
tracelint check --format {openinference,otel,openai,langfuse}reads provider trace exports directly — no manual conversion to the tracelint schema. Multi-trace inputs (.jsonl, a JSON array, or an OTLP export with severaltrace_ids) fan out to one report each.- New public API:
load_source,lint_otel_trace,lint_openai_trace,lint_langfuse_trace, andSUPPORTED_FORMATS. - New keyless example
examples/lint_openinference_phoenix.py— lint Arize Phoenix-shaped OpenInference spans end to end. __version__is now read from the installed package metadata, so it can no longer drift frompyproject.toml(it previously reported0.1.0).
- Deterministic agent-trace linter: flags schema-violating tool calls, ignored tool errors, hallucinated arguments, loops, and redundant calls — each with the exact trace lines as evidence and a CI exit code. No model ever judges the trace.
- Canonical trace schema + adapters for OpenTelemetry / OpenInference, the OpenAI SDK, and Langfuse.
- Fault injector + per-fault recovery scorecard.
- Keyless
tracelint demovalidation suite (one planted instance of every defect, clean controls, and legitimate-but-suspicious cases) with a live HTML report.