@salesforce/afv-skills 1.67.0 → 1.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +55 -5
  2. package/package.json +1 -1
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  29. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  30. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  31. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  32. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  33. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  34. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  35. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  36. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  37. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  38. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  39. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  40. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  41. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  42. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  43. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  44. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  45. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  46. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  47. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  48. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  49. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  50. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  51. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  52. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  53. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  54. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  55. package/skills/education-cloud-domain-configure/SKILL.md +1 -1
  56. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  57. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  58. package/skills/experience-design-validate/SKILL.md +1 -1
  59. package/skills/experience-search-coordinate/SKILL.md +18 -12
  60. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  61. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  62. package/skills/platform-apex-generate/SKILL.md +14 -11
  63. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  64. package/skills/platform-datamask-run/SKILL.md +52 -71
  65. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  66. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  67. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  68. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  69. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  70. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  71. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  72. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  73. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  74. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  75. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  76. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  77. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  78. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  79. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  80. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  81. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  82. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  83. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  84. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  85. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  86. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  87. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  88. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  89. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  90. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  91. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  92. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  93. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  94. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  95. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  96. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  97. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  98. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  99. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  100. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  101. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  102. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  103. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  104. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  105. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  106. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  107. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  108. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  109. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  110. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  111. package/skills/platform-report-generate/SKILL.md +9 -4
  112. package/skills/platform-report-generate/references/chart-types.md +26 -2
  113. package/skills/platform-report-generate/references/column-names.md +43 -0
  114. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  115. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  116. package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
  117. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  118. package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +110 -14
@@ -0,0 +1,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