Skip to content

Commit d1cb685

Browse files
anonymousclaude
andcommitted
fix(early-stop): decide skill activation on the tool call, not its result
Armed skill-activation rows that clearly trigger a skill were not early-stopping and burned their full turn budget. Two facts combined to break the stop at scale: 1. The live watcher evaluated armed criteria on the tool *result* (`ToolEndEvent`). When a turn is cut short (e.g. a timeout), the `Skill` tool call is left unresolved: the deadline check discards the pending result and `finalize` force-closes the call as UNRESOLVED, which the watcher (correctly) ignores. So the decision, though fully determined by the call itself, was never observed and the run kept going. 2. The final `skill_triggered` scorer used "any engagement" while the live verdict used "first engagement". Because the run kept going, the agent could engage additional skills, and each extra engagement was counted as a false positive against its own criterion. This change: - (early_stop) Evaluates armed criteria on the tool *call* (`ToolStartEvent`) in addition to the result. For an observable criterion the verdict is fully determined by the call's inputs (which skill / which command), so the watcher latches the instant the call is dispatched. The agent polls `should_stop` immediately after dispatching each message, so the loop breaks before a cut-short turn can strip the result. The in-flight call (not yet in the collector, which reduces commands from `ToolEndEvent`) is appended to the partial trajectory and reported at `tool_call_index + 1`; the counter still advances on the resolved `ToolEndEvent`. UNRESOLVED ends remain ignored, so an orphaned tool closed post-loop can never latch a false stop. - (skill_triggered) Scores the final check on the FIRST engaged skill, matching the live verdict, via a shared `_first_engaged_skill_names` helper. A second skill invoked alongside or after the first is no longer counted as a competing activation, so it is not a false positive. The live verdict and the authoritative score now agree by construction, whether or not the run stopped early. Removes the now-unused `_engaged_skill` helper. - (formatting) Skips the SDK `RateLimitEvent` (an out-of-band throttling notice) in `format_messages` alongside `StreamEvent`, so it no longer logs a spurious "unhandled SDK message type" warning. Cosmetic; does not affect scoring. Adds unit coverage for the tool-call decision (call fires before/without a result, latches before a later UNRESOLVED end, index accounting), the orchestrator cut on a resultless call, first-engagement scoring including the parallel-call case, and the rate-limit event skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 52aa7b8 commit d1cb685

6 files changed

Lines changed: 236 additions & 68 deletions

File tree

src/coder_eval/criteria/skill_triggered.py

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,22 @@
4444
def _engaged_skill_names(cmd: CommandTelemetry) -> set[str]:
4545
"""All skill names engaged by ONE command, agent-agnostically (any-skill).
4646
47-
Generalizes ``_engaged_skill`` (which asks about one named skill) so a
48-
live verdict can detect a *competing* skill engagement. Collects:
49-
50-
- the Claude ``Skill`` tool call's ``skill`` parameter, namespace-stripped
51-
via ``.split(":")[-1]`` (the SDK reports ``plugin:skill-name``); and
52-
- every ``skills/<name>/`` or ``skills\\<name>\\`` path segment appearing
53-
in any string parameter (the Codex / file-read signal — repo layout or
54-
the ``.agents/skills`` sandbox symlink).
55-
56-
Returns the (possibly empty) set of engaged skill names for this command.
47+
Detects both engagement signals so the criterion scores identically across
48+
agents, and returns the (possibly empty) set of engaged skill names:
49+
50+
- Claude: an explicit ``Skill`` tool call carries the skill in
51+
``parameters['skill']``, optionally namespaced (e.g.
52+
``plugin:uipath-agents``); the namespace is stripped via ``.split(":")[-1]``.
53+
- Codex (and any non-Claude agent): no ``Skill`` tool exists, so a skill is
54+
engaged by reading its files off disk via shell. Both the repo layout
55+
(``.../skills/<name>/...``) and the sandbox symlink
56+
(``.agents/skills/<name>/...``) contain the substring ``skills/<name>/``,
57+
matched here in any string parameter (Bash ``parameters['command']`` or a
58+
file-path parameter). The trailing separator required by ``_SKILL_PATH_RE``
59+
prevents prefix collisions (``uipath-agents`` vs ``uipath-agents-foo``).
60+
61+
Returning the full set (rather than a single-skill yes/no) lets callers detect
62+
a *competing* skill engagement.
5763
"""
5864
names: set[str] = set()
5965
if cmd.tool_name == "Skill":
@@ -66,21 +72,22 @@ def _engaged_skill_names(cmd: CommandTelemetry) -> set[str]:
6672
return names
6773

6874

69-
def _engaged_skill(cmd: CommandTelemetry, skill_name: str) -> bool:
70-
"""True when one command engaged ``skill_name`` — agent-agnostically.
75+
def _first_engaged_skill_names(turn_records: list[TurnRecord]) -> set[str]:
76+
"""Skills engaged by the FIRST command that engages any skill (else empty).
7177
72-
Claude: an explicit ``Skill`` tool call carries the skill in
73-
``parameters['skill']`` (optionally namespaced, e.g. ``plugin:uipath-agents``).
74-
75-
Codex (and any non-Claude agent): no ``Skill`` tool exists, so the skill is
76-
engaged by reading its files off disk via shell. Both the repo layout
77-
(``.../skills/<skill_name>/...``) and the sandbox symlink
78-
(``.agents/skills/<skill_name>/...``) contain the substring
79-
``skills/<skill_name>/``, which appears in the recorded command string
80-
(Bash ``parameters['command']``) or a file-path parameter. The trailing
81-
slash prevents prefix collisions (``uipath-agents`` vs ``uipath-agents-foo``).
78+
Activation measures which skill the agent selects *first*, so scoring keys off
79+
the first engaging command rather than "any command anywhere in the run". This
80+
keeps the final check and the live verdict consistent and prevents a later,
81+
incidental engagement (or a second skill invoked alongside the first) from
82+
being counted as a competing activation and mis-scored as a false positive.
83+
Commands are scanned in ``sequence_number`` order (turn records preserve it).
8284
"""
83-
return skill_name in _engaged_skill_names(cmd)
85+
for turn in turn_records:
86+
for cmd in turn.commands:
87+
engaged = _engaged_skill_names(cmd)
88+
if engaged:
89+
return engaged
90+
return set()
8491

8592

8693
@register_criterion
@@ -116,9 +123,10 @@ def _check_impl(
116123
error="turn_records not provided to checker",
117124
)
118125

119-
triggered: bool = any(
120-
_engaged_skill(cmd, criterion.skill_name) for turn in turn_records for cmd in turn.commands
121-
)
126+
# First-engagement policy (mirrors ``live_verdict``): the run is scored on
127+
# the FIRST skill the agent engages, so a second skill invoked alongside or
128+
# after it is not counted as a competing activation.
129+
triggered: bool = criterion.skill_name in _first_engaged_skill_names(turn_records)
122130
expected_yes: bool = criterion.expected_skill == criterion.skill_name
123131
score = 1.0 if triggered == expected_yes else 0.0
124132
observed = _YES if triggered else _NO
@@ -152,18 +160,16 @@ def live_verdict(
152160
153161
This covers the "wrong skill loads" case (a positive wrong signal) and
154162
negative rows (``expected_skill == ""`` -> any engagement of the target
155-
fails that criterion). It is a *policy* made consistent by stopping: the
156-
trajectory is frozen at the deciding event, so the standard checker on the
157-
truncated trajectory agrees with this verdict by construction.
163+
fails that criterion). ``_check_impl`` applies the SAME first-engagement
164+
policy on the full trajectory, so the live verdict and the authoritative
165+
score agree by construction — whether or not the run stopped early.
158166
"""
167+
engaged = _first_engaged_skill_names(turn_records)
168+
if not engaged:
169+
return "undecided"
159170
expected_yes = criterion.expected_skill == criterion.skill_name
160-
for turn in turn_records:
161-
for cmd in turn.commands:
162-
engaged = _engaged_skill_names(cmd)
163-
if engaged:
164-
observed_yes = criterion.skill_name in engaged
165-
return "pass" if observed_yes == expected_yes else "fail"
166-
return "undecided"
171+
observed_yes = criterion.skill_name in engaged
172+
return "pass" if observed_yes == expected_yes else "fail"
167173

168174
def aggregate(
169175
self,

src/coder_eval/formatting.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,12 @@ def format_messages(
8080
continue
8181

8282
type_name = type(msg).__name__
83-
# StreamEvent is a known SDK type used for token-delta capture
84-
# elsewhere; don't surface it as an "unhandled" warning here.
85-
if type_name == "StreamEvent":
83+
# Known, non-transcript SDK types: StreamEvent carries token deltas
84+
# (captured elsewhere) and RateLimitEvent is an out-of-band throttling
85+
# notice the SDK interleaves into the stream. Neither is transcript
86+
# content, so skip both rather than surfacing an "unhandled" warning.
87+
# Matched by name (not import) to stay robust across SDK versions.
88+
if type_name in ("StreamEvent", "RateLimitEvent"):
8689
continue
8790
if type_name not in warned_unknown_types:
8891
warned_unknown_types.add(type_name)

src/coder_eval/orchestration/early_stop.py

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,21 @@
99
never a silent no-op.
1010
* ``EarlyStopWatcher`` — the runtime observer. A ``StreamCallback`` composed
1111
into the agent's event stream that maintains its own ``EventCollector``,
12-
evaluates every armed criterion's ``live_verdict`` on each tool completion,
13-
applies the stop rule, and exposes ``should_stop()`` (the cooperative
14-
interrupt the agent polls) plus ``info`` (the ``EarlyStopInfo`` the
12+
evaluates every armed criterion's ``live_verdict`` on each tool *call* (and on
13+
its result), applies the stop rule, and exposes ``should_stop()`` (the
14+
cooperative interrupt the agent polls) plus ``info`` (the ``EarlyStopInfo`` the
1515
orchestrator records). Fail-open: a raising ``live_verdict`` disarms the
1616
watcher and degrades to a full run — a verdict bug can never cause a *false*
1717
early stop.
1818
19+
Deciding on the tool *call* (``ToolStartEvent``), not the result, is what makes
20+
the stop robust: for an observable criterion the verdict is fully determined by
21+
the call's inputs (which skill / which command), so the watcher can latch the
22+
instant the call is dispatched — before a cut-short turn (e.g. a timeout) can
23+
strip the result and leave the call unresolved. The agent polls ``should_stop``
24+
immediately after dispatching each message, so a latch on the call breaks the
25+
loop before the result message is ever pulled.
26+
1927
Live verdicts only *trigger* the stop; the authoritative scores always come
2028
from the standard ``check_all`` on the frozen trajectory after the cut.
2129
"""
@@ -28,12 +36,19 @@
2836

2937
from coder_eval.models import EarlyStopInfo, EarlyStopReason
3038
from coder_eval.streaming.collector import EventCollector
31-
from coder_eval.streaming.events import AgentStartEvent, StreamEvent, ToolEndEvent, ToolEndStatus, TurnStartEvent
39+
from coder_eval.streaming.events import (
40+
AgentStartEvent,
41+
StreamEvent,
42+
ToolEndEvent,
43+
ToolEndStatus,
44+
ToolStartEvent,
45+
TurnStartEvent,
46+
)
3247

3348

3449
if TYPE_CHECKING:
3550
from coder_eval.criteria.base import BaseCriterion, LiveVerdict
36-
from coder_eval.models import BaseSuccessCriterion, TaskDefinition
51+
from coder_eval.models import BaseSuccessCriterion, CommandTelemetry, TaskDefinition
3752

3853
# Armed pair the watcher holds: (criterion model, its checker). Lives in the
3954
# TYPE_CHECKING block (only annotations reference it, and those are lazy under
@@ -210,25 +225,35 @@ def for_task(cls, task: TaskDefinition) -> EarlyStopWatcher:
210225
# --- StreamCallback -------------------------------------------------- #
211226

212227
def on_event(self, event: StreamEvent) -> None:
213-
"""Forward the event to the internal collector; evaluate on tool completions.
228+
"""Forward the event to the internal collector; evaluate on each tool call.
214229
215230
Short-circuits once the decision is latched (fired or disarmed). Counts
216-
``TurnStartEvent`` for ``sdk_turn_index`` and ``ToolEndEvent`` for the
217-
1-based ``tool_call_index``, and stamps the wall-clock origin at the
218-
FIRST ``AgentStartEvent`` only (a retry's second AgentStart does not
231+
``TurnStartEvent`` for ``sdk_turn_index`` and each dispatched tool call
232+
for the 1-based ``tool_call_index``, and stamps the wall-clock origin at
233+
the FIRST ``AgentStartEvent`` only (a retry's second AgentStart does not
219234
reset it).
220235
221-
UNRESOLVED tool ends are ignored entirely (not collected, counted, or
222-
evaluated). ``_ClaudeTurnState.finalize`` force-closes orphaned tools as
223-
UNRESOLVED *after* the message loop has ended and the terminal status is
224-
already chosen (COMPLETED / TIMEOUT / crash) — those are not live tool
225-
activity and must not trip the stop rule, or a run that ran to completion
226-
(or timed out / crashed) would latch a false early stop (e.g. an
227-
unresolved ``Skill`` call counts as engagement because ``skill_triggered``
228-
ignores ``result_status``). A legitimate stop only ever fires on an
229-
OBSERVED tool result during the loop, so dropping unresolved ends can
230-
never suppress a real stop; it also keeps a crashed attempt's orphan tools
231-
out of the retry-persistent partial trajectory.
236+
The decision is evaluated on the tool *call* (``ToolStartEvent``): for an
237+
observable criterion the verdict is fully determined by the call's inputs,
238+
so latching here lets the agent's post-dispatch ``should_stop`` poll break
239+
the loop before a cut-short turn can strip the result. The call is not in
240+
the collector yet (it reduces commands from ``ToolEndEvent``), so it is
241+
passed to ``_evaluate`` as the in-flight command, reported at
242+
``tool_call_index + 1`` (it has no ``ToolEndEvent`` to count yet). The
243+
matching ``ToolEndEvent`` still evaluates, which covers a verdict that only
244+
becomes decidable once the result is known and is a no-op once a call has
245+
already latched the stop. ``tool_call_index`` is incremented on the
246+
resolved ``ToolEndEvent`` so it stays a count of completed tool calls.
247+
248+
UNRESOLVED tool ends are ignored entirely (not counted or evaluated).
249+
``_ClaudeTurnState.finalize`` force-closes orphaned tools as UNRESOLVED
250+
*after* the message loop has ended and the terminal status is already
251+
chosen (COMPLETED / TIMEOUT / crash) — those are not live tool activity
252+
and must not trip the stop rule, or a run that ran to completion (or timed
253+
out / crashed) without a real, in-loop decision would latch a false early
254+
stop. A legitimate stop always fires on the in-loop call, so dropping
255+
unresolved ends can never suppress a real stop; it also keeps a crashed
256+
attempt's orphan tools out of the retry-persistent partial trajectory.
232257
"""
233258
if self._info is not None or self._disarmed:
234259
return
@@ -237,13 +262,19 @@ def on_event(self, event: StreamEvent) -> None:
237262
self._started_monotonic = time.monotonic()
238263
elif isinstance(event, TurnStartEvent):
239264
self._sdk_turn_index += 1
265+
elif isinstance(event, ToolStartEvent):
266+
# Decide on the call, evaluating with it appended as the in-flight
267+
# command (it has no ToolEnd to count yet, so report it as +1).
268+
self._evaluate(in_flight=event.tool)
269+
return
240270
elif isinstance(event, ToolEndEvent):
241271
if event.status == ToolEndStatus.UNRESOLVED:
242272
return
243273
self._tool_call_index += 1
244-
self._collector.on_event(event)
245-
if isinstance(event, ToolEndEvent):
274+
self._collector.on_event(event)
246275
self._evaluate()
276+
return
277+
self._collector.on_event(event)
247278

248279
def should_stop(self) -> bool:
249280
"""The cooperative interrupt the agent polls after each dispatched message."""
@@ -261,8 +292,17 @@ def disarmed(self) -> bool:
261292

262293
# --- Stop rule -------------------------------------------------- #
263294

264-
def _evaluate(self) -> None:
265-
records = [self._collector.build_turn_record()]
295+
def _evaluate(self, in_flight: CommandTelemetry | None = None) -> None:
296+
record = self._collector.build_turn_record()
297+
if in_flight is not None:
298+
# The in-flight call has no ToolEnd yet, so the collector (which
299+
# reduces commands from ToolEnd) has not captured it. Append it and
300+
# re-sort by sequence so the verdict sees it in first-engagement order.
301+
record.commands = sorted([*record.commands, in_flight], key=lambda c: c.sequence_number)
302+
records = [record]
303+
# An in-flight call has not been counted by a ToolEnd yet, so report it as
304+
# the next (1-based) tool call.
305+
tool_call_index = self._tool_call_index + (1 if in_flight is not None else 0)
266306
verdicts: list[LiveVerdict] = []
267307
for criterion, checker in self._armed:
268308
try:
@@ -283,7 +323,7 @@ def _evaluate(self) -> None:
283323
# whose stop_when permits fail decides the run.
284324
for (criterion, _checker), verdict in zip(self._armed, verdicts, strict=True):
285325
if verdict == "fail" and criterion.stop_when in ("fail", "decided"):
286-
self._fire(EarlyStopReason.CRITERION_FAILED, criterion)
326+
self._fire(EarlyStopReason.CRITERION_FAILED, criterion, tool_call_index=tool_call_index)
287327
return
288328

289329
# Pass-stop: EVERY armed criterion live-passes AND each permits pass.
@@ -296,13 +336,13 @@ def _evaluate(self) -> None:
296336
for (criterion, _checker), verdict, prev in zip(self._armed, verdicts, self._prev_verdicts, strict=True):
297337
if verdict != prev:
298338
deciding = criterion
299-
self._fire(EarlyStopReason.CRITERION_PASSED, deciding)
339+
self._fire(EarlyStopReason.CRITERION_PASSED, deciding, tool_call_index=tool_call_index)
300340
return
301341

302342
# No stop this round — record the verdicts so the next round can detect flips.
303343
self._prev_verdicts = verdicts
304344

305-
def _fire(self, reason: EarlyStopReason, criterion: BaseSuccessCriterion) -> None:
345+
def _fire(self, reason: EarlyStopReason, criterion: BaseSuccessCriterion, *, tool_call_index: int) -> None:
306346
elapsed = 0.0
307347
if self._started_monotonic is not None:
308348
elapsed = max(time.monotonic() - self._started_monotonic, 0.0)
@@ -313,7 +353,7 @@ def _fire(self, reason: EarlyStopReason, criterion: BaseSuccessCriterion) -> Non
313353
deciding_criterion_description=criterion.description,
314354
armed_criteria=[f"{c.type}: {c.description}" for c, _ in self._armed],
315355
sdk_turn_index=self._sdk_turn_index,
316-
tool_call_index=self._tool_call_index,
356+
tool_call_index=tool_call_index,
317357
elapsed_seconds=elapsed,
318358
turns_remaining_at_stop=turns_remaining,
319359
)
@@ -323,6 +363,6 @@ def _fire(self, reason: EarlyStopReason, criterion: BaseSuccessCriterion) -> Non
323363
reason.value,
324364
criterion.type,
325365
self._sdk_turn_index,
326-
self._tool_call_index,
366+
tool_call_index,
327367
elapsed,
328368
)

tests/test_agent.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,18 @@ class BareMessage:
671671
assert "[ASSISTANT] Second response" in formatted
672672
assert formatted.count("[ASSISTANT]") == 2
673673

674+
# Test 12: RateLimitEvent — an out-of-band throttling notice the SDK
675+
# interleaves into the stream. It is not transcript content, so it is skipped
676+
# (matched by class name) and does NOT surface as an "unhandled" tag/warning.
677+
class RateLimitEvent:
678+
pass
679+
680+
formatted = agent._format_messages([RateLimitEvent()])
681+
assert formatted == "[No output]"
682+
# It also does not crowd out real content when interleaved.
683+
formatted = agent._format_messages([RateLimitEvent(), _make_assistant("still here")])
684+
assert formatted == "[ASSISTANT] still here"
685+
674686

675687
def test_format_messages_system_message_subclasses_are_filtered():
676688
"""Regression: SystemMessage SUBCLASSES (TaskStartedMessage, etc.) must

0 commit comments

Comments
 (0)