@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.
- package/README.md +55 -5
- package/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
- package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
- package/skills/dx-lifecycle-center/SKILL.md +5 -2
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
- package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
- package/skills/dx-lifecycle-center/references/deploy.md +2 -2
- package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
- package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
- package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
- package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
- package/skills/dx-org-devhub-configure/SKILL.md +85 -84
- package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
- package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
- package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
- package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
- package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
- package/skills/dx-scratch-def-configure/SKILL.md +163 -0
- package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
- package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
- package/skills/dx-scratch-def-configure/references/features.json +3270 -0
- package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
- package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
- package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
- package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
- package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
- package/skills/education-cloud-domain-configure/SKILL.md +1 -1
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
- package/skills/experience-design-validate/SKILL.md +1 -1
- package/skills/experience-search-coordinate/SKILL.md +18 -12
- package/skills/experience-search-coordinate/references/content-route.md +79 -1
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
- package/skills/platform-apex-generate/SKILL.md +14 -11
- package/skills/platform-apex-test-generate/SKILL.md +2 -1
- package/skills/platform-datamask-run/SKILL.md +52 -71
- package/skills/platform-datamask-run/references/api-surface.md +10 -4
- package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
- package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
- package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
- package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
- package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
- package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
- package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
- package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
- package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
- package/skills/platform-policy-rule-generate/SKILL.md +84 -232
- package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
- package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
- package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
- package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
- package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
- package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
- package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
- package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
- package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
- package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
- package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
- package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
- package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
- package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
- package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
- package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
- package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
- package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
- package/skills/platform-policy-rule-generate/references/templates.md +2 -2
- package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
- package/skills/platform-report-generate/SKILL.md +9 -4
- package/skills/platform-report-generate/references/chart-types.md +26 -2
- package/skills/platform-report-generate/references/column-names.md +43 -0
- package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
- package/skills/platform-report-generate/references/verification-checklist.md +10 -2
- package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
- package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
- 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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
320
|
-
"""
|
|
321
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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))}")
|