tabagent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. tabagent/__init__.py +57 -0
  2. tabagent/adapters/__init__.py +68 -0
  3. tabagent/adapters/common.py +138 -0
  4. tabagent/adapters/cuga.py +623 -0
  5. tabagent/adapters/detect.py +182 -0
  6. tabagent/adapters/live.py +111 -0
  7. tabagent/adapters/logfiles.py +728 -0
  8. tabagent/adapters/openai_messages.py +672 -0
  9. tabagent/adapters/tau2.py +195 -0
  10. tabagent/agent.py +219 -0
  11. tabagent/api.py +1016 -0
  12. tabagent/artifact.py +262 -0
  13. tabagent/auto.py +260 -0
  14. tabagent/baselines.py +62 -0
  15. tabagent/cards/__init__.py +1 -0
  16. tabagent/cards/appworld_subagent_routing.yaml +77 -0
  17. tabagent/cards/llmrouterbench_model_routing.yaml +113 -0
  18. tabagent/cards/mind2web_element.yaml +88 -0
  19. tabagent/cards/mind2web_operation.yaml +55 -0
  20. tabagent/cards/skillsbench_skill_gating.yaml +57 -0
  21. tabagent/cards/skillsbench_skill_gating_enriched.yaml +81 -0
  22. tabagent/cards/skillsbench_skill_select.yaml +61 -0
  23. tabagent/cards/skillsbench_skill_select_noprior.yaml +53 -0
  24. tabagent/cards/tau2_domain_shortlisting.yaml +85 -0
  25. tabagent/cards/tau2_tool_shortlisting.yaml +58 -0
  26. tabagent/cli.py +851 -0
  27. tabagent/datasets/__init__.py +138 -0
  28. tabagent/datasets/_core.py +171 -0
  29. tabagent/datasets/appworld.py +207 -0
  30. tabagent/datasets/folds/appworld_subagent_routing.json +590 -0
  31. tabagent/datasets/folds/llmrouterbench_tau2.json +283 -0
  32. tabagent/datasets/folds/mind2web_element_150.json +155 -0
  33. tabagent/datasets/folds/mind2web_groupkfold5.json +1014 -0
  34. tabagent/datasets/folds/mind2web_split.json +1014 -0
  35. tabagent/datasets/folds/skillsbench_skill_gating.json +89 -0
  36. tabagent/datasets/folds/skillsbench_skill_gating_enriched.json +92 -0
  37. tabagent/datasets/folds/skillsbench_skill_select.json +92 -0
  38. tabagent/datasets/folds/tau2_agents.json +599 -0
  39. tabagent/datasets/folds/tau2_airline.json +29 -0
  40. tabagent/datasets/folds/tau2_all_domains.json +144 -0
  41. tabagent/datasets/folds/tau2_retail.json +59 -0
  42. tabagent/datasets/folds/tau2_telecom.json +66 -0
  43. tabagent/datasets/llmrouterbench.py +249 -0
  44. tabagent/datasets/manifests/llmrouterbench_tau2_dense.json +102 -0
  45. tabagent/datasets/manifests/mind2web_train.json +71 -0
  46. tabagent/datasets/manifests/skillsbench_official_selected.json +328 -0
  47. tabagent/datasets/manifests/tau2_official_final.json +185 -0
  48. tabagent/datasets/manifests/tau2_sft_final.json +17 -0
  49. tabagent/datasets/mind2web.py +342 -0
  50. tabagent/datasets/skillsbench.py +207 -0
  51. tabagent/datasets/tau2.py +361 -0
  52. tabagent/defaults.py +23 -0
  53. tabagent/drift.py +701 -0
  54. tabagent/evaluation/__init__.py +36 -0
  55. tabagent/evaluation/cv.py +220 -0
  56. tabagent/evaluation/discovery.py +320 -0
  57. tabagent/evaluation/metrics.py +266 -0
  58. tabagent/evaluation/stats.py +116 -0
  59. tabagent/hub.py +44 -0
  60. tabagent/integrations/__init__.py +1 -0
  61. tabagent/integrations/cuga.py +186 -0
  62. tabagent/integrations/openai_tools.py +232 -0
  63. tabagent/prometheus.py +196 -0
  64. tabagent/py.typed +0 -0
  65. tabagent/report.py +237 -0
  66. tabagent/scanner.py +1113 -0
  67. tabagent/serve.py +873 -0
  68. tabagent/sklearn.py +353 -0
  69. tabagent/tabhead.py +427 -0
  70. tabagent/table.py +178 -0
  71. tabagent/tabschema/__init__.py +22 -0
  72. tabagent/tabschema/agents.py +1304 -0
  73. tabagent/tabschema/audit.py +408 -0
  74. tabagent/tabschema/card.py +392 -0
  75. tabagent/tabschema/checks.py +307 -0
  76. tabagent/tabschema/compile.py +1811 -0
  77. tabagent/tabschema/estimate.py +326 -0
  78. tabagent/tabschema/features.py +114 -0
  79. tabagent/tabschema/fitted.py +474 -0
  80. tabagent/tabschema/judges.py +203 -0
  81. tabagent/tabschema/llm.py +908 -0
  82. tabagent/tabschema/orchestrator.py +1823 -0
  83. tabagent/tabschema/prompts/__init__.py +80 -0
  84. tabagent/tabschema/prompts/code_extractor.txt +93 -0
  85. tabagent/tabschema/prompts/code_validator.txt +39 -0
  86. tabagent/tabschema/prompts/feature_aggregator.txt +37 -0
  87. tabagent/tabschema/prompts/feature_analyzer.txt +35 -0
  88. tabagent/tabschema/prompts/judge_generality.txt +51 -0
  89. tabagent/tabschema/prompts/judge_impact.txt +52 -0
  90. tabagent/tabschema/prompts/judge_relevance.txt +49 -0
  91. tabagent/tabschema/prompts/meta_judge.txt +65 -0
  92. tabagent/tabschema/prompts/output_auditor.txt +34 -0
  93. tabagent/tabschema/prompts/tabagent_analyzer_candidates.txt +6 -0
  94. tabagent/tabschema/prompts/tabagent_auditor_candidates.txt +5 -0
  95. tabagent/tabschema/prompts/tabagent_candidate_note.txt +3 -0
  96. tabagent/tabschema/prompts/tabagent_extractor.txt +3 -0
  97. tabagent/tabschema/prompts/tabagent_extractor_candidates.txt +9 -0
  98. tabagent/tabschema/prompts/tabagent_validator_candidates.txt +9 -0
  99. tabagent/tabschema/runlog.py +626 -0
  100. tabagent/tabschema/runstore.py +267 -0
  101. tabagent/tabschema/sampling.py +143 -0
  102. tabagent/tabschema/sandbox.py +623 -0
  103. tabagent/tabschema/trajectory.py +989 -0
  104. tabagent/tabschema/usefulness.py +193 -0
  105. tabagent/tabschema/verify.py +1303 -0
  106. tabagent/tabschema/warm.py +1073 -0
  107. tabagent/tabsynth.py +344 -0
  108. tabagent-0.1.0.dist-info/METADATA +313 -0
  109. tabagent-0.1.0.dist-info/RECORD +113 -0
  110. tabagent-0.1.0.dist-info/WHEEL +4 -0
  111. tabagent-0.1.0.dist-info/entry_points.txt +2 -0
  112. tabagent-0.1.0.dist-info/licenses/LICENSE +201 -0
  113. tabagent-0.1.0.dist-info/licenses/NOTICE +39 -0
@@ -0,0 +1,623 @@
1
+ """CUGA agent trajectories (github.com/cuga-project/cuga-agent, Apache-2.0).
2
+
3
+ A CUGA trajectory file is ``{"intent", "task_id", "steps", "score", "eval", ...}``; each step names the agent
4
+ node that produced it (``TaskAnalyzerAgent``, ``PlanControllerAgent``, ``APIPlannerAgent``,
5
+ ``ShortlisterAgent``, ``CodeAgent``, ``FinalAnswerAgent``, ...) and carries its ``prompts`` (role/value) and
6
+ ``data`` (the node's output, usually JSON). Evaluated runs end with an ``EvaluationResult`` step.
7
+
8
+ Two decisions are built in, the two CUGA components the paper replaces. The API shortlister (the paper's CUGA
9
+ tool shortlisting, a ranking): at each
10
+ ``ShortlisterAgent`` step the candidates are the APIs offered to it
11
+ (the ``Available APIs`` block of its prompt, else a catalog given here, e.g. CUGA API docs, else the APIs
12
+ seen for that app across the loaded runs), and the labels are the APIs the ``CodeAgent`` then called
13
+ (``call_api(app, api)``, app-prefixed as ``<app>_<api>``) before the next shortlisting. The prefix is the
14
+ trajectory truncated before the step, with only the ``intent`` and ``dataset_name`` fields (``task_keys``;
15
+ never the ``task_id``, the cross-validation group, nor ``score``, ``eval`` or ``actions_count``, a post-hoc
16
+ count) and without the ``EvaluationResult`` step.
17
+
18
+ The app analyzer (``TaskAnalyzerAgent``, the paper's app selection, a multi-label decision: onboard it
19
+ with ``kind="multilabel"``, which ``onboard`` and ``scan`` pick by themselves): the candidates are the apps
20
+ listed in its prompt's input (``applications``, else the apps seen across the loaded runs) and the labels
21
+ are the apps the successful run then called (the paper's G(t), the candidates the run used).
22
+ ``app_labels="offered"`` labels the apps the analyzer itself chose instead, as the paper's legacy AppWorld
23
+ data did; on the successful runs checked, the two almost always agree.
24
+
25
+ Other components work with ``candidates_fn`` and ``labels_fn``.
26
+
27
+ For ``tabagent.scan``, every step of an agent node (or with recorded prompts) is an LLM call, one per recorded
28
+ generation: the shortlister's choices are the APIs it returned, other nodes report the categorical fields of
29
+ their JSON output, and the code, answer and chat nodes generate free text. CUGA records no tokens, cost or
30
+ latency per step (its ``results.json`` holds per-task totals only), so a scan reports them as not recorded.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import ast
36
+ import dataclasses
37
+ import json
38
+ import re
39
+ from collections import Counter
40
+ from collections.abc import Callable, Iterable
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ from tabagent.adapters.common import parse_catalog
45
+ from tabagent.tabschema.trajectory import (
46
+ Candidate,
47
+ DecisionPoint,
48
+ GenericJsonAdapter,
49
+ LLMCall,
50
+ Trajectory,
51
+ output_fields,
52
+ parse_json,
53
+ read_outcome,
54
+ read_usage,
55
+ spec_text,
56
+ step_name,
57
+ steps_of,
58
+ )
59
+
60
+ SHORTLISTER = "ShortlisterAgent"
61
+ ANALYZER = "TaskAnalyzerAgent"
62
+ #: Top-level fields of a CUGA trajectory that its prefixes keep: what describes the task, never the task id.
63
+ TASK_KEYS = ("intent", "dataset_name")
64
+ CUGA_STEPS = frozenset(
65
+ {
66
+ "TaskAnalyzerAgent",
67
+ "TaskDecompositionAgent",
68
+ "PlanControllerAgent",
69
+ "APIPlannerAgent",
70
+ "APICodePlannerAgent",
71
+ "ShortlisterAgent",
72
+ "CodeAgent",
73
+ "FinalAnswerAgent",
74
+ "BrowserPlannerAgent",
75
+ "ActionAgent",
76
+ "QaAgent",
77
+ "ChatAgent",
78
+ "ReuseAgent",
79
+ "Assistant_code", # CUGA's CodeAct mode
80
+ "Raw_Assistant_Response",
81
+ }
82
+ )
83
+ #: Nodes whose output is free text (code, answers, chat), whatever its shape.
84
+ FREE_TEXT_STEPS = frozenset(
85
+ {"CodeAgent", "FinalAnswerAgent", "QaAgent", "ChatAgent", "Assistant_code", "Raw_Assistant_Response"}
86
+ )
87
+ _GENERATION_ROLES = frozenset({"assistant", "generation", "ai"})
88
+ _AVAILABLE = re.compile(r'`Available APIs`:\s*"""(.*?)"""', re.S)
89
+ _APP = re.compile(r"`Current Application`:\s*([^\s`]+)")
90
+ _APPS = re.compile(r"""['"](?:applications|available_apps)['"]\s*:\s*\[""")
91
+ _Q = r"""\\?["']""" # a quote, possibly escaped when the code sits inside a JSON string
92
+ _CALL = re.compile(
93
+ rf"call_api\(\s*(?:(\w+)\s*=\s*)?{_Q}([^\"'\\]+){_Q}\s*,\s*(?:(\w+)\s*=\s*)?{_Q}([^\"'\\]+){_Q}"
94
+ )
95
+ _COMMENT = re.compile(r"(?m)^\s*#.*$|\s#[^\"'\n]*$")
96
+ _CODE_FENCE = re.compile(r"```[\w+.-]*[^\S\n]*\n?(.*?)(?:```|\Z)", re.S)
97
+ _APP_KEYWORDS = ("app_name", "app")
98
+ _API_KEYWORDS = ("api_name", "api", "function_name")
99
+
100
+
101
+ def _prefixed(app: str, api: str) -> str:
102
+ return api if not app or api.startswith(f"{app}_") else f"{app}_{api}"
103
+
104
+
105
+ #: Top-level keys of a CUGA API entry that CUGA writes anew in every run; left out of ``Candidate.spec`` so
106
+ #: that a live entry equals the training one.
107
+ VOLATILE_API_KEYS = ("canary_string",)
108
+
109
+
110
+ def _prompts(step: Any) -> list[dict]:
111
+ """A step's recorded prompts (``{"role", "value"}`` objects); anything malformed counts as none."""
112
+ prompts = step.get("prompts") if isinstance(step, dict) else None
113
+ return [p for p in prompts if isinstance(p, dict)] if isinstance(prompts, list) else []
114
+
115
+
116
+ def _value(prompt: dict) -> str:
117
+ v = prompt.get("value", "")
118
+ return v if isinstance(v, str) else json.dumps(v, default=str) if v is not None else ""
119
+
120
+
121
+ def _clean(cands: dict[str, Candidate]) -> dict[str, Candidate]:
122
+ """CUGA API descriptions read ``description: <text>\\nmodel: ...\\n---\\n<schema>``; keep the text."""
123
+ out = {}
124
+ for k, c in cands.items():
125
+ head = [ln for ln in c.description.split("\n---")[0].splitlines() if not ln.startswith("model:")]
126
+ d = re.sub(r"^description:\s*", "", "\n".join(head)).strip()
127
+ out[k] = dataclasses.replace(c, description=d)
128
+ return out
129
+
130
+
131
+ def available_apis(step: dict[str, Any]) -> dict[str, Candidate]:
132
+ """The APIs a shortlister step was offered: the ``Available APIs`` JSON block of its prompt."""
133
+ for p in _prompts(step):
134
+ m = _AVAILABLE.search(_value(p))
135
+ if m:
136
+ data = parse_json(m.group(1))
137
+ if isinstance(data, (dict, list)):
138
+ return _clean(parse_catalog(data, VOLATILE_API_KEYS))
139
+ return {}
140
+
141
+
142
+ def _literal_list(text: str, start: int) -> list | None:
143
+ """The JSON or Python-literal list that opens at ``text[start]`` (a ``[``), or None."""
144
+ end = start
145
+ for _ in range(64): # the closing bracket is one of the next ``]``: try them in order
146
+ end = text.find("]", end + 1)
147
+ if end < 0:
148
+ return None
149
+ chunk = text[start : end + 1]
150
+ for parse in (json.loads, ast.literal_eval):
151
+ try:
152
+ value = parse(chunk)
153
+ except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError):
154
+ continue
155
+ if isinstance(value, list):
156
+ return value
157
+ return None
158
+
159
+
160
+ def available_apps(step: dict[str, Any]) -> dict[str, Candidate]:
161
+ """The apps an app-analyzer step could choose: the ``applications`` (or ``available_apps``) list of the
162
+ input in its prompt, which CUGA writes as a Python literal after ``Human:``, as candidates with their
163
+ descriptions and their entries (``Candidate.spec``). The instructions before the input show example lists
164
+ under the same names; the last list is the input."""
165
+ for p in reversed(_prompts(step)):
166
+ text = _value(p)
167
+ human = text.rfind("Human:")
168
+ text = text[human:] if human >= 0 and _APPS.search(text, human) else text
169
+ items = None
170
+ for m in reversed(list(_APPS.finditer(text))):
171
+ items = _literal_list(text, m.end() - 1)
172
+ if items:
173
+ break
174
+ if items:
175
+ out = {}
176
+ for a in items:
177
+ name = a.get("name") if isinstance(a, dict) else a
178
+ if isinstance(name, str) and name.strip():
179
+ desc = a.get("description", "") if isinstance(a, dict) else ""
180
+ entry = spec_text(a) if isinstance(a, dict) else ""
181
+ out[name.strip()] = Candidate(name.strip(), name.strip(), str(desc or ""), spec=entry)
182
+ if out:
183
+ return out
184
+ return {}
185
+
186
+
187
+ def chosen_apps(step: dict[str, Any]) -> set[str]:
188
+ """The apps an app-analyzer step chose: the names in its output, a list of app objects (or
189
+ ``{"apps": [...]}``); items without a name are left out."""
190
+ out = step.get("data")
191
+ if not out:
192
+ gens = [p.get("value") for p in _prompts(step) if str(p.get("role", "")).lower() in _GENERATION_ROLES]
193
+ out = gens[-1] if gens else None
194
+ d = parse_json(out) if isinstance(out, str) else out
195
+ if isinstance(d, dict):
196
+ d = d.get("apps") or d.get("result") or []
197
+ if not isinstance(d, list):
198
+ return set()
199
+ names = (a.get("name") if isinstance(a, dict) else a for a in d)
200
+ return {n.strip() for n in names if isinstance(n, str) and n.strip()}
201
+
202
+
203
+ def _constant(node: ast.AST) -> str | None:
204
+ """The text of a string literal (or an f-string without placeholders), else None."""
205
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
206
+ return node.value
207
+ if isinstance(node, ast.JoinedStr) and all(
208
+ isinstance(v, ast.Constant) and isinstance(v.value, str) for v in node.values
209
+ ):
210
+ return "".join(v.value for v in node.values) # type: ignore[attr-defined]
211
+ return None
212
+
213
+
214
+ def _code_text(code: str) -> str:
215
+ """Generated code without its Markdown fences (CUGA's code node writes them): the code between the opening
216
+ fence and the last one when that parses (a fence inside a string of the code stays), else every fenced
217
+ block, joined."""
218
+ if "```" not in code:
219
+ return code
220
+ text = code.strip()
221
+ if text.startswith("```"):
222
+ body = text.partition("\n")[2]
223
+ end = body.rfind("```")
224
+ whole = body[:end] if end >= 0 else body
225
+ if _parse_code(whole) is not None:
226
+ return whole
227
+ return "\n".join(m.group(1) for m in _CODE_FENCE.finditer(code))
228
+
229
+
230
+ def _parse_code(code: str) -> ast.AST | None:
231
+ flags = ast.PyCF_ONLY_AST | ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
232
+ for src in (code, "async def _f():\n" + "\n".join(f" {ln}" for ln in code.splitlines())):
233
+ try:
234
+ return compile(src, "<code>", "exec", flags=flags, dont_inherit=True)
235
+ except (SyntaxError, ValueError, RecursionError, MemoryError, OverflowError):
236
+ continue
237
+ return None
238
+
239
+
240
+ def _literal_names(tree: ast.AST) -> dict[str, str]:
241
+ """The names the code binds exactly once, by assigning them a string literal (``app = "phone"``). A name
242
+ bound in any other way as well (assigned twice, a loop variable, a parameter, augmented, imported) is
243
+ dynamic and left out."""
244
+ bound: Counter = Counter()
245
+ values: dict[str, str] = {}
246
+ for node in ast.walk(tree):
247
+ if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
248
+ bound[node.id] += 1
249
+ elif isinstance(node, ast.arg):
250
+ bound[node.arg] += 1
251
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
252
+ bound[node.name] += 1
253
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
254
+ bound.update((a.asname or a.name).split(".")[0] for a in node.names)
255
+ elif isinstance(node, (ast.Global, ast.Nonlocal)):
256
+ bound.update({n: 2 for n in node.names})
257
+ elif isinstance(node, ast.ExceptHandler) and node.name:
258
+ bound[node.name] += 1
259
+ target = (
260
+ node.targets[0]
261
+ if isinstance(node, ast.Assign) and len(node.targets) == 1
262
+ else node.target
263
+ if isinstance(node, ast.AnnAssign) and node.value is not None
264
+ else None
265
+ )
266
+ if isinstance(target, ast.Name):
267
+ value = _constant(node.value) # type: ignore[attr-defined]
268
+ if value is not None:
269
+ values[target.id] = value
270
+ return {k: v for k, v in values.items() if bound[k] == 1}
271
+
272
+
273
+ def _resolved(node: ast.AST | None, names: dict[str, str]) -> str | None:
274
+ if node is None:
275
+ return None
276
+ if isinstance(node, ast.Name):
277
+ return names.get(node.id)
278
+ return _constant(node)
279
+
280
+
281
+ def code_api_calls_counted(code: str) -> tuple[set[tuple[str, str]], int]:
282
+ """``(app, api)`` pairs of the ``call_api(app_name, api_name, ...)`` calls in generated code, and how many
283
+ calls name their app or API dynamically (left out). Keyword arguments count in any order; an app or API
284
+ given as a name the code assigns a string literal once (``app = "phone"``) is resolved. Code that parses
285
+ is read as Python, so calls in comments and strings do not count; Markdown code fences are removed only
286
+ when the code does not parse with them. Code that never parses (cut off, escaped inside a JSON string)
287
+ is searched with a pattern, comments removed (no call is then counted as dynamic)."""
288
+ tree = _parse_code(code)
289
+ if tree is None and "```" in code:
290
+ code = _code_text(code)
291
+ tree = _parse_code(code)
292
+ if tree is None:
293
+ out = set()
294
+ for kw1, v1, kw2, v2 in _CALL.findall(_COMMENT.sub("", code)):
295
+ app, api = (v2, v1) if kw1 in _API_KEYWORDS or kw2 in _APP_KEYWORDS else (v1, v2)
296
+ out.add((app.strip(), api.strip()))
297
+ return {(app, api) for app, api in out if app and api}, 0
298
+ names = _literal_names(tree)
299
+ out = set()
300
+ dynamic = 0
301
+ for node in ast.walk(tree):
302
+ if not isinstance(node, ast.Call):
303
+ continue
304
+ f = node.func
305
+ name = f.id if isinstance(f, ast.Name) else f.attr if isinstance(f, ast.Attribute) else None
306
+ if name != "call_api":
307
+ continue
308
+ kw = {k.arg: k.value for k in node.keywords if k.arg}
309
+ app_node = node.args[0] if node.args else next((kw[k] for k in _APP_KEYWORDS if k in kw), None)
310
+ if len(node.args) > 1:
311
+ api_node = node.args[1]
312
+ else:
313
+ api_node = next((kw[k] for k in _API_KEYWORDS if k in kw), None)
314
+ app, api = _resolved(app_node, names), _resolved(api_node, names)
315
+ if app and api and app.strip() and api.strip():
316
+ out.add((app.strip(), api.strip()))
317
+ else:
318
+ dynamic += 1
319
+ return out, dynamic
320
+
321
+
322
+ def code_api_calls(code: str) -> set[tuple[str, str]]:
323
+ """The ``(app, api)`` pairs of :func:`code_api_calls_counted`."""
324
+ return code_api_calls_counted(code)[0]
325
+
326
+
327
+ def _step_calls(step: Any) -> tuple[set[tuple[str, str]], int]:
328
+ """``(app, api)`` pairs a step called (``call_api`` in CodeAgent code, or an ``api_call`` step), and the
329
+ calls with dynamic names it made."""
330
+ if not isinstance(step, dict):
331
+ return set(), 0
332
+ data = step.get("data") or ""
333
+ if step.get("name") == "api_call":
334
+ d = parse_json(data)
335
+ fn = d.get("function_name") if isinstance(d, dict) else None
336
+ if isinstance(fn, str) and fn.strip():
337
+ app = d.get("app_name")
338
+ app = app.strip() if isinstance(app, str) else ""
339
+ return {(app, _prefixed(app, fn.strip()))}, 0
340
+ return set(), 0
341
+ if step.get("name") != "CodeAgent":
342
+ return set(), 0
343
+ d = parse_json(data)
344
+ code = d.get("code", "") if isinstance(d, dict) else data if isinstance(data, str) else ""
345
+ if not isinstance(code, str):
346
+ code = ""
347
+ calls, dynamic = code_api_calls_counted(code)
348
+ return {(app, _prefixed(app, api)) for app, api in calls}, dynamic
349
+
350
+
351
+ def called_apis(step: dict[str, Any]) -> set[tuple[str, str]]:
352
+ """``(app, api)`` pairs a step called: ``call_api(app, api)`` in CodeAgent code (see
353
+ :func:`code_api_calls_counted`), or an ``api_call`` step."""
354
+ return _step_calls(step)[0]
355
+
356
+
357
+ def decision_app(dp: DecisionPoint) -> str | None:
358
+ """The app a shortlisting call is about: ``Current Application`` in the step's prompt, else the
359
+ ``action_input_shortlisting_agent`` of the last ``APIPlannerAgent`` step before it."""
360
+ for p in _prompts(dp.target_step):
361
+ m = _APP.search(_value(p))
362
+ if m:
363
+ return m.group(1)
364
+ for s in reversed(steps_of(dp.prefix, "steps")):
365
+ if step_name(s) != "APIPlannerAgent":
366
+ continue
367
+ for text in (s.get("data"), *(p.get("value") for p in reversed(_prompts(s)))):
368
+ d = parse_json(text)
369
+ short = d.get("action_input_shortlisting_agent") if isinstance(d, dict) else None
370
+ if isinstance(short, dict) and isinstance(short.get("app_name"), str):
371
+ return short["app_name"]
372
+ break
373
+ return None
374
+
375
+
376
+ def _shortlisted(output: Any, offered: dict[str, Candidate]) -> tuple[str, ...] | None:
377
+ """The APIs a shortlister returned (``{"result": [{"name": ...}]}``), named as in its catalog."""
378
+ items = output.get("result") if isinstance(output, dict) else output
379
+ if not isinstance(items, list):
380
+ return None
381
+ names = [str(i.get("name") or i.get("api_name") or "") if isinstance(i, dict) else str(i) for i in items]
382
+ return tuple(
383
+ n if n in offered or not offered else next((k for k in offered if k.endswith(f"_{n}")), n)
384
+ for n in names
385
+ if n
386
+ )
387
+
388
+
389
+ def _score_positive(raw: dict) -> bool:
390
+ """CUGA's success: ``score > 0`` (see ``read_outcome``; a missing or unreadable score is a failure)."""
391
+ return read_outcome(raw.get("score")) is True
392
+
393
+
394
+ def _see(pool: dict[str, Counter], key: str, entry: Candidate, n: int = 1) -> None:
395
+ pool.setdefault(key, Counter())[entry] += n
396
+
397
+
398
+ def _best(entries: Counter) -> Candidate:
399
+ """The description most runs gave (ties: the longest), the same whatever order the runs were read in."""
400
+ return max(entries.items(), key=lambda kv: (kv[1], len(kv[0].description), kv[0].description))[0]
401
+
402
+
403
+ class CugaAdapter(GenericJsonAdapter):
404
+ """CUGA trajectories -> decision points for any agent node; candidates and labels built in for the
405
+ ``ShortlisterAgent`` and the ``TaskAnalyzerAgent`` (see the module docstring). A run succeeds when
406
+ ``score > 0``; a missing or unreadable score counts as a failure and is reported in ``warnings``.
407
+ ``app_labels`` is ``"used"`` (the apps the run called) or ``"offered"`` (the apps the analyzer chose)."""
408
+
409
+ #: The decision pattern of each built-in decision, for ``onboard`` and ``scan`` when none is given.
410
+ decision_kinds = {SHORTLISTER: "ranking", ANALYZER: "multilabel"}
411
+ format_name = "CUGA trajectories"
412
+
413
+ scan_notes = (
414
+ "CUGA trajectories record no tokens, cost or latency per step (results.json holds per-task "
415
+ "totals), so this scan ranks components by their LLM calls",
416
+ )
417
+
418
+ def __init__(
419
+ self,
420
+ catalog: Any = None,
421
+ success: Callable[[dict], bool] | None = None,
422
+ outcome_keys: Iterable[str] = ("score", "eval", "actions_count"),
423
+ outcome_steps: Iterable[str] = ("EvaluationResult",),
424
+ candidates_fn: Callable[[DecisionPoint], list[Candidate]] | None = None,
425
+ labels_fn: Callable[[DecisionPoint, dict], set[str]] | None = None,
426
+ task_keys: Iterable[str] | None = TASK_KEYS,
427
+ app_labels: str = "used",
428
+ ):
429
+ if app_labels not in ("used", "offered"):
430
+ raise ValueError(f"app_labels must be 'used' or 'offered', not {app_labels!r}")
431
+ super().__init__(
432
+ success=success or self._score,
433
+ group_key="task_id",
434
+ outcome_keys=outcome_keys,
435
+ outcome_steps=outcome_steps,
436
+ candidates_fn=candidates_fn,
437
+ labels_fn=labels_fn,
438
+ task_keys=task_keys,
439
+ )
440
+ self.target = SHORTLISTER
441
+ self.catalog = _clean(parse_catalog(catalog, VOLATILE_API_KEYS))
442
+ self.app_labels = app_labels
443
+
444
+ def _reset(self) -> None:
445
+ super()._reset()
446
+ self._observed_apis: dict[str, Counter] = {} # API -> its catalog entries in the loaded runs
447
+ self._apps_seen: dict[str, Counter] = {} # app -> its entries offered to the analyzer or called
448
+ self._labels: dict[tuple[str, int], frozenset[str]] = {} # (trajectory id, step) -> built-in labels
449
+ self._step_calls: dict[str, list[frozenset]] = {} # run id -> the API calls of each step, from load
450
+
451
+ @property
452
+ def _observed(self) -> dict[str, Candidate]:
453
+ """The APIs offered to or called in the loaded runs."""
454
+ return {k: _best(v) for k, v in sorted(self._observed_apis.items())}
455
+
456
+ @property
457
+ def _apps(self) -> dict[str, Candidate]:
458
+ """The apps offered to the app analyzer or called in the loaded runs."""
459
+ return {k: _best(v) for k, v in sorted(self._apps_seen.items())}
460
+
461
+ # ------------------------------------------------------------------ loading
462
+ _missing_outcome = "failed"
463
+
464
+ @property
465
+ def _outcome_fields(self) -> str:
466
+ return "score"
467
+
468
+ def _score(self, raw: dict) -> bool:
469
+ """CUGA's success, ``score > 0``; a missing or unreadable score is a failure (see ``warnings``)."""
470
+ if raw.get("score") is None:
471
+ self._no_outcome += 1
472
+ return False
473
+ return self._read_outcome("score", raw["score"])
474
+
475
+ def _is_run(self, record: Any) -> bool:
476
+ """A CUGA run: a ``steps`` list with an agent node, or the fields of a CUGA trajectory (``intent``,
477
+ ``task_id`` or ``score``; a run that stopped before any agent step). Another log with named steps
478
+ (a pipeline configuration next to the runs, say) is not one."""
479
+ if not (isinstance(record, dict) and isinstance(record.get(self.steps_key), list)):
480
+ return False
481
+ names = {step_name(s, self.step_name_key) for s in record[self.steps_key]}
482
+ return bool(names & CUGA_STEPS) or any(k in record for k in ("intent", "task_id", "score"))
483
+
484
+ def _group(self, raw: dict, rid: str, f: Path, single: bool) -> str:
485
+ """The ``task_id``; without one (or ``"default"``), the file name (the run id for a run that shares
486
+ its file)."""
487
+ tid = str(raw.get(self.group_key) or "")
488
+ if tid and tid != "default":
489
+ return tid
490
+ return f.stem if single else rid
491
+
492
+ def _on_run(self, raw: dict, rid: str) -> None:
493
+ self._step_calls[rid] = self._observe(raw, rid)
494
+
495
+ def load(self, path: str | Path, *, lazy: bool | None = None) -> list[Trajectory]:
496
+ """Every trajectory under ``path`` (recursive; ``results.json``/``metadata.json`` skipped); see
497
+ ``GenericJsonAdapter.load``. A run is an object with a ``steps`` list."""
498
+ return super().load(path, lazy=lazy)
499
+
500
+ def _observe(self, raw: dict, rid: str = "") -> list[frozenset[tuple[str, str]]]:
501
+ """Count the APIs and apps a run was offered (with their descriptions) or called (names only, counted
502
+ 0 times, so that any described entry wins); returns the ``(app, api)`` calls of each step. Calls
503
+ whose app or API name is dynamic are counted in the load's notes."""
504
+ out = []
505
+ for s in steps_of(raw, self.steps_key):
506
+ found, dynamic = _step_calls(s)
507
+ calls = frozenset(found)
508
+ out.append(calls)
509
+ if dynamic and self.notes is not None:
510
+ self.notes.add(
511
+ "dynamic_calls",
512
+ rid,
513
+ "calls",
514
+ n=dynamic,
515
+ level="info",
516
+ found="call_api calls whose app or API name is not a literal (nor a name assigned a "
517
+ "literal once), not counted in the labels",
518
+ )
519
+ for app, api in calls:
520
+ _see(self._observed_apis, api, Candidate(api, api), 0)
521
+ if app:
522
+ _see(self._apps_seen, app, Candidate(app, app), 0)
523
+ name = step_name(s, self.step_name_key)
524
+ if name == SHORTLISTER:
525
+ for k, c in available_apis(s).items():
526
+ _see(self._observed_apis, k, c)
527
+ elif name == ANALYZER:
528
+ for k, c in available_apps(s).items():
529
+ _see(self._apps_seen, k, c)
530
+ return out
531
+
532
+ # ------------------------------------------------------------------ scan
533
+ def llm_calls(self, traj: Trajectory) -> list[LLMCall]:
534
+ """The LLM calls of a run, for ``tabagent.scan``: each step of an agent node (or with recorded
535
+ prompts), one call per recorded generation (see the module docstring)."""
536
+ out: list[LLMCall] = []
537
+ for s in steps_of(traj.raw, self.steps_key):
538
+ name = step_name(s, self.step_name_key)
539
+ prompts = _prompts(s)
540
+ if name is None or name in self.outcome_steps or not (name in CUGA_STEPS or prompts):
541
+ continue
542
+ gens = [p.get("value") for p in prompts if str(p.get("role", "")).lower() in _GENERATION_ROLES]
543
+ raw_output = s.get("data") or (gens[-1] if gens else "")
544
+ output = parse_json(raw_output)
545
+ out.extend(LLMCall(name) for _ in gens[1:]) # earlier generations of the step (retries)
546
+ usage = read_usage(s)
547
+ if name == SHORTLISTER:
548
+ offered = available_apis(s)
549
+ choices = _shortlisted(output, offered)
550
+ out.append(LLMCall(name, choices=choices, catalog=tuple(offered) or None, **usage))
551
+ elif name == ANALYZER and (apps := available_apps(s)):
552
+ chosen = tuple(sorted(chosen_apps(s))) or None
553
+ out.append(LLMCall(name, choices=chosen, catalog=tuple(apps), **usage))
554
+ elif name in FREE_TEXT_STEPS:
555
+ text = raw_output if isinstance(raw_output, str) else json.dumps(raw_output, default=str)
556
+ out.append(LLMCall(name, text_chars=len(text), **usage))
557
+ else:
558
+ fields, chars = output_fields(raw_output if output is None else output)
559
+ out.append(LLMCall(name, fields=fields, text_chars=chars, **usage))
560
+ return out
561
+
562
+ # ------------------------------------------------------------------ the adapter protocol
563
+ def decision_points(self, traj: Trajectory, target: str) -> list[DecisionPoint]:
564
+ """See ``GenericJsonAdapter.decision_points``. For the built-in decisions the labels are read from
565
+ the full run now and kept (a few names per decision point), so they need not read the run again."""
566
+ dps = super().decision_points(traj, target)
567
+ if dps and self.labels_fn is None and target in (SHORTLISTER, ANALYZER):
568
+ steps = steps_of(traj.raw, self.steps_key)
569
+ loaded = self._names.get(traj.id) # the calls read at load, if this is the loaded run
570
+ calls = self._step_calls.get(traj.id) if loaded is not None and loaded[0] is traj else None
571
+ for dp in dps:
572
+ self._labels[(traj.id, dp.step_index)] = frozenset(self._labels_in(steps, dp, calls))
573
+ return dps
574
+
575
+ def _require_builtin(self, dp: DecisionPoint) -> str:
576
+ name = step_name(dp.target_step, self.step_name_key)
577
+ if name not in (SHORTLISTER, ANALYZER):
578
+ raise NotImplementedError(
579
+ f"CugaAdapter builds candidates and labels for {SHORTLISTER} and {ANALYZER}; "
580
+ f"pass candidates_fn= and labels_fn= to replace {name}"
581
+ )
582
+ return name
583
+
584
+ def candidates(self, dp: DecisionPoint) -> list[Candidate]:
585
+ if self.candidates_fn is not None:
586
+ return self.candidates_fn(dp)
587
+ if self._require_builtin(dp) == ANALYZER:
588
+ apps = available_apps(dp.target_step) or self._apps
589
+ return sorted(apps.values(), key=lambda c: c.id)
590
+ offered = available_apis(dp.target_step)
591
+ if not offered: # CUGA API names are app-prefixed (<app>_<api>)
592
+ pool, app = self.catalog or self._observed, decision_app(dp)
593
+ offered = {k: c for k, c in pool.items() if app and k.startswith(f"{app}_")} or pool
594
+ return sorted(offered.values(), key=lambda c: c.id)
595
+
596
+ def _labels_in(self, steps: list[Any], dp: DecisionPoint, calls: list | None = None) -> set[str]:
597
+ """The built-in labels of ``dp`` from its run's steps (``calls``: each step's API calls, if known)."""
598
+ start = dp.step_index + 1
599
+
600
+ def called(i: int) -> Any:
601
+ return calls[i] if calls is not None and len(calls) == len(steps) else called_apis(steps[i])
602
+
603
+ if self._require_builtin(dp) == ANALYZER:
604
+ return {app for i in range(start, len(steps)) for app, _ in called(i) if app}
605
+ out: set[str] = set()
606
+ for i in range(start, len(steps)):
607
+ if step_name(steps[i], self.step_name_key) == SHORTLISTER:
608
+ break
609
+ out |= {api for _, api in called(i)}
610
+ return out
611
+
612
+ def labels(self, dp: DecisionPoint) -> set[str]:
613
+ """Shortlister: the APIs the CodeAgent called after this shortlisting and before the next one. App
614
+ analyzer: the apps the run called after it (``app_labels="used"``), or the apps it chose
615
+ (``"offered"``). Read from the full trajectory."""
616
+ if self.labels_fn is not None:
617
+ return super().labels(dp)
618
+ if self._require_builtin(dp) == ANALYZER and self.app_labels == "offered":
619
+ return chosen_apps(dp.target_step)
620
+ kept = self._labels.get((dp.trajectory_id, dp.step_index))
621
+ if kept is not None:
622
+ return set(kept)
623
+ return self._labels_in(steps_of(self._run_of(dp), self.steps_key), dp)