Skip to content

Commit 4dc087c

Browse files
committed
fix(evals): reject duplicate expected tools, add machine-readable expected_outcomes contract
- validate_golden_sample rejects duplicate expected_tools names so a hand-edited golden file cannot inflate hit rates (["quote", "quote", "history"] with one quote call must read 1/2, not 2/3); scoring normalizes duplicates to first occurrences as a defense - GoldenSample gains expected_outcomes (guarded / cached / retry): a required outcome not observed in the log is reported as a violation, so the 600036 guard/retry sample now actually detects a trajectory that skips the out-of-scope call it describes - 13 regression tests (71 total), flake8/black clean
1 parent d489684 commit 4dc087c

4 files changed

Lines changed: 256 additions & 7 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
4545
- [修复] 阻止任意更新的非 bundled 指数候选(含 legacy `static` 子集)在 remote 缺失/损坏时以 active-index 子集覆盖 bundled baseline:所有非 bundled 候选必须为 bundled active-index canonical 集合的合法超集,否则回退 bundled 并记录 WARNING。
4646
- [新功能] 桌面端全局右上角增加更新入口,与设置页共用更新状态;普通浏览器 WebUI 不展示,且不会在挂载时重复触发后台检查。
4747
- [修复] 桌面端右上角更新入口与设置页共用检查中状态,避免一侧检查时另一侧仍可重复触发 GitHub Releases 检查;主进程手动检查路径同步增加 in-flight 防重。
48-
- [新功能] 新增 agent trajectory 评估管线(`evals/agent_trajectory`):纯函数指标模块消费 `tool_calls_log` 计算工具命中率、冗余与缓存调用、失败重试与步数效率(含 `golden_samples.json` 3 个样例与单元测试);不修改 `src/` 执行语义、不接入 CI 阻断门、不新增运行时配置。
48+
- [新功能] 新增 agent trajectory 评估管线(`evals/agent_trajectory`):纯函数指标模块消费 `tool_calls_log` 计算工具命中率、冗余与缓存调用、失败重试与步数效率(含 `golden_samples.json` 3 个样例、`expected_outcomes` 轨迹特征断言与单元测试);不修改 `src/` 执行语义、不接入 CI 阻断门、不新增运行时配置。
4949

5050
## [3.31.0] - 2026-08-23
5151

evals/agent_trajectory/golden_samples.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"skills": [],
2525
"expected_tools": ["get_stock_info", "get_daily_history"],
2626
"allowed_max_steps": 10,
27-
"allow_optional_tools": true
27+
"allow_optional_tools": true,
28+
"expected_outcomes": ["guarded", "retry"]
2829
}
2930
]

evals/agent_trajectory/metrics.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@
4646
success`` counts exactly one retry, not two.
4747
* ``cached_calls``: entries with ``cached=True`` (runner semantics: reuse of a
4848
non-retriable failure result).
49+
* ``expected_outcomes``: machine-readable trajectory features a golden sample
50+
may require, from the fixed vocabulary ``guarded`` / ``cached`` / ``retry``.
51+
``guarded`` is observed when any entry carries ``guarded=True`` (the stock-
52+
scope guard interception), ``cached`` when any entry carries ``cached=True``
53+
and ``retry`` when at least one retry is counted per the contract above. A
54+
required outcome that is not observed is reported as a violation, so a
55+
golden sample that declares guard / cache / retry expectations cannot be
56+
passed by a trajectory that skips the behaviour it describes.
4957
* ``max_steps_touched``: the log does not carry ``max_steps`` itself, so this
5058
is the conservative heuristic ``max(step) >= golden.allowed_max_steps`` —
5159
a proxy for "the run reached the step budget", not proof of the loop
@@ -62,13 +70,21 @@
6270
from pathlib import Path
6371
from typing import Any, Dict, Iterable, List, Optional
6472

73+
#: Machine-readable trajectory features a golden sample may require of a log
74+
#: (``guarded`` = a guarded call, ``cached`` = a cached call, ``retry`` = at
75+
#: least one retry). See the "Metric semantics" section of the module docstring.
76+
EXPECTED_OUTCOME_TAGS = ("guarded", "cached", "retry")
77+
6578

6679
@dataclass
6780
class GoldenSample:
6881
"""Expected trajectory for one evaluation task.
6982
7083
``expected_tools`` are the tool names the agent should call; tools outside
7184
this set are tolerated only when ``allow_optional_tools`` is true.
85+
``expected_outcomes`` are required trajectory features from the vocabulary
86+
``guarded`` / ``cached`` / ``retry`` (see the module docstring); every
87+
declared outcome must be observable in the log.
7288
"""
7389

7490
id: str
@@ -78,6 +94,7 @@ class GoldenSample:
7894
skills: List[str] = field(default_factory=list)
7995
allowed_max_steps: int = 10
8096
allow_optional_tools: bool = True
97+
expected_outcomes: List[str] = field(default_factory=list)
8198

8299

83100
@dataclass
@@ -150,6 +167,7 @@ def compute_trajectory_metrics(
150167
key_retries: Dict[tuple, int] = {}
151168
failed_calls = 0
152169
cached_calls = 0
170+
guarded_calls = 0
153171
redundant_calls = 0
154172
distinct_steps = 0
155173
max_step = 0
@@ -171,6 +189,8 @@ def compute_trajectory_metrics(
171189
failed_calls += 1
172190
if entry.get("cached"):
173191
cached_calls += 1
192+
if entry.get("guarded"):
193+
guarded_calls += 1
174194

175195
key = (tool, _args_key(_entry_arguments(entry)))
176196
if key_counts.get(key, 0):
@@ -196,18 +216,26 @@ def compute_trajectory_metrics(
196216
distinct_steps = max(distinct_steps, total)
197217
max_step = max(max_step, total)
198218

219+
retries = sum(key_retries.values())
220+
violations: List[str] = []
221+
199222
if isinstance(golden.expected_tools, list):
200223
expected = [t for t in golden.expected_tools if isinstance(t, str) and t]
201224
else:
202225
# Defend against hand-edited samples passing a bare string:
203226
# validation rejects it at load time, but scoring must not misparse
204227
# it into per-character tool names either.
205228
expected = []
229+
# A hand-edited sample may repeat a tool name; normalize to first
230+
# occurrences before scoring so the hit rate cannot be inflated
231+
# (["quote", "quote"] with one quote call must read 1/2, not 2/3).
232+
if len(set(expected)) != len(expected):
233+
violations.append("expected_tools contains duplicate names")
234+
expected = list(dict.fromkeys(expected))
206235
missing_expected = [t for t in expected if t not in used_tools]
207236
expected_hit_rate = (len(expected) - len(missing_expected)) / len(expected) if expected else 0.0
208237
optional_tools_used = [t for t in used_tools if t not in expected]
209238

210-
violations: List[str] = []
211239
if not expected:
212240
violations.append("golden sample has no expected_tools")
213241

@@ -221,6 +249,31 @@ def compute_trajectory_metrics(
221249
if optional_tools_used and not optional_allowed:
222250
violations.append(f"optional tools used but not allowed: {', '.join(optional_tools_used)}")
223251

252+
# Machine-readable outcome expectations: each declared tag must be
253+
# observable in the log, otherwise the sample's guard / cache / retry
254+
# contract is violated (see the module docstring).
255+
if not isinstance(golden.expected_outcomes, list):
256+
violations.append("expected_outcomes must be a list of outcome tags")
257+
outcomes: List[str] = []
258+
else:
259+
outcomes = [t for t in golden.expected_outcomes if isinstance(t, str) and t]
260+
if len(set(outcomes)) != len(outcomes):
261+
violations.append("expected_outcomes contains duplicate tags")
262+
outcomes = list(dict.fromkeys(outcomes))
263+
unknown = [t for t in outcomes if t not in EXPECTED_OUTCOME_TAGS]
264+
if unknown:
265+
violations.append(f"unknown expected outcome tags: {', '.join(unknown)}")
266+
observed = []
267+
if guarded_calls:
268+
observed.append("guarded")
269+
if cached_calls:
270+
observed.append("cached")
271+
if retries:
272+
observed.append("retry")
273+
missing_outcomes = [t for t in outcomes if t in EXPECTED_OUTCOME_TAGS and t not in observed]
274+
if missing_outcomes:
275+
violations.append(f"expected outcomes not observed: {', '.join(missing_outcomes)}")
276+
224277
limit = golden.allowed_max_steps
225278
if isinstance(limit, bool) or not isinstance(limit, int):
226279
violations.append("allowed_max_steps is not an integer")
@@ -237,7 +290,7 @@ def compute_trajectory_metrics(
237290
redundant_calls=redundant_calls,
238291
cached_calls=cached_calls,
239292
failed_calls=failed_calls,
240-
retries=sum(key_retries.values()),
293+
retries=retries,
241294
distinct_steps=distinct_steps,
242295
max_steps_touched=max_steps_touched,
243296
violations=violations,
@@ -323,9 +376,11 @@ def validate_golden_sample(
323376
324377
Field *types* are part of the structural contract — hand-edited golden
325378
JSON must fail with a clear message instead of crashing or silently
326-
passing: text fields must be strings, ``expected_tools``/``skills`` must
327-
be lists, ``allowed_max_steps`` an integer and ``allow_optional_tools`` a
328-
boolean.
379+
passing: text fields must be strings, ``expected_tools``/``skills`` and
380+
``expected_outcomes`` must be lists, ``allowed_max_steps`` an integer and
381+
``allow_optional_tools`` a boolean. ``expected_tools`` and
382+
``expected_outcomes`` must be duplicate-free, and outcome tags must come
383+
from the fixed vocabulary ``guarded`` / ``cached`` / ``retry``.
329384
"""
330385
issues: List[str] = []
331386
known = set(known_tool_names) if known_tool_names is not None else None
@@ -341,6 +396,8 @@ def validate_golden_sample(
341396
issues.append("expected_tools must be a non-empty list")
342397
elif any(not isinstance(t, str) or not t.strip() for t in sample.expected_tools):
343398
issues.append("expected_tools must contain only non-empty strings")
399+
elif len(set(sample.expected_tools)) != len(sample.expected_tools):
400+
issues.append("expected_tools must not contain duplicate names")
344401
elif known is not None:
345402
# Only reachable when expected_tools is a non-empty list of non-empty
346403
# strings, so malformed values can never crash the membership check.
@@ -357,4 +414,14 @@ def validate_golden_sample(
357414
issues.append("allowed_max_steps must be >= 1")
358415
if not isinstance(sample.allow_optional_tools, bool):
359416
issues.append("allow_optional_tools must be a boolean")
417+
if not isinstance(sample.expected_outcomes, list):
418+
issues.append("expected_outcomes must be a list of outcome tags")
419+
elif any(not isinstance(t, str) or not t.strip() for t in sample.expected_outcomes):
420+
issues.append("expected_outcomes must contain only non-empty strings")
421+
elif len(set(sample.expected_outcomes)) != len(sample.expected_outcomes):
422+
issues.append("expected_outcomes must not contain duplicate tags")
423+
else:
424+
unknown = [t for t in sample.expected_outcomes if t not in EXPECTED_OUTCOME_TAGS]
425+
if unknown:
426+
issues.append(f"unknown expected_outcomes: {', '.join(unknown)}")
360427
return issues

0 commit comments

Comments
 (0)