Repository navigation
FEAT: Score the relative order of tool calls from trace evidence - #4152
Open
WatchTree-19 wants to merge 1 commit into
Open
WatchTree-19 wants to merge 1 commit into
WatchTree-19 wants to merge 1 commit into
Conversation
Add a ToolsCalledInOrder condition and OtelToolCallOrderScorer, a separate leaf that reads the same trace source and stored tool events as OtelToolCallScorer. A tool counts as earlier only when one of its execution spans ended strictly before a span of the next listed tool started, beyond an optional clock_skew_tolerance. Overlapping, unfinished or near-simultaneous spans give no order, so they stay undetermined. An observed order stands on partial evidence; a negative needs complete coverage, and unlinked requests still turn a negative into undetermined. Stored observations replay offline against new orders.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The ordering search loses constraints from earlier tools, causing an impossible three-tool sequence to return undetermined instead of being rejected.
1 open issue
What changed in this PR
Extends PyRIT’s trace-backed tool scoring from presence checks to relative execution order, building on #2725 while reusing stored evidence and offline replay.
Changes:
- Adds
ToolsCalledInOrder, a registered condition requiring at least two distinct tool names. - Adds
OtelToolCallOrderScorerwith interval-based matching and configurable clock-skew tolerance. - Adds validation and scorer tests, plus runnable ordering examples.
| File | Description |
|---|---|
tests/unit/score/test_otel_tool_call_order_scorer.py |
Tests ordering, tolerance, replay, and condition routing. |
tests/unit/models/test_condition.py |
Tests condition validation and serialization. |
pyrit/score/true_false/otel_tool_call_scorer.py |
Implements interval matching and the order scorer. |
pyrit/score/__init__.py |
Exports the new scorer lazily. |
pyrit/models/score/condition.py |
Defines the ordering condition and validation. |
pyrit/models/score/__init__.py |
Exports the condition from scoring models. |
pyrit/models/__init__.py |
Exposes the condition through the public models API. |
doc/code/scoring/5_tool_call_scorer.py |
Adds sequential, reversed, and overlapping-span examples. |
doc/code/scoring/5_tool_call_scorer.ipynb |
Adds matching notebook examples and outputs. |
doc/code/framework.md |
Describes order scoring alongside presence scoring. |
Review effort: Balanced
Comment on lines
+96
to
+99
| # The next tool must not end before this attempt starts, so the earliest start is best. | ||
| if not candidates: | ||
| return False | ||
| previous = min(candidates, key=lambda run: run.start_time) |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Description
Phase 5 of the scorer contract proposal shipped name-only
ToolsCalledmatching over trace evidence (#2725) and deferred ordering conditions. This adds them as a separate leaf, following section 5: one declared condition type per leaf (Rule 4), with presence and order combined in the scorer tree rather than inside one scorer (Rule 5).ToolsCalledInOrderis a new condition (condition_type: "tools_called_in_order") holding at least two distinct exact tool names. It registers through the existing condition registry, so expectations persist and round-trip like the other conditions.OtelToolCallOrderScorermatches it. It reads the sameObservationSource[TraceScorable]and the same storedToolEventsObservationPayloadasOtelToolCallScorer, so no payload, model, or serialized form changes. Message scorables resolve trace links exactly asOtelToolCallScorerdoes.What counts as an order: a listed tool comes before the next one only when one of its execution spans ended strictly before a span of the next tool started, by more than an optional
clock_skew_tolerance. Other tools and repeated attempts may run in between. Overlapping spans, unfinished spans, and spans with equal or near-equal timestamps establish no order in either direction, because the evidence does not say which came first.Verdicts follow the existing evidence rules:
OtelToolCallScorer, requests in the conversation that carry no trace link.The tolerance is part of the scorer identifier. Stored observations replay offline against new orders without calling the trace client. Ordering uses only what span intervals establish, so it does not rely on argument or result capture.
Tests and Documentation
tests/unit/score/test_otel_tool_call_order_scorer.py(34 tests): the interval rules (sequential, reversed, concurrent, equal timestamps, unfinished spans, repeats and interleaving), the earliest-finishing and earliest-starting choices across repeated attempts, clock tolerance in the matcher and through the scorer, error and unavailable acquisition, identifier contents, negative tolerance, condition routing, offline replay, unlinked requests, and a composite that routesToolsCalledandToolsCalledInOrderto separate leaves.tests/unit/models/test_condition.py: round trip through the registry and persisted expectations, order sensitivity, and the two-distinct-names rule.doc/code/scoring/5_tool_call_scorer.pyand.ipynb: a section that scores a sequential trace in both orders and an overlapping trace.doc/code/framework.mdmentions the new scorer.How it was tested
Rebased on main 935afa8:
pytest tests/unit/score tests/unit/models tests/unit/common tests/unit/_code_execution tests/unit/output tests/unit/executor/attack -n 2: 11578 passed, 65 skipped. Ruff and format are clean; ty reports nothing on the touchedpyrit/and test files. The notebook was executed and its outputs match the.pycells; the.pyand.ipynbcell sources are identical.Mutations, each applied alone and then restored, with the number of failing tests:
<to<=)One further mutation, treating an unfinished span as ending at its start inside the precedence check, leaves every test green. It is unreachable: the definite search filters unfinished spans first, and the possible-order search runs only on complete coverage, which cannot contain unfinished spans.