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.
- tabagent/__init__.py +57 -0
- tabagent/adapters/__init__.py +68 -0
- tabagent/adapters/common.py +138 -0
- tabagent/adapters/cuga.py +623 -0
- tabagent/adapters/detect.py +182 -0
- tabagent/adapters/live.py +111 -0
- tabagent/adapters/logfiles.py +728 -0
- tabagent/adapters/openai_messages.py +672 -0
- tabagent/adapters/tau2.py +195 -0
- tabagent/agent.py +219 -0
- tabagent/api.py +1016 -0
- tabagent/artifact.py +262 -0
- tabagent/auto.py +260 -0
- tabagent/baselines.py +62 -0
- tabagent/cards/__init__.py +1 -0
- tabagent/cards/appworld_subagent_routing.yaml +77 -0
- tabagent/cards/llmrouterbench_model_routing.yaml +113 -0
- tabagent/cards/mind2web_element.yaml +88 -0
- tabagent/cards/mind2web_operation.yaml +55 -0
- tabagent/cards/skillsbench_skill_gating.yaml +57 -0
- tabagent/cards/skillsbench_skill_gating_enriched.yaml +81 -0
- tabagent/cards/skillsbench_skill_select.yaml +61 -0
- tabagent/cards/skillsbench_skill_select_noprior.yaml +53 -0
- tabagent/cards/tau2_domain_shortlisting.yaml +85 -0
- tabagent/cards/tau2_tool_shortlisting.yaml +58 -0
- tabagent/cli.py +851 -0
- tabagent/datasets/__init__.py +138 -0
- tabagent/datasets/_core.py +171 -0
- tabagent/datasets/appworld.py +207 -0
- tabagent/datasets/folds/appworld_subagent_routing.json +590 -0
- tabagent/datasets/folds/llmrouterbench_tau2.json +283 -0
- tabagent/datasets/folds/mind2web_element_150.json +155 -0
- tabagent/datasets/folds/mind2web_groupkfold5.json +1014 -0
- tabagent/datasets/folds/mind2web_split.json +1014 -0
- tabagent/datasets/folds/skillsbench_skill_gating.json +89 -0
- tabagent/datasets/folds/skillsbench_skill_gating_enriched.json +92 -0
- tabagent/datasets/folds/skillsbench_skill_select.json +92 -0
- tabagent/datasets/folds/tau2_agents.json +599 -0
- tabagent/datasets/folds/tau2_airline.json +29 -0
- tabagent/datasets/folds/tau2_all_domains.json +144 -0
- tabagent/datasets/folds/tau2_retail.json +59 -0
- tabagent/datasets/folds/tau2_telecom.json +66 -0
- tabagent/datasets/llmrouterbench.py +249 -0
- tabagent/datasets/manifests/llmrouterbench_tau2_dense.json +102 -0
- tabagent/datasets/manifests/mind2web_train.json +71 -0
- tabagent/datasets/manifests/skillsbench_official_selected.json +328 -0
- tabagent/datasets/manifests/tau2_official_final.json +185 -0
- tabagent/datasets/manifests/tau2_sft_final.json +17 -0
- tabagent/datasets/mind2web.py +342 -0
- tabagent/datasets/skillsbench.py +207 -0
- tabagent/datasets/tau2.py +361 -0
- tabagent/defaults.py +23 -0
- tabagent/drift.py +701 -0
- tabagent/evaluation/__init__.py +36 -0
- tabagent/evaluation/cv.py +220 -0
- tabagent/evaluation/discovery.py +320 -0
- tabagent/evaluation/metrics.py +266 -0
- tabagent/evaluation/stats.py +116 -0
- tabagent/hub.py +44 -0
- tabagent/integrations/__init__.py +1 -0
- tabagent/integrations/cuga.py +186 -0
- tabagent/integrations/openai_tools.py +232 -0
- tabagent/prometheus.py +196 -0
- tabagent/py.typed +0 -0
- tabagent/report.py +237 -0
- tabagent/scanner.py +1113 -0
- tabagent/serve.py +873 -0
- tabagent/sklearn.py +353 -0
- tabagent/tabhead.py +427 -0
- tabagent/table.py +178 -0
- tabagent/tabschema/__init__.py +22 -0
- tabagent/tabschema/agents.py +1304 -0
- tabagent/tabschema/audit.py +408 -0
- tabagent/tabschema/card.py +392 -0
- tabagent/tabschema/checks.py +307 -0
- tabagent/tabschema/compile.py +1811 -0
- tabagent/tabschema/estimate.py +326 -0
- tabagent/tabschema/features.py +114 -0
- tabagent/tabschema/fitted.py +474 -0
- tabagent/tabschema/judges.py +203 -0
- tabagent/tabschema/llm.py +908 -0
- tabagent/tabschema/orchestrator.py +1823 -0
- tabagent/tabschema/prompts/__init__.py +80 -0
- tabagent/tabschema/prompts/code_extractor.txt +93 -0
- tabagent/tabschema/prompts/code_validator.txt +39 -0
- tabagent/tabschema/prompts/feature_aggregator.txt +37 -0
- tabagent/tabschema/prompts/feature_analyzer.txt +35 -0
- tabagent/tabschema/prompts/judge_generality.txt +51 -0
- tabagent/tabschema/prompts/judge_impact.txt +52 -0
- tabagent/tabschema/prompts/judge_relevance.txt +49 -0
- tabagent/tabschema/prompts/meta_judge.txt +65 -0
- tabagent/tabschema/prompts/output_auditor.txt +34 -0
- tabagent/tabschema/prompts/tabagent_analyzer_candidates.txt +6 -0
- tabagent/tabschema/prompts/tabagent_auditor_candidates.txt +5 -0
- tabagent/tabschema/prompts/tabagent_candidate_note.txt +3 -0
- tabagent/tabschema/prompts/tabagent_extractor.txt +3 -0
- tabagent/tabschema/prompts/tabagent_extractor_candidates.txt +9 -0
- tabagent/tabschema/prompts/tabagent_validator_candidates.txt +9 -0
- tabagent/tabschema/runlog.py +626 -0
- tabagent/tabschema/runstore.py +267 -0
- tabagent/tabschema/sampling.py +143 -0
- tabagent/tabschema/sandbox.py +623 -0
- tabagent/tabschema/trajectory.py +989 -0
- tabagent/tabschema/usefulness.py +193 -0
- tabagent/tabschema/verify.py +1303 -0
- tabagent/tabschema/warm.py +1073 -0
- tabagent/tabsynth.py +344 -0
- tabagent-0.1.0.dist-info/METADATA +313 -0
- tabagent-0.1.0.dist-info/RECORD +113 -0
- tabagent-0.1.0.dist-info/WHEEL +4 -0
- tabagent-0.1.0.dist-info/entry_points.txt +2 -0
- tabagent-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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)
|