@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
@@ -1,811 +1,487 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- SFAP-based replay pipeline for offline experimentation.
4
- Reads STDM data, replays prompts via SFAP REST API with structured tool calling,
5
- scores results, and generates a CSV report.
6
- """
7
- import json, re, csv, os, sys, subprocess, time
8
-
9
- # ---------------------------------------------------------------------------
10
- # Config
11
- # ---------------------------------------------------------------------------
12
- SFAP_BASE_URL = os.environ.get("SFAP_BASE_URL", "https://test.api.salesforce.com/ai/gpt/v1")
13
- SFAP_TOKEN_FILE = os.environ.get("SFAP_TOKEN_FILE", "")
14
- CORE_TENANT_ID = os.environ.get("CORE_TENANT_ID", "")
15
- REPLAY_MODEL = os.environ.get("REPLAY_MODEL", "llmgateway__OpenAIGPT41")
16
- JUDGE_MODEL = os.environ.get("JUDGE_MODEL", REPLAY_MODEL)
17
- OUTPUT_CSV = os.environ.get("OUTPUT_CSV", "replay_results_sfap.csv")
18
- ORG_ID = os.environ.get("ORG_ID", "")
19
- AGENT_API_NAME = os.environ.get("AGENT_API_NAME", "")
20
- OUTPUT_JSON = os.environ.get("OUTPUT_JSON", "replay_results.json")
21
- STDM_DATA_FILE = os.environ.get("STDM_DATA_FILE", "/tmp/details_result.json")
22
- LLM_STEPS_FILES = os.environ.get("LLM_STEPS_FILES", "/tmp/llm_steps_1.json,/tmp/llm_steps_2.json").split(",")
23
-
24
- # ---------------------------------------------------------------------------
25
- # Tool extraction is dynamic-only: definitions come from STDM step inputs
26
- # (gen_ai.request.tools) at runtime. No hardcoded tool schemas.
27
- # ---------------------------------------------------------------------------
28
-
29
-
30
- def _is_routing_tools(tools):
31
- """True when ALL tools in the list are routing functions (go_to_* / __end_session_action__).
32
- Action steps may include a single go_to_* for cross-topic transfer alongside domain tools,
33
- so checking 'any' would mis-classify them."""
34
- if not tools:
35
- return False
36
- for t in tools:
37
- name = t.get('function', {}).get('name', '')
38
- if not (name.startswith('go_to_') or name == '__end_session_action__'):
39
- return False
40
- return True
41
-
42
-
43
- def _extract_tool_call_from_step_output(step):
44
- """Extract the first tool invocation from a step's gen_ai.output.messages."""
45
- step_output = step.get('output', '') or ''
46
- try:
47
- out_json = json.loads(step_output) if isinstance(step_output, str) else step_output
48
- out_msgs = out_json.get('gen_ai.output.messages', [])
49
- for om in out_msgs:
50
- for inv in om.get('tool_invocations', []):
51
- func = inv.get('function', {})
52
- if func.get('name'):
53
- args = func.get('arguments', '')
54
- if isinstance(args, dict):
55
- args = json.dumps(args)
56
- return func['name'], args
57
- except Exception:
58
- pass
59
- return None, None
60
-
61
-
62
- def sanitize_tools_for_sfap(tools):
63
- """Clean STDM tool schemas to be compatible with SFAP's OpenAI-format endpoint.
64
- Removes additionalProperties and empty required arrays that cause 400 errors."""
65
- sanitized = []
66
- for tool in tools:
67
- tool = json.loads(json.dumps(tool)) # deep copy
68
- func = tool.get('function', {})
69
- params = func.get('parameters', {})
70
- params.pop('additionalProperties', None)
71
- if 'required' in params and not params['required']:
72
- del params['required']
73
- sanitized.append(tool)
74
- return sanitized
75
-
76
-
77
- def extract_tools_from_llm_responses(llm_step_map):
78
- """Scan all LLM responses to discover the actual tool names used by the agent."""
79
- seen = set()
80
- for step in llm_step_map.values():
81
- resp_text = step.get('llm_response', '') or ''
82
- if not resp_text:
83
- continue
84
- try:
85
- resp = json.loads(resp_text)
86
- for inv in resp.get('toolInvocations', []):
87
- name = inv.get('function', {}).get('name', '')
88
- if name:
89
- seen.add(name)
90
- except:
91
- pass
92
- return seen
93
-
94
-
95
- def read_token():
96
- if SFAP_TOKEN_FILE and os.path.exists(SFAP_TOKEN_FILE):
97
- with open(SFAP_TOKEN_FILE) as f:
98
- return f.read().strip()
99
- raise RuntimeError("SFAP_TOKEN_FILE not set or file not found")
100
-
101
-
102
- def parse_prompt_to_messages(prompt_text):
103
- """Parse role-based prompt text into OpenAI-style messages array.
104
-
105
- Keeps system messages in their original position — production keeps
106
- subagent instructions at the end of the conversation, not merged to the top.
107
- Merges consecutive same-role messages to keep alternation valid.
108
- Appends a user "Continue." if conversation ends on assistant."""
109
- messages = []
110
- current_role = None
111
- current_content = []
112
-
113
- for line in prompt_text.split('\n'):
114
- stripped = line.strip()
115
- if stripped in ('role: system', 'role: user', 'role: assistant'):
116
- if current_role and current_content:
117
- content = '\n'.join(current_content).strip()
118
- if content:
119
- messages.append({"role": current_role, "content": content})
120
- current_role = stripped.replace('role: ', '')
121
- current_content = []
122
- else:
123
- if current_role:
124
- current_content.append(line)
125
-
126
- if current_role and current_content:
127
- content = '\n'.join(current_content).strip()
128
- if content:
129
- messages.append({"role": current_role, "content": content})
130
-
131
- # Merge consecutive same-role messages (keeps system at original position)
132
- merged = []
133
- for m in messages:
134
- if merged and merged[-1]['role'] == m['role']:
135
- merged[-1]['content'] = f"{merged[-1]['content']}\n{m['content']}".strip()
136
- else:
137
- merged.append(m)
138
-
139
- if merged and merged[-1]['role'] == 'assistant':
140
- merged.append({"role": "user", "content": "Continue."})
141
- return merged
142
-
143
-
144
- def convert_stdm_messages_for_sfap(stdm_messages):
145
- """Convert STDM gen_ai.input.messages to SFAP-compatible OpenAI format.
146
-
147
- Matches the temporal version's approach (src/replay/traces.py:_messages):
148
- - Folds tool-role messages into the previous message as text
149
- - Converts assistant tool_invocations to text (keeps content)
150
- - Merges consecutive same-role messages
151
- - Keeps system messages in their original position (does NOT merge trailing
152
- system into the first — production keeps subagent instructions at the end)
153
- - Appends a user "Continue." if conversation ends on assistant
154
- """
155
- raw = []
156
- for m in stdm_messages:
157
- role = m.get('role', '')
158
- content = m.get('content', '') or ''
159
- tool_invocations = m.get('tool_invocations', [])
160
- if role == 'assistant' and tool_invocations:
161
- parts = []
162
- if content.strip():
163
- parts.append(content)
164
- for inv in tool_invocations:
165
- func = inv.get('function', {})
166
- name = func.get('name', '')
167
- args = func.get('arguments', '')
168
- if isinstance(args, dict):
169
- args = json.dumps(args)
170
- parts.append(f"[tool_call] {name}({args})")
171
- raw.append({"role": "assistant", "content": "\n".join(parts)})
172
- else:
173
- raw.append({"role": role, "content": content})
174
-
175
- out = []
176
- for m in raw:
177
- role, content = m["role"], m["content"]
178
- if role == "tool":
179
- text = f"[tool result] {content}".strip()
180
- if out:
181
- out[-1]["content"] = f"{out[-1]['content']}\n{text}".strip()
182
- else:
183
- out.append({"role": "assistant", "content": text})
184
- continue
185
- if not content.strip():
186
- continue
187
- if out and out[-1]["role"] == role:
188
- out[-1]["content"] = f"{out[-1]['content']}\n{content}".strip()
189
- else:
190
- out.append({"role": role, "content": content})
191
-
192
- if out and out[-1]["role"] == "assistant":
193
- out.append({"role": "user", "content": "Continue."})
194
- return out
195
-
196
-
197
- def call_sfap(messages, model, tools=None, max_tokens=2048):
198
- """Call SFAP chat generations endpoint."""
199
- token = read_token()
200
- url = f"{SFAP_BASE_URL}/chat/generations"
201
-
202
- payload = {
203
- "model": model,
204
- "messages": messages,
205
- "max_tokens": max_tokens,
206
- }
207
- if tools:
208
- payload["tools"] = tools
209
- payload["tool_choice"] = "required"
210
-
211
- payload_json = json.dumps(payload)
212
-
213
- cmd = [
214
- "curl", "-sS", "-w", "\n__HTTP_STATUS:%{http_code}",
215
- url,
216
- "-H", f"Authorization: Bearer {token}",
217
- "-H", "Content-Type: application/json",
218
- "-H", "x-sfdc-app-context: EinsteinGPT",
219
- "-H", "x-client-feature-id: AgentBacktesting",
220
- "-H", f"x-sfdc-core-tenant-id: {CORE_TENANT_ID}",
221
- "-d", payload_json,
222
- ]
223
-
224
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
225
- output = result.stdout
226
-
227
- # Split response body and HTTP status
228
- parts = output.rsplit('\n__HTTP_STATUS:', 1)
229
- body = parts[0] if parts else output
230
- status = int(parts[1].strip()) if len(parts) > 1 else 0
231
-
232
- if status != 200:
233
- return {"error": f"HTTP {status}", "body": body[:500]}
234
-
235
- return json.loads(body)
236
-
237
-
238
- def extract_tool_call(sfap_response):
239
- """Extract tool invocation from SFAP response."""
240
- try:
241
- gens = sfap_response.get("generation_details", {}).get("generations", [])
242
- if not gens:
243
- return None, None, None
2
+ """Replay production LLM steps (from STDM) on a candidate model and score them against production.
244
3
 
245
- gen = gens[0]
246
- content = gen.get("content", "")
247
- invocations = gen.get("tool_invocations") or []
4
+ Every LLM_STEP of every turn is one unit (btlib/units.py): the request exactly as production
5
+ sent it, and production's own answer as the golden. The candidate model answers the same
6
+ request through SFAP and is scored step by step (btlib/scoring.py).
248
7
 
249
- if invocations:
250
- inv = invocations[0]
251
- func = inv.get("function", {})
252
- return func.get("name"), func.get("arguments", ""), content
253
- return None, None, content
254
- except Exception as e:
255
- return None, None, str(e)
8
+ python3 scripts/bt_fetch.py --org <alias> --agent <api name> --start <date> --end <date> \\
9
+ --out-dir runs/fetch
10
+ bash scripts/resolve-token.sh --org <alias> > /tmp/token_info.json
11
+ python3 scripts/sfap_replay.py --details runs/fetch/details.json \\
12
+ --token-info /tmp/token_info.json --model llmgateway__EinsteinSalesforceCerebro \\
13
+ --out-dir runs/cerebro
14
+ python3 scripts/build_report.py runs/cerebro
256
15
 
16
+ --model trace replays each step on the model that served it in production. That self-control
17
+ should agree almost fully; what it misses is replay noise, not a model difference.
257
18
 
258
- def extract_original_tool_call(llm_response_text):
259
- """Extract tool invocation from the original STDM LLM response."""
260
- if not llm_response_text:
261
- return None, None, llm_response_text
19
+ --patch FILE applies edit ops to the requests first (references/patching.md): the replay
20
+ then scores the edited agent without a deploy. Untouched steps are sent unchanged.
262
21
 
263
- try:
264
- resp = json.loads(llm_response_text)
265
- invocations = resp.get("toolInvocations", [])
266
- if invocations:
267
- func = invocations[0].get("function", {})
268
- return func.get("name"), func.get("arguments", ""), resp.get("content", "")
269
- return None, None, resp.get("content", llm_response_text)
270
- except:
271
- return None, None, llm_response_text
272
-
273
-
274
- def judge_text(actual, expected, question, model):
275
- """LLM-as-judge for text responses via SFAP."""
276
- prompt = f"""You are a scoring judge. Compare the predicted response against the reference response.
277
-
278
- Output ONLY valid JSON with "reasoning" and "score" fields.
279
-
280
- **Scoring Rubric**
281
- 1. score=1 -- the predicted response covers key information of the reference response OR it takes a different but reasonable action
282
- 2. score=0.66 -- the predicted response covers a good amount of information OR it takes a somewhat reasonable action
283
- 3. score=0.33 -- the predicted response covers little information OR it takes a less reasonable action
284
- 4. score=0 -- the predicted response covers no information OR it takes a completely unreasonable action
285
-
286
- **Guidelines**
287
- 1. The reference answer is a reference, not strict ground truth. Be tolerant to different but reasonable answers.
288
- 2. It is fine if the predicted response sounds like an intermediate step rather than a direct answer.
289
- 3. The use case context is Salesforce Agentforce assisting customers.
290
- 4. Output valid JSON only.
291
-
292
- [question]: {question}
293
- [expected text]: {expected}
294
- [predicted text]: {actual}
295
-
296
- Output:
297
- """
298
- messages = [{"role": "user", "content": prompt}]
299
- resp = call_sfap(messages, model, max_tokens=512)
300
- if "error" in resp:
301
- return None, f"Judge error: {resp['error']}"
22
+ An SFAP token lasts about 2 h. With --token-org <alias>, a refused token is minted again
23
+ with resolve-token.sh during the run; with --cache-dir, a rerun sends only what is unanswered.
302
24
 
303
- gens = resp.get("generation_details", {}).get("generations", [])
304
- if not gens:
305
- return None, "No judge response"
25
+ Outputs in --out-dir: units.jsonl, results.jsonl, summary.json, replay_results_sfap.csv,
26
+ and patch_coverage.json with --patch. They contain user utterances: keep them out of git.
27
+ """
306
28
 
307
- text = gens[0].get("content", "")
29
+ import argparse
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import sys
34
+ import time
35
+ from collections import Counter
36
+ from concurrent.futures import ThreadPoolExecutor, as_completed
37
+
38
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39
+
40
+ from btlib import SCHEMA_UNIT, agentsrc # noqa: E402
41
+ from btlib import cache as cachelib # noqa: E402
42
+ from btlib import judge as judgelib # noqa: E402
43
+ from btlib import patch as patchlib # noqa: E402
44
+ from btlib import results, scoring, stdm # noqa: E402
45
+ from btlib import splits as splitlib # noqa: E402
46
+ from btlib import tokens as tokenlib # noqa: E402
47
+ from btlib import units as unitlib # noqa: E402
48
+ from btlib import versions as versionlib # noqa: E402
49
+ from btlib.redact import redact # noqa: E402
50
+ from btlib.sfap import DEFAULT_TIMEOUT, SfapClient, build_payload, request_sha # noqa: E402
51
+
52
+ AGENT_CONFIG_KEYS = {"router_nodes", "equivalent_tools", "arg_defaults", "description"}
53
+
54
+
55
+ def parse_args(argv=None):
56
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
57
+ src = p.add_mutually_exclusive_group(required=True)
58
+ src.add_argument("--details", action="extend", nargs="+", metavar="FILE",
59
+ help="bt_fetch.py's details.json, or getMultipleConversationDetails output "
60
+ "(`sf apex run --json` file or plain JSON); one or more files, repeatable")
61
+ src.add_argument("--from-units", metavar="FILE", help="units.jsonl of an earlier run")
62
+ p.add_argument("--out-dir", required=True, help="run dir for all outputs (created; must not hold a run)")
63
+ p.add_argument("--model", default=os.environ.get("REPLAY_MODEL"),
64
+ help="candidate model alias, or 'trace' for each step's production model (env REPLAY_MODEL)")
65
+ p.add_argument("--judge-model", default=os.environ.get("JUDGE_MODEL") or judgelib.DEFAULT_JUDGE_MODEL,
66
+ help=f"model for text / free-text-argument equivalence (env JUDGE_MODEL; default "
67
+ f"{judgelib.DEFAULT_JUDGE_MODEL}); must differ from the candidate")
68
+ p.add_argument("--token-info", metavar="FILE",
69
+ help="resolve-token.sh output; otherwise env SFAP_BASE_URL, SFAP_TOKEN_FILE, CORE_TENANT_ID")
70
+ p.add_argument("--token-org", metavar="ALIAS",
71
+ help="when SFAP refuses the token (HTTP 401: it expired), mint a new one with "
72
+ "resolve-token.sh --org ALIAS into the token file and send the request again, once")
73
+ p.add_argument("--steps", choices=("router", "subagent", "all"), default="all",
74
+ help="which LLM steps to replay (default all)")
75
+ p.add_argument("--router-nodes", help="comma-separated router step names (default: agent config, else "
76
+ + ",".join(unitlib.DEFAULT_ROUTER_NODES) + ")")
77
+ p.add_argument("--agent-config", metavar="FILE",
78
+ help='JSON {"router_nodes": [...], "equivalent_tools": [[tool, tool], ...], '
79
+ '"arg_defaults": {tool: {param: value}}}')
80
+ p.add_argument("--org-id", default=os.environ.get("ORG_ID"),
81
+ help="recorded in the outputs (env ORG_ID; default: from the core tenant id)")
82
+ p.add_argument("--agent", default=os.environ.get("AGENT_API_NAME"),
83
+ help="agent API name recorded in the outputs (env AGENT_API_NAME)")
84
+ p.add_argument("--k", type=int, default=1, help="repeats per unit (seed = --seed + repeat)")
85
+ p.add_argument("--seed", type=int, default=0)
86
+ p.add_argument("--limit", type=int, help="replay only the first N units")
87
+ p.add_argument("--concurrency", type=int, default=4)
88
+ p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT,
89
+ help="seconds per SFAP call; above the provider's own limit (~180 s) by default")
90
+ p.add_argument("--max-retries", type=int, default=3, help="retries after the first attempt; a timeout is "
91
+ "retried once")
92
+ p.add_argument("--cache-dir", metavar="DIR",
93
+ help="answer a payload already answered from DIR instead of SFAP, and store every new answer "
94
+ "there (judge calls too); the payload as sent is the key, model and seed included")
95
+ p.add_argument("--agent-versions", metavar="DIR",
96
+ help="the retrieved .agent versions (a bundle dir, a dir of bundle dirs or of .agent files): "
97
+ "attributes each unit to the versions that render it; writes versions.json")
98
+ p.add_argument("--agent-version", metavar="LABEL",
99
+ help="replay only the sessions this version can have served (needs --agent-versions)")
100
+ p.add_argument("--val-fraction", type=float, metavar="F",
101
+ help="label each session test or val: a share of about F goes to val, by a hash of the session ID")
102
+ p.add_argument("--split-salt", default=splitlib.DEFAULT_SALT, help="salt of that hash (default %(default)s)")
103
+ p.add_argument("--split", choices=("all",) + splitlib.SPLITS, default="all",
104
+ help="replay only this split (needs --val-fraction)")
105
+ p.add_argument("--exclude-sessions", metavar="FILE",
106
+ help="skip these sessions: a file that names them, e.g. one ID per line, a "
107
+ "units.jsonl, a sessions or details JSON, a replay CSV (references/splits.md)")
108
+ p.add_argument("--patch", metavar="FILE",
109
+ help="edit ops (bt.patch/1) applied to the requests before the replay; "
110
+ "writes patch_coverage.json and stops if an op matches nothing")
111
+ p.add_argument("--dry-run", action="store_true",
112
+ help="build units.jsonl (and patch_coverage.json) only; no SFAP calls")
113
+ p.add_argument("--overwrite", action="store_true", help="replace an existing run in --out-dir")
114
+ args = p.parse_args(argv)
115
+ if not args.dry_run and not args.model:
116
+ p.error("--model is required (an alias, or 'trace')")
117
+ if args.agent_version and not args.agent_versions:
118
+ p.error("--agent-version needs --agent-versions")
119
+ if args.val_fraction is not None and not 0 < args.val_fraction < 1:
120
+ p.error("--val-fraction must be between 0 and 1")
121
+ if args.split != "all" and args.val_fraction is None:
122
+ p.error("--split needs --val-fraction (and the --split-salt the units were labelled with)")
123
+ if args.k < 1 or args.concurrency < 1:
124
+ p.error("--k and --concurrency must be >= 1")
125
+ return args
126
+
127
+
128
+ def load_agent_config(path):
129
+ if not path:
130
+ return {}
131
+ with open(path) as f:
132
+ cfg = json.load(f)
133
+ unknown = set(cfg) - AGENT_CONFIG_KEYS
134
+ if unknown:
135
+ raise SystemExit(f"--agent-config: unknown keys {sorted(unknown)}")
136
+ groups = cfg.get("equivalent_tools") or []
137
+ if not all(isinstance(g, list) and all(isinstance(n, str) for n in g) for g in groups):
138
+ raise SystemExit("--agent-config: equivalent_tools must be a list of lists of tool names")
139
+ defaults = cfg.get("arg_defaults") or {}
140
+ if not (isinstance(defaults, dict) and all(isinstance(d, dict) for d in defaults.values())):
141
+ raise SystemExit("--agent-config: arg_defaults must map each tool name to {param: value}")
142
+ return cfg
143
+
144
+
145
+ def sfap_config(token_info):
146
+ info = {}
147
+ if token_info:
148
+ with open(token_info) as f:
149
+ info = json.load(f)
150
+ if info.get("error"):
151
+ raise SystemExit(f"--token-info reports an error: {info['error']} {info.get('hint', '')}")
152
+ return (info.get("baseUrl") or os.environ.get("SFAP_BASE_URL"),
153
+ info.get("tokenFile") or os.environ.get("SFAP_TOKEN_FILE"),
154
+ info.get("coreTenantId") or os.environ.get("CORE_TENANT_ID"))
155
+
156
+
157
+ def load_units(args, router_nodes, org_id):
158
+ """(every unit of the inputs, skipped steps Counter, input paths, duplicate sessions)."""
159
+ if args.from_units:
160
+ units = results.read_jsonl(args.from_units)
161
+ wrong = {u.get("schema") for u in units} - {SCHEMA_UNIT}
162
+ if wrong:
163
+ raise SystemExit(f"{args.from_units}: not a units file (schema {sorted(map(str, wrong))})")
164
+ for u in units: # the router set of this run decides the kind
165
+ u["step_kind"] = "router" if u["node"] in router_nodes else "subagent"
166
+ return units, Counter(), [args.from_units], 0
167
+ convos, dupes = stdm.load_conversations(args.details)
168
+ units, skipped = unitlib.build_units(convos, org=org_id, agent=args.agent, router_nodes=router_nodes)
169
+ return units, skipped, args.details, dupes
170
+
171
+
172
+ def attribute_versions(units, args):
173
+ """(session version sets, versions.json report) with --agent-versions, else (None, None)."""
174
+ if not args.agent_versions:
175
+ return None, None
176
+ try:
177
+ found = agentsrc.load_versions(args.agent_versions)
178
+ except (OSError, agentsrc.AgentSourceError) as e:
179
+ raise SystemExit(f"--agent-versions {args.agent_versions}: {e}")
180
+ labels = [v.label for v in found]
181
+ if args.agent_version and args.agent_version not in labels:
182
+ raise SystemExit(f"--agent-version {args.agent_version}: not among the versions in "
183
+ f"{args.agent_versions} ({', '.join(labels)})")
184
+ session_sets, report = versionlib.attribute(units, found)
185
+ report["source"] = os.path.basename(os.path.normpath(args.agent_versions))
186
+ s = report["sessions"]
187
+ print(f"versions: {report['units_rendered']} of {report['units']} units render from a version in "
188
+ f"{report['source']} (" + ", ".join(f"{v['label']} {v['units_rendered']}" for v in report["versions"])
189
+ + f"); sessions {s['total']}: one version {s['one_version']}, several {s['several_versions']}, "
190
+ f"conflict {s['conflict']}, unknown {s['unknown']}")
191
+ missing = report["units_not_rendered_by_node"]
192
+ if missing:
193
+ where = "; ".join(f"{node} " + ", ".join(f"{check} {n}" for check, n in c.items()) for node, c in missing.items())
194
+ print(f"WARNING: {report['units'] - report['units_rendered']} units render from none of the versions "
195
+ f"(node, then the check the closest version failed: {where}); retrieve every version the "
196
+ "traces may come from")
197
+ return session_sets, report
198
+
199
+
200
+ def label_splits(units, args):
201
+ """Label every unit test or val (--val-fraction); the split config, else None."""
202
+ if args.val_fraction is None:
203
+ return None
204
+ try:
205
+ counts = splitlib.label(units, args.val_fraction, args.split_salt)
206
+ except splitlib.SplitError as e:
207
+ raise SystemExit(f"--val-fraction: {e}")
208
+ print(f"split (salt {args.split_salt}, val fraction {args.val_fraction}): "
209
+ + ", ".join(f"{s} {n} sessions {k} units" for s, (n, k) in counts.items()))
210
+ return {"val_fraction": args.val_fraction, "salt": args.split_salt, "selected": args.split,
211
+ "sessions": {s: n for s, (n, _) in counts.items()}}
212
+
213
+
214
+ def exclude_sessions(units, skipped, args):
215
+ """(the units of sessions not listed in --exclude-sessions, its config or None)."""
216
+ if not args.exclude_sessions:
217
+ return units, None
218
+ try:
219
+ excluded = splitlib.read_session_ids(args.exclude_sessions)
220
+ except (OSError, splitlib.SplitError) as e:
221
+ raise SystemExit(f"--exclude-sessions {args.exclude_sessions}: {e}")
222
+ present = {u["session_id"] for u in units} & excluded
223
+ keep = [u for u in units if u["session_id"] not in excluded]
224
+ skipped["excluded_session"] += len(units) - len(keep)
225
+ print(f"--exclude-sessions: {len(present)} of its {len(excluded)} sessions are in the input; "
226
+ f"{len(units) - len(keep)} units skipped")
227
+ return keep, {"file": os.path.basename(args.exclude_sessions), "sessions": len(excluded),
228
+ "in_input": len(present)}
229
+
230
+
231
+ def select_units(units, skipped, args, session_sets, report):
232
+ """The units to replay: --steps, then --agent-version, then --split, then --limit."""
233
+ if args.steps != "all":
234
+ keep = [u for u in units if u["step_kind"] == args.steps]
235
+ skipped["not_selected"] += len(units) - len(keep)
236
+ units = keep
237
+ if args.agent_version:
238
+ units, dropped = versionlib.select(units, args.agent_version, session_sets)
239
+ skipped.update(dropped)
240
+ unrendered = sum(1 for u in units if not u["compatible_versions"])
241
+ report.update(selected=args.agent_version, kept=len(units), dropped=dict(dropped),
242
+ kept_not_rendered=unrendered)
243
+ print(f"--agent-version {args.agent_version}: {len(units)} units kept; dropped: "
244
+ + (", ".join(f"{k} {v}" for k, v in sorted(dropped.items())) or "none"))
245
+ if unrendered:
246
+ print(f"WARNING: {unrendered} kept units do not render from {args.agent_version} exactly; "
247
+ "a patch may not apply to them (see patch_coverage.json)")
248
+ if args.split != "all":
249
+ keep = [u for u in units if u["split"] == args.split]
250
+ skipped["other_split"] += len(units) - len(keep)
251
+ units = keep
252
+ if args.limit:
253
+ units = units[:args.limit]
254
+ return units
255
+
256
+
257
+ RUN_FILES = ("summary.json", "results.jsonl", "units.jsonl", "replay_results_sfap.csv", "patch_coverage.json",
258
+ "versions.json", "compare.json", "report.html")
259
+
260
+
261
+ def prepare_out_dir(out_dir, overwrite):
262
+ if os.path.exists(os.path.join(out_dir, "results.jsonl")) and not overwrite:
263
+ raise SystemExit(f"{out_dir} already holds a run (results.jsonl); use a new --out-dir or --overwrite")
264
+ if not os.path.isdir(out_dir):
265
+ os.makedirs(out_dir)
266
+ os.chmod(out_dir, 0o700) # user utterances inside
267
+ elif overwrite: # the old run's files go first: a re-run that stops early must not pass for it
268
+ for name in RUN_FILES:
269
+ if os.path.isfile(os.path.join(out_dir, name)):
270
+ os.remove(os.path.join(out_dir, name))
271
+
272
+
273
+ def warn_loose_mode(path, what, expected):
308
274
  try:
309
- # Extract JSON from response
310
- json_match = re.search(r'\{[^{}]*"score"[^{}]*\}', text, re.DOTALL)
311
- if json_match:
312
- parsed = json.loads(json_match.group())
313
- return float(parsed.get("score", 0)), parsed.get("reasoning", "")
314
- except:
275
+ if os.stat(path).st_mode & 0o077:
276
+ print(f"WARNING: {what} is readable by other users (expected mode {expected})")
277
+ except OSError:
315
278
  pass
316
- return None, f"Could not parse judge response: {text[:200]}"
317
279
 
318
280
 
319
- def judge_args(actual_args, expected_args, func_name, question, model):
320
- """LLM-as-judge for function arguments via SFAP."""
321
- prompt = f"""You are a scoring judge. Compare function call arguments.
322
-
323
- Output ONLY valid JSON with "reasoning" and "score" fields.
324
-
325
- **Scoring Rubric**
326
- 1. score=1 -- the arguments are semantically equivalent (same values, possibly different format)
327
- 2. score=0.66 -- most arguments match, minor differences in non-critical fields
328
- 3. score=0.33 -- some arguments match but key fields differ
329
- 4. score=0 -- arguments are completely different or wrong
330
-
331
- **Guidelines**
332
- 1. Compare semantic meaning, not exact string match.
333
- 2. Order of JSON keys does not matter.
334
- 3. The use case context is Salesforce Agentforce.
335
- 4. Output valid JSON only.
336
-
337
- [user question]: {question}
338
- [function name]: {func_name}
339
- [expected arguments]: {expected_args}
340
- [predicted arguments]: {actual_args}
341
-
342
- Output:
343
- """
344
- messages = [{"role": "user", "content": prompt}]
345
- resp = call_sfap(messages, model, max_tokens=512)
346
- if "error" in resp:
347
- return None, f"Judge error: {resp['error']}"
348
-
349
- gens = resp.get("generation_details", {}).get("generations", [])
350
- if not gens:
351
- return None, "No judge response"
352
-
353
- text = gens[0].get("content", "")
281
+ def apply_patch(units, path, out_dir):
282
+ """(requests, patch config): the (messages, tools, patch info) to send per unit."""
283
+ if not path:
284
+ return [(u["request"]["messages"], u["request"]["tools"], None) for u in units], None
354
285
  try:
355
- json_match = re.search(r'\{[^{}]*"score"[^{}]*\}', text, re.DOTALL)
356
- if json_match:
357
- parsed = json.loads(json_match.group())
358
- return float(parsed.get("score", 0)), parsed.get("reasoning", "")
359
- except:
360
- pass
361
- return None, f"Could not parse judge response: {text[:200]}"
362
-
363
-
364
- # ---------------------------------------------------------------------------
365
- # Enriched-output helpers (for HTML report)
366
- # ---------------------------------------------------------------------------
367
- TOOL_GROUPS = {
368
- 'record_lookup': {'IdentifyRecordByName', 'GetRecordDetails', 'QueryRecords'},
369
- 'record_modify': {'UpdateRecordFields', 'ExtractRecordFieldsAndValuesFromUserInput'},
370
- 'activity': {'GetActivitiesTimeline', 'GetActivityDetails'},
371
- 'routing_discovery': {'go_to_product_discovery'},
372
- 'routing_cart': {'go_to_cart_and_checkout'},
373
- 'routing_help': {'go_to_store_help'},
374
- 'routing_off': {'go_to_off_topic'},
375
- 'shopper_search': {'SearchProducts', 'GetProductDetails', 'GetRecommendations'},
376
- 'shopper_cart': {'AddToCart', 'ViewCart', 'UpdateCart', 'Checkout'},
377
- }
378
-
379
-
380
- def names_match_lenient(golden, predicted):
381
- if not golden or not predicted:
382
- return False
383
- if golden.lower() == predicted.lower():
384
- return True
385
- for group in TOOL_GROUPS.values():
386
- if golden in group and predicted in group:
387
- return True
388
- return False
389
-
390
-
391
- def build_structured_answer(tool_name, tool_args, text_content):
392
- answer = {"content": text_content or ""}
393
- if tool_name:
394
- args = tool_args
395
- if isinstance(args, str):
286
+ ops = patchlib.load_patch(path)
287
+ patched, coverage = patchlib.patch_units(units, ops)
288
+ except (OSError, patchlib.PatchError) as e:
289
+ raise SystemExit(f"--patch {path}: {e}")
290
+ requests, changed = [], 0
291
+ for u, (messages, tools, applied) in zip(units, patched):
292
+ base = request_sha(u["request"]["messages"], u["request"]["tools"])
293
+ changed += request_sha(messages, tools) != base
294
+ requests.append((messages, tools, {"base_request_sha": base, "ops": applied}))
295
+ report = patchlib.coverage_report(os.path.basename(path), coverage, changed, len(units))
296
+ results.write_json(os.path.join(out_dir, "patch_coverage.json"), report)
297
+ for c in coverage:
298
+ print(f" patch {c['id']:<16} {c['target']:<40} matched {c['matched']}/{c['units_with_target']} "
299
+ f"{c['status']}" + (f": {c['message']}" if c["message"] else ""))
300
+ print(f"patch: {changed} of {len(units)} requests changed; coverage in {out_dir}/patch_coverage.json")
301
+ if report["errors"]:
302
+ raise SystemExit(f"--patch: {len(report['errors'])} op(s) failed, nothing replayed "
303
+ f"(see patch_coverage.json): " + "; ".join(report["errors"]))
304
+ with open(path, "rb") as f:
305
+ digest = hashlib.sha256(f.read()).hexdigest()[:16]
306
+ return requests, {"file": os.path.basename(path), "sha256": digest, "ops": [op.id for op in ops],
307
+ "units_changed": changed}
308
+
309
+
310
+ def replay(units, requests, args, client, judge, equivalence, out_dir, arg_defaults=None):
311
+ jobs = []
312
+ for u, (messages, tools, patch) in zip(units, requests):
313
+ sha = request_sha(messages, tools)
314
+ model = u["request"]["model"] if args.model == "trace" else args.model
315
+ for rep in range(args.k):
316
+ jobs.append((u, messages, tools, patch, rep, model, args.seed + rep, sha))
317
+
318
+ def work(job):
319
+ u, messages, tools, patch, rep, model, seed, sha = job
320
+ call = client.generate(build_payload(model, messages, tools, seed=seed))
321
+ # scored against the unit as captured: a patch edits texts, never tool names or schemas
322
+ scores = (scoring.score_unit(u, call["response"], judge, equivalence, arg_defaults)
323
+ if call["status"] == "ok" else None)
324
+ return results.make_record(u, rep, model, seed, sha, call, scores, patch=patch)
325
+
326
+ records, interrupted, collected = [], False, set()
327
+ path = os.path.join(out_dir, "results.jsonl")
328
+ with results.open_private(path) as f:
329
+ with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
330
+ futures = [pool.submit(work, j) for j in jobs]
396
331
  try:
397
- args = json.loads(args)
332
+ for i, fut in enumerate(as_completed(futures), 1):
333
+ collected.add(fut)
334
+ rec = fut.result()
335
+ records.append(rec)
336
+ results.append_jsonl(f, rec) # partial results survive a crash
337
+ print(f" [{i}/{len(jobs)}] {rec['node'][:28]:<28} r{rec['repeat']} {rec['status']:<11} "
338
+ f"{rec['bucket'] or rec['error_kind'] or ''}", flush=True)
339
+ except KeyboardInterrupt:
340
+ interrupted = True
341
+ for fut in futures:
342
+ fut.cancel()
343
+ print("\nInterrupted: waiting for calls in flight, then writing partial results.")
398
344
  except Exception:
399
- args = {"raw": args} if args else {}
400
- answer["tool_calls"] = [{"name": tool_name, "arguments": args or {}}]
401
- return answer
402
-
403
-
404
- def classify_golden_type(tool_name):
405
- if not tool_name:
406
- return "action"
407
- lower = tool_name.lower()
408
- if any(p in lower for p in ('route_to', 'go_to', 'transfer_to')):
409
- return "topic"
410
- return "action"
411
-
412
-
413
- def build_report_json(results, csv_rows):
414
- rows = []
415
- recs = {}
416
- for i, (r, cr) in enumerate(zip(results, csv_rows)):
417
- golden = build_structured_answer(
418
- r.get('original_tool_name'), r.get('original_tool_args'), r.get('original_text'))
419
- predicted = build_structured_answer(
420
- r.get('replayed_tool_name'), r.get('replayed_tool_args'), r.get('replayed_text'))
421
-
422
- g_name = r.get('original_tool_name')
423
- p_name = r.get('replayed_tool_name')
424
- name_match = (g_name == p_name) if (g_name and p_name) else (not g_name and not p_name)
425
- lenient = names_match_lenient(g_name, p_name) or name_match
426
-
427
- args_score = cr.get('function_args_score')
428
- args_is_num = isinstance(args_score, (int, float))
429
- args_match = args_is_num and args_score >= 0.8
430
- args_lenient = args_is_num and args_score >= 0.5
431
-
432
- tool_alt = ""
433
- scoring_err = r.get('replay_error') or ""
434
- if g_name and not p_name:
435
- scoring_err = f"Expected tool call ({g_name}), got text response"
436
- elif not g_name and p_name:
437
- scoring_err = f"Expected text, got tool call ({p_name})"
438
- elif g_name and p_name and not name_match and lenient:
439
- tool_alt = f"Used {p_name} instead of {g_name} (functionally similar)"
440
-
441
- rows.append({
442
- 'SessionRowIndex': i,
443
- 'AiAgentSessionId': r['session_id'],
444
- 'GoldenType': r.get('golden_type', classify_golden_type(g_name)),
445
- 'Utterance': r['user_utterance'],
446
- 'GoldenAnswer': json.dumps(golden),
447
- 'PredictedAnswer': json.dumps(predicted),
448
- 'FunctionNameAgreement': name_match,
449
- 'FunctionNameAgreementLenient': lenient,
450
- 'PassStrict': name_match and args_match,
451
- 'FunctionArgsAgreement': args_match,
452
- 'FunctionArgsAgreementLenient': args_lenient,
453
- 'FunctionArgsScore': args_score if args_is_num else None,
454
- 'FunctionArgsScoreReason': r.get('args_reasoning', ''),
455
- 'ToolAltReason': tool_alt,
456
- 'SchemaViolationNotes': '',
457
- 'ScoringError': scoring_err,
458
- 'BaselineModel': cr.get('original_model', ''),
459
- 'ReplayModel': cr.get('replay_model', REPLAY_MODEL),
460
- 'OrgId': ORG_ID,
461
- 'AgentApiName': AGENT_API_NAME,
462
- })
463
-
464
- recs[str(i)] = {
465
- 'messages': r.get('replay_messages', []),
466
- 'tools': [t['function']['name'] for t in (r.get('replay_tools') or [])],
467
- 'session_id': r['session_id'],
468
- }
469
-
470
- return {
471
- 'metadata': {
472
- 'baseline_model': 'openai/gpt-4.1',
473
- 'replay_model': REPLAY_MODEL,
474
- 'org_id': ORG_ID,
475
- 'agent_api_name': AGENT_API_NAME,
476
- 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
477
- 'total_rows': len(rows),
478
- },
479
- 'rows': rows,
480
- 'recs': recs,
481
- }
482
-
483
-
484
- # ---------------------------------------------------------------------------
485
- # Main pipeline
486
- # ---------------------------------------------------------------------------
487
- def main():
488
- print("=" * 60)
489
- print("SFAP Replay Pipeline")
490
- print("=" * 60)
491
-
492
- # 1. Load STDM conversation data
493
- print(f"\n[Phase 1] Loading STDM data from {STDM_DATA_FILE}...")
494
- with open(STDM_DATA_FILE) as f:
495
- logs = json.load(f)['result']['logs']
496
- idx = logs.find('DEBUG|STDM_RESULT:')
497
- convos = json.loads(logs[idx + len('DEBUG|STDM_RESULT:'):].split('\n')[0].strip())
498
- print(f" Loaded {len(convos)} conversations")
499
-
500
- # Load LLM step details
501
- all_llm_steps = []
502
- for f_path in LLM_STEPS_FILES:
503
- f_path = f_path.strip()
504
- try:
505
- all_llm_steps.extend(json.load(open(f_path)))
506
- except:
507
- pass
508
- llm_step_map = {s['step_id']: s for s in all_llm_steps}
509
- print(f" Loaded {len(all_llm_steps)} LLM step details")
510
-
511
- # 2. Build replay targets — both router (topic) and action steps per turn
512
- print("\n[Phase 1.5] Building replay targets...")
513
- replay_targets = []
514
-
515
- for convo in convos:
516
- session_id = convo['session_id']
517
- for turn_idx, turn in enumerate(convo.get('turns', [])):
518
- user_msg = None
519
- output_msg = None
520
- for msg in turn.get('messages', []):
521
- if msg.get('message_type') == 'Input' and msg.get('text'):
522
- user_msg = msg['text'].strip()
523
- if msg.get('message_type') == 'Output' and msg.get('text'):
524
- output_msg = msg['text'].strip()
525
-
526
- if not user_msg:
527
- continue
528
-
529
- topic = turn.get('topic')
530
-
531
- # Collect all LLM steps with tools, separating router vs action
532
- router_step = None
533
- action_step = None
534
-
535
- for step in turn.get('steps', []):
536
- if step.get('step_type') != 'LLM_STEP' or not step.get('input'):
537
- continue
538
- try:
539
- step_input = json.loads(step['input'])
540
- except Exception:
541
- continue
542
-
543
- tools_from_input = step_input.get('gen_ai.request.tools', [])
544
- if not tools_from_input:
545
- continue
546
-
547
- if _is_routing_tools(tools_from_input):
548
- if not router_step:
549
- router_step = (step, step_input, tools_from_input)
550
- else:
551
- if not action_step:
552
- action_step = (step, step_input, tools_from_input)
553
-
554
- # --- Emit router target (golden_type = "topic") ---
555
- if router_step:
556
- r_step, r_input, r_tools = router_step
557
- r_msgs = r_input.get('gen_ai.input.messages', [])
558
- r_tool_name, r_tool_args = _extract_tool_call_from_step_output(r_step)
559
- replay_targets.append({
560
- 'session_id': session_id,
561
- 'turn_index': turn_idx,
562
- 'user_utterance': user_msg,
563
- 'topic': topic,
564
- 'golden_type': 'topic',
565
- 'original_tool_name': r_tool_name,
566
- 'original_tool_args': r_tool_args,
567
- 'original_text': output_msg,
568
- 'prompt': (llm_step_map.get(r_step.get('step_id', ''), {})
569
- .get('prompt', '')),
570
- 'step_name': r_step.get('name', ''),
571
- 'step_tools': r_tools,
572
- 'step_messages': r_msgs,
573
- })
574
-
575
- # --- Emit action target (golden_type = "action") ---
576
- if action_step:
577
- a_step, a_input, a_tools = action_step
578
- a_msgs = a_input.get('gen_ai.input.messages', [])
579
- a_tool_name, a_tool_args = _extract_tool_call_from_step_output(a_step)
580
-
581
- # Fallback: fold ACTION_STEP tool call onto this target
582
- if not a_tool_name:
583
- for step in turn.get('steps', []):
584
- if step.get('step_type') == 'ACTION_STEP':
585
- try:
586
- si = json.loads(step.get('input', '{}') or '{}')
587
- name = si.get('actionName', '').split('.')[-1]
588
- if name:
589
- a_tool_name = name
590
- args = si.get('actionInput', '')
591
- a_tool_args = json.dumps(args) if isinstance(args, dict) else (args or '')
592
- break
593
- except Exception:
594
- pass
595
-
596
- # Fallback: check llm_step_map
597
- if not a_tool_name:
598
- step_id = a_step.get('step_id')
599
- if step_id in llm_step_map:
600
- resp = llm_step_map[step_id].get('llm_response', '') or ''
601
- name, args, _ = extract_original_tool_call(resp)
602
- if name:
603
- a_tool_name = name
604
- a_tool_args = args
605
-
606
- replay_targets.append({
607
- 'session_id': session_id,
608
- 'turn_index': turn_idx,
609
- 'user_utterance': user_msg,
610
- 'topic': topic,
611
- 'golden_type': 'action',
612
- 'original_tool_name': a_tool_name,
613
- 'original_tool_args': a_tool_args,
614
- 'original_text': output_msg,
615
- 'prompt': (llm_step_map.get(a_step.get('step_id', ''), {})
616
- .get('prompt', '')),
617
- 'step_name': a_step.get('name', ''),
618
- 'step_tools': a_tools,
619
- 'step_messages': a_msgs,
620
- })
621
-
622
- topic_count = sum(1 for t in replay_targets if t['golden_type'] == 'topic')
623
- action_count = sum(1 for t in replay_targets if t['golden_type'] == 'action')
624
- print(f" Built {len(replay_targets)} replay targets ({topic_count} router, {action_count} action)")
625
- for t in replay_targets:
626
- print(f" Turn {t['turn_index']} [{t['golden_type']:6s}]: tool={t['original_tool_name']} user=\"{t['user_utterance'][:55]}\"")
627
-
628
- # 3. Replay via SFAP
629
- print(f"\n[Phase 2] Replaying via SFAP (model: {REPLAY_MODEL})...")
630
- results = []
631
-
632
- for i, target in enumerate(replay_targets):
633
- gtype = target.get('golden_type', '?')
634
- print(f"\n [{i+1}/{len(replay_targets)}] Turn {target['turn_index']} [{gtype}]: \"{target['user_utterance'][:50]}\"")
635
-
636
- # Parse messages from prompt text, or fall back to structured STDM messages
637
- messages = parse_prompt_to_messages(target['prompt'])
638
- if not messages and target.get('step_messages'):
639
- messages = convert_stdm_messages_for_sfap(target['step_messages'])
640
- if not messages:
641
- print(f" SKIP: Could not parse prompt into messages")
642
- continue
643
-
644
- # Use tool definitions from STDM step input (dynamic-only, no hardcoded fallback)
645
- tools = target.get('step_tools')
646
- if tools:
647
- tools = sanitize_tools_for_sfap(tools)
648
-
649
- print(f" Messages: {len(messages)}, Tools: {'yes' if tools else 'no'}")
650
-
651
- # Call SFAP
652
- try:
653
- sfap_resp = call_sfap(messages, REPLAY_MODEL, tools=tools, max_tokens=2048)
654
- except Exception as e:
655
- print(f" ERROR: {e}")
656
- results.append({**target, 'replayed_tool_name': None, 'replayed_tool_args': None,
657
- 'replayed_text': str(e), 'replay_error': str(e),
658
- 'replay_messages': messages, 'replay_tools': tools})
659
- continue
660
-
661
- if "error" in sfap_resp:
662
- print(f" ERROR: {sfap_resp['error']}")
663
- results.append({**target, 'replayed_tool_name': None, 'replayed_tool_args': None,
664
- 'replayed_text': sfap_resp.get('body', ''), 'replay_error': sfap_resp['error'],
665
- 'replay_messages': messages, 'replay_tools': tools})
666
- continue
667
-
668
- # Extract tool call from SFAP response
669
- rep_tool_name, rep_tool_args, rep_text = extract_tool_call(sfap_resp)
670
-
671
- print(f" Original: tool={target['original_tool_name']}, args={str(target['original_tool_args'])[:80]}")
672
- print(f" Replayed: tool={rep_tool_name}, args={str(rep_tool_args)[:80]}")
673
- if rep_text:
674
- print(f" Text: {str(rep_text)[:100]}")
675
-
676
- results.append({
677
- **target,
678
- 'replayed_tool_name': rep_tool_name,
679
- 'replayed_tool_args': rep_tool_args,
680
- 'replayed_text': rep_text,
681
- 'replay_error': None,
682
- 'replay_messages': messages,
683
- 'replay_tools': tools,
684
- })
685
-
686
- # Small delay to avoid rate limiting
687
- time.sleep(0.5)
688
-
689
- # 4. Score
690
- print(f"\n[Phase 3] Scoring...")
691
- csv_rows = []
692
-
693
- for r in results:
694
- row = {
695
- 'session_id': r['session_id'],
696
- 'utterance': r['user_utterance'],
697
- 'golden_type': r.get('golden_type', ''),
698
- 'original_model': 'openai/gpt-4.1',
699
- 'replay_model': REPLAY_MODEL,
700
- 'actual_topic': r.get('topic', ''),
701
- 'replayed_topic': r.get('topic', ''),
702
- 'topic_match': 1 if r.get('topic') else '',
703
- 'actual_action': r.get('original_tool_name', ''),
704
- 'replayed_action': r.get('replayed_tool_name', ''),
705
- 'function_match_score': '',
706
- 'function_args_score': '',
707
- 'actual_outcome': r.get('original_text', ''),
708
- 'replayed_outcome': r.get('replayed_text', ''),
709
- 'text_resp_score': '',
710
- }
711
-
712
- # Function name match
713
- if r.get('original_tool_name') and r.get('replayed_tool_name'):
714
- row['function_match_score'] = 1 if r['original_tool_name'] == r['replayed_tool_name'] else 0
715
- print(f" Turn {r['turn_index']} [{r.get('golden_type', '?')}]: func match = {row['function_match_score']} "
716
- f"({r['original_tool_name']} vs {r['replayed_tool_name']})")
717
-
718
- # Judge arguments on exact OR lenient name match
719
- lenient = names_match_lenient(r['original_tool_name'], r['replayed_tool_name'])
720
- if (row['function_match_score'] == 1 or lenient) and r.get('original_tool_args') and r.get('replayed_tool_args'):
721
- label = "exact" if row['function_match_score'] == 1 else "lenient"
722
- print(f" Judging arguments ({label} name match)...")
723
- score, reasoning = judge_args(
724
- r['replayed_tool_args'], r['original_tool_args'],
725
- r['original_tool_name'], r['user_utterance'], JUDGE_MODEL
726
- )
727
- row['function_args_score'] = score if score is not None else ''
728
- r['args_reasoning'] = reasoning or ''
729
- print(f" Args score: {score} - {str(reasoning)[:100]}")
730
- time.sleep(0.5)
731
- elif r.get('original_tool_name') and not r.get('replayed_tool_name'):
732
- row['function_match_score'] = 0
733
- print(f" Turn {r['turn_index']}: func match = 0 (expected {r['original_tool_name']}, got text)")
734
- elif not r.get('original_tool_name') and r.get('replayed_tool_name'):
735
- row['function_match_score'] = 0
736
- print(f" Turn {r['turn_index']}: func match = 0 (expected text, got {r['replayed_tool_name']})")
737
-
738
- # Text response scoring — for turns where replayed model returned text
739
- if r.get('original_text') and r.get('replayed_text'):
740
- print(f" Turn {r['turn_index']}: Judging text response...")
741
- score, reasoning = judge_text(
742
- r['replayed_text'], r['original_text'], r['user_utterance'], JUDGE_MODEL
743
- )
744
- row['text_resp_score'] = score if score is not None else ''
745
- r['text_reasoning'] = reasoning or ''
746
- print(f" Text score: {score} - {str(reasoning)[:100]}")
747
- time.sleep(0.5)
748
-
749
- csv_rows.append(row)
750
-
751
- # 5. Write CSV
752
- print(f"\n[Phase 4] Writing CSV to {OUTPUT_CSV}...")
753
- fieldnames = [
754
- 'session_id', 'utterance', 'golden_type', 'original_model', 'replay_model',
755
- 'actual_topic', 'replayed_topic', 'topic_match',
756
- 'actual_action', 'replayed_action', 'function_match_score',
757
- 'function_args_score',
758
- 'actual_outcome', 'replayed_outcome', 'text_resp_score'
759
- ]
760
- with open(OUTPUT_CSV, 'w', newline='') as f:
761
- writer = csv.DictWriter(f, fieldnames=fieldnames)
762
- writer.writeheader()
763
- writer.writerows(csv_rows)
764
-
765
- # 5b. Write enriched JSON for HTML report
766
- print(f" Writing JSON to {OUTPUT_JSON}...")
767
- report_data = build_report_json(results, csv_rows)
768
- with open(OUTPUT_JSON, 'w') as f:
769
- json.dump(report_data, f, indent=2, default=str)
770
-
771
- # 6. Summary
772
- print(f"\n{'=' * 60}")
773
- print(f"REPLAY SUMMARY")
774
- print(f"{'=' * 60}")
775
- print(f"Sessions: {len(set(r['session_id'] for r in results))}")
776
- print(f"Total turns replayed: {len(results)}")
777
- print(f"Replay model: {REPLAY_MODEL}")
778
-
779
- func_matches = [r['function_match_score'] for r in csv_rows if r['function_match_score'] != '']
780
- func_args = [r['function_args_score'] for r in csv_rows if r['function_args_score'] != '']
781
- text_scores = [r['text_resp_score'] for r in csv_rows if r['text_resp_score'] != '']
782
- topic_matches = [r['topic_match'] for r in csv_rows if r['topic_match'] != '']
783
-
784
- if topic_matches:
785
- print(f"\nTopic Agreement: {sum(topic_matches)/len(topic_matches)*100:.2f}% ({len(topic_matches)} turns)")
786
- if func_matches:
787
- print(f"Function Name Accuracy: {sum(func_matches)/len(func_matches):.4f} ({len(func_matches)} turns)")
788
- if func_args:
789
- print(f"Function Argument Accuracy: {sum(func_args)/len(func_args):.4f} ({len(func_args)} turns)")
790
- if text_scores:
791
- print(f"Text Response Accuracy: {sum(text_scores)/len(text_scores):.4f} ({len(text_scores)} turns)")
792
-
793
- # Breakdown by golden_type
794
- for gtype in ('topic', 'action'):
795
- gt_rows = [r for r in csv_rows if r.get('golden_type') == gtype]
796
- if not gt_rows:
797
- continue
798
- gt_func = [r['function_match_score'] for r in gt_rows if r['function_match_score'] != '']
799
- gt_args = [r['function_args_score'] for r in gt_rows if r['function_args_score'] != '']
800
- label = "Router" if gtype == "topic" else "Action"
801
- print(f"\n [{label} steps: {len(gt_rows)} targets]")
802
- if gt_func:
803
- print(f" Function Name Accuracy: {sum(gt_func)/len(gt_func):.4f} ({len(gt_func)} turns)")
804
- if gt_args:
805
- print(f" Function Argument Accuracy: {sum(gt_args)/len(gt_args):.4f} ({len(gt_args)} turns)")
806
-
807
- print(f"\nCSV saved to: {OUTPUT_CSV}")
808
-
809
-
810
- if __name__ == '__main__':
811
- main()
345
+ for fut in futures: # a bug: stop spending calls; results.jsonl keeps what finished
346
+ fut.cancel()
347
+ raise
348
+ # The pool has shut down, so calls that were in flight at an interrupt are done: keep them.
349
+ for fut in futures:
350
+ if fut not in collected and not fut.cancelled() and fut.exception() is None:
351
+ rec = fut.result()
352
+ records.append(rec)
353
+ results.append_jsonl(f, rec)
354
+ order = {u["unit_id"]: i for i, u in enumerate(units)}
355
+ records.sort(key=lambda r: (order[r["unit_id"]], r["repeat"]))
356
+ results.write_jsonl(path, records)
357
+ return records, interrupted
358
+
359
+
360
+ def print_summary(summary, out_dir):
361
+ c, o = summary["counts"], summary["overall"]
362
+ skipped = ", ".join(f"{k} {v}" for k, v in c["skipped_steps"].items()) or "none"
363
+ print("\n" + "=" * 64)
364
+ print(f"units {c['units']} x k={summary['config'].get('k')} | scored {c['scored']} | "
365
+ f"infra errors {c['infra_error']} ({100 * c['infra_rate']:.1f}%) | skipped steps: {skipped}")
366
+ if c["infra_by_kind"]:
367
+ print(" infra errors by kind: " + ", ".join(f"{k} {v}" for k, v in c["infra_by_kind"].items()))
368
+ if o["n"]:
369
+ print(f"agreement {o['agreement']:.3f} strict {o['strict']:.3f} ({o['match']}/{o['n']} match)")
370
+ for kind, agg in summary["by_kind"].items():
371
+ print(f" {kind:<16} n={agg['n']:<4} agreement {agg['agreement']:.3f}")
372
+ for split, agg in summary["by_split"].items():
373
+ print(f" split {split:<10} n={agg['n']:<4} agreement {agg['agreement']:.3f}")
374
+ t = summary["topic"]
375
+ if t["n"]:
376
+ print(f" topic (router go_to) {t['match']}/{t['n']}")
377
+ print("buckets: " + ", ".join(f"{b} {n}" for b, n in summary["by_bucket"].items()))
378
+ cache = summary["config"].get("cache")
379
+ if cache:
380
+ print(f"cache {cache['dir']}: {cache['hits']} answers reused, {cache['calls']} calls made (replay and judge)"
381
+ + (f"; WARNING: {cache['write_errors']} answers could not be stored" if cache["write_errors"] else ""))
382
+ refresh = summary["config"].get("token_refresh")
383
+ if refresh and (refresh["refreshes"] or refresh["failures"]):
384
+ print(f"SFAP token (--token-org {refresh['org']}): {refresh['refreshes']} new tokens minted"
385
+ + (f"; {refresh['failures']} refreshes failed (see the WARNING above)" if refresh["failures"] else ""))
386
+ unauthorized = summary["counts"]["infra_by_kind"].get("http_401")
387
+ if unauthorized and not refresh:
388
+ print(f"{unauthorized} calls were refused with HTTP 401: the SFAP token has probably expired (it lasts "
389
+ "about 2 h). Run resolve-token.sh again before a rerun. Pass --token-org <alias> to refresh it "
390
+ "during the run, and --cache-dir so that a rerun sends only what is unanswered")
391
+ if c.get("timed_out_units"):
392
+ print(f"{c['timed_out_units']} units timed out on every repeat: the model may not finish them (a runaway "
393
+ "answer). Here they are infra errors, left out of the scores; bt_compare scores them 0 (no answer) "
394
+ "against a run that answered them on another request or model")
395
+ print(f"outputs: {out_dir}/ units.jsonl results.jsonl summary.json replay_results_sfap.csv")
396
+ print(f"report: python3 {os.path.relpath(os.path.join(os.path.dirname(__file__), 'build_report.py'))} {out_dir}")
397
+
398
+
399
+ def main(argv=None):
400
+ args = parse_args(argv)
401
+ cfg = load_agent_config(args.agent_config)
402
+ if args.router_nodes:
403
+ router_nodes = [n.strip() for n in args.router_nodes.split(",") if n.strip()]
404
+ else:
405
+ router_nodes = cfg.get("router_nodes") or list(unitlib.DEFAULT_ROUTER_NODES)
406
+ base_url, token_file, tenant = sfap_config(args.token_info)
407
+ org_id = args.org_id or (tenant.rstrip("/").rsplit("/", 1)[-1] if tenant else None)
408
+
409
+ prepare_out_dir(args.out_dir, args.overwrite)
410
+ units, skipped, inputs, dupes = load_units(args, set(router_nodes), org_id)
411
+ units, exclude_info = exclude_sessions(units, skipped, args) # another run's sessions: not input at all
412
+ session_sets, versions_report = attribute_versions(units, args) # on every unit: a session's other steps count
413
+ split_info = label_splits(units, args)
414
+ units = select_units(units, skipped, args, session_sets, versions_report)
415
+ if versions_report is not None:
416
+ results.write_json(os.path.join(args.out_dir, "versions.json"), versions_report)
417
+ results.write_jsonl(os.path.join(args.out_dir, "units.jsonl"), units)
418
+ kinds = Counter(results.unit_kind(u) for u in units)
419
+ print(f"{len(units)} units ({', '.join(f'{k} {v}' for k, v in sorted(kinds.items()))}); "
420
+ f"skipped steps: {dict(skipped) or 'none'}" + (f"; duplicate sessions ignored: {dupes}" if dupes else ""))
421
+ if not units:
422
+ raise SystemExit("no units to replay (the skipped steps above say why)")
423
+ requests, patch_info = apply_patch(units, args.patch, args.out_dir)
424
+ if args.dry_run:
425
+ print(f"dry run: wrote {args.out_dir}/units.jsonl" + (" and patch_coverage.json" if args.patch else ""))
426
+ return 0
427
+
428
+ candidates = sorted({u["request"]["model"] for u in units}) if args.model == "trace" else [args.model]
429
+ try:
430
+ judgelib.check_judge_model(args.judge_model, candidates)
431
+ refresher = (tokenlib.TokenRefresher(token_file, args.token_org, base_url, tenant)
432
+ if args.token_org and token_file else None)
433
+ client = SfapClient(base_url, token_file, tenant, timeout=args.timeout, max_retries=args.max_retries,
434
+ on_unauthorized=refresher)
435
+ if args.cache_dir:
436
+ client = cachelib.CachingClient(client, args.cache_dir)
437
+ except (ValueError, OSError) as e:
438
+ raise SystemExit(f"config error: {e}")
439
+ family = judgelib.same_family(args.judge_model, candidates)
440
+ if family:
441
+ print(f"WARNING: judge {args.judge_model} is the same model family as {', '.join(family)}; "
442
+ "its text verdicts may favour that model")
443
+ warn_loose_mode(token_file, "the SFAP token file", "0600")
444
+ if args.cache_dir:
445
+ warn_loose_mode(args.cache_dir, f"--cache-dir {args.cache_dir}", "0700")
446
+ judge = judgelib.Judge(client, args.judge_model)
447
+ equivalence = scoring.Equivalence(cfg.get("equivalent_tools"))
448
+
449
+ config = {"model": args.model, "candidate_models": candidates, "judge_model": args.judge_model,
450
+ "judge_same_family": family, "steps": args.steps, "router_nodes": sorted(router_nodes),
451
+ "equivalent_tools": cfg.get("equivalent_tools") or [], "arg_defaults": cfg.get("arg_defaults") or {},
452
+ "k": args.k, "seed": args.seed,
453
+ "limit": args.limit, "timeout": args.timeout, "max_retries": args.max_retries,
454
+ "concurrency": args.concurrency, "inputs": [os.path.basename(p) for p in inputs],
455
+ "org": org_id, "agent": args.agent, "sfap_base_url": base_url, "patch": patch_info,
456
+ "versions": None if versions_report is None else {
457
+ "source": versions_report["source"], "labels": [v["label"] for v in versions_report["versions"]],
458
+ "selected": args.agent_version},
459
+ "split": split_info, "exclude_sessions": exclude_info,
460
+ "cache": {"dir": os.path.basename(os.path.abspath(args.cache_dir))} if args.cache_dir else None,
461
+ "token_refresh": {"org": args.token_org} if refresher else None,
462
+ "code": results.code_fingerprint(),
463
+ "started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
464
+ print(f"replaying {len(units)} units x {args.k} on {args.model} (judge {args.judge_model}, "
465
+ f"concurrency {args.concurrency})")
466
+ records, interrupted = replay(units, requests, args, client, judge, equivalence, args.out_dir,
467
+ cfg.get("arg_defaults"))
468
+ config["finished"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
469
+ config["interrupted"] = interrupted
470
+ if args.cache_dir:
471
+ config["cache"].update(client.stats())
472
+ if refresher:
473
+ config["token_refresh"].update(refresher.stats())
474
+ summary = results.summarize(records, config, skipped, unit_count=len(units))
475
+ results.write_json(os.path.join(args.out_dir, "summary.json"), summary)
476
+ results.write_csv(os.path.join(args.out_dir, "replay_results_sfap.csv"), records)
477
+ print_summary(summary, args.out_dir)
478
+ if interrupted:
479
+ return 130
480
+ return 0 if summary["counts"]["scored"] else 1
481
+
482
+
483
+ if __name__ == "__main__":
484
+ try:
485
+ sys.exit(main())
486
+ except stdm.StdmError as e:
487
+ sys.exit(f"STDM input error: {redact(str(e))}")