@salesforce/afv-skills 1.67.0 → 1.68.0

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 (118) hide show
  1. package/README.md +55 -5
  2. package/package.json +1 -1
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  29. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  30. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  31. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  32. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  33. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  34. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  35. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  36. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  37. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  38. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  39. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  40. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  41. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  42. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  43. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  44. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  45. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  46. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  47. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  48. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  49. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  50. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  51. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  52. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  53. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  54. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  55. package/skills/education-cloud-domain-configure/SKILL.md +1 -1
  56. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  57. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  58. package/skills/experience-design-validate/SKILL.md +1 -1
  59. package/skills/experience-search-coordinate/SKILL.md +18 -12
  60. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  61. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  62. package/skills/platform-apex-generate/SKILL.md +14 -11
  63. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  64. package/skills/platform-datamask-run/SKILL.md +52 -71
  65. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  66. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  67. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  68. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  69. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  70. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  71. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  72. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  73. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  74. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  75. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  76. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  77. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  78. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  79. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  80. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  81. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  82. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  83. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  84. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  85. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  86. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  87. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  88. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  89. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  90. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  91. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  92. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  93. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  94. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  95. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  96. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  97. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  98. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  99. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  100. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  101. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  102. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  103. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  104. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  105. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  106. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  107. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  108. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  109. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  110. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  111. package/skills/platform-report-generate/SKILL.md +9 -4
  112. package/skills/platform-report-generate/references/chart-types.md +26 -2
  113. package/skills/platform-report-generate/references/column-names.md +43 -0
  114. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  115. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  116. package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
  117. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  118. package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +110 -14
@@ -0,0 +1,288 @@
1
+ """Run-dir artifacts — the stored traces of one replay run.
2
+
3
+ units.jsonl replayed units: request + golden (bt.unit/1); replay again with --from-units
4
+ results.jsonl one line per unit x repeat: response, scores, status (bt.result/1)
5
+ summary.json aggregates, run config, code fingerprint (bt.summary/1)
6
+ replay_results_sfap.csv the legacy CSV columns, one row per unit x repeat, plus unit columns
7
+
8
+ Run dirs hold user utterances: keep them out of git. Every writer goes through redact_obj and
9
+ creates its file readable by its owner only.
10
+ """
11
+
12
+ import csv
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import statistics
17
+ import subprocess
18
+ from collections import Counter, defaultdict
19
+
20
+ from . import SCHEMA_RESULT, SCHEMA_SUMMARY
21
+ from .redact import redact_obj
22
+ from .scoring import MATCH_BUCKETS
23
+ from .sfap import TIMEOUT_KINDS
24
+
25
+ UNIT_FIELDS = ("unit_id", "session_id", "turn", "step", "step_id", "interaction_id", "org", "agent",
26
+ "agent_version", "compatible_versions", "node", "step_kind", "split", "channel", "topic",
27
+ "user_utterance", "production")
28
+ SCORE_FIELDS = ("decision", "names", "names_strict", "args", "text", "score", "strict", "bucket",
29
+ "detail", "args_detail", "judge", "schema_violations", "topic_match", "confusion",
30
+ "proxies")
31
+ LEGACY_CSV_FIELDS = ["session_id", "utterance", "original_model", "replay_model", "actual_topic",
32
+ "replayed_topic", "topic_match", "actual_action", "replayed_action",
33
+ "function_match_score", "function_args_score", "actual_outcome",
34
+ "replayed_outcome", "text_resp_score"]
35
+ CSV_FIELDS = LEGACY_CSV_FIELDS + ["unit_id", "node", "step_kind", "repeat", "status", "bucket",
36
+ "unit_score"]
37
+
38
+
39
+ def unit_kind(unit):
40
+ """router_tool | router_text | subagent_tool | subagent_text."""
41
+ return f"{unit.get('step_kind')}_{unit['golden']['kind']}"
42
+
43
+
44
+ def make_record(unit, repeat, model, seed, request_sha, call, scores=None, patch=None):
45
+ """One results.jsonl line. A failed judge call turns the record into an infra error.
46
+ patch: {"base_request_sha", "ops"} of a patched run (--patch): the unpatched request's
47
+ sha and the ops that matched this unit."""
48
+ rec = {"schema": SCHEMA_RESULT}
49
+ rec.update({k: unit.get(k) for k in UNIT_FIELDS})
50
+ patch = patch or {}
51
+ rec.update(kind=unit_kind(unit), repeat=repeat, model=model,
52
+ golden_model=unit["request"].get("model"), seed=seed, request_sha=request_sha,
53
+ base_request_sha=patch.get("base_request_sha"), patch_ops=patch.get("ops"),
54
+ status=call["status"], error=call.get("error"), error_kind=call.get("error_kind"),
55
+ http_status=call.get("http_status"), attempts=call.get("attempts"),
56
+ latency_ms=call.get("latency_ms"), cached=bool(call.get("cached")))
57
+ resp = call.get("response")
58
+ rec["response"] = {k: resp.get(k) for k in ("text", "tool_calls", "finish_reason")} if resp else None
59
+ rec["usage"] = resp.get("usage") if resp else None
60
+ rec["golden"] = unit["golden"]
61
+ rec.update({k: None for k in SCORE_FIELDS})
62
+ if scores is not None:
63
+ if scores.get("judge_error"):
64
+ rec.update(status="infra_error", error_kind="judge", error=scores["judge_error"])
65
+ else:
66
+ rec.update({k: scores.get(k) for k in SCORE_FIELDS})
67
+ return rec
68
+
69
+
70
+ def open_private(path, **kwargs):
71
+ """A file opened for writing that only its owner can read (mode 0600), before anything is written."""
72
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
73
+ try:
74
+ os.chmod(path, 0o600) # an existing file keeps its mode through os.open
75
+ return os.fdopen(fd, "w", encoding="utf-8", **kwargs)
76
+ except BaseException:
77
+ os.close(fd)
78
+ raise
79
+
80
+
81
+ def write_jsonl(path, rows):
82
+ with open_private(path) as f:
83
+ for row in rows:
84
+ f.write(json.dumps(redact_obj(row), ensure_ascii=False) + "\n")
85
+
86
+
87
+ def append_jsonl(f, row):
88
+ f.write(json.dumps(redact_obj(row), ensure_ascii=False) + "\n")
89
+ f.flush()
90
+
91
+
92
+ def read_jsonl(path):
93
+ with open(path, encoding="utf-8") as f:
94
+ return [json.loads(line) for line in f if line.strip()]
95
+
96
+
97
+ def write_json(path, obj):
98
+ with open_private(path) as f:
99
+ json.dump(redact_obj(obj), f, indent=2, ensure_ascii=False)
100
+ f.write("\n")
101
+
102
+
103
+ # Modules that neither replay nor score: editing them must not make two runs incomparable.
104
+ NOT_FINGERPRINTED = ("agentpatch.py", "compare.py", "fetch.py")
105
+
106
+
107
+ def code_fingerprint():
108
+ """Hash of the btlib sources that replay and score, plus the git commit, so runs scored by
109
+ different code are told apart."""
110
+ here = os.path.dirname(os.path.abspath(__file__))
111
+ h = hashlib.sha256()
112
+ for name in sorted(os.listdir(here)):
113
+ if name.endswith(".py") and name not in NOT_FINGERPRINTED:
114
+ with open(os.path.join(here, name), "rb") as f:
115
+ h.update(name.encode() + b"\0" + f.read() + b"\0")
116
+ out = {"btlib_sha256": h.hexdigest()[:16], "git": None, "git_dirty": None}
117
+ try:
118
+ git = subprocess.run(["git", "-C", here, "rev-parse", "--short=12", "HEAD"],
119
+ capture_output=True, text=True, timeout=10)
120
+ if git.returncode == 0:
121
+ out["git"] = git.stdout.strip()
122
+ dirty = subprocess.run(["git", "-C", here, "status", "--porcelain", "--", "."],
123
+ capture_output=True, text=True, timeout=10)
124
+ out["git_dirty"] = bool(dirty.stdout.strip())
125
+ except (OSError, subprocess.SubprocessError):
126
+ pass
127
+ return out
128
+
129
+
130
+ def aggregate(rows):
131
+ n = len(rows)
132
+ if not n:
133
+ return {"n": 0, "agreement": None, "strict": None, "match": 0}
134
+ return {"n": n,
135
+ "agreement": round(sum(r["score"] for r in rows) / n, 4),
136
+ "strict": round(sum(r["strict"] for r in rows) / n, 4),
137
+ "match": sum(1 for r in rows if r["bucket"] in MATCH_BUCKETS)}
138
+
139
+
140
+ def _group(rows, keys_of):
141
+ groups = defaultdict(list)
142
+ for r in rows:
143
+ for k in keys_of(r):
144
+ groups[k].append(r)
145
+ out = {}
146
+ for k in sorted(groups):
147
+ agg = aggregate(groups[k])
148
+ agg["buckets"] = dict(Counter(r["bucket"] for r in groups[k]).most_common())
149
+ out[k] = agg
150
+ return out
151
+
152
+
153
+ def golden_tools(rec):
154
+ """Tools the golden called; '(text)' for a text golden."""
155
+ return sorted({c["name"] for c in rec["golden"]["tool_calls"]}) or ["(text)"]
156
+
157
+
158
+ def groundedness(rec):
159
+ """The category production's groundedness check gave the record's turn; '(none)' without one."""
160
+ return (rec.get("production") or {}).get("groundedness") or "(none)"
161
+
162
+
163
+ def production_summary(records):
164
+ """Production's own checks over the turns and sessions of these records (report-only)."""
165
+ turns, sessions = {}, {}
166
+ for r in records:
167
+ p = r.get("production")
168
+ if p:
169
+ turns.setdefault((r["session_id"], r["turn"]), p)
170
+ sessions.setdefault(r["session_id"], p)
171
+ guardrails = defaultdict(Counter)
172
+ for p in turns.values():
173
+ for name, value in (p.get("guardrails") or {}).items():
174
+ guardrails[name][value] += 1
175
+ return {"turns": len(turns),
176
+ "groundedness": dict(Counter(p.get("groundedness") or "(none)" for p in turns.values()).most_common()),
177
+ "guardrails": {name: dict(c.most_common()) for name, c in sorted(guardrails.items())},
178
+ "turns_with_step_errors": sum(1 for p in turns.values() if p.get("step_errors")),
179
+ "sessions": len(sessions),
180
+ "end_type": dict(Counter(p.get("session_end_type") or "(none)" for p in sessions.values()).most_common()),
181
+ "sessions_with_action_errors": sum(1 for p in sessions.values() if p.get("session_action_errors"))}
182
+
183
+
184
+ def summarize(records, config=None, skipped=None, unit_count=None):
185
+ ok = [r for r in records if r["status"] == "ok"]
186
+ infra = [r for r in records if r["status"] != "ok"]
187
+ n = len(records)
188
+ routed = [r for r in ok if r["topic_match"] is not None]
189
+ texts = [r for r in ok if r["proxies"]]
190
+ confusion = Counter(tuple(r["confusion"]) for r in ok if r["confusion"])
191
+ violations = Counter(v for r in ok for v in r["schema_violations"] or [])
192
+ return {
193
+ "schema": SCHEMA_SUMMARY,
194
+ "config": config or {},
195
+ "counts": {
196
+ "units": unit_count if unit_count is not None else len({r["unit_id"] for r in records}),
197
+ "records": n, "scored": len(ok), "infra_error": len(infra),
198
+ "cached": sum(1 for r in records if r.get("cached")),
199
+ "infra_rate": round(len(infra) / n, 4) if n else 0.0,
200
+ "infra_by_kind": dict(Counter(r["error_kind"] for r in infra).most_common()),
201
+ "timed_out_units": timed_out_units(records),
202
+ "skipped_steps": dict(skipped or {}),
203
+ },
204
+ "overall": aggregate(ok),
205
+ "by_kind": _group(ok, lambda r: [r["kind"]]),
206
+ "by_node": _group(ok, lambda r: [r["node"]]),
207
+ "by_tool": _group(ok, golden_tools),
208
+ "by_bucket": dict(Counter(r["bucket"] for r in ok).most_common()),
209
+ "by_split": _group(ok, lambda r: [r["split"]] if r.get("split") else []),
210
+ "by_groundedness": _group(ok, lambda r: [groundedness(r)]),
211
+ "production": production_summary(records),
212
+ "topic": {"n": len(routed), "match": sum(1 for r in routed if r["topic_match"]),
213
+ "rate": round(sum(1 for r in routed if r["topic_match"]) / len(routed), 4) if routed else None},
214
+ "confusion": [{"golden": g, "candidate": c, "n": k} for (g, c), k in confusion.most_common()],
215
+ "schema_violations": {"records": sum(1 for r in ok if r["schema_violations"]),
216
+ "notes": dict(violations.most_common())},
217
+ "text": {
218
+ "n": len(texts),
219
+ "median_len_ratio": round(statistics.median(r["proxies"]["len_ratio"] for r in texts), 3) if texts else None,
220
+ "question_ending_agrees": sum(1 for r in texts if r["proxies"]["golden_ends_with_question"]
221
+ == r["proxies"]["candidate_ends_with_question"]),
222
+ "raw_id_leaks": sum(1 for r in texts if r["proxies"]["candidate_raw_ids"] > r["proxies"]["golden_raw_ids"]),
223
+ },
224
+ "per_repeat": {str(rep): aggregate([r for r in ok if r["repeat"] == rep])
225
+ for rep in sorted({r["repeat"] for r in records})},
226
+ "judge_calls": sum(len(r["judge"] or []) for r in ok),
227
+ "usage": _usage_totals(records),
228
+ }
229
+
230
+
231
+ def timed_out_units(records):
232
+ """The units whose every record timed out: the model may not finish them (a runaway answer)."""
233
+ timed_out = defaultdict(list)
234
+ for r in records:
235
+ timed_out[r["unit_id"]].append(r["status"] != "ok" and r.get("error_kind") in TIMEOUT_KINDS)
236
+ return sum(1 for flags in timed_out.values() if all(flags))
237
+
238
+
239
+ def _usage_totals(records):
240
+ totals = Counter()
241
+ for r in records:
242
+ for k, v in (r.get("usage") or {}).items():
243
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
244
+ totals[k] += v
245
+ return dict(totals)
246
+
247
+
248
+ def _names(calls):
249
+ return "+".join(c["name"] or "" for c in calls or [])
250
+
251
+
252
+ def csv_row(rec):
253
+ """Legacy CSV columns for one record (topic columns only on router steps)."""
254
+ g_calls = rec["golden"]["tool_calls"]
255
+ resp = rec["response"] or {}
256
+ c_calls = resp.get("tool_calls") or []
257
+ ok = rec["status"] == "ok"
258
+ router = rec["step_kind"] == "router"
259
+ row = {
260
+ "session_id": rec["session_id"], "utterance": rec["user_utterance"],
261
+ "original_model": rec["golden_model"], "replay_model": rec["model"],
262
+ "actual_topic": _names(g_calls) if router else (rec["topic"] or ""),
263
+ "replayed_topic": _names(c_calls) if router and ok else "",
264
+ "topic_match": int(bool(rec["topic_match"])) if router and ok else "",
265
+ "actual_action": _names(g_calls), "replayed_action": _names(c_calls),
266
+ "function_match_score": "", "function_args_score": "",
267
+ "actual_outcome": rec["golden"]["text"], "replayed_outcome": resp.get("text") or "",
268
+ "text_resp_score": "",
269
+ "unit_id": rec["unit_id"], "node": rec["node"], "step_kind": rec["step_kind"],
270
+ "repeat": rec["repeat"], "status": rec["status"], "bucket": rec["bucket"] or "",
271
+ "unit_score": rec["score"] if ok else "",
272
+ }
273
+ if ok:
274
+ if g_calls or c_calls:
275
+ row["function_match_score"] = int(bool(g_calls and c_calls and rec["names_strict"]))
276
+ if rec["names"]:
277
+ row["function_args_score"] = int(bool(rec["args"]))
278
+ if not g_calls and not c_calls:
279
+ row["text_resp_score"] = int(bool(rec["text"]))
280
+ return row
281
+
282
+
283
+ def write_csv(path, records):
284
+ with open_private(path, newline="") as f:
285
+ w = csv.DictWriter(f, fieldnames=CSV_FIELDS)
286
+ w.writeheader()
287
+ for rec in records:
288
+ w.writerow(redact_obj(csv_row(rec)))
@@ -0,0 +1,340 @@
1
+ """Score one replayed step against its golden (the production answer to the same request).
2
+
3
+ Golden is a tool call:
4
+ decision candidate also called a tool (not text)
5
+ names same multiset of tool names (optionally lenient via configured equivalence groups)
6
+ args every argument the same: IDs, codes, enums and numbers compared exactly,
7
+ missing == empty, free-text strings sent to the judge. A key one call leaves out
8
+ takes its default (the agent config's arg_defaults, else the schema's "default").
9
+ Not compared on a transition that declares no parameters (it takes none)
10
+ unit score 1 (all match) | 0.5 (decision + names, args differ) | 0
11
+ Golden is text:
12
+ candidate must reply with text the judge finds equivalent: 1 | 0. A tool call scores 0.
13
+ Strict score: 1 only for exact names (no equivalence groups), matching args with no default
14
+ taken, or equivalent text.
15
+
16
+ Buckets: match, transition_args (a match only because arguments on such a transition were not
17
+ compared), wrong_args, wrong_tool, missed_call, extra_call, tool_to_text, text_to_tool,
18
+ text_divergence.
19
+ """
20
+
21
+ import json
22
+ import re
23
+ from collections import Counter
24
+
25
+ from .agentsrc import TRANSITION_SUFFIX
26
+
27
+ MATCH_BUCKETS = ("match", "transition_args") # the buckets that score 1
28
+ # Last word of a parameter name that marks an identifier (productCode, order_id, SKU, email).
29
+ _ID_WORDS = {"id", "ids", "code", "codes", "number", "num", "sku", "email", "phone", "uuid", "url"}
30
+ _KEY_WORDS = re.compile(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])")
31
+ _ID_VALUE = re.compile(r"^(?=\S*\d)\S+$") # one token containing a digit: SHOE-M-088, 006Rt00000AbCdE
32
+ _SF_ID = re.compile(r"\b(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?\b")
33
+ _SF_ID_FULL = re.compile(r"^(?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{15}(?:[A-Za-z0-9]{3})?$")
34
+ _UUID = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
35
+
36
+
37
+ def _id_key(key):
38
+ words = _KEY_WORDS.findall(key or "")
39
+ return bool(words) and words[-1].lower() in _ID_WORDS
40
+
41
+
42
+ def _empty(v):
43
+ return v is None or v == "" or v == [] or v == {}
44
+
45
+
46
+ def _num(v):
47
+ if isinstance(v, bool):
48
+ return None
49
+ if isinstance(v, (int, float)):
50
+ return float(v)
51
+ if isinstance(v, str):
52
+ try:
53
+ return float(v.strip())
54
+ except ValueError:
55
+ return None
56
+ return None
57
+
58
+
59
+ def _bool(v):
60
+ if isinstance(v, bool):
61
+ return v
62
+ if isinstance(v, str) and v.strip().lower() in ("true", "false"):
63
+ return v.strip().lower() == "true"
64
+ return None
65
+
66
+
67
+ def values_equal(a, b):
68
+ """Deterministic equality with light normalisation (case/space for strings, '2' == 2).
69
+
70
+ Salesforce record ids stay case-sensitive: 15-character ids differing only in case
71
+ are different records.
72
+ """
73
+ if _empty(a) and _empty(b):
74
+ return True
75
+ if isinstance(a, str) and isinstance(b, str):
76
+ a, b = a.strip(), b.strip()
77
+ if _SF_ID_FULL.match(a) or _SF_ID_FULL.match(b):
78
+ return a == b
79
+ return " ".join(a.split()).lower() == " ".join(b.split()).lower()
80
+ na, nb = _num(a), _num(b)
81
+ if na is not None and nb is not None:
82
+ return na == nb
83
+ ba, bb = _bool(a), _bool(b)
84
+ if ba is not None and bb is not None:
85
+ return ba == bb
86
+ return json.dumps(a, sort_keys=True) == json.dumps(b, sort_keys=True)
87
+
88
+
89
+ def is_free_text(key, golden_value, candidate_value, prop_schema):
90
+ """Free text may be equivalent without being equal (a search phrase); IDs and enums may not."""
91
+ if not (isinstance(golden_value, str) and isinstance(candidate_value, str)):
92
+ return False
93
+ if (prop_schema or {}).get("enum") or (prop_schema or {}).get("pattern"):
94
+ return False
95
+ if _id_key(key):
96
+ return False
97
+ if _ID_VALUE.match(golden_value.strip()) or _ID_VALUE.match(candidate_value.strip()):
98
+ return False
99
+ return True
100
+
101
+
102
+ def compare_args(golden_args, candidate_args, schema=None, defaults=None):
103
+ """{"equal": [...], "differ": [...], "free_text": [...], "defaulted": [...], "omitted": {...}}
104
+ over the union of keys.
105
+
106
+ A key one call leaves out (or sends empty) takes its default: defaults[key] (the agent
107
+ config's arg_defaults for the tool), else the schema's "default". "defaulted" lists the
108
+ equal keys that are equal only so; "omitted" maps each differing key one call left out to
109
+ that call, "golden" or "candidate".
110
+ """
111
+ props = (schema or {}).get("properties") or {}
112
+ known = {k: p["default"] for k, p in props.items() if isinstance(p, dict) and "default" in p}
113
+ known.update(defaults or {})
114
+ out = {"equal": [], "differ": [], "free_text": [], "defaulted": [], "omitted": {}}
115
+ for key in sorted(set(golden_args) | set(candidate_args)):
116
+ g, c = golden_args.get(key), candidate_args.get(key)
117
+ left_out = "golden" if _empty(g) else "candidate" if _empty(c) else None
118
+ if values_equal(g, c):
119
+ out["equal"].append(key)
120
+ elif left_out and key in known and values_equal(known[key], c if left_out == "golden" else g):
121
+ out["equal"].append(key)
122
+ out["defaulted"].append(key)
123
+ elif is_free_text(key, g, c, props.get(key)):
124
+ out["free_text"].append(key)
125
+ else:
126
+ out["differ"].append(key)
127
+ if left_out:
128
+ out["omitted"][key] = left_out
129
+ return out
130
+
131
+
132
+ def schema_violations(calls, schemas):
133
+ """Problems of candidate calls against the offered tools' parameter schemas."""
134
+ notes = []
135
+ types = {"string": str, "boolean": bool, "object": dict, "array": list}
136
+ for call in calls:
137
+ name, args = call.get("name"), call.get("args") or {}
138
+ if "args_raw" in call:
139
+ notes.append(f"{name}: arguments are not a JSON object")
140
+ if name not in schemas:
141
+ notes.append(f"{name}: tool was not offered")
142
+ continue
143
+ schema = schemas[name] or {}
144
+ props = schema.get("properties") or {}
145
+ for req in schema.get("required") or []:
146
+ if _empty(args.get(req)):
147
+ notes.append(f"{name}: missing required '{req}'")
148
+ for key, val in args.items():
149
+ if props and key not in props:
150
+ notes.append(f"{name}: unknown parameter '{key}'")
151
+ continue
152
+ ps = props.get(key) or {}
153
+ t = ps.get("type")
154
+ if t in ("number", "integer"):
155
+ n = _num(val)
156
+ if n is None or (t == "integer" and not n.is_integer()):
157
+ notes.append(f"{name}.{key}: expected {t}")
158
+ elif t in types and val is not None and not isinstance(val, types[t]):
159
+ notes.append(f"{name}.{key}: expected {t}")
160
+ if ps.get("enum") and val not in ps["enum"]:
161
+ notes.append(f"{name}.{key}: not one of the allowed values")
162
+ if isinstance(val, str):
163
+ if ps.get("pattern"):
164
+ try:
165
+ if not re.search(ps["pattern"], val):
166
+ notes.append(f"{name}.{key}: does not match the pattern")
167
+ except re.error:
168
+ pass
169
+ if ps.get("maxLength") is not None and len(val) > ps["maxLength"]:
170
+ notes.append(f"{name}.{key}: longer than maxLength {ps['maxLength']}")
171
+ if ps.get("minLength") is not None and len(val) < ps["minLength"]:
172
+ notes.append(f"{name}.{key}: shorter than minLength {ps['minLength']}")
173
+ return notes
174
+
175
+
176
+ def text_proxies(golden_text, candidate_text):
177
+ """Report-only text signals; not part of the unit score."""
178
+ def ids(t):
179
+ return len(_SF_ID.findall(t or "")) + len(_UUID.findall(t or ""))
180
+ g, c = golden_text or "", candidate_text or ""
181
+ return {
182
+ "len_ratio": round(len(c) / max(1, len(g)), 3),
183
+ "golden_ends_with_question": g.rstrip().endswith("?"),
184
+ "candidate_ends_with_question": c.rstrip().endswith("?"),
185
+ "golden_raw_ids": ids(g),
186
+ "candidate_raw_ids": ids(c),
187
+ }
188
+
189
+
190
+ class Equivalence:
191
+ """Tool-name equivalence groups from the agent config; empty (exact names) by default."""
192
+
193
+ def __init__(self, groups=None):
194
+ self.canon = {}
195
+ for i, group in enumerate(groups or []):
196
+ for name in group:
197
+ self.canon[name] = f"group{i}:" + "|".join(sorted(group))
198
+
199
+ def key(self, name):
200
+ return self.canon.get(name, name)
201
+
202
+ def same(self, a, b):
203
+ return a == b or self.key(a) == self.key(b)
204
+
205
+
206
+ def takes_no_args(fn):
207
+ """Is this offered function a transition (the platform's suffix ends its description) that
208
+ declares no parameters?"""
209
+ fn = fn or {}
210
+ return ((fn.get("description") or "").endswith(TRANSITION_SUFFIX)
211
+ and not (fn.get("parameters") or {}).get("properties"))
212
+
213
+
214
+ def _pair_calls(golden_calls, candidate_calls, eq):
215
+ """Pair golden and candidate calls with equivalent names; prefer pairs whose args are equal."""
216
+ pairs, used = [], set()
217
+ for g in golden_calls:
218
+ options = [i for i, c in enumerate(candidate_calls)
219
+ if i not in used and eq.same(g["name"], c["name"])]
220
+ if not options:
221
+ continue
222
+ best = next((i for i in options if values_equal(g.get("args") or {}, candidate_calls[i].get("args") or {})),
223
+ options[0])
224
+ used.add(best)
225
+ pairs.append((g, candidate_calls[best]))
226
+ return pairs
227
+
228
+
229
+ def score_unit(unit, response, judge=None, equivalence=None, arg_defaults=None):
230
+ """Score one ok response. Returns a dict; 'judge_error' is set when a needed judge call failed.
231
+ arg_defaults: {tool: {param: value}}, the agent config's defaults of the params a call may leave out."""
232
+ eq = equivalence or Equivalence()
233
+ arg_defaults = arg_defaults or {}
234
+ golden = unit["golden"]
235
+ msgs = unit["request"]["messages"]
236
+ offered = {(t.get("function") or {}).get("name"): t.get("function") or {} for t in unit["request"]["tools"]}
237
+ schemas = {name: fn.get("parameters") or {} for name, fn in offered.items()}
238
+ g_calls = golden.get("tool_calls") or []
239
+ c_calls = response.get("tool_calls") or []
240
+ c_text = response.get("text") or ""
241
+ out = {"golden_kind": golden["kind"], "decision": None, "names": None, "names_strict": None,
242
+ "args": None, "text": None, "score": 0.0, "strict": 0.0, "bucket": None,
243
+ "detail": "", "args_detail": [], "judge": [], "judge_error": None,
244
+ "schema_violations": schema_violations(c_calls, schemas) if c_calls else [],
245
+ "topic_match": None, "confusion": None, "proxies": None}
246
+
247
+ if unit.get("step_kind") == "router":
248
+ # Topic = where the router sent the turn (its go_to_* calls); text = stayed put.
249
+ out["topic_match"] = sorted(c["name"] for c in g_calls) == sorted(c["name"] for c in c_calls)
250
+
251
+ if golden["kind"] == "tool":
252
+ out["decision"] = bool(c_calls)
253
+ if not c_calls:
254
+ out["bucket"] = "tool_to_text"
255
+ out["detail"] = f"expected {', '.join(c['name'] for c in g_calls)}, replied with text"
256
+ out["confusion"] = ["+".join(sorted(c["name"] for c in g_calls)), "(text)"]
257
+ return out
258
+ g_names = Counter(eq.key(c["name"]) for c in g_calls)
259
+ c_names = Counter(eq.key(c["name"]) for c in c_calls)
260
+ out["names"] = g_names == c_names
261
+ out["names_strict"] = Counter(c["name"] for c in g_calls) == Counter(c["name"] for c in c_calls)
262
+ if not out["names"]:
263
+ if not (g_names - c_names) and (c_names - g_names):
264
+ out["bucket"] = "extra_call"
265
+ elif (g_names - c_names) and not (c_names - g_names):
266
+ out["bucket"] = "missed_call"
267
+ else:
268
+ out["bucket"] = "wrong_tool"
269
+ g_list = "+".join(sorted(c["name"] for c in g_calls))
270
+ c_list = "+".join(sorted(c["name"] for c in c_calls))
271
+ out["confusion"] = [g_list, c_list]
272
+ out["detail"] = f"expected {g_list}, got {c_list}"
273
+ return out
274
+ all_equal, not_compared, defaulted = True, [], False
275
+ for g, c in _pair_calls(g_calls, c_calls, eq):
276
+ cmp = compare_args(g.get("args") or {}, c.get("args") or {}, schemas.get(g["name"]),
277
+ arg_defaults.get(g["name"]))
278
+ entry = {"name": g["name"], "candidate_name": c["name"], **cmp, "equivalent": None}
279
+ if ((cmp["differ"] or cmp["free_text"]) and "args_raw" not in c
280
+ and takes_no_args(offered.get(g["name"])) and takes_no_args(offered.get(c["name"]))):
281
+ entry["equivalent"], entry["not_compared"] = True, True
282
+ not_compared += [f"{g['name']}.{k}" for k in cmp["differ"] + cmp["free_text"]]
283
+ elif cmp["differ"] or "args_raw" in c:
284
+ entry["equivalent"] = False
285
+ elif cmp["free_text"]:
286
+ if judge is None:
287
+ entry["equivalent"] = False
288
+ entry["judge_skipped"] = True
289
+ else:
290
+ verdict, err = judge.args(g["name"], schemas.get(g["name"]), g.get("args") or {},
291
+ c.get("args") or {}, msgs)
292
+ if verdict is None:
293
+ out["judge_error"] = err
294
+ return out
295
+ out["judge"].append({"for": f"args:{g['name']}", **verdict})
296
+ entry["equivalent"] = verdict["equivalent"]
297
+ else:
298
+ entry["equivalent"] = True
299
+ all_equal = all_equal and entry["equivalent"]
300
+ defaulted = defaulted or bool(cmp["defaulted"])
301
+ out["args_detail"].append(entry)
302
+ out["args"] = all_equal
303
+ if all_equal and not_compared:
304
+ out["bucket"], out["score"] = "transition_args", 1.0
305
+ out["detail"] = "arguments on a transition that takes none, not compared: " + ", ".join(not_compared)
306
+ elif all_equal:
307
+ out["bucket"], out["score"] = "match", 1.0
308
+ out["strict"] = 1.0 if out["names_strict"] and not defaulted else 0.0
309
+ else:
310
+ out["bucket"], out["score"] = "wrong_args", 0.5
311
+ differ = [f"{e['name']}.{k}" + (f" (left out by the {e['omitted'][k]})" if k in e["omitted"] else "")
312
+ for e in out["args_detail"] if not e["equivalent"]
313
+ for k in (e["differ"] + e["free_text"])]
314
+ out["detail"] = "arguments differ: " + ", ".join(differ) if differ else "arguments differ"
315
+ return out
316
+
317
+ # golden is text
318
+ out["decision"] = not c_calls
319
+ if c_calls:
320
+ out["bucket"] = "text_to_tool"
321
+ out["detail"] = f"expected a text reply, called {', '.join(c['name'] for c in c_calls)}"
322
+ out["confusion"] = ["(text)", "+".join(sorted(c["name"] for c in c_calls))]
323
+ return out
324
+ out["proxies"] = text_proxies(golden.get("text"), c_text)
325
+ if " ".join((golden.get("text") or "").split()) == " ".join(c_text.split()):
326
+ out["text"] = True
327
+ elif judge is None:
328
+ out["text"] = False
329
+ out["detail"] = "no judge configured"
330
+ else:
331
+ verdict, err = judge.text(golden.get("text") or "", c_text, msgs)
332
+ if verdict is None:
333
+ out["judge_error"] = err
334
+ return out
335
+ out["judge"].append({"for": "text", **verdict})
336
+ out["text"] = verdict["equivalent"]
337
+ out["detail"] = verdict.get("reasoning", "")
338
+ out["bucket"] = "match" if out["text"] else "text_divergence"
339
+ out["score"] = out["strict"] = 1.0 if out["text"] else 0.0
340
+ return out