@salesforce/afv-skills 1.66.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 (165) hide show
  1. package/README.md +55 -5
  2. package/package.json +2 -4
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +26 -49
  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/commerce-react-storefront-create/SKILL.md +350 -0
  29. package/skills/consumer-goods-promotion-bo-api-deploy/SKILL.md +2 -2
  30. package/skills/consumer-goods-promotion-bo-api-deploy/references/generate-and-wire.md +28 -16
  31. package/skills/consumer-goods-promotion-bo-api-deploy/references/smoke-and-verify.md +18 -1
  32. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  33. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  34. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  35. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  36. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  37. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  38. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  39. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  40. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  41. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  42. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  43. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  44. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  45. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  46. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  47. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  48. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  49. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  50. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  51. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  52. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  53. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  54. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  55. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  56. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  57. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  58. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  59. package/skills/education-cloud-domain-configure/SKILL.md +22 -24
  60. package/skills/education-cloud-domain-configure/references/dependent-features.md +9 -0
  61. package/skills/education-cloud-domain-configure/references/domain-features.md +15 -0
  62. package/skills/education-cloud-domain-configure/references/gotchas-extended.md +8 -0
  63. package/skills/education-cloud-domain-configure/references/setup-widgets.md +123 -0
  64. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  65. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  66. package/skills/experience-design-validate/SKILL.md +1 -1
  67. package/skills/experience-search-coordinate/SKILL.md +19 -13
  68. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  69. package/skills/experience-search-coordinate/references/media-route.md +30 -4
  70. package/skills/experience-ui-bundle-features-generate/SKILL.md +1 -1
  71. package/skills/experience-ui-bundle-mfa-configure/SKILL.md +1 -1
  72. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  73. package/skills/platform-apex-generate/SKILL.md +14 -11
  74. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  75. package/skills/platform-datamask-run/SKILL.md +52 -71
  76. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  77. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  78. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  79. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  80. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  81. package/skills/platform-metadata-deploy/README.md +1 -1
  82. package/skills/platform-metadata-deploy/SKILL.md +114 -15
  83. package/skills/platform-metadata-deploy/references/agent-deployment-guide.md +3 -4
  84. package/skills/platform-metadata-deploy/references/orchestration.md +2 -3
  85. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  86. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  87. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  88. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  89. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  90. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  91. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  92. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  93. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  94. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  95. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  96. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  97. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  98. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  99. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  100. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  101. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  102. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  103. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  104. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  105. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  106. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  107. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  108. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  109. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  110. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  111. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  112. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  113. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  114. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  115. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  116. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  117. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  118. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  119. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  120. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  121. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  122. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  123. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  124. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  125. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  126. package/skills/platform-report-generate/SKILL.md +9 -4
  127. package/skills/platform-report-generate/references/chart-types.md +26 -2
  128. package/skills/platform-report-generate/references/column-names.md +43 -0
  129. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  130. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  131. package/skills/platform-value-set-generate/SKILL.md +19 -19
  132. package/skills/platform-widget-generate/SKILL.md +25 -51
  133. package/skills/platform-widget-generate/examples/formulas.json +19 -2
  134. package/skills/platform-widget-generate/examples/localized-labels.json +57 -0
  135. package/skills/platform-widget-generate/references/widget-actions.md +29 -0
  136. package/skills/platform-widget-generate/references/widget-bundle-validation.md +1 -0
  137. package/skills/platform-widget-generate/references/widget-formulas.md +24 -7
  138. package/skills/platform-widget-generate/references/widget-gotchas.md +10 -0
  139. package/skills/platform-widget-generate/references/widget-labels.md +78 -0
  140. package/skills/platform-widget-generate/references/widget-styling.md +12 -0
  141. package/skills/sales-call-scoring-configure/SKILL.md +17 -10
  142. package/skills/sales-call-scoring-configure/scripts/enable-call-scoring.sh +55 -33
  143. package/skills/service-digital-engagement-channel-configure/SKILL.md +11 -7
  144. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  145. package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +123 -16
  146. package/skills/service-digital-engagement-deployment-configure/SKILL.md +37 -100
  147. package/skills/service-digital-engagement-deployment-configure/assets/esd_web_update_template.xml +83 -8
  148. package/skills/service-digital-engagement-deployment-configure/examples/esd_web_prechat_fields.xml +74 -0
  149. package/skills/service-digital-engagement-deployment-configure/references/connect_api_creation.md +13 -3
  150. package/skills/service-digital-engagement-deployment-configure/references/deployment_settings.md +13 -78
  151. package/skills/service-digital-engagement-deployment-configure/references/onboarding_contract.md +5 -6
  152. package/skills/service-digital-engagement-deployment-configure/references/prechat_forms.md +245 -0
  153. package/skills/service-itsm-agentic-setup-incident-management/SKILL.md +164 -120
  154. package/skills/service-itsm-agentic-setup-incident-management/examples/output-templates.md +61 -37
  155. package/skills/service-itsm-agentic-setup-incident-management/references/audit-tracking.md +42 -0
  156. package/skills/service-itsm-agentic-setup-incident-management/references/broadcast-communications.md +23 -0
  157. package/skills/service-itsm-agentic-setup-incident-management/references/email-to-incident.md +23 -0
  158. package/skills/service-itsm-agentic-setup-incident-management/references/incident-dashboards.md +25 -0
  159. package/skills/service-itsm-agentic-setup-incident-management/references/incident-persona-psg.md +30 -146
  160. package/skills/service-itsm-agentic-setup-incident-management/references/incident-preferences.md +32 -136
  161. package/skills/service-itsm-agentic-setup-incident-management/references/major-incident-management.md +32 -155
  162. package/skills/service-itsm-agentic-setup-incident-management/references/manage-incidents-with-ai.md +45 -0
  163. package/skills/service-itsm-agentic-setup-incident-management/references/priority-matrix.md +32 -0
  164. package/skills/service-itsm-agentic-setup-incident-management/references/service-mgmt-privilege.md +30 -140
  165. package/skills/service-itsm-agentic-setup-incident-management/references/sla-milestones.md +110 -0
@@ -0,0 +1,238 @@
1
+ """SFAP /chat/generations client and the production request shape.
2
+
3
+ The token is read from its 0600 file on every call: it never enters argv, the environment
4
+ of child processes, or any artifact, and a token refreshed in place is picked up without a
5
+ restart. On a 401, on_unauthorized(token_used) may refresh it (tokens.TokenRefresher); a True
6
+ return sends the request once more. Failures come back as status 'infra_error' after
7
+ retries, never as an answer.
8
+
9
+ A timeout is retried once only: each one costs the whole timeout, and a model that runs away on a
10
+ request runs away again. The provider has its own limit (EinsteinBYOM: ~180 s, then a 502 whose
11
+ body says "Timeout"), recorded as provider_timeout; the default --timeout is above it, so that the
12
+ provider, not the client, cuts a slow answer off.
13
+
14
+ Production request fields (from captured gateway payloads) reproduced here:
15
+ tools [{"function": {name, description, parameters}}] (no "type")
16
+ tool_config {"mode": "auto", "allowed_tools": [all tools], "parallel_calls": true}
17
+ generation_settings {"num_generations": 1, penalties 0, "stop_sequences": [],
18
+ "parameters": {"seed": n}} — no max_tokens, no temperature
19
+ system_prompt_strategy "use_model_parameter"; enable_pii_masking false
20
+ Not reproduced: safety/bias scoring flags, trust settings, localization, turn ids, tags.
21
+ """
22
+
23
+ import copy
24
+ import hashlib
25
+ import json
26
+ import socket
27
+ import time
28
+ import urllib.error
29
+ import urllib.request
30
+
31
+ from .redact import redact, register_secret
32
+ from .units import parse_arguments
33
+
34
+ RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
35
+ # No answer in time: the client's timeout, or the provider's (a 502/504 whose body says so).
36
+ TIMEOUT_KINDS = ("timeout", "provider_timeout")
37
+ DEFAULT_TIMEOUT = 200
38
+
39
+
40
+ def sanitize_tools_for_sfap(tools):
41
+ """Drop additionalProperties and empty `required` (SFAP has rejected both with a 400)."""
42
+ sanitized = []
43
+ for tool in tools:
44
+ tool = copy.deepcopy(tool)
45
+ params = (tool.get("function") or {}).get("parameters") or {}
46
+ params.pop("additionalProperties", None)
47
+ if "required" in params and not params["required"]:
48
+ del params["required"]
49
+ sanitized.append(tool)
50
+ return sanitized
51
+
52
+
53
+ def to_sfap_tools(tools):
54
+ """Step tool definitions (gen_ai.request.tools) in the production request shape."""
55
+ return [{"function": t.get("function") or {}} for t in sanitize_tools_for_sfap(tools)]
56
+
57
+
58
+ def build_payload(model, messages, tools=None, seed=None, parameters=None):
59
+ payload = {"model": model, "messages": messages}
60
+ if tools:
61
+ payload["tools"] = to_sfap_tools(tools)
62
+ payload["tool_config"] = {
63
+ "mode": "auto",
64
+ "allowed_tools": [{"name": (t.get("function") or {}).get("name"), "type": "function"}
65
+ for t in tools],
66
+ "parallel_calls": True,
67
+ }
68
+ gen_params = {}
69
+ if seed is not None:
70
+ gen_params["seed"] = seed
71
+ gen_params.update(parameters or {})
72
+ payload["generation_settings"] = {
73
+ "num_generations": 1, "frequency_penalty": 0.0, "presence_penalty": 0.0,
74
+ "stop_sequences": [], "parameters": gen_params,
75
+ }
76
+ payload["system_prompt_strategy"] = "use_model_parameter"
77
+ payload["enable_pii_masking"] = False
78
+ return payload
79
+
80
+
81
+ def request_sha(messages, tools):
82
+ """Identity of a request's content (messages + tools), independent of model and seed."""
83
+ blob = json.dumps({"messages": messages, "tools": tools}, sort_keys=True,
84
+ separators=(",", ":"), ensure_ascii=False)
85
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
86
+
87
+
88
+ def _usage(body):
89
+ gd = body.get("generation_details") or {}
90
+ for holder in (gd.get("parameters"), body.get("parameters"), body):
91
+ if isinstance(holder, dict) and isinstance(holder.get("usage"), dict):
92
+ return holder["usage"]
93
+ return None
94
+
95
+
96
+ def parse_generation(body):
97
+ """{text, tool_calls, finish_reason, usage} from a 200 body, or None if it has no generation."""
98
+ gens = ((body or {}).get("generation_details") or {}).get("generations") or []
99
+ if not gens:
100
+ return None
101
+ g = gens[0]
102
+ calls = []
103
+ for inv in g.get("tool_invocations") or []:
104
+ fn = inv.get("function") or {}
105
+ args, raw = parse_arguments(fn.get("arguments"))
106
+ call = {"name": fn.get("name"), "args": args}
107
+ if raw is not None:
108
+ call["args_raw"] = raw
109
+ calls.append(call)
110
+ params = g.get("parameters") if isinstance(g.get("parameters"), dict) else {}
111
+ return {"text": g.get("content") or "", "tool_calls": calls,
112
+ "finish_reason": params.get("finish_reason") or g.get("finish_reason"),
113
+ "usage": _usage(body)}
114
+
115
+
116
+ class SfapClient:
117
+ def __init__(self, base_url, token_file, core_tenant_id, timeout=DEFAULT_TIMEOUT, max_retries=3,
118
+ backoff=2.0, rate_limit_backoff=10.0, timeout_retries=1, feature_id="AgentBacktesting",
119
+ app_context="EinsteinGPT", opener=None, sleep=time.sleep, on_unauthorized=None):
120
+ missing = [n for n, v in (("base_url", base_url), ("token_file", token_file),
121
+ ("core_tenant_id", core_tenant_id)) if not v]
122
+ if missing:
123
+ raise ValueError("SFAP config missing: " + ", ".join(missing)
124
+ + " (run scripts/resolve-token.sh and pass --token-info)")
125
+ self.url = base_url.rstrip("/") + "/chat/generations"
126
+ self.token_file = token_file
127
+ self.core_tenant_id = core_tenant_id
128
+ self.timeout = timeout
129
+ self.max_retries = max_retries
130
+ self.backoff = backoff
131
+ self.rate_limit_backoff = rate_limit_backoff
132
+ self.timeout_retries = timeout_retries
133
+ self.feature_id = feature_id
134
+ self.app_context = app_context
135
+ self._open = (opener.open if opener else urllib.request.urlopen)
136
+ self._sleep = sleep
137
+ self._on_unauthorized = on_unauthorized
138
+
139
+ def _token(self):
140
+ with open(self.token_file) as f:
141
+ token = f.read().strip()
142
+ if not token:
143
+ raise RuntimeError("SFAP token file is empty")
144
+ register_secret(token)
145
+ return token
146
+
147
+ def _delay(self, attempt, retry_after=None, http_status=None):
148
+ if retry_after:
149
+ try:
150
+ return min(60.0, float(retry_after))
151
+ except ValueError:
152
+ pass
153
+ if http_status == 429: # a quota, usually per minute: a few seconds' wait meets it again
154
+ return min(60.0, self.rate_limit_backoff * (2 ** (attempt - 1)))
155
+ return min(30.0, self.backoff * (2 ** (attempt - 1)))
156
+
157
+ def post(self, payload):
158
+ """POST once per attempt. Returns {status, http_status, body, error, error_kind, attempts, latency_ms}."""
159
+ data = json.dumps(payload).encode("utf-8")
160
+ attempts, timeouts, refreshed, t0 = 0, 0, False, time.monotonic()
161
+ while True:
162
+ attempts += 1
163
+ err_kind, err, http_status, retry_after = None, None, None, None
164
+ try:
165
+ token = self._token()
166
+ except (OSError, RuntimeError) as e:
167
+ return self._fail("token", f"cannot read the SFAP token file: {type(e).__name__}",
168
+ None, attempts, t0)
169
+ try:
170
+ req = urllib.request.Request(self.url, data=data, method="POST", headers={
171
+ "Authorization": f"Bearer {token}",
172
+ "Content-Type": "application/json",
173
+ "x-sfdc-app-context": self.app_context,
174
+ "x-client-feature-id": self.feature_id,
175
+ "x-sfdc-core-tenant-id": self.core_tenant_id,
176
+ })
177
+ with self._open(req, timeout=self.timeout) as resp:
178
+ raw = resp.read().decode("utf-8", "replace")
179
+ http_status = resp.status
180
+ try:
181
+ body = json.loads(raw)
182
+ except json.JSONDecodeError:
183
+ return self._fail("bad_json", f"HTTP {http_status}: response is not JSON: {redact(raw)[:200]}",
184
+ http_status, attempts, t0)
185
+ return {"status": "ok", "http_status": http_status, "body": body, "error": None,
186
+ "error_kind": None, "attempts": attempts,
187
+ "latency_ms": int((time.monotonic() - t0) * 1000)}
188
+ except urllib.error.HTTPError as e:
189
+ http_status = e.code
190
+ retry_after = e.headers.get("Retry-After") if e.headers else None
191
+ try:
192
+ body = e.read().decode("utf-8", "replace")
193
+ except Exception:
194
+ body = ""
195
+ finally:
196
+ e.close()
197
+ err_kind, err = f"http_{e.code}", f"HTTP {e.code}: {redact(body)[:300]}" # redact before cutting
198
+ if e.code in (502, 504) and "timeout" in body.lower():
199
+ err_kind = "provider_timeout"
200
+ if e.code == 401 and self._on_unauthorized and not refreshed:
201
+ refreshed = True
202
+ if self._on_unauthorized(token):
203
+ continue
204
+ if e.code not in RETRY_STATUS:
205
+ return self._fail(err_kind, err, http_status, attempts, t0)
206
+ except (socket.timeout, TimeoutError):
207
+ err_kind, err = "timeout", f"timeout after {self.timeout}s"
208
+ except urllib.error.URLError as e:
209
+ if isinstance(e.reason, (socket.timeout, TimeoutError)):
210
+ err_kind, err = "timeout", f"timeout after {self.timeout}s"
211
+ else:
212
+ err_kind, err = "network", f"network error: {e.reason}"
213
+ except (ConnectionError, OSError) as e:
214
+ err_kind, err = "network", f"network error: {e}"
215
+ if err_kind in TIMEOUT_KINDS:
216
+ timeouts += 1
217
+ if attempts > self.max_retries or timeouts > self.timeout_retries:
218
+ return self._fail(err_kind, err, http_status, attempts, t0)
219
+ self._sleep(self._delay(attempts, retry_after, http_status))
220
+
221
+ @staticmethod
222
+ def _fail(kind, err, http_status, attempts, t0):
223
+ return {"status": "infra_error", "http_status": http_status, "body": None,
224
+ "error": redact(err), "error_kind": kind, "attempts": attempts,
225
+ "latency_ms": int((time.monotonic() - t0) * 1000)}
226
+
227
+ def generate(self, payload):
228
+ """post() plus parsing; a 200 without a generation is an infra error too."""
229
+ res = self.post(payload)
230
+ if res["status"] != "ok":
231
+ res["response"] = None
232
+ return res
233
+ parsed = parse_generation(res["body"])
234
+ if parsed is None:
235
+ res.update(status="infra_error", error_kind="empty_response",
236
+ error="HTTP 200 without generation_details.generations")
237
+ res["response"] = parsed
238
+ return res
@@ -0,0 +1,136 @@
1
+ """Session splits (--val-fraction, --split) and excluded sessions (--exclude-sessions).
2
+
3
+ A unit's split depends on its session alone: sha256 of the salt and the session ID, read as a
4
+ number in [0, 1), gives "val" below the val fraction and "test" otherwise. So every step of a
5
+ session lands in one split, a session keeps its split when other sessions are added or
6
+ removed, and another salt gives an independent split.
7
+ """
8
+
9
+ import csv
10
+ import hashlib
11
+ import io
12
+ import json
13
+ import re
14
+ from collections import Counter
15
+
16
+ from . import stdm
17
+
18
+ DEFAULT_SALT = "bt-split/1"
19
+ SPLITS = ("test", "val")
20
+
21
+
22
+ class SplitError(ValueError):
23
+ pass
24
+
25
+
26
+ def session_split(session_id, val_fraction, salt=DEFAULT_SALT):
27
+ h = hashlib.sha256(f"{salt}\0{session_id}".encode("utf-8")).hexdigest()
28
+ return "val" if int(h[:13], 16) / 16 ** 13 < val_fraction else "test"
29
+
30
+
31
+ def label(units, val_fraction, salt=DEFAULT_SALT):
32
+ """Set each unit's split; returns {split: [sessions, units]}. Units that already carry
33
+ another split (an earlier run's labels) are refused: relabelling would mix the sets."""
34
+ new = [session_split(u["session_id"], val_fraction, salt) for u in units]
35
+ clash = sum(1 for u, s in zip(units, new) if u.get("split") not in (None, s))
36
+ if clash:
37
+ raise SplitError(f"{clash} units already carry another split (labelled with another salt or "
38
+ "fraction); use those, or split anew from --details")
39
+ for u, s in zip(units, new):
40
+ u["split"] = s
41
+ sessions = {u["session_id"]: u["split"] for u in units}
42
+ counts = Counter(sessions.values())
43
+ return {s: [counts[s], new.count(s)] for s in SPLITS}
44
+
45
+
46
+ def read_session_ids(path):
47
+ """Session IDs from a file that names sessions:
48
+ - one ID per line (blank lines and '#' comments skipped);
49
+ - JSONL with a session_id on each line: units.jsonl, results.jsonl;
50
+ - JSON: a list of objects with a session_id (bt_fetch's sessions.json or details.json, the
51
+ Apex class's output, also as an `sf apex run --json` file), or the old replay's
52
+ replay_results.json. Not a list of strings: step and generation IDs look like session IDs;
53
+ - CSV with a session_id column: replay_results_sfap.csv, the old replay's too.
54
+ Any other file is refused, so that it never passes as a list of IDs that excludes nothing. So is
55
+ an ID that is not letters, digits, '_', '.' and '-': matched exactly, " s1" would exclude nothing.
56
+ """
57
+ with open(path, encoding="utf-8-sig") as f: # -sig: a CSV saved by Excel starts with a BOM
58
+ text = f.read()
59
+ lines = [(n, s.strip()) for n, s in enumerate(text.split("\n"), 1)]
60
+ lines = [(n, s) for n, s in lines if s and not s.startswith("#")]
61
+ if not lines:
62
+ return set()
63
+ first = lines[0][1]
64
+ if first[0] in "[{":
65
+ try:
66
+ doc = json.loads(text)
67
+ except ValueError as e:
68
+ if first[0] == "[":
69
+ raise SplitError(f"not valid JSON ({e})") from None
70
+ return _jsonl_ids(lines)
71
+ return _document_ids(doc)
72
+ if "," in first or first == "session_id":
73
+ return _csv_ids(text)
74
+ for n, s in lines:
75
+ if not _SESSION_ID.match(s):
76
+ raise SplitError(f"line {n} is not a session ID, and the file is not JSON, JSONL or CSV")
77
+ return {s for _, s in lines}
78
+
79
+
80
+ _SESSION_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z")
81
+
82
+
83
+ def _jsonl_ids(lines):
84
+ ids = set()
85
+ for n, s in lines:
86
+ try:
87
+ row = json.loads(s)
88
+ except ValueError:
89
+ raise SplitError(f"line {n}: not JSON") from None
90
+ ids.add(_session_id(row.get("session_id") if isinstance(row, dict) else None, f"line {n}"))
91
+ return ids
92
+
93
+
94
+ def _document_ids(doc):
95
+ if isinstance(doc, list):
96
+ items = doc
97
+ elif not isinstance(doc, dict):
98
+ raise SplitError("JSON that is neither a list nor an object")
99
+ else:
100
+ try:
101
+ found = stdm.parse_sf_output(doc, "sf apex run output")
102
+ except stdm.StdmError as e:
103
+ raise SplitError(str(e)) from None
104
+ if found is not None: # an `sf apex run --json` file
105
+ if not found:
106
+ raise SplitError("an sf apex run output without an STDM_RESULT line")
107
+ items = [x for payload in found.values() for x in (payload if isinstance(payload, list) else [payload])]
108
+ elif isinstance(doc.get("rows"), list): # the old replay's replay_results.json
109
+ items = [{"session_id": r.get("AiAgentSessionId")} if isinstance(r, dict) else r for r in doc["rows"]]
110
+ else:
111
+ items = [doc]
112
+ ids = set()
113
+ for i, item in enumerate(items, 1):
114
+ if isinstance(item, str):
115
+ raise SplitError(f"item {i} is a string, not an object with a session_id (step IDs look the "
116
+ "same); write a list of session IDs one per line")
117
+ ids.add(_session_id(item.get("session_id") if isinstance(item, dict) else None, f"item {i}"))
118
+ return ids
119
+
120
+
121
+ def _csv_ids(text):
122
+ rows = csv.DictReader(io.StringIO(text))
123
+ if "session_id" not in (rows.fieldnames or ()):
124
+ raise SplitError("a CSV without a session_id column")
125
+ ids = set()
126
+ for i, row in enumerate(rows, 1):
127
+ ids.add(_session_id((row.get("session_id") or "").strip(), f"CSV row {i}"))
128
+ return ids
129
+
130
+
131
+ def _session_id(value, where):
132
+ if not isinstance(value, str) or not value:
133
+ raise SplitError(f"{where}: no session_id")
134
+ if not _SESSION_ID.match(value):
135
+ raise SplitError(f"{where}: its session_id is not a session ID")
136
+ return value
@@ -0,0 +1,98 @@
1
+ """Read STDM query results.
2
+
3
+ The skill's fetch phases run Apex with `sf apex run --json` and print each result as one
4
+ debug line, `...|DEBUG|STDM_RESULT:<json>` (or `STDM_RESULT_<suffix>:` when one script
5
+ prints several). The source echo of the script also contains `STDM_RESULT:`, which is why
6
+ only the `|DEBUG|` form is matched. Inside a debug message the log writes '|' as '&#124;';
7
+ that is undone before parsing (the old skill parser kept the entity, e.g. in prompts).
8
+ bt_fetch.py's scripts print several pages per query, `STDM_RESULT_<query>_<page>:`.
9
+ """
10
+
11
+ import json
12
+ import re
13
+
14
+ from .redact import redact
15
+
16
+ _MARKER = re.compile(r"\|DEBUG\|(STDM_RESULT(?:_[A-Za-z0-9_]+)?):")
17
+
18
+
19
+ class StdmError(Exception):
20
+ pass
21
+
22
+
23
+ def parse_apex_logs(logs):
24
+ """{marker: parsed JSON} for every STDM_RESULT debug line in an Apex log."""
25
+ found = {}
26
+ for m in _MARKER.finditer(logs):
27
+ line_end = logs.find("\n", m.end())
28
+ raw = logs[m.end():] if line_end < 0 else logs[m.end():line_end]
29
+ # The debug log escapes its own field separator inside messages: '|' -> '&#124;'.
30
+ raw = raw.strip().replace("&#124;", "|")
31
+ try:
32
+ found[m.group(1)] = json.loads(raw)
33
+ except json.JSONDecodeError as e:
34
+ raise StdmError(
35
+ f"{m.group(1)}: debug line is not valid JSON ({e.msg} at char {e.pos} of {len(raw)}). "
36
+ "The debug log was probably truncated (Apex caps a log at 20 MB); "
37
+ "fetch fewer sessions or steps per batch.") from None
38
+ return found
39
+
40
+
41
+ def parse_sf_output(data, where):
42
+ """{marker: payload} from the parsed output of `sf apex run --json`: {} when its log has no
43
+ STDM_RESULT line, None when data is not that output. A run that failed to compile or threw,
44
+ and an error of the sf CLI itself (no org, expired session), raise StdmError."""
45
+ if not isinstance(data, dict):
46
+ return None
47
+ res = data.get("result")
48
+ if isinstance(res, dict) and "logs" in res:
49
+ if res.get("success") is False or res.get("compiled") is False:
50
+ msg = str(res.get("compileProblem") or res.get("exceptionMessage") or "unknown error")
51
+ if res.get("compiled") is False and isinstance(res.get("line"), int) and res["line"] > 0:
52
+ msg += f" (line {res['line']}, column {res.get('column')})"
53
+ raise StdmError(f"{where}: Apex run failed: {redact(msg)[:300]}")
54
+ return parse_apex_logs(res.get("logs") or "")
55
+ if data.get("status") not in (None, 0) and isinstance(data.get("message"), str):
56
+ name = data.get("name")
57
+ raise StdmError(f"{where}: sf failed" + (f" ({name})" if isinstance(name, str) else "")
58
+ + f": {redact(data['message'])[:300]}")
59
+ return None
60
+
61
+
62
+ def load_result_file(path):
63
+ """{marker: payload} from an `sf apex run --json` output file.
64
+
65
+ A file holding plain JSON (a list or an object without the sf CLI envelope) is
66
+ returned as {None: payload}, so already-extracted files work too.
67
+ """
68
+ with open(path, encoding="utf-8") as f:
69
+ data = json.load(f)
70
+ found = parse_sf_output(data, path)
71
+ if found is None:
72
+ return {None: data}
73
+ if not found:
74
+ raise StdmError(f"{path}: no |DEBUG|STDM_RESULT line in the Apex log")
75
+ return found
76
+
77
+
78
+ def load_conversations(paths):
79
+ """Merge ConversationData lists from one or more details files.
80
+
81
+ Accepts getMultipleConversationDetails output (a list), getConversationDetails output
82
+ (one object), or several markers per file. The first copy of a session wins.
83
+ Returns (conversations, duplicate_count).
84
+ """
85
+ convos, seen, dupes = [], set(), 0
86
+ for path in paths:
87
+ for payload in load_result_file(path).values():
88
+ items = payload if isinstance(payload, list) else [payload]
89
+ for c in items:
90
+ if not isinstance(c, dict) or "turns" not in c or not isinstance(c.get("session_id"), str) \
91
+ or not c["session_id"].strip():
92
+ raise StdmError(f"{path}: expected ConversationData objects (a session_id, turns)")
93
+ if c["session_id"] in seen:
94
+ dupes += 1
95
+ continue
96
+ seen.add(c["session_id"])
97
+ convos.append(c)
98
+ return convos, dupes
@@ -0,0 +1,173 @@
1
+ """SFAP token refresh (--token-org): mint a new token when the run's token expires.
2
+
3
+ An SFAP token lasts about two hours, so a long replay outlives it. On an HTTP 401 the client
4
+ calls the refresher with the token that was refused. The refresher runs
5
+
6
+ bash scripts/resolve-token.sh --org <alias>
7
+
8
+ which writes a new token to a new 0600 temp file and prints that file's path. The new token
9
+ replaces the old one in the run's token file (atomically, mode 0600), the script's file is
10
+ deleted, and the client sends the request again, once.
11
+
12
+ - One mint per expiry: requests refused with the same token wait for the first refresh, then
13
+ retry with its token. A lock file next to the token file (<token file>.lock, empty) extends
14
+ this to other processes reading the same file.
15
+ - No loops: a token minted here and refused within FRESH_SECONDS is not an expired token (wrong
16
+ org, missing scope), and a mint that failed is not repeated for the same token. Those requests
17
+ stay http_401 infra errors.
18
+ - The new token must be for the run's SFAP endpoint and core tenant: another org's is not used.
19
+ - The token never enters argv or output: the script gets the org alias only, and its stdout (a
20
+ file path and IDs) is parsed, never printed.
21
+ """
22
+
23
+ import contextlib
24
+ import fcntl
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import signal
29
+ import subprocess
30
+ import tempfile
31
+ import threading
32
+ import time
33
+
34
+ from .redact import redact, register_secret
35
+
36
+ RESOLVE_TOKEN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "resolve-token.sh")
37
+ FRESH_SECONDS = 300
38
+ SCRIPT_FILE_PREFIX = "offexp-jwt." # resolve-token.sh's temp files: the only files deleted here
39
+
40
+
41
+ def read_token(path):
42
+ try:
43
+ with open(path) as f:
44
+ return f.read().strip()
45
+ except OSError:
46
+ return ""
47
+
48
+
49
+ def _digest(token):
50
+ return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
51
+
52
+
53
+ def _last_json(out):
54
+ """The script's JSON line, {} if there is none."""
55
+ for line in reversed((out or "").strip().splitlines()):
56
+ try:
57
+ obj = json.loads(line)
58
+ except ValueError:
59
+ continue
60
+ return obj if isinstance(obj, dict) else {}
61
+ return {}
62
+
63
+
64
+ def _print(msg):
65
+ print(msg, flush=True)
66
+
67
+
68
+ class TokenRefresher:
69
+ """SfapClient's on_unauthorized(token_used): True when the token file now holds a token worth a retry."""
70
+
71
+ def __init__(self, token_file, org, base_url, core_tenant_id, script=None, timeout=180, log=_print,
72
+ clock=time.monotonic):
73
+ self.token_file = os.path.abspath(token_file)
74
+ self.org = org
75
+ self.base_url = (base_url or "").rstrip("/")
76
+ self.core_tenant_id = core_tenant_id
77
+ self.script = script or RESOLVE_TOKEN
78
+ self.timeout = timeout
79
+ self.log = log
80
+ self._clock = clock
81
+ self.refreshes = self.failures = 0
82
+ self._lock = threading.Lock()
83
+ self._minted = {} # digest of a token minted here -> when
84
+ self._given_up = set() # digests of tokens not to refresh again
85
+
86
+ def __call__(self, token_used):
87
+ used = _digest(token_used)
88
+ with self._lock:
89
+ try:
90
+ with self._file_lock():
91
+ if read_token(self.token_file) not in ("", token_used):
92
+ return True # refreshed meanwhile, by another thread or process
93
+ if used in self._given_up:
94
+ return False
95
+ minted = self._minted.get(used)
96
+ if minted is not None and self._clock() - minted < FRESH_SECONDS:
97
+ why = "a token minted moments ago was refused too, so expiry is not the problem"
98
+ else:
99
+ why = self._mint()
100
+ except OSError as e:
101
+ why = f"cannot update the token file ({type(e).__name__})"
102
+ if why is None:
103
+ self.refreshes += 1
104
+ self.log(f"SFAP token refreshed (resolve-token.sh --org {self.org})")
105
+ return True
106
+ self._given_up.add(used)
107
+ self.failures += 1
108
+ self.log(f"WARNING: SFAP token not refreshed: {redact(why)}. The calls refused with HTTP 401 are "
109
+ "infra errors; with --cache-dir a rerun sends only those")
110
+ return False
111
+
112
+ def _mint(self):
113
+ """Run the script and install its token: None on success, else why not."""
114
+ try:
115
+ proc = subprocess.Popen(["bash", self.script, "--org", self.org], stdin=subprocess.DEVNULL,
116
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
117
+ universal_newlines=True, start_new_session=True)
118
+ except OSError as e:
119
+ return f"cannot run resolve-token.sh ({type(e).__name__})"
120
+ try:
121
+ out, _ = proc.communicate(timeout=self.timeout)
122
+ except subprocess.TimeoutExpired:
123
+ with contextlib.suppress(OSError):
124
+ os.killpg(proc.pid, signal.SIGKILL) # the script's sf and curl children too
125
+ proc.communicate()
126
+ return f"resolve-token.sh --org {self.org} took more than {self.timeout:g}s"
127
+ info = _last_json(out)
128
+ new_file = info.get("tokenFile")
129
+ try:
130
+ if info.get("error"):
131
+ return f"resolve-token.sh --org {self.org}: {info['error']} ({info.get('hint') or 'no hint'})"
132
+ if proc.returncode or not new_file:
133
+ return f"resolve-token.sh --org {self.org} exited {proc.returncode} without a token file"
134
+ tenant = info.get("coreTenantId")
135
+ if (info.get("baseUrl") or "").rstrip("/") != self.base_url or (tenant and tenant != self.core_tenant_id):
136
+ return (f"resolve-token.sh --org {self.org} gives a token for another SFAP endpoint or core tenant "
137
+ "than the run's (is --token-org the org --token-info was made for?)")
138
+ token = read_token(new_file)
139
+ if not token:
140
+ return "resolve-token.sh wrote an empty token file"
141
+ register_secret(token)
142
+ self._install(token)
143
+ self._minted[_digest(token)] = self._clock()
144
+ return None
145
+ finally:
146
+ if (new_file and os.path.abspath(new_file) != self.token_file
147
+ and os.path.basename(new_file).startswith(SCRIPT_FILE_PREFIX) and os.path.isfile(new_file)):
148
+ os.unlink(new_file)
149
+
150
+ def _install(self, token):
151
+ """Replace the token file atomically: a reader sees the old token or the new one, never a partial file."""
152
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.token_file), prefix=".bt-token-") # mode 0600
153
+ try:
154
+ with os.fdopen(fd, "w") as f:
155
+ f.write(token)
156
+ os.replace(tmp, self.token_file)
157
+ except BaseException:
158
+ with contextlib.suppress(OSError):
159
+ os.unlink(tmp)
160
+ raise
161
+
162
+ @contextlib.contextmanager
163
+ def _file_lock(self):
164
+ fd = os.open(self.token_file + ".lock", os.O_RDWR | os.O_CREAT, 0o600)
165
+ try:
166
+ fcntl.flock(fd, fcntl.LOCK_EX)
167
+ yield
168
+ finally:
169
+ os.close(fd) # releases the lock
170
+
171
+ def stats(self):
172
+ with self._lock:
173
+ return {"refreshes": self.refreshes, "failures": self.failures}