@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Binary LLM judge: is the candidate equivalent to the production (golden) answer?
|
|
2
|
+
|
|
3
|
+
Used only where a deterministic comparison cannot decide: text replies, and tool arguments
|
|
4
|
+
that are free text (a search phrase). The judge sees the recent conversation and, for
|
|
5
|
+
arguments, the tool's parameter schema. A judge that cannot be reached or parsed yields
|
|
6
|
+
None, which the caller records as an infra error rather than a verdict.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from .redact import redact
|
|
13
|
+
|
|
14
|
+
DEFAULT_JUDGE_MODEL = "llmgateway__OpenAIGPT41"
|
|
15
|
+
|
|
16
|
+
TEXT_PROMPT = """You compare two replies an AI assistant could send at the same point of a customer conversation.
|
|
17
|
+
The REFERENCE reply is what production sent. The CANDIDATE reply was produced by another model for the identical request.
|
|
18
|
+
|
|
19
|
+
The CANDIDATE is equivalent when, for the customer, it does the same thing:
|
|
20
|
+
- same intent and next step: answers the same question, asks for the same missing information, offers the same options, or hands off the same way;
|
|
21
|
+
- no fact, number, product, price or commitment that conflicts with the REFERENCE or that the conversation does not support.
|
|
22
|
+
Wording, order, tone and length may differ.
|
|
23
|
+
|
|
24
|
+
Answer with JSON only: {{"equivalent": true or false, "reasoning": "<one sentence>"}}
|
|
25
|
+
|
|
26
|
+
[recent conversation, oldest first]
|
|
27
|
+
{context}
|
|
28
|
+
|
|
29
|
+
[REFERENCE reply]
|
|
30
|
+
{golden}
|
|
31
|
+
|
|
32
|
+
[CANDIDATE reply]
|
|
33
|
+
{candidate}
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
ARGS_PROMPT = """You compare the arguments two AI models passed to the same tool at the same point of a conversation.
|
|
37
|
+
The REFERENCE arguments are what production sent. The CANDIDATE arguments were produced by another model for the identical request.
|
|
38
|
+
|
|
39
|
+
The CANDIDATE is equivalent when the tool would do the same thing with it: the same search results, the same record, the same change.
|
|
40
|
+
Differences in wording, case or word order that the tool would treat the same do not matter. A missing optional argument matters only if the REFERENCE value changes the result.
|
|
41
|
+
|
|
42
|
+
Answer with JSON only: {{"equivalent": true or false, "reasoning": "<one sentence>"}}
|
|
43
|
+
|
|
44
|
+
[tool] {tool}
|
|
45
|
+
[parameter schema] {schema}
|
|
46
|
+
|
|
47
|
+
[recent conversation, oldest first]
|
|
48
|
+
{context}
|
|
49
|
+
|
|
50
|
+
[REFERENCE arguments] {golden}
|
|
51
|
+
[CANDIDATE arguments] {candidate}
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JudgeConfigError(ValueError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_judge_model(judge_model, candidate_models):
|
|
60
|
+
"""Refuse a judge that is one of the models being judged (self-preference)."""
|
|
61
|
+
if not judge_model:
|
|
62
|
+
raise JudgeConfigError("no judge model set (--judge-model)")
|
|
63
|
+
clash = sorted({m for m in candidate_models if m and m == judge_model})
|
|
64
|
+
if clash:
|
|
65
|
+
raise JudgeConfigError(
|
|
66
|
+
f"judge model {judge_model} is also the replay model; pick a different --judge-model")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def model_family(alias):
|
|
70
|
+
"""'sfdc_ai__DefaultGPT41', 'llmgateway__AzureOpenAIGPT41' -> 'gpt41' (vendor prefixes dropped)."""
|
|
71
|
+
name = (alias or "").split("__")[-1].lower()
|
|
72
|
+
for prefix in ("default", "azureopenai", "openai", "bedrockanthropic", "vertexai"):
|
|
73
|
+
if name.startswith(prefix):
|
|
74
|
+
name = name[len(prefix):]
|
|
75
|
+
return name
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def same_family(judge_model, candidate_models):
|
|
79
|
+
"""Candidate aliases that name the judge's model under another alias (warn, do not refuse)."""
|
|
80
|
+
fam = model_family(judge_model)
|
|
81
|
+
return sorted({m for m in candidate_models if m and m != judge_model and model_family(m) == fam})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_context(messages, max_messages=6, max_chars=800):
|
|
85
|
+
"""The last few non-system messages, tool calls written as name(args)."""
|
|
86
|
+
lines = []
|
|
87
|
+
for m in [m for m in messages if m.get("role") != "system"][-max_messages:]:
|
|
88
|
+
if m.get("tool_invocations"):
|
|
89
|
+
calls = ", ".join(f"{(c.get('function') or {}).get('name')}({(c.get('function') or {}).get('arguments')})"
|
|
90
|
+
for c in m["tool_invocations"])
|
|
91
|
+
text = f"assistant called {calls}"
|
|
92
|
+
elif m.get("role") == "tool":
|
|
93
|
+
text = f"tool result ({m.get('tool_call_name') or 'tool'}): {m.get('content')}"
|
|
94
|
+
else:
|
|
95
|
+
text = f"{m.get('role')}: {m.get('content')}"
|
|
96
|
+
lines.append(text if len(text) <= max_chars else text[:max_chars] + " ...")
|
|
97
|
+
return "\n".join(lines) or "(none)"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_verdict(text):
|
|
101
|
+
"""{"equivalent": bool, "reasoning": str} or None."""
|
|
102
|
+
if not text:
|
|
103
|
+
return None
|
|
104
|
+
candidates = [text.strip()]
|
|
105
|
+
candidates += re.findall(r"\{.*?\}", text, re.DOTALL)
|
|
106
|
+
candidates += re.findall(r"\{.*\}", text, re.DOTALL)
|
|
107
|
+
for c in candidates:
|
|
108
|
+
try:
|
|
109
|
+
v = json.loads(c)
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
continue
|
|
112
|
+
if isinstance(v, dict) and "equivalent" in v:
|
|
113
|
+
eq = v["equivalent"]
|
|
114
|
+
if isinstance(eq, str):
|
|
115
|
+
eq = {"true": True, "false": False, "yes": True, "no": False}.get(eq.strip().lower())
|
|
116
|
+
if isinstance(eq, bool):
|
|
117
|
+
return {"equivalent": eq, "reasoning": str(v.get("reasoning", ""))[:500]}
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Judge:
|
|
122
|
+
"""Calls the judge model through an SfapClient-like object (generate(payload))."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, client, model):
|
|
125
|
+
self.client = client
|
|
126
|
+
self.model = model
|
|
127
|
+
|
|
128
|
+
def _ask(self, prompt):
|
|
129
|
+
payload = {"model": self.model,
|
|
130
|
+
"messages": [{"role": "user", "content": redact(prompt)}], # a reply may echo a token
|
|
131
|
+
"generation_settings": {"num_generations": 1, "max_tokens": 400, "temperature": 0.0}}
|
|
132
|
+
res = self.client.generate(payload)
|
|
133
|
+
if res["status"] != "ok":
|
|
134
|
+
return None, res.get("error") or "judge call failed"
|
|
135
|
+
verdict = parse_verdict(res["response"]["text"])
|
|
136
|
+
if verdict is None:
|
|
137
|
+
return None, "judge reply has no {equivalent: true|false} JSON"
|
|
138
|
+
verdict["model"] = self.model
|
|
139
|
+
return verdict, None
|
|
140
|
+
|
|
141
|
+
def text(self, golden, candidate, messages):
|
|
142
|
+
return self._ask(TEXT_PROMPT.format(context=render_context(messages),
|
|
143
|
+
golden=golden, candidate=candidate))
|
|
144
|
+
|
|
145
|
+
def args(self, tool, schema, golden_args, candidate_args, messages):
|
|
146
|
+
return self._ask(ARGS_PROMPT.format(
|
|
147
|
+
tool=tool, schema=json.dumps(schema or {}), context=render_context(messages),
|
|
148
|
+
golden=json.dumps(golden_args, sort_keys=True), candidate=json.dumps(candidate_args, sort_keys=True)))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Rebuild an LLM step's request messages exactly as production sent them.
|
|
2
|
+
|
|
3
|
+
STDM keeps every LLM_STEP's request in `gen_ai.input.messages`. Production sends those
|
|
4
|
+
messages in that order, nothing merged or dropped:
|
|
5
|
+
|
|
6
|
+
system agent-level prompt
|
|
7
|
+
user / assistant ... conversation so far
|
|
8
|
+
assistant + tool_invocations, tool ... calls made earlier in the turn and their results
|
|
9
|
+
system the step's own instruction, LAST ("...\\n\\n# CONTEXT ...")
|
|
10
|
+
|
|
11
|
+
STDM stores tool results and call arguments as objects; production sends them as JSON
|
|
12
|
+
strings — tool results compact, arguments with json.dumps defaults (verified on captured
|
|
13
|
+
gateway payloads). Production also names each tool result (`tool_call_name`), resolved here
|
|
14
|
+
from the matching call id.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tool_result_content(content):
|
|
21
|
+
if isinstance(content, str):
|
|
22
|
+
return content
|
|
23
|
+
if content is None:
|
|
24
|
+
return ""
|
|
25
|
+
return json.dumps(content, separators=(",", ":"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tool_call_arguments(arguments):
|
|
29
|
+
if isinstance(arguments, str):
|
|
30
|
+
return arguments
|
|
31
|
+
return json.dumps(arguments if arguments is not None else {})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _text(content):
|
|
35
|
+
if isinstance(content, str):
|
|
36
|
+
return content
|
|
37
|
+
return "" if content is None else json.dumps(content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_messages(stdm_messages):
|
|
41
|
+
"""Production-layout messages from a step's gen_ai.input.messages."""
|
|
42
|
+
out, name_by_id = [], {}
|
|
43
|
+
for m in stdm_messages:
|
|
44
|
+
role = m.get("role")
|
|
45
|
+
if role == "assistant" and m.get("tool_invocations"):
|
|
46
|
+
calls = []
|
|
47
|
+
for inv in m["tool_invocations"]:
|
|
48
|
+
fn = inv.get("function") or {}
|
|
49
|
+
name_by_id[inv.get("id")] = fn.get("name")
|
|
50
|
+
calls.append({
|
|
51
|
+
"id": inv.get("id"),
|
|
52
|
+
"type": inv.get("type") or "function",
|
|
53
|
+
"function": {"name": fn.get("name"),
|
|
54
|
+
"arguments": tool_call_arguments(fn.get("arguments"))},
|
|
55
|
+
})
|
|
56
|
+
out.append({"role": "assistant", "content": _text(m.get("content")),
|
|
57
|
+
"tool_invocations": calls})
|
|
58
|
+
elif role == "tool":
|
|
59
|
+
msg = {"role": "tool", "content": tool_result_content(m.get("content")),
|
|
60
|
+
"tool_call_id": m.get("tool_call_id")}
|
|
61
|
+
name = m.get("tool_call_name") or name_by_id.get(m.get("tool_call_id"))
|
|
62
|
+
if name:
|
|
63
|
+
msg["tool_call_name"] = name
|
|
64
|
+
out.append(msg)
|
|
65
|
+
else:
|
|
66
|
+
out.append({"role": role, "content": _text(m.get("content"))})
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def layout(messages):
|
|
71
|
+
"""Role sequence, e.g. 'S,U,A,C,T,S' (C = assistant tool call). For checks and tests."""
|
|
72
|
+
code = {"system": "S", "user": "U", "assistant": "A", "tool": "T"}
|
|
73
|
+
return ",".join("C" if m.get("tool_invocations") else code.get(m.get("role"), "?")
|
|
74
|
+
for m in messages)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def unresolved_tool_results(messages):
|
|
78
|
+
"""tool_call_ids of tool results with no earlier matching call."""
|
|
79
|
+
seen, missing = set(), []
|
|
80
|
+
for m in messages:
|
|
81
|
+
for inv in m.get("tool_invocations") or []:
|
|
82
|
+
seen.add(inv.get("id"))
|
|
83
|
+
if m.get("role") == "tool" and m.get("tool_call_id") not in seen:
|
|
84
|
+
missing.append(m.get("tool_call_id"))
|
|
85
|
+
return missing
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def last_user_text(messages):
|
|
89
|
+
for m in reversed(messages):
|
|
90
|
+
if m.get("role") == "user" and m.get("content"):
|
|
91
|
+
return m["content"]
|
|
92
|
+
return ""
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Edit ops on the captured requests: the patch hook between the units and the replay call.
|
|
2
|
+
|
|
3
|
+
A step's request carries the agent's texts verbatim (agentsrc.py), so an edit to the .agent
|
|
4
|
+
can be scored without a deploy: apply the same edit to every captured request it would
|
|
5
|
+
change, and replay those. One op:
|
|
6
|
+
|
|
7
|
+
{"id": "add-copy-rule", "target": "node:cart_and_checkout", "op": "replace",
|
|
8
|
+
"find": "ADD: Pass the productCode from the search results to {!@actions.AddToCart}.",
|
|
9
|
+
"with": "ADD: Copy the productCode exactly as the search results give it to {!@actions.AddToCart}."}
|
|
10
|
+
|
|
11
|
+
Targets:
|
|
12
|
+
node:<name> the node's instruction text (the last system message, up to
|
|
13
|
+
"\\n\\n# CONTEXT") in the steps of that node
|
|
14
|
+
system the first system message: platform preamble + system.instructions
|
|
15
|
+
tool:<name> a tool's description (action or transition), wherever it is offered
|
|
16
|
+
transition:<name> the same; the platform suffix of a transition stays in place
|
|
17
|
+
tool:<name>.<param> a parameter's description
|
|
18
|
+
Ops:
|
|
19
|
+
replace {find, with} delete {find} insert_after / insert_before {anchor, text}
|
|
20
|
+
set_description {text} (tool / transition targets: the whole description)
|
|
21
|
+
Optional fields: id (default op<n>), nodes (only the steps of these nodes), all (every
|
|
22
|
+
occurrence; without it a find that occurs twice in one step is an error), note.
|
|
23
|
+
|
|
24
|
+
find / anchor / with / text are .agent source text, dedented, merge fields as written:
|
|
25
|
+
{!@actions.X} renders as X, and any other merge field matches whatever value it rendered to
|
|
26
|
+
(the same field in with / text gets that value back). A merge field may not start or end a
|
|
27
|
+
find, two may not be adjacent, and with / text may only use fields of their find / anchor.
|
|
28
|
+
Descriptions take no merge fields. Inserted text goes in exactly as given: start it with a
|
|
29
|
+
newline to add a line after an anchor. Ops apply in order, each to the result of the ones
|
|
30
|
+
before; a step no op changes keeps its request byte-identical.
|
|
31
|
+
|
|
32
|
+
Approximate: a line whose text starts with a merge field renders without its leading
|
|
33
|
+
whitespace. That is modelled within the op's own lines, not for a line an edit creates by
|
|
34
|
+
joining source text with op text.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import re
|
|
39
|
+
from collections import Counter, defaultdict
|
|
40
|
+
|
|
41
|
+
from . import SCHEMA_COVERAGE, SCHEMA_PATCH
|
|
42
|
+
from .agentsrc import CONTEXT_MARKER, MERGE_FIELD, TRANSITION_SUFFIX, fragment_pattern, render_text, value_fields
|
|
43
|
+
|
|
44
|
+
OPS = ("replace", "delete", "insert_after", "insert_before", "set_description")
|
|
45
|
+
OP_FIELDS = {"replace": ("find", "with"), "delete": ("find",), "insert_after": ("anchor", "text"),
|
|
46
|
+
"insert_before": ("anchor", "text"), "set_description": ("text",)}
|
|
47
|
+
COMMON_FIELDS = ("id", "target", "op", "nodes", "all", "note")
|
|
48
|
+
_TARGET = re.compile(r"^(?:(node|tool|transition):(\w+)(?:\.(\w+))?|(system))$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PatchError(ValueError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Op:
|
|
56
|
+
"""One validated edit op."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, spec, index):
|
|
59
|
+
if not isinstance(spec, dict):
|
|
60
|
+
raise PatchError(f"op {index + 1}: not a JSON object")
|
|
61
|
+
self.id = str(spec.get("id") or f"op{index + 1}")
|
|
62
|
+
where = f"op {self.id}"
|
|
63
|
+
self.op = spec.get("op")
|
|
64
|
+
if self.op not in OPS:
|
|
65
|
+
raise PatchError(f"{where}: op must be one of {', '.join(OPS)}")
|
|
66
|
+
unknown = set(spec) - set(COMMON_FIELDS) - set(OP_FIELDS[self.op])
|
|
67
|
+
if unknown:
|
|
68
|
+
raise PatchError(f"{where}: fields {sorted(unknown)} do not apply to {self.op}")
|
|
69
|
+
for field in OP_FIELDS[self.op]:
|
|
70
|
+
if not isinstance(spec.get(field), str):
|
|
71
|
+
raise PatchError(f"{where}: {self.op} needs a string '{field}'")
|
|
72
|
+
self.target = spec.get("target")
|
|
73
|
+
m = _TARGET.match(self.target if isinstance(self.target, str) else "")
|
|
74
|
+
if not m:
|
|
75
|
+
raise PatchError(f"{where}: target must be node:<name>, system, tool:<name>, "
|
|
76
|
+
"tool:<name>.<param> or transition:<name>")
|
|
77
|
+
self.kind, self.name, self.param = m.group(1) or "system", m.group(2), m.group(3)
|
|
78
|
+
if self.param and self.kind != "tool":
|
|
79
|
+
raise PatchError(f"{where}: only tool: targets have parameters")
|
|
80
|
+
self.is_description = self.kind in ("tool", "transition")
|
|
81
|
+
if self.op == "set_description" and not self.is_description:
|
|
82
|
+
raise PatchError(f"{where}: set_description is for tool / transition targets")
|
|
83
|
+
|
|
84
|
+
nodes = spec.get("nodes")
|
|
85
|
+
if nodes is not None:
|
|
86
|
+
if self.kind == "node":
|
|
87
|
+
raise PatchError(f"{where}: a node: target is already limited to its node")
|
|
88
|
+
if not (isinstance(nodes, list) and nodes and all(isinstance(n, str) for n in nodes)):
|
|
89
|
+
raise PatchError(f"{where}: nodes must be a non-empty list of node names")
|
|
90
|
+
self.nodes = set(nodes) if nodes else None
|
|
91
|
+
self.all = spec.get("all", False)
|
|
92
|
+
if not isinstance(self.all, bool) or (self.all and self.op == "set_description"):
|
|
93
|
+
raise PatchError(f"{where}: all must be true or false, and only with a find / anchor")
|
|
94
|
+
|
|
95
|
+
self.locator = spec.get("find", spec.get("anchor"))
|
|
96
|
+
self.new = "" if self.op == "delete" else spec.get("with", spec.get("text"))
|
|
97
|
+
if self.is_description:
|
|
98
|
+
for field in OP_FIELDS[self.op]:
|
|
99
|
+
if "{!" in spec[field]:
|
|
100
|
+
raise PatchError(f"{where}: descriptions take no merge fields")
|
|
101
|
+
self.rx, self.groups = None, {}
|
|
102
|
+
if self.locator is not None:
|
|
103
|
+
self.rx, self.groups = _compile_locator(self.locator, where)
|
|
104
|
+
missing = [f for f in value_fields(self.new) if f not in self.groups]
|
|
105
|
+
if missing:
|
|
106
|
+
raise PatchError(f"{where}: {', '.join(missing)} not in the find / anchor, so its value is "
|
|
107
|
+
"unknown offline (new merge fields need a deployed version)")
|
|
108
|
+
self.rendered_new = render_text(self.new)
|
|
109
|
+
|
|
110
|
+
def in_scope(self, unit):
|
|
111
|
+
node = unit.get("node")
|
|
112
|
+
if self.kind == "node":
|
|
113
|
+
return node == self.name
|
|
114
|
+
return self.nodes is None or node in self.nodes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _compile_locator(text, where):
|
|
118
|
+
if not text.strip():
|
|
119
|
+
raise PatchError(f"{where}: empty find / anchor")
|
|
120
|
+
rendered = render_text(text)
|
|
121
|
+
pieces, pos = [], 0 # literal, field, literal, ... (rendered: only value fields are left)
|
|
122
|
+
for m in MERGE_FIELD.finditer(rendered):
|
|
123
|
+
pieces += [rendered[pos:m.start()], m.group(0)]
|
|
124
|
+
pos = m.end()
|
|
125
|
+
pieces.append(rendered[pos:])
|
|
126
|
+
literals = pieces[0::2]
|
|
127
|
+
if not "".join(literals).strip():
|
|
128
|
+
raise PatchError(f"{where}: the find / anchor is only merge fields")
|
|
129
|
+
if len(pieces) > 1:
|
|
130
|
+
if not literals[0].strip() or not literals[-1].strip():
|
|
131
|
+
raise PatchError(f"{where}: a merge field may not start or end the find / anchor")
|
|
132
|
+
if any(not lit for lit in literals[1:-1]):
|
|
133
|
+
raise PatchError(f"{where}: two merge fields may not be adjacent in the find / anchor")
|
|
134
|
+
pattern, groups = fragment_pattern(text)
|
|
135
|
+
return re.compile(pattern), groups
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_ops(data):
|
|
139
|
+
"""Validated ops from a patch document: {"schema": "bt.patch/1", "ops": [...]} or a list."""
|
|
140
|
+
if isinstance(data, dict):
|
|
141
|
+
if data.get("schema", SCHEMA_PATCH) != SCHEMA_PATCH:
|
|
142
|
+
raise PatchError(f"not a {SCHEMA_PATCH} document (schema {data.get('schema')!r})")
|
|
143
|
+
unknown = set(data) - {"schema", "ops", "note"}
|
|
144
|
+
if unknown:
|
|
145
|
+
raise PatchError(f"unknown keys {sorted(unknown)}")
|
|
146
|
+
data = data.get("ops")
|
|
147
|
+
if not isinstance(data, list):
|
|
148
|
+
raise PatchError("expected a list of ops")
|
|
149
|
+
ops = [Op(spec, i) for i, spec in enumerate(data)]
|
|
150
|
+
dupes = [i for i, n in Counter(op.id for op in ops).items() if n > 1]
|
|
151
|
+
if dupes:
|
|
152
|
+
raise PatchError(f"duplicate op ids {sorted(dupes)}")
|
|
153
|
+
return ops
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def load_patch(path):
|
|
157
|
+
with open(path, encoding="utf-8") as f:
|
|
158
|
+
try:
|
|
159
|
+
data = json.load(f)
|
|
160
|
+
except json.JSONDecodeError as e:
|
|
161
|
+
raise PatchError(f"not JSON ({e})")
|
|
162
|
+
return parse_ops(data)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------------------
|
|
166
|
+
# regions: (text, write) where write(new text) returns the new (messages, tools)
|
|
167
|
+
|
|
168
|
+
def _node_region(messages, tools):
|
|
169
|
+
if len(messages) < 2 or messages[-1].get("role") != "system":
|
|
170
|
+
return None
|
|
171
|
+
content = messages[-1].get("content") or ""
|
|
172
|
+
cut = content.find(CONTEXT_MARKER)
|
|
173
|
+
head, tail = (content[:cut], content[cut:]) if cut >= 0 else (content, "")
|
|
174
|
+
|
|
175
|
+
def write(new):
|
|
176
|
+
return messages[:-1] + [dict(messages[-1], content=new + tail)], tools
|
|
177
|
+
return head, write
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _system_region(messages, tools):
|
|
181
|
+
if not messages or messages[0].get("role") != "system":
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def write(new):
|
|
185
|
+
return [dict(messages[0], content=new)] + messages[1:], tools
|
|
186
|
+
return messages[0].get("content") or "", write
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _description_region(messages, tools, name, param):
|
|
190
|
+
for i, tool in enumerate(tools):
|
|
191
|
+
fn = tool.get("function") or {}
|
|
192
|
+
if fn.get("name") == name:
|
|
193
|
+
break
|
|
194
|
+
else:
|
|
195
|
+
return None
|
|
196
|
+
if param:
|
|
197
|
+
props = (fn.get("parameters") or {}).get("properties") or {}
|
|
198
|
+
if not isinstance(props.get(param), dict):
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def write(new):
|
|
202
|
+
params = dict(fn["parameters"], properties=dict(props, **{param: dict(props[param], description=new)}))
|
|
203
|
+
return messages, tools[:i] + [dict(tool, function=dict(fn, parameters=params))] + tools[i + 1:]
|
|
204
|
+
return props[param].get("description") or "", write
|
|
205
|
+
desc = fn.get("description") or ""
|
|
206
|
+
suffix = TRANSITION_SUFFIX if desc.endswith(TRANSITION_SUFFIX) else ""
|
|
207
|
+
head = desc[:len(desc) - len(suffix)]
|
|
208
|
+
|
|
209
|
+
def write(new):
|
|
210
|
+
return messages, tools[:i] + [dict(tool, function=dict(fn, description=new + suffix))] + tools[i + 1:]
|
|
211
|
+
return head, write
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _region(messages, tools, op):
|
|
215
|
+
if op.kind == "node":
|
|
216
|
+
return _node_region(messages, tools)
|
|
217
|
+
if op.kind == "system":
|
|
218
|
+
return _system_region(messages, tools)
|
|
219
|
+
return _description_region(messages, tools, op.name, op.param)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def edit_text(text, op):
|
|
223
|
+
"""The op on one rendered text: (new text or None, outcome), outcome matched | unmatched | ambiguous.
|
|
224
|
+
agentpatch.py checks its .agent edit against this."""
|
|
225
|
+
if op.op == "set_description":
|
|
226
|
+
return op.new, "matched"
|
|
227
|
+
found = list(op.rx.finditer(text))
|
|
228
|
+
if not found:
|
|
229
|
+
return None, "unmatched"
|
|
230
|
+
if len(found) > 1 and not op.all:
|
|
231
|
+
return None, "ambiguous"
|
|
232
|
+
out, pos = [], 0
|
|
233
|
+
for m in found:
|
|
234
|
+
new = MERGE_FIELD.sub(lambda f: m.group(op.groups[f.group(0)]), op.rendered_new)
|
|
235
|
+
if op.op == "insert_after":
|
|
236
|
+
out += [text[pos:m.end()], new]
|
|
237
|
+
elif op.op == "insert_before":
|
|
238
|
+
out += [text[pos:m.start()], new, m.group(0)]
|
|
239
|
+
else:
|
|
240
|
+
out += [text[pos:m.start()], new]
|
|
241
|
+
pos = m.end()
|
|
242
|
+
out.append(text[pos:])
|
|
243
|
+
return "".join(out), "matched"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def apply_ops(unit, ops):
|
|
247
|
+
"""(messages, tools, outcomes) of one unit after the ops. Per op the outcome is None (out
|
|
248
|
+
of scope, or the step lacks the target), matched, unmatched or ambiguous. Untouched parts
|
|
249
|
+
are the unit's own objects, so an unchanged request serializes byte-identically."""
|
|
250
|
+
messages, tools = unit["request"]["messages"], unit["request"]["tools"]
|
|
251
|
+
outcomes = []
|
|
252
|
+
for op in ops:
|
|
253
|
+
region = _region(messages, tools, op) if op.in_scope(unit) else None
|
|
254
|
+
if region is None:
|
|
255
|
+
outcomes.append(None)
|
|
256
|
+
continue
|
|
257
|
+
text, write = region
|
|
258
|
+
new, outcome = edit_text(text, op)
|
|
259
|
+
if new is not None and new != text:
|
|
260
|
+
messages, tools = write(new)
|
|
261
|
+
outcomes.append(outcome)
|
|
262
|
+
return messages, tools, outcomes
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_set_description(units, ops):
|
|
266
|
+
"""set_description replaces one description; one name with several in scope (per-node
|
|
267
|
+
bindings, or agent versions) would be ambiguous as an .agent edit."""
|
|
268
|
+
for op in ops:
|
|
269
|
+
if op.op != "set_description":
|
|
270
|
+
continue
|
|
271
|
+
seen = defaultdict(set)
|
|
272
|
+
for u in units:
|
|
273
|
+
region = _region(u["request"]["messages"], u["request"]["tools"], op) if op.in_scope(u) else None
|
|
274
|
+
if region is not None:
|
|
275
|
+
seen[region[0]].add(u.get("node"))
|
|
276
|
+
if len(seen) > 1:
|
|
277
|
+
where = "; ".join(f"{len(seen_nodes)} node(s): {', '.join(sorted(map(str, seen_nodes)))}"
|
|
278
|
+
for seen_nodes in seen.values())
|
|
279
|
+
raise PatchError(f"op {op.id}: {op.target} has {len(seen)} different descriptions in scope ({where}); "
|
|
280
|
+
"limit the op with nodes, or replay one agent version")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def patch_units(units, ops):
|
|
284
|
+
"""([(messages, tools, ids of the ops that matched)] per unit, coverage per op)."""
|
|
285
|
+
_check_set_description(units, ops)
|
|
286
|
+
patched = []
|
|
287
|
+
tally = [{"with_target": Counter(), "matched": Counter(), "ambiguous": 0} for _ in ops]
|
|
288
|
+
for u in units:
|
|
289
|
+
messages, tools, outcomes = apply_ops(u, ops)
|
|
290
|
+
for t, outcome in zip(tally, outcomes):
|
|
291
|
+
if outcome is None:
|
|
292
|
+
continue
|
|
293
|
+
t["with_target"][u.get("node")] += 1
|
|
294
|
+
if outcome == "matched":
|
|
295
|
+
t["matched"][u.get("node")] += 1
|
|
296
|
+
elif outcome == "ambiguous":
|
|
297
|
+
t["ambiguous"] += 1
|
|
298
|
+
patched.append((messages, tools, [op.id for op, o in zip(ops, outcomes) if o == "matched"]))
|
|
299
|
+
return patched, [_coverage(op, t) for op, t in zip(ops, tally)]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _coverage(op, t):
|
|
303
|
+
n, m = sum(t["with_target"].values()), sum(t["matched"].values())
|
|
304
|
+
if t["ambiguous"]:
|
|
305
|
+
status, msg = "error", (f"found more than once in {t['ambiguous']} step(s): lengthen the find / anchor, "
|
|
306
|
+
"or set all")
|
|
307
|
+
elif not n:
|
|
308
|
+
status, msg = "warning", "no replayed step has this target"
|
|
309
|
+
elif not m:
|
|
310
|
+
status, msg = "error", f"matched none of the {n} steps with this target: copy the text from the .agent"
|
|
311
|
+
elif m < n:
|
|
312
|
+
status, msg = "partial", (f"matched {m} of {n} steps; the others differ (another agent version, "
|
|
313
|
+
"or a branch not taken)")
|
|
314
|
+
else:
|
|
315
|
+
status, msg = "ok", None
|
|
316
|
+
return {"id": op.id, "target": op.target, "op": op.op, "nodes": sorted(op.nodes) if op.nodes else None,
|
|
317
|
+
"units_with_target": n, "matched": m, "ambiguous": t["ambiguous"],
|
|
318
|
+
"units_with_target_by_node": dict(sorted(t["with_target"].items(), key=lambda kv: str(kv[0]))),
|
|
319
|
+
"matched_by_node": dict(sorted(t["matched"].items(), key=lambda kv: str(kv[0]))),
|
|
320
|
+
"status": status, "message": msg}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def coverage_report(path, coverage, units_changed, unit_count):
|
|
324
|
+
"""patch_coverage.json: counts only, never rendered text (rendered values can hold user data)."""
|
|
325
|
+
return {"schema": SCHEMA_COVERAGE, "patch": path, "ops": coverage, "units": unit_count,
|
|
326
|
+
"units_changed": units_changed,
|
|
327
|
+
"errors": [f"{c['id']}: {c['message']}" for c in coverage if c["status"] == "error"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Scrub credentials from text before it is written to disk or printed.
|
|
2
|
+
|
|
3
|
+
Every artifact writer and log line in btlib goes through redact(). Besides the patterns
|
|
4
|
+
below, the SFAP client registers the token it read, so an opaque (non-JWT) token is
|
|
5
|
+
scrubbed too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
_BEARER = re.compile(r"(?i)\b(bearer\s+)[^\s\"'<>\\]{16,}") # any long run: session ids contain "!"
|
|
11
|
+
_JWT = re.compile(r"eyJ[\w-]+\.[\w-]+\.[\w-]+")
|
|
12
|
+
_SECRETS = set()
|
|
13
|
+
REDACTED = "<REDACTED>"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_secret(value):
|
|
17
|
+
"""Scrub this exact string from every later redact() call (ignored if short)."""
|
|
18
|
+
if isinstance(value, str) and len(value) >= 16:
|
|
19
|
+
_SECRETS.add(value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def redact(text):
|
|
23
|
+
if not isinstance(text, str):
|
|
24
|
+
text = str(text)
|
|
25
|
+
for secret in _SECRETS:
|
|
26
|
+
if secret in text:
|
|
27
|
+
text = text.replace(secret, REDACTED)
|
|
28
|
+
text = _BEARER.sub(lambda m: m.group(1) + REDACTED, text)
|
|
29
|
+
return _JWT.sub(REDACTED, text)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def redact_obj(obj):
|
|
33
|
+
"""redact() applied to every string inside a JSON-like value."""
|
|
34
|
+
if isinstance(obj, str):
|
|
35
|
+
return redact(obj)
|
|
36
|
+
if isinstance(obj, dict):
|
|
37
|
+
return {k: redact_obj(v) for k, v in obj.items()}
|
|
38
|
+
if isinstance(obj, (list, tuple)):
|
|
39
|
+
return [redact_obj(v) for v in obj]
|
|
40
|
+
return obj
|