@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,199 @@
1
+ """One replay unit per production LLM_STEP (schema bt.unit/1).
2
+
3
+ A turn makes several LLM calls: the router picks a sub-agent (go_to_*), then the sub-agent
4
+ calls tools and finally replies. Each call is its own unit, with its own request and its own
5
+ golden (that step's gen_ai.output.messages[0]) — never the turn's final reply and never the
6
+ ACTION_STEP's platform action name (Shopper_Add_To_Cart is not the AddToCart tool the model
7
+ called).
8
+
9
+ unit_id = "<session_id>:<turn index>:<step ordinal>"; the ordinal counts every LLM_STEP of
10
+ the turn ordered by (start_time, step_id), skipped ones included, so ids do not depend on
11
+ filters.
12
+ """
13
+
14
+ import json
15
+ import re
16
+ from collections import Counter
17
+
18
+ from . import SCHEMA_UNIT
19
+ from .messages import build_messages
20
+
21
+ DEFAULT_ROUTER_NODES = ("agent_router", "topic_selector")
22
+ GROUNDEDNESS_NODE = "Atlas__GroundednessValidationPrompt"
23
+ # LLM steps that validate a reply instead of making an agent decision. They carry no
24
+ # input messages either; listed so the skip reason is explicit.
25
+ NON_DECISION_NODES = (GROUNDEDNESS_NODE,)
26
+ # What production's own checks said of a turn. The steps are not replayed, and their results are
27
+ # text: "category=GROUNDED, reason=..." and "InstructionAdherence: value=UNCERTAIN, ...;
28
+ # TaskResolution: value=...". Only these values are kept, never the reasons.
29
+ _GROUNDEDNESS = re.compile(r"\bcategory=([A-Z][A-Z_]*)")
30
+ _GUARDRAIL = re.compile(r"\b([A-Za-z]\w*): value=([A-Z][A-Z_]*)")
31
+
32
+
33
+ def _load(text):
34
+ if not text:
35
+ return None
36
+ try:
37
+ return json.loads(text)
38
+ except (TypeError, json.JSONDecodeError):
39
+ return None
40
+
41
+
42
+ def parse_finish_reasons(value):
43
+ """"('tool_calls',)" -> 'tool_calls'; lists and plain strings pass through."""
44
+ if isinstance(value, (list, tuple)):
45
+ return value[0] if value else None
46
+ if not isinstance(value, str):
47
+ return None
48
+ v = value.strip().strip("()[]").strip().rstrip(",").strip().strip("'\"")
49
+ return v or None
50
+
51
+
52
+ def parse_arguments(arguments):
53
+ """(args dict, raw string or None). Unparseable arguments are kept raw."""
54
+ if isinstance(arguments, dict):
55
+ return arguments, None
56
+ if arguments in (None, ""):
57
+ return {}, None
58
+ if isinstance(arguments, str):
59
+ try:
60
+ v = json.loads(arguments)
61
+ if isinstance(v, dict):
62
+ return v, None
63
+ except json.JSONDecodeError:
64
+ pass
65
+ return {}, arguments if isinstance(arguments, str) else json.dumps(arguments)
66
+
67
+
68
+ def golden_from_output(output):
69
+ """The step's own answer, from gen_ai.output.messages[0]: every tool call, or the text."""
70
+ msgs = (output or {}).get("gen_ai.output.messages") or []
71
+ if not msgs:
72
+ return None
73
+ m = msgs[0]
74
+ finish = parse_finish_reasons(output.get("gen_ai.response.finish_reasons")) or m.get("finish_reason")
75
+ calls = []
76
+ for inv in m.get("tool_invocations") or []:
77
+ fn = inv.get("function") or {}
78
+ args, raw = parse_arguments(fn.get("arguments"))
79
+ call = {"name": fn.get("name"), "args": args}
80
+ if raw is not None:
81
+ call["args_raw"] = raw
82
+ calls.append(call)
83
+ golden = {"kind": "tool" if calls else "text", "tool_calls": calls,
84
+ "text": m.get("content") or "", "finish_reason": finish}
85
+ if len(msgs) > 1:
86
+ golden["extra_output_messages"] = len(msgs) - 1
87
+ return golden
88
+
89
+
90
+ def _step_result(step):
91
+ out = _load(step.get("output"))
92
+ result = out.get("mgr.sensitive.step.result") if isinstance(out, dict) else None
93
+ return result if isinstance(result, str) else ""
94
+
95
+
96
+ def production_signals(convo, turn):
97
+ """Report-only: production's own checks of a turn, which never change a score (the golden is
98
+ production's answer, right or wrong). groundedness: the category the groundedness check gave
99
+ the turn's reply (GROUNDED, PARTIALLY_GROUNDED_SUGGESTION, SMALL_TALK, ...); guardrails: the
100
+ value of each trust guardrail (InstructionAdherence, TaskResolution); step_errors: the turn's
101
+ steps with an error; and the session's end type and action error count. None or {} where STDM
102
+ has no such record."""
103
+ steps = sorted(turn.get("steps") or [], key=lambda s: (s.get("start_time") or "", s.get("step_id") or ""))
104
+ groundedness, guardrails = None, {}
105
+ for s in steps:
106
+ if s.get("step_type") == "LLM_STEP" and s.get("name") == GROUNDEDNESS_NODE:
107
+ m = _GROUNDEDNESS.search(_step_result(s))
108
+ groundedness = m.group(1) if m else groundedness
109
+ elif s.get("step_type") == "TRUST_GUARDRAILS_STEP":
110
+ guardrails.update(_GUARDRAIL.findall(_step_result(s)))
111
+ return {"groundedness": groundedness, "guardrails": guardrails,
112
+ "step_errors": sum(1 for s in steps if s.get("error")),
113
+ "session_end_type": convo.get("end_type"), "session_action_errors": convo.get("action_error_count")}
114
+
115
+
116
+ def _turn_user_text(turn):
117
+ for msg in turn.get("messages") or []:
118
+ if msg.get("message_type") == "Input" and msg.get("text"):
119
+ return msg["text"].strip()
120
+ return ""
121
+
122
+
123
+ def build_units(conversations, org=None, agent=None, router_nodes=DEFAULT_ROUTER_NODES,
124
+ steps="all"):
125
+ """(units, skipped Counter). steps: 'router' | 'subagent' | 'all'."""
126
+ if steps not in ("router", "subagent", "all"):
127
+ raise ValueError(f"steps must be router|subagent|all, not {steps!r}")
128
+ router_nodes = set(router_nodes)
129
+ units, skipped = [], Counter()
130
+ for convo in conversations:
131
+ sid = convo.get("session_id")
132
+ for t_idx, turn in enumerate(convo.get("turns") or []):
133
+ llm = [s for s in turn.get("steps") or [] if s.get("step_type") == "LLM_STEP"]
134
+ llm.sort(key=lambda s: (s.get("start_time") or "", s.get("step_id") or ""))
135
+ for ordinal, step in enumerate(llm):
136
+ node = step.get("name") or ""
137
+ if node in NON_DECISION_NODES:
138
+ skipped["non_decision_step"] += 1
139
+ continue
140
+ inp = _load(step.get("input"))
141
+ if inp is None:
142
+ skipped["input_unparseable" if step.get("input") else "no_input"] += 1
143
+ continue
144
+ stdm_msgs = inp.get("gen_ai.input.messages")
145
+ if not stdm_msgs:
146
+ skipped["no_input_messages"] += 1
147
+ continue
148
+ out = _load(step.get("output"))
149
+ golden = golden_from_output(out)
150
+ if golden is None:
151
+ skipped["no_output_message"] += 1
152
+ continue
153
+ kind = "router" if node in router_nodes else "subagent"
154
+ if steps != "all" and steps != kind:
155
+ skipped["not_selected"] += 1
156
+ continue
157
+ units.append({
158
+ "schema": SCHEMA_UNIT,
159
+ "unit_id": f"{sid}:{t_idx}:{ordinal}",
160
+ "session_id": sid,
161
+ "turn": t_idx,
162
+ "step": ordinal,
163
+ "step_id": step.get("step_id"),
164
+ "interaction_id": turn.get("interaction_id"),
165
+ "org": org,
166
+ "agent": agent,
167
+ "agent_version": None,
168
+ "node": node,
169
+ "step_kind": kind,
170
+ "request": {
171
+ "messages": build_messages(stdm_msgs),
172
+ "tools": inp.get("gen_ai.request.tools") or [],
173
+ "model": inp.get("gen_ai.request.model"),
174
+ "settings": {"prompt_template": inp.get("af.prompt_template_dev_name"),
175
+ "stream": inp.get("gen_ai.request.stream")},
176
+ },
177
+ "golden": golden,
178
+ "split": None,
179
+ "channel": convo.get("channel"),
180
+ "topic": turn.get("topic"),
181
+ "user_utterance": _turn_user_text(turn),
182
+ "start_time": step.get("start_time"),
183
+ "production": production_signals(convo, turn),
184
+ })
185
+ return units, skipped
186
+
187
+
188
+ def tool_names(unit):
189
+ return [(t.get("function") or {}).get("name") for t in unit["request"]["tools"]]
190
+
191
+
192
+ def tool_schemas(unit):
193
+ """{tool name: parameters schema} for the tools offered to this step."""
194
+ out = {}
195
+ for t in unit["request"]["tools"]:
196
+ fn = t.get("function") or {}
197
+ if fn.get("name"):
198
+ out[fn["name"]] = fn.get("parameters") or {}
199
+ return out
@@ -0,0 +1,194 @@
1
+ """Which agent version each unit was rendered from (--agent-versions).
2
+
3
+ STDM records no agent version per step, and one pull can span several versions. A version
4
+ renders a unit when, for the unit's node:
5
+ instruction its text blocks render to the unit's instruction text (the last system
6
+ message, before "\\n\\n# CONTEXT"): the blocks in order, a block inside an
7
+ if / else possibly absent, merge fields matching any value
8
+ tools the tools offered are the node's, with this version's descriptions,
9
+ parameters and required lists (platform tools such as
10
+ __end_session_action__ aside; a tool with `available when` may be absent)
11
+ system its system text ends the first system message, or sits in it as whole lines
12
+ with the platform's own text after it (a voice agent); there, not where
13
+ another version's longer system text holds the same lines, as when a version
14
+ only added lines to it
15
+ Most steps render from several versions: a node that did not change between them renders
16
+ the same. A session runs one version, so the session's version is the one that renders
17
+ every unit of it that renders at all: the intersection. A unit's agent_version is its
18
+ session's version when that is a single one; compatible_versions lists the versions that
19
+ render the unit itself.
20
+
21
+ How several blocks of a node are joined is not verified (agentsrc.py), so any whitespace
22
+ may separate them. A loose check only adds versions to a unit's set, which can make a
23
+ session ambiguous but not wrong: the version that served a step renders it, so it is always
24
+ in the intersection.
25
+ """
26
+
27
+ import hashlib
28
+ import os
29
+ import re
30
+ from collections import Counter, defaultdict
31
+
32
+ from . import SCHEMA_VERSIONS
33
+ from .agentsrc import CONTEXT_MARKER, fragment_pattern
34
+
35
+ PLATFORM_TOOL = re.compile(r"^__\w+__$")
36
+ CHECKS = ("node", "instruction", "tools", "system") # in the order they are tried
37
+ _WHOLE_LINES = r"(?m)^[ \t]*(?:%s)[ \t]*$"
38
+
39
+
40
+ def unit_instruction(unit):
41
+ """The node's instruction text in a unit's request ('' without a trailing system message)."""
42
+ messages = unit["request"]["messages"]
43
+ if len(messages) < 2 or messages[-1].get("role") != "system":
44
+ return ""
45
+ return (messages[-1].get("content") or "").split(CONTEXT_MARKER)[0]
46
+
47
+
48
+ def _tools_agree(unit, tools):
49
+ offered = {}
50
+ for t in unit["request"].get("tools") or []:
51
+ fn = t.get("function") or {}
52
+ if fn.get("name") and not PLATFORM_TOOL.match(fn["name"]):
53
+ offered[fn["name"]] = fn
54
+ if set(offered) - set(tools) or {n for n, t in tools.items() if not t["conditional"]} - set(offered):
55
+ return False
56
+ for name, fn in offered.items():
57
+ mine = tools[name]
58
+ if mine["description"] is None: # not defined in the .agent: only its presence is known
59
+ continue
60
+ params = fn.get("parameters") or {}
61
+ props = params.get("properties") or {}
62
+ sent = (fn.get("description"), {p: d.get("description") if isinstance(d, dict) else None
63
+ for p, d in props.items()}, sorted(params.get("required") or []))
64
+ if sent != (mine["description"], mine["params"], sorted(mine["required"])):
65
+ return False
66
+ return True
67
+
68
+
69
+ class Matcher:
70
+ """Checks units against versions; patterns are compiled once per version and node, and
71
+ an instruction text is matched once per version and node (steps repeat them)."""
72
+
73
+ def __init__(self, versions):
74
+ self.versions = versions
75
+ self._cache = {}
76
+ self._instructions = {}
77
+ self._lines = None # {system text: its whole-lines pattern}, every version's
78
+ self._held = {} # {first system message: {system text: its spans there}}
79
+
80
+ def _inside(self, first):
81
+ """{system text: its spans in `first` as whole lines that no other system text of the
82
+ versions holds as well}. A version that only added lines to the system text holds the
83
+ older text's lines, which then must not match its steps."""
84
+ if self._lines is None:
85
+ texts = {b.text for v in self.versions for b in [v.system] + [n.system for n in v.nodes.values()]
86
+ if b is not None}
87
+ self._lines = {t: re.compile(_WHOLE_LINES % fragment_pattern(t)[0]) for t in texts}
88
+ if first not in self._held:
89
+ found = {t: [m.span() for m in p.finditer(first)] for t, p in self._lines.items()}
90
+ spans = {s for f in found.values() for s in f}
91
+ self._held[first] = {t: [s for s in f if not any(o != s and o[0] <= s[0] and s[1] <= o[1] for o in spans)]
92
+ for t, f in found.items()}
93
+ return self._held[first]
94
+
95
+ def _compiled(self, version, node):
96
+ key = (version.label, node.name)
97
+ if key not in self._cache:
98
+ parts = [r"\s*"]
99
+ for i, block in enumerate(node.blocks):
100
+ p = "(?:%s)" % fragment_pattern(block.text, prefix=f"b{i}_")[0]
101
+ parts.append((p + "?" if block.conditional else p) + r"\s*")
102
+ systems = [fragment_pattern(b.text, prefix=f"s{i}_")[0]
103
+ for i, b in enumerate((node.system, version.system)) if b is not None]
104
+ system = re.compile("(?:%s)\\s*\\Z" % "|".join(systems)) if systems else None
105
+ self._cache[key] = (re.compile("".join(parts)), version.node_tools(node.name), system)
106
+ return self._cache[key]
107
+
108
+ def check(self, version, unit):
109
+ """None when the version renders the unit, else the first check that fails."""
110
+ node = version.nodes.get(unit.get("node"))
111
+ if node is None:
112
+ return "node"
113
+ instruction, tools, system = self._compiled(version, node)
114
+ key = (version.label, node.name, unit_instruction(unit))
115
+ if key not in self._instructions:
116
+ self._instructions[key] = instruction.fullmatch(key[2]) is not None
117
+ if not self._instructions[key]:
118
+ return "instruction"
119
+ if not _tools_agree(unit, tools):
120
+ return "tools"
121
+ if system is not None:
122
+ messages = unit["request"]["messages"]
123
+ first = (messages[0].get("content") or "") if messages and messages[0].get("role") == "system" else ""
124
+ if not system.search(first) and not any(self._inside(first).get(b.text)
125
+ for b in (node.system, version.system) if b is not None):
126
+ return "system"
127
+ return None
128
+
129
+
130
+ def _sha16(path):
131
+ with open(path, "rb") as f:
132
+ return hashlib.sha256(f.read()).hexdigest()[:16]
133
+
134
+
135
+ def attribute(units, versions):
136
+ """Set compatible_versions and agent_version on every unit. Returns (the version set of
137
+ each session: None when no unit of it renders, empty on a conflict; the report)."""
138
+ matcher, labels = Matcher(versions), [v.label for v in versions]
139
+ unrendered = defaultdict(Counter) # node -> how far the closest version got
140
+ for u in units:
141
+ failed = {v.label: matcher.check(v, u) for v in versions}
142
+ u["compatible_versions"] = [label for label in labels if failed[label] is None]
143
+ if not u["compatible_versions"]:
144
+ unrendered[str(u.get("node"))][max(failed.values(), key=CHECKS.index)] += 1
145
+ by_session = defaultdict(list)
146
+ for u in units:
147
+ by_session[u.get("session_id")].append(u)
148
+ session_sets, session_counts, version_sessions = {}, Counter(), Counter()
149
+ for sid, members in by_session.items():
150
+ sets = [set(u["compatible_versions"]) for u in members if u["compatible_versions"]]
151
+ found = set.intersection(*sets) if sets else None
152
+ session_sets[sid] = found
153
+ only = next(iter(found)) if found and len(found) == 1 else None
154
+ for u in members:
155
+ u["agent_version"] = only
156
+ if found is None:
157
+ session_counts["unknown"] += 1
158
+ elif not found:
159
+ session_counts["conflict"] += 1
160
+ elif only:
161
+ session_counts["one_version"] += 1
162
+ version_sessions[only] += 1
163
+ else:
164
+ session_counts["several_versions"] += 1
165
+ rendered = Counter(label for u in units for label in u["compatible_versions"])
166
+ report = {
167
+ "schema": SCHEMA_VERSIONS,
168
+ "versions": [{"label": v.label, "target": v.target, "file": os.path.basename(v.path) if v.path else None,
169
+ "sha256": _sha16(v.path) if v.path else None, "units_rendered": rendered[v.label],
170
+ "sessions": version_sessions[v.label]} for v in versions],
171
+ "units": len(units),
172
+ "units_rendered": sum(1 for u in units if u["compatible_versions"]),
173
+ "units_not_rendered_by_node": {node: dict(c) for node, c in sorted(unrendered.items())},
174
+ "sessions": dict({"total": len(by_session)}, **{k: session_counts[k] for k in
175
+ ("one_version", "several_versions", "conflict", "unknown")}),
176
+ "selected": None,
177
+ }
178
+ return session_sets, report
179
+
180
+
181
+ def select(units, label, session_sets):
182
+ """(the units of sessions that version `label` can have served, dropped counts by reason)."""
183
+ kept, dropped = [], Counter()
184
+ for u in units:
185
+ found = session_sets.get(u.get("session_id"))
186
+ if found is None:
187
+ dropped["unknown_version"] += 1
188
+ elif not found:
189
+ dropped["version_conflict"] += 1
190
+ elif label not in found:
191
+ dropped["other_version"] += 1
192
+ else:
193
+ kept.append(u)
194
+ return kept, dropped