Skip to content

Commit b722091

Browse files
ajay-kesavanclaude
andauthored
feat(eval): match tool calls by id with name fallback (#1725)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7604026 commit b722091

7 files changed

Lines changed: 468 additions & 38 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.11.3"
3+
version = "2.11.4"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py

Lines changed: 195 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
41121
def 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

88158
def 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+
212383
def 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

packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"""Tool call count evaluator for validating expected tool usage patterns."""
22

3-
from collections import Counter
4-
53
from .._helpers.evaluators_helpers import (
6-
extract_tool_calls_names,
4+
count_tool_calls_by_name_and_id,
5+
extract_tool_calls,
76
tool_calls_count_score,
87
)
98
from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult
@@ -72,8 +71,8 @@ async def evaluate(
7271
Returns:
7372
EvaluationResult: Boolean result indicating correct tool call order (True/False)
7473
"""
75-
tool_calls_count = Counter(
76-
extract_tool_calls_names(agent_execution.agent_trace)
74+
tool_calls_count = count_tool_calls_by_name_and_id(
75+
extract_tool_calls(agent_execution.agent_trace, include_args=False)
7776
)
7877
score, justification = tool_calls_count_score(
7978
tool_calls_count,

packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Tool call order evaluator for validating correct sequence of tool calls."""
22

33
from .._helpers.evaluators_helpers import (
4-
extract_tool_calls_names,
5-
tool_calls_order_score,
4+
extract_tool_calls,
5+
tool_calls_order_score_with_ids,
66
)
77
from ..models import AgentExecution, EvaluationResult, NumericEvaluationResult
88
from ..models.models import EvaluatorType
@@ -69,9 +69,11 @@ async def evaluate(
6969
Returns:
7070
EvaluationResult: Boolean result indicating correct tool call order (True/False)
7171
"""
72-
tool_calls_order = extract_tool_calls_names(agent_execution.agent_trace)
73-
score, justification = tool_calls_order_score(
74-
tool_calls_order,
72+
actual_calls = extract_tool_calls(
73+
agent_execution.agent_trace, include_args=False
74+
)
75+
score, justification = tool_calls_order_score_with_ids(
76+
actual_calls,
7577
evaluation_criteria.tool_calls_order,
7678
self.evaluator_config.strict,
7779
)

packages/uipath/src/uipath/eval/models/models.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -303,17 +303,29 @@ class EvaluatorType(str, Enum):
303303

304304

305305
class ToolCall(BaseModel):
306-
"""Represents a tool call with its arguments."""
306+
"""Represents a tool call with its arguments.
307+
308+
`id` is the stable identifier from the tool's resource definition (e.g. a
309+
UUID from `bindings.json`). When present on both the actual call and the
310+
expected criterion, scorers match by `id` so a rename of `name` does not
311+
break eval sets. When `id` is absent on either side, scorers fall back to
312+
matching by `name` (the legacy behavior).
313+
"""
307314

308315
name: str
309316
args: dict[str, Any]
317+
id: str | None = None
310318

311319

312320
class ToolOutput(BaseModel):
313-
"""Represents a tool output with its output."""
321+
"""Represents a tool output with its output.
322+
323+
See `ToolCall.id` for the id semantics.
324+
"""
314325

315326
name: str
316327
output: str
328+
id: str | None = None
317329

318330

319331
class UiPathEvaluationErrorCategory(str, Enum):

0 commit comments

Comments
 (0)