@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,110 @@
1
+ #!/usr/bin/env python3
2
+ """Make a scored patch's edit ops in the .agent file: the version that ships is the one scored.
3
+
4
+ python3 scripts/bt_patch_agent.py AGENT PATCH --out FILE [--overwrite]
5
+
6
+ AGENT is an .agent file, or a retrieved bundle dir holding one. PATCH is the patch file the replay
7
+ scored (sfap_replay.py --patch). The edited .agent goes to --out, never over AGENT, and the change
8
+ is printed as a unified diff. Only the lines an edit changes are rewritten, at their block's
9
+ indent and in its | style.
10
+
11
+ An op is refused, and nothing is written, when its edit in the .agent could differ from the edit
12
+ the replay made to the requests, or cannot be written as intended (references/patching.md, "The
13
+ same edit in the .agent"). Exit 0: written. Exit 2: refused, or a file could not be read or
14
+ written.
15
+ """
16
+
17
+ import argparse
18
+ import difflib
19
+ import os
20
+ import sys
21
+ import traceback
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
+
25
+ from btlib import agentpatch # noqa: E402
26
+ from btlib import patch as patchlib # noqa: E402
27
+ from btlib.agentsrc import AgentSourceError, agent_file # noqa: E402
28
+ from btlib.redact import redact # noqa: E402
29
+
30
+
31
+ def parse_args(argv=None):
32
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
33
+ p.add_argument("agent", help=".agent file, or a bundle dir holding one")
34
+ p.add_argument("patch", help="the patch file the replay scored (bt.patch/1)")
35
+ p.add_argument("--out", metavar="FILE", required=True, help="where to write the edited .agent (not AGENT)")
36
+ p.add_argument("--overwrite", action="store_true", help="replace --out if it exists")
37
+ return p.parse_args(argv)
38
+
39
+
40
+ def same_file(a, b):
41
+ return os.path.realpath(a) == os.path.realpath(b) or (os.path.exists(b) and os.path.samefile(a, b))
42
+
43
+
44
+ def read_text(path):
45
+ """The text as it is: universal newlines would turn CR LF into LF on every line."""
46
+ with open(path, encoding="utf-8", newline="") as f:
47
+ try:
48
+ return f.read()
49
+ except UnicodeDecodeError:
50
+ raise AgentSourceError(f"{path}: not UTF-8 text")
51
+
52
+
53
+ def load_ops(path):
54
+ try:
55
+ return patchlib.load_patch(path)
56
+ except UnicodeDecodeError:
57
+ raise patchlib.PatchError("not UTF-8 text")
58
+
59
+
60
+ def summary_lines(report):
61
+ out = []
62
+ for r in report:
63
+ n = len(r["edits"])
64
+ out.append(f"op {r['id']} ({r['op']} {r['target']}): " + (f"{n} edit(s)" if n else "no change"))
65
+ return out
66
+
67
+
68
+ def main(argv=None):
69
+ args = parse_args(argv)
70
+ try:
71
+ path = agent_file(args.agent)
72
+ if same_file(path, args.out):
73
+ print(f"error: --out is the input {path}: write the edited agent to another file", file=sys.stderr)
74
+ return 2
75
+ if os.path.exists(args.out) and not args.overwrite:
76
+ print(f"error: {args.out} exists (--overwrite replaces it)", file=sys.stderr)
77
+ return 2
78
+ try:
79
+ ops = load_ops(args.patch)
80
+ except patchlib.PatchError as e:
81
+ print(f"error: {args.patch}: {redact(str(e))}", file=sys.stderr)
82
+ return 2
83
+ text = read_text(path)
84
+ new, report = agentpatch.patch_agent(text, ops, path)
85
+ with open(args.out, "w" if args.overwrite else "x", encoding="utf-8", newline="") as f:
86
+ f.write(new)
87
+ except agentpatch.AgentPatchError as e:
88
+ print(f"error: refused ({e.reason}): {redact(str(e))}; nothing written", file=sys.stderr)
89
+ return 2
90
+ except AgentSourceError as e:
91
+ print(f"error: {redact(str(e))}", file=sys.stderr)
92
+ return 2
93
+ except OSError as e:
94
+ print(f"error: {e.filename or ''}: {e.strerror or e}", file=sys.stderr)
95
+ return 2
96
+ except Exception as e: # a bug: say where, never a half-checked file
97
+ frame = traceback.extract_tb(e.__traceback__)[-1]
98
+ print(f"error: bt_patch_agent failed ({type(e).__name__} at {os.path.basename(frame.filename)}:{frame.lineno})",
99
+ file=sys.stderr)
100
+ return 2
101
+ for line in difflib.unified_diff(text.splitlines(True), new.splitlines(True), fromfile=path, tofile=args.out):
102
+ sys.stdout.write(redact(line) if line.endswith("\n") else redact(line) + "\n\\n")
103
+ for line in summary_lines(report):
104
+ print(line)
105
+ print(f"written: {args.out}" + ("" if new != text else " (unchanged)"))
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ sys.exit(main())
@@ -0,0 +1,34 @@
1
+ """btlib — step-level replay of Agentforce STDM traces (stdlib only).
2
+
3
+ One replay unit = one production LLM_STEP: the exact request that step sent (messages in
4
+ production order, the step's own tool definitions) and the answer it got (the golden).
5
+
6
+ redact scrub bearer tokens / JWTs from anything written or printed
7
+ stdm read STDM_RESULT payloads out of `sf apex run --json` output
8
+ messages rebuild a step's request messages in production layout
9
+ units one bt.unit/1 record per LLM_STEP
10
+ sfap SFAP /chat/generations client (urllib, token read per call, retries)
11
+ judge binary LLM judge for text replies and free-text arguments
12
+ scoring decision / names / args / text scores, failure buckets, schema checks
13
+ results run-dir artifacts: results.jsonl, summary.json, the legacy CSV
14
+ agentsrc read an .agent file: node instruction blocks, tools, rendering rules
15
+ patch edit ops on the captured requests (--patch), with per-op coverage
16
+ agentpatch the same edit ops in the .agent file: the version that ships is the one scored
17
+ versions which retrieved agent version each unit renders from (--agent-versions)
18
+ splits session-level test / val labels (--val-fraction) and --exclude-sessions
19
+ cache answers already received, keyed by the payload as sent (--cache-dir)
20
+ tokens mint a new SFAP token when the run's expires (--token-org)
21
+ compare paired comparison of two runs of the same units, with a noise band (bt_compare.py)
22
+ fetch STDM sessions through read-only anonymous Apex, nothing deployed (bt_fetch.py)
23
+ """
24
+
25
+ SCHEMA_UNIT = "bt.unit/1"
26
+ SCHEMA_RESULT = "bt.result/1"
27
+ SCHEMA_SUMMARY = "bt.summary/1"
28
+ SCHEMA_PATCH = "bt.patch/1"
29
+ SCHEMA_COVERAGE = "bt.patch_coverage/1"
30
+ SCHEMA_VERSIONS = "bt.versions/1"
31
+ SCHEMA_CACHE = "bt.cache/1"
32
+ SCHEMA_COMPARE = "bt.compare/1"
33
+ SCHEMA_FETCH = "bt.fetch/1"
34
+ SCHEMA_KIT = "bt.fetch_kit/1"
@@ -0,0 +1,483 @@
1
+ """The same edit ops in the .agent file: the source-side twin of patch.py.
2
+
3
+ patch.py scores an edit by applying it to the captured requests. This makes that edit in the
4
+ agent's source, so the version that ships is the one that was scored:
5
+
6
+ new_text, report = agentpatch.patch_agent(text, patch.parse_ops(doc))
7
+
8
+ The targets are patch.py's, in the source:
9
+ node:<name> the node's reasoning instruction text blocks
10
+ system system.instructions, and the system.instructions override of a node in
11
+ scope (how an override renders is not verified on traces)
12
+ tool:<name>, transition:<name>
13
+ a transition binding's description; for an action binding, the
14
+ description of its action definition
15
+ tool:<name>.<param> that input's description, where the binding offers it (with p = ...)
16
+
17
+ find / anchor / with / text are matched and written verbatim, as .agent source text (dedented,
18
+ merge fields as written). Only the lines an edit changes are rewritten, at the block's indent
19
+ and in its | style; procedure statements are never touched. A quoted description stays one
20
+ quoted string, in JSON escapes (the compiler decodes \\" \\\\ \\n \\t); set_description adds a
21
+ missing description. The result is parsed back: it must be the same agent, with only the edited
22
+ texts changed, as the parser reads them.
23
+
24
+ An op is refused (AgentPatchError, its .reason one of REASONS) when the .agent edit could
25
+ differ from the request edit that was scored, or when it cannot be written:
26
+ no_target the agent lacks the target, or a node named in nodes
27
+ unmatched the find / anchor is in none of the target's texts
28
+ ambiguous it occurs more than once without all, counted over all of a node's blocks (each
29
+ branch of an if is a block); set_description over different descriptions
30
+ render the source edit renders otherwise than the same edit on the rendered text: the
31
+ find matches the rendered text but not the source ({!@actions.X} written as X),
32
+ or more often in the rendered text than in the source
33
+ shared the text is also used where the op does not apply: an action definition bound
34
+ to another tool, or in a node outside nodes; system.instructions with nodes that
35
+ leave out a node using it
36
+ unwritable the result cannot be written as intended, or does not read back as intended
37
+ (an empty block, a trailing blank line, a CR, a control character, ...)
38
+ """
39
+
40
+ import json
41
+ import re
42
+ from difflib import SequenceMatcher
43
+
44
+ from .agentsrc import MERGE_FIELD, AgentSourceError, _child, _str, parse_agent, render_text
45
+ from .patch import edit_text
46
+
47
+ REASONS = ("no_target", "unmatched", "ambiguous", "render", "shared", "unwritable")
48
+ DESCRIPTION_INDENT = 4 # a description added to an entry without children: this much deeper
49
+
50
+
51
+ class AgentPatchError(ValueError):
52
+ def __init__(self, reason, message):
53
+ super().__init__(message)
54
+ self.reason = reason
55
+
56
+
57
+ def _err(op, reason, msg):
58
+ return AgentPatchError(reason, f"op {op.id}: {msg}")
59
+
60
+
61
+ class _Text:
62
+ """One editable text: a block, a one-line value, or a missing description (text None)."""
63
+
64
+ def __init__(self, where, block=None, item=None, owner=None):
65
+ self.where, self.block, self.item, self.owner = where, block, item, owner
66
+ if block is not None:
67
+ self.text = block.text
68
+ elif item is not None:
69
+ self.text = _str(item.value)
70
+ else:
71
+ self.text = None
72
+
73
+
74
+ def _description(where, owner):
75
+ it = _child(owner, "description")
76
+ if it is None:
77
+ return _Text(where, owner=owner)
78
+ if it.block is not None:
79
+ return _Text(where, block=it.block)
80
+ return _Text(where, item=it)
81
+
82
+
83
+ def _editable(src):
84
+ """{key: _Text} for every text an op can edit, and the descriptions of action bindings."""
85
+ out = {}
86
+ if src.system is not None:
87
+ out[("system", None)] = _Text("system.instructions", block=src.system)
88
+ for n, node in src.nodes.items():
89
+ if node.system is not None:
90
+ out[("system", n)] = _Text(f"the system.instructions of node {n}", block=node.system)
91
+ for k, b in enumerate(node.blocks):
92
+ out[("block", n, k)] = _Text(f"instruction block {k + 1} of node {n} (line {b.start})", block=b)
93
+ for b in node.bindings.values():
94
+ out[("binding", n, b.name)] = _description(f"the description of {b.name} in node {n}", b.item)
95
+ for d in node.action_defs.values():
96
+ out[("action", n, d.name)] = _description(f"the description of action {d.name} (node {n})", d.item)
97
+ for inp in d.inputs.values():
98
+ out[("input", n, d.name, inp.name)] = _description(
99
+ f"the description of input {inp.name} of action {d.name} (node {n})", inp.item)
100
+ return out
101
+
102
+
103
+ def _shape(src):
104
+ """All of the agent but its editable texts and line numbers: an edit must keep it."""
105
+ nodes = []
106
+ for node in src.nodes.values():
107
+ nodes.append((node.kind, node.name, node.label, node.description, node.model, node.procedure,
108
+ node.system is not None, [(b.style, b.conditional) for b in node.blocks],
109
+ [(b.name, b.kind, b.target, b.llm_inputs, sorted(b.bound_inputs.items()), b.available_when)
110
+ for b in node.bindings.values()],
111
+ [(d.name, d.label, d.target,
112
+ [(i.name, i.type, i.label, i.is_required, i.is_user_input) for i in d.inputs.values()])
113
+ for d in node.action_defs.values()]))
114
+ return src.model, src.developer_name, src.system is not None, nodes
115
+
116
+
117
+ # ---------------------------------------------------------------------------------------
118
+ # targets
119
+
120
+ def _home(src, d):
121
+ for n, node in src.nodes.items():
122
+ if node.action_defs.get(d.name) is d:
123
+ return n
124
+ return None
125
+
126
+
127
+ def _scope(src, op):
128
+ if op.nodes is None:
129
+ return list(src.nodes)
130
+ missing = sorted(op.nodes - set(src.nodes))
131
+ if missing:
132
+ raise _err(op, "no_target", f"nodes {missing} are not in the agent")
133
+ return [n for n in src.nodes if n in op.nodes]
134
+
135
+
136
+ def _targets(src, texts, op):
137
+ """[(key, _Text)] of the texts the op applies to."""
138
+ if op.kind == "node":
139
+ node = src.nodes.get(op.name)
140
+ if node is None:
141
+ raise _err(op, "no_target", f"the agent has no node {op.name}")
142
+ if not node.blocks:
143
+ raise _err(op, "no_target", f"node {op.name} has no instruction text")
144
+ keys = [("block", op.name, k) for k in range(len(node.blocks))]
145
+ elif op.kind == "system":
146
+ keys = _system_keys(src, op, _scope(src, op))
147
+ else:
148
+ keys = _description_keys(src, op, _scope(src, op))
149
+ return [(k, texts[k]) for k in keys]
150
+
151
+
152
+ def _system_keys(src, op, scope):
153
+ users = [n for n, node in src.nodes.items() if node.system is None] # nodes on the global text
154
+ keys = []
155
+ if src.system is not None and (op.nodes is None or any(n in op.nodes for n in users)):
156
+ left = [n for n in users if op.nodes is not None and n not in op.nodes]
157
+ if left:
158
+ raise _err(op, "shared", f"system.instructions is also the system text of nodes {left}, which nodes "
159
+ "leaves out: list them too, or drop nodes")
160
+ keys.append(("system", None))
161
+ keys += [("system", n) for n in scope if src.nodes[n].system is not None]
162
+ if not keys:
163
+ raise _err(op, "no_target", "no system.instructions in "
164
+ + ("the agent" if op.nodes is None else f"nodes {sorted(op.nodes)}"))
165
+ return keys
166
+
167
+
168
+ def _description_keys(src, op, scope):
169
+ keys, defs = [], []
170
+ for n in scope:
171
+ node = src.nodes[n]
172
+ b = node.bindings.get(op.name)
173
+ if b is None:
174
+ continue
175
+ if b.kind == "transition":
176
+ if op.param:
177
+ raise _err(op, "no_target", f"{op.name} in node {n} is a transition: it has no parameters")
178
+ key = ("binding", n, op.name)
179
+ elif b.kind == "action":
180
+ d = src.action_def(node, b.action)
181
+ if d is None:
182
+ raise _err(op, "no_target", f"{op.name} in node {n} is bound to @actions.{b.action}, "
183
+ "which the agent does not define")
184
+ if op.param:
185
+ if op.param not in d.inputs:
186
+ raise _err(op, "no_target", f"action {d.name} has no input {op.param}")
187
+ if op.param not in b.llm_inputs:
188
+ continue # bound to an expression: not a parameter of the tool
189
+ key = ("input", _home(src, d), d.name, op.param)
190
+ else:
191
+ key = ("action", _home(src, d), d.name)
192
+ if d not in defs:
193
+ defs.append(d)
194
+ else:
195
+ raise _err(op, "no_target", f"{op.name} in node {n} is neither an action nor a transition")
196
+ if key not in keys:
197
+ keys.append(key)
198
+ if not keys:
199
+ where = "the agent" if op.nodes is None else f"nodes {sorted(op.nodes)}"
200
+ offers = f" with {op.param} = ..." if op.param else ""
201
+ raise _err(op, "no_target", f"no node of {where} binds {op.name}{offers}")
202
+ for d in defs:
203
+ _check_shared_def(src, op, d)
204
+ return keys
205
+
206
+
207
+ def _check_shared_def(src, op, d):
208
+ """An action definition's text is the description of every tool bound to it: each of
209
+ those must be the op's tool, in its scope, or the .agent edit changes more tools than the
210
+ request edit did."""
211
+ others = []
212
+ for n, node in src.nodes.items():
213
+ for b in node.bindings.values():
214
+ if b.kind != "action" or src.action_def(node, b.action) is not d:
215
+ continue
216
+ if op.param and op.param not in b.llm_inputs:
217
+ continue
218
+ if b.name != op.name or (op.nodes is not None and n not in op.nodes):
219
+ others.append(f"{b.name} in node {n}")
220
+ if others:
221
+ what = f"input {op.param} of action {d.name}" if op.param else f"action {d.name}"
222
+ raise _err(op, "shared", f"the description of {what} is also that of {', '.join(others)}, which the op "
223
+ "leaves alone: widen nodes, or give the tool its own action definition first")
224
+
225
+
226
+ # ---------------------------------------------------------------------------------------
227
+ # the edits: {key: new text}
228
+
229
+ def _what(op):
230
+ word = "find" if op.op in ("replace", "delete") else "anchor"
231
+ loc = op.locator if len(op.locator) <= 60 else op.locator[:57] + "..."
232
+ return f"the {word} {loc!r}"
233
+
234
+
235
+ def _where(targets):
236
+ if len(targets) == 1:
237
+ return targets[0][1].where
238
+ return f"{len(targets)} texts ({'; '.join(t.where for _, t in targets)})"
239
+
240
+
241
+ def _verbatim(text, op, pattern):
242
+ out, pos = [], 0
243
+ for m in pattern.finditer(text):
244
+ if op.op == "insert_after":
245
+ out += [text[pos:m.end()], op.new]
246
+ elif op.op == "insert_before":
247
+ out += [text[pos:m.start()], op.new, m.group(0)]
248
+ else:
249
+ out += [text[pos:m.start()], op.new]
250
+ pos = m.end()
251
+ out.append(text[pos:])
252
+ return "".join(out)
253
+
254
+
255
+ def _cut_field(text, pattern):
256
+ """The first merge field that a match of pattern starts or ends inside of, if any."""
257
+ fields = [(m.start(), m.end(), m.group(0)) for m in MERGE_FIELD.finditer(text)]
258
+ for m in pattern.finditer(text):
259
+ for start, end, field in fields:
260
+ if start < m.end() and m.start() < end and not (m.start() <= start and end <= m.end()):
261
+ return field
262
+ return None
263
+
264
+
265
+ def _block_edits(op, targets):
266
+ """The verbatim source edit of each block, checked against the request edit of its
267
+ rendered text (patch.py's, which the replay scored)."""
268
+ pattern = re.compile(re.escape(op.locator))
269
+ for _, t in targets:
270
+ field = _cut_field(t.text, pattern)
271
+ if field:
272
+ raise _err(op, "render", f"{_what(op)} cuts into the merge field {field} in {t.where}: the edit "
273
+ "would change what it refers to. Find whole merge fields, as written")
274
+ found = {key: len(pattern.findall(t.text)) for key, t in targets}
275
+ for key, t in targets:
276
+ if not found[key] and edit_text(render_text(t.text), op)[1] != "unmatched":
277
+ raise _err(op, "render", f"{_what(op)} matches the rendered text of {t.where} but not its source: copy "
278
+ "it from the .agent, with merge fields ({!@actions.X}) and the indentation of "
279
+ "its lines as written")
280
+ total = sum(found.values())
281
+ if not total:
282
+ raise _err(op, "unmatched", f"{_what(op)} is not in {_where(targets)}: copy it from the .agent")
283
+ hit = [key for key, _ in targets if found[key]]
284
+ if total > 1 and not op.all:
285
+ branches = " (in different blocks: each branch of an if is one)" if len(hit) > 1 else ""
286
+ raise _err(op, "ambiguous", f"{_what(op)} occurs {total} times in {_where(targets)}{branches}: "
287
+ "lengthen it, or set all")
288
+ edits = {}
289
+ for key, t in targets:
290
+ if not found[key]:
291
+ continue
292
+ new = _verbatim(t.text, op, pattern)
293
+ req_new, outcome = edit_text(render_text(t.text), op)
294
+ if outcome == "ambiguous":
295
+ raise _err(op, "render", f"{_what(op)} occurs once in the source of {t.where} but more often in its "
296
+ "rendered text ({!@actions.X} renders as X): lengthen it")
297
+ if outcome != "matched" or render_text(new) != req_new:
298
+ raise _err(op, "render", f"the edit of {t.where} renders otherwise than the same edit on its rendered "
299
+ "text (a line that starts with a merge field loses its indent): keep merge "
300
+ "fields off the start of a line the edit changes")
301
+ edits[key] = new
302
+ return edits
303
+
304
+
305
+ def _description_edits(op, targets):
306
+ if op.op == "set_description":
307
+ current = {t.text for _, t in targets}
308
+ if len(current) > 1:
309
+ raise _err(op, "ambiguous", f"{op.target} has {len(current)} different descriptions in scope "
310
+ f"({'; '.join(t.where for _, t in targets)}): limit the op with nodes")
311
+ return {key: op.new for key, _ in targets}
312
+ edits, matched = {}, False
313
+ for key, t in targets:
314
+ new, outcome = edit_text(t.text or "", op)
315
+ if outcome == "ambiguous":
316
+ raise _err(op, "ambiguous", f"{_what(op)} occurs more than once in {t.where}: lengthen it, or set all")
317
+ if outcome == "matched":
318
+ matched = True
319
+ edits[key] = new
320
+ if not matched:
321
+ raise _err(op, "unmatched", f"{_what(op)} is not in {_where(targets)}: copy it from the .agent")
322
+ return edits
323
+
324
+
325
+ # ---------------------------------------------------------------------------------------
326
+ # writing: (first line index, end index, new raw lines) splices of the file's lines
327
+
328
+ def _block_raws(op, t, new):
329
+ """The block's new raw lines: unchanged lines as they were, changed ones at its indent."""
330
+ b = t.block
331
+ if not b.lines:
332
+ raise _err(op, "unwritable", f"{t.where} has no text lines")
333
+ old, lines = b.text.split("\n"), new.split("\n")
334
+ if not new.strip():
335
+ raise _err(op, "unwritable", f"the edit would leave {t.where} empty")
336
+ if not lines[-1].strip():
337
+ raise _err(op, "unwritable", f"the edit would end {t.where} with a blank line, which is not part of a block "
338
+ "in the .agent: take the line break before the text into the find instead")
339
+ if any(ln and not ln.strip() for ln in lines):
340
+ raise _err(op, "unwritable", f"the edit would give {t.where} a line of only spaces")
341
+ inline = b.style == "inline"
342
+ if inline and (not lines[0] or lines[0].startswith(" ")):
343
+ raise _err(op, "unwritable", f"the edit would leave the | line of {t.where} empty or starting with a space")
344
+ if not inline and next(ln for ln in lines if ln).startswith(" "):
345
+ raise _err(op, "unwritable", f"the edit would indent the first text line of {t.where}, which sets the "
346
+ "block's indent")
347
+ raws = [raw for _, raw in b.lines]
348
+
349
+ def fmt(j, text):
350
+ if inline and j == 0:
351
+ return raws[0][:b.base] + text
352
+ return " " * b.base + text if text else ""
353
+
354
+ out = []
355
+ for tag, i1, _, j1, j2 in SequenceMatcher(None, old, lines, autojunk=False).get_opcodes():
356
+ for d in range(j2 - j1):
357
+ i, j = i1 + d, j1 + d
358
+ keep = tag == "equal" and not (inline and (i == 0) != (j == 0))
359
+ out.append(raws[i] if keep else fmt(j, lines[j]))
360
+ return out
361
+
362
+
363
+ def _string(op, t, new):
364
+ bad = sorted({hex(ord(c)) for c in new if ord(c) < 32 and c not in "\n\t"})
365
+ if bad:
366
+ raise _err(op, "unwritable", f"the new text of {t.where} holds control characters {bad}")
367
+ return json.dumps(new, ensure_ascii=False)
368
+
369
+
370
+ def _quoted_raw(op, t, new, lines):
371
+ it = t.item
372
+ body = lines[it.line - 1].rstrip()
373
+ try:
374
+ one_string = isinstance(json.loads(it.value), str)
375
+ except ValueError:
376
+ one_string = False
377
+ if it.children or not one_string or not body.endswith(it.value):
378
+ raise _err(op, "unwritable", f"{t.where} is not a one-line quoted string: make it one, or a "
379
+ "description: | block, first")
380
+ return body[:len(body) - len(it.value)] + _string(op, t, new)
381
+
382
+
383
+ def _missing_raw(op, t, new):
384
+ owner = t.owner
385
+ indent = owner.children[0].indent if owner.children else owner.indent + DESCRIPTION_INDENT
386
+ return " " * indent + "description: " + _string(op, t, new)
387
+
388
+
389
+ _KINDS = {"block": "instructions", "system": "system", "binding": "transition", "action": "action",
390
+ "input": "input"}
391
+
392
+
393
+ def _about(key):
394
+ out = {"text": _KINDS[key[0]]}
395
+ if key[0] == "block":
396
+ out.update(node=key[1], block=key[2])
397
+ elif key[0] == "system":
398
+ if key[1] is not None:
399
+ out["node"] = key[1]
400
+ else:
401
+ out.update(node=key[1], name=key[2])
402
+ if key[0] == "input":
403
+ out["input"] = key[3]
404
+ return out
405
+
406
+
407
+ def _hunks(key, start, old, new):
408
+ out = []
409
+ for tag, i1, i2, j1, j2 in SequenceMatcher(None, old, new, autojunk=False).get_opcodes():
410
+ if tag != "equal":
411
+ out.append(dict(_about(key), line=start + i1 + 1, removed=old[i1:i2], added=new[j1:j2]))
412
+ return out
413
+
414
+
415
+ def _apply(text, src, op):
416
+ """(new text, new source, report edits) of one op."""
417
+ if "\r" in op.new:
418
+ raise _err(op, "unwritable", "the op's text holds a carriage return")
419
+ texts = _editable(src)
420
+ targets = _targets(src, texts, op)
421
+ edits = (_description_edits if op.is_description else _block_edits)(op, targets)
422
+ lines = text.split("\n")
423
+ splices = []
424
+ for key, t in targets:
425
+ new = edits.get(key)
426
+ if new is None or new == t.text:
427
+ continue
428
+ if t.block is not None:
429
+ splices.append((t.block.start - 1, t.block.end, _block_raws(op, t, new), key))
430
+ elif t.item is not None:
431
+ splices.append((t.item.line - 1, t.item.line, [_quoted_raw(op, t, new, lines)], key))
432
+ else:
433
+ splices.append((t.owner.line, t.owner.line, [_missing_raw(op, t, new)], key))
434
+ report = []
435
+ for i0, i1, raws, key in sorted(splices, key=lambda s: s[0], reverse=True):
436
+ report[:0] = _hunks(key, i0, lines[i0:i1], raws)
437
+ lines[i0:i1] = raws
438
+ new_text = "\n".join(lines)
439
+ return new_text, _read_back(op, src, texts, new_text, edits), report
440
+
441
+
442
+ def _parsed(src, key):
443
+ """The text at key as the parser reads it, which is what node_tools gives the scorer; it is
444
+ the one _editable found unless an entry holds two descriptions (the parser keeps the last)."""
445
+ if key[0] == "system":
446
+ return (src.system if key[1] is None else src.nodes[key[1]].system).text
447
+ node = src.nodes[key[1]]
448
+ if key[0] == "block":
449
+ return node.blocks[key[2]].text
450
+ if key[0] == "binding":
451
+ return node.bindings[key[2]].description
452
+ d = node.action_defs[key[2]]
453
+ return d.description if key[0] == "action" else d.inputs[key[3]].description
454
+
455
+
456
+ def _read_back(op, src, texts, new_text, edits):
457
+ """The edited agent, parsed: the same agent with only the edited texts changed."""
458
+ try:
459
+ new_src = parse_agent(new_text, src.path)
460
+ except AgentSourceError as e:
461
+ raise _err(op, "unwritable", f"the edited agent does not parse: {e}")
462
+ if _shape(new_src) != _shape(src):
463
+ raise _err(op, "unwritable", "the edit changes more than texts (nodes, blocks, bindings or definitions "
464
+ "read back otherwise)")
465
+ wrong = [t.where for key, t in texts.items() if _parsed(new_src, key) != edits.get(key, _parsed(src, key))]
466
+ if wrong:
467
+ raise _err(op, "unwritable", f"the edited agent reads back otherwise in {'; '.join(wrong)}")
468
+ return new_src
469
+
470
+
471
+ def patch_agent(text, ops, path=None):
472
+ """(new text, report) of the ops applied in order to an .agent's source text, each to the
473
+ result of the ones before; no ops gives the text unchanged. The report has one entry per
474
+ op, {id, target, op, edits}; an edit is {text, node, block | name [, input], line,
475
+ removed, added}, its line 1-based in the text the op applied to."""
476
+ if "\r" in text:
477
+ raise AgentPatchError("unwritable", f"{path or 'agent'}: has CR line endings; convert it to LF first")
478
+ src = parse_agent(text, path)
479
+ report = []
480
+ for op in ops:
481
+ text, src, edits = _apply(text, src, op)
482
+ report.append({"id": op.id, "target": op.target, "op": op.op, "edits": edits})
483
+ return text, report