@@ -38,6 +38,86 @@ def _unsynthesized_tool_attrs(span: ReadableSpan) -> Mapping[str, Any] | None:
3838 return attrs
3939
4040
41+ def _match_key (actual_name : str , actual_id : str | None , expected_key : str ) -> bool :
42+ """True when `expected_key` matches either the actual call's `id` or `name`.
43+
44+ Eval-set criteria can be authored against either the tool's stable id or
45+ its display name; the scorers accept whichever the author used.
46+ """
47+ if actual_id is not None and expected_key == actual_id :
48+ return True
49+ return expected_key == actual_name
50+
51+
52+ def _calls_match (actual , expected ) -> bool :
53+ """True when an actual ToolCall/ToolOutput matches an expected one.
54+
55+ Prefers id-equality when both sides carry an id; otherwise falls back to
56+ name-equality. This keeps the legacy name-keyed behavior intact while
57+ making id-keyed eval-sets rename-safe.
58+ """
59+ if actual .id is not None and expected .id is not None :
60+ return actual .id == expected .id
61+ return actual .name == expected .name
62+
63+
64+ def _parse_tool_args (input_value : Any ) -> dict [str , Any ]:
65+ """Coerce a span's `input.value` into a dict of tool args.
66+
67+ Tries JSON first (handles `true`/`false`/`null` and double-quoted keys),
68+ falls back to `ast.literal_eval` for Python literal syntax (single-quoted
69+ dict repr). Returns `{}` for non-dict parsed values or any parse failure.
70+ """
71+ if isinstance (input_value , dict ):
72+ return input_value
73+ if not isinstance (input_value , str ):
74+ return {}
75+ try :
76+ try :
77+ parsed = json .loads (input_value )
78+ except ValueError : # JSONDecodeError is a ValueError
79+ parsed = ast .literal_eval (input_value )
80+ except (SyntaxError , ValueError ):
81+ return {}
82+ return parsed if isinstance (parsed , dict ) else {}
83+
84+
85+ def _tool_id_from (attrs : Mapping [str , Any ]) -> str | None :
86+ """Return the span's `tool.id` as a string when present, else None.
87+
88+ Uses `is not None` (not truthiness) so an id of 0 or '' isn't dropped.
89+ """
90+ tool_id = attrs .get ("tool.id" )
91+ return str (tool_id ) if tool_id is not None else None
92+
93+
94+ def _build_tool_call (span : ReadableSpan , include_args : bool ) -> ToolCall | None :
95+ """Build a ToolCall from a span, or None for synthesized / non-tool spans."""
96+ attrs = _unsynthesized_tool_attrs (span )
97+ if attrs is None :
98+ return None
99+ tool_name = str (attrs [TOOL_NAME_ATTR ])
100+ tool_id = _tool_id_from (attrs )
101+ args = _parse_tool_args (attrs .get ("input.value" , {})) if include_args else {}
102+ return ToolCall (name = tool_name , args = args , id = tool_id )
103+
104+
105+ def count_tool_calls_by_name_and_id (tool_calls : Sequence [ToolCall ]) -> dict [str , int ]:
106+ """Count tool calls under BOTH their name and id keys.
107+
108+ Each call contributes one unit to its name bucket and (when present and
109+ different) one unit to its id bucket. Lookups by either return the same
110+ count for that tool. This lets `tool_calls_count_score` honour eval-set
111+ criteria keyed by id with no signature change to the score function.
112+ """
113+ counts : dict [str , int ] = {}
114+ for c in tool_calls :
115+ counts [c .name ] = counts .get (c .name , 0 ) + 1
116+ if c .id is not None and c .id != c .name :
117+ counts [c .id ] = counts .get (c .id , 0 ) + 1
118+ return counts
119+
120+
41121def extract_tool_calls_names (spans : Sequence [ReadableSpan ]) -> list [str ]:
42122 """Extract the tool call names from execution spans IN ORDER.
43123
@@ -56,33 +136,23 @@ def extract_tool_calls_names(spans: Sequence[ReadableSpan]) -> list[str]:
56136 return tool_calls_names
57137
58138
59- def extract_tool_calls (spans : Sequence [ReadableSpan ]) -> list [ToolCall ]:
60- """Extract the tool calls from execution spans with their arguments.
139+ def extract_tool_calls (
140+ spans : Sequence [ReadableSpan ],
141+ include_args : bool = True ,
142+ ) -> list [ToolCall ]:
143+ """Extract the tool calls from execution spans.
61144
62145 Args:
63146 spans: List of ReadableSpan objects from agent execution.
147+ include_args: When False, skip parsing `input.value` and return
148+ ToolCall objects with `args={}`. Use for evaluators that only
149+ need name/id (count, order) — avoids a parse per span on large
150+ traces.
64151
65152 Returns:
66- Dict of tool calls with their arguments.
153+ List of tool calls with their arguments.
67154 """
68- tool_calls = []
69-
70- for span in spans :
71- if (attrs := _unsynthesized_tool_attrs (span )) is not None :
72- tool_name = str (attrs [TOOL_NAME_ATTR ])
73- try :
74- input_value : Any = attrs .get ("input.value" , {})
75- if isinstance (input_value , str ):
76- arguments = ast .literal_eval (input_value )
77- elif isinstance (input_value , dict ):
78- arguments = input_value
79- else :
80- arguments = {}
81- tool_calls .append (ToolCall (name = tool_name , args = arguments ))
82- except (json .JSONDecodeError , SyntaxError , ValueError ):
83- tool_calls .append (ToolCall (name = tool_name , args = {}))
84-
85- return tool_calls
155+ return [c for s in spans if (c := _build_tool_call (s , include_args )) is not None ]
86156
87157
88158def extract_tool_calls_outputs (spans : Sequence [ReadableSpan ]) -> list [ToolOutput ]:
@@ -101,6 +171,7 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput
101171 for span in spans :
102172 if (attrs := _unsynthesized_tool_attrs (span )) is not None :
103173 tool_name = str (attrs [TOOL_NAME_ATTR ])
174+ tool_id = _tool_id_from (attrs )
104175 output = attrs .get ("output.value" , "" )
105176 final_output = ""
106177
@@ -131,8 +202,9 @@ def extract_tool_calls_outputs(spans: Sequence[ReadableSpan]) -> list[ToolOutput
131202
132203 tool_calls_outputs .append (
133204 ToolOutput (
134- name = str ( tool_name ) ,
205+ name = tool_name ,
135206 output = str (final_output ) if final_output else "" ,
207+ id = tool_id ,
136208 )
137209 )
138210 return tool_calls_outputs
@@ -209,6 +281,105 @@ def tool_calls_order_score(
209281 return lcs_length / n , justification
210282
211283
284+ def _strict_order_score (
285+ actual : Sequence [ToolCall ],
286+ expected : Sequence [str ],
287+ justification : dict [str , Any ],
288+ ) -> tuple [float , dict [str , Any ]]:
289+ """Strict-mode evaluation — only an exact positional match scores 1.0."""
290+ if len (actual ) != len (expected ):
291+ return 0.0 , justification
292+ for i , key in enumerate (expected ):
293+ if not _match_key (actual [i ].name , actual [i ].id , key ):
294+ return 0.0 , justification
295+ justification ["lcs" ] = list (expected )
296+ return 1.0 , justification
297+
298+
299+ def _build_lcs_dp (
300+ actual : Sequence [ToolCall ], expected : Sequence [str ]
301+ ) -> list [list [int ]]:
302+ """Fill the LCS dynamic-programming table for id-aware matching."""
303+ m , n = len (actual ), len (expected )
304+ dp = [[0 ] * (n + 1 ) for _ in range (m + 1 )]
305+ for i in range (1 , m + 1 ):
306+ for j in range (1 , n + 1 ):
307+ if _match_key (actual [i - 1 ].name , actual [i - 1 ].id , expected [j - 1 ]):
308+ dp [i ][j ] = dp [i - 1 ][j - 1 ] + 1
309+ else :
310+ dp [i ][j ] = max (dp [i - 1 ][j ], dp [i ][j - 1 ])
311+ return dp
312+
313+
314+ def _reconstruct_lcs (
315+ actual : Sequence [ToolCall ],
316+ expected : Sequence [str ],
317+ dp : list [list [int ]],
318+ ) -> list [str ]:
319+ """Walk the DP table backwards to recover the LCS as a list of expected keys."""
320+ lcs : list [str ] = []
321+ i , j = len (actual ), len (expected )
322+ while i > 0 and j > 0 :
323+ if _match_key (actual [i - 1 ].name , actual [i - 1 ].id , expected [j - 1 ]):
324+ lcs .append (expected [j - 1 ])
325+ i -= 1
326+ j -= 1
327+ elif dp [i - 1 ][j ] > dp [i ][j - 1 ]:
328+ i -= 1
329+ else :
330+ j -= 1
331+ lcs .reverse ()
332+ return lcs
333+
334+
335+ def tool_calls_order_score_with_ids (
336+ actual_tool_calls : Sequence [ToolCall ],
337+ expected_tool_calls_keys : Sequence [str ],
338+ strict : bool = False ,
339+ ) -> tuple [float , dict [str , Any ]]:
340+ """LCS-based ordering score with id-aware matching.
341+
342+ Identical scoring algorithm to `tool_calls_order_score`, but each expected
343+ key string is allowed to match either the actual call's `id` or its
344+ `name`. Use this when eval-set criteria may be authored against the
345+ stable tool id so renames of `name` don't silently break ordering checks.
346+
347+ Args:
348+ actual_tool_calls: ToolCall objects in the actual order. Each may carry
349+ an `id` from the runtime's `tool.id` span attribute.
350+ expected_tool_calls_keys: List of names OR ids in the expected order.
351+ strict: When True, only perfect matches score above 0.
352+
353+ Returns:
354+ Same shape as `tool_calls_order_score`. The "actual" justification
355+ renders the resolved match-key sequence (id when available, else name)
356+ so the LCS reconstruction reads clearly.
357+ """
358+ actual_keys : list [str ] = [
359+ (c .id if c .id is not None else c .name ) for c in actual_tool_calls
360+ ]
361+ justification : dict [str , Any ] = {
362+ "actual" : str (list (actual_keys )),
363+ "expected" : str (list (expected_tool_calls_keys )),
364+ "lcs" : [],
365+ }
366+
367+ if not expected_tool_calls_keys and not actual_tool_calls :
368+ return 1.0 , justification
369+ if not expected_tool_calls_keys or not actual_tool_calls :
370+ return 0.0 , justification
371+
372+ if strict :
373+ return _strict_order_score (
374+ actual_tool_calls , expected_tool_calls_keys , justification
375+ )
376+
377+ dp = _build_lcs_dp (actual_tool_calls , expected_tool_calls_keys )
378+ lcs = _reconstruct_lcs (actual_tool_calls , expected_tool_calls_keys , dp )
379+ justification ["lcs" ] = lcs
380+ return len (lcs ) / len (expected_tool_calls_keys ), justification
381+
382+
212383def tool_calls_count_score (
213384 actual_tool_calls_count : Mapping [str , int ],
214385 expected_tool_calls_count : Mapping [str , tuple [str , int ]],
@@ -323,7 +494,7 @@ def tool_calls_args_score(
323494
324495 for expected_tool_call in expected_tool_calls :
325496 for idx , call in enumerate (actual_tool_calls ):
326- if call . name == expected_tool_call . name and idx not in visited :
497+ if _calls_match ( call , expected_tool_call ) and idx not in visited :
327498 # Get or initialize counter for this tool name
328499 tool_counters [call .name ] = tool_counters .get (call .name , 0 )
329500 tool_key = f"{ call .name } _{ tool_counters [call .name ]} "
@@ -415,7 +586,7 @@ def tool_calls_output_score(
415586 for idx , actual_tool_call_output in enumerate (actual_tool_calls_outputs ):
416587 if idx in visited :
417588 continue
418- if actual_tool_call_output . name == expected_tool_call_output . name :
589+ if _calls_match ( actual_tool_call_output , expected_tool_call_output ) :
419590 # Get or initialize counter for this tool name
420591 tool_counters [actual_tool_call_output .name ] = tool_counters .get (
421592 actual_tool_call_output .name , 0
0 commit comments