@forwardimpact/libeval 0.1.53 → 0.1.54

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/profile-prompt.js +130 -48
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libeval",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
4
4
  "description": "Agent evaluation framework — prove whether agent changes improved outcomes with reproducible evidence.",
5
5
  "keywords": [
6
6
  "eval",
@@ -13,12 +13,26 @@
13
13
  * </session_protocol>
14
14
  *
15
15
  * The two tags are siblings joined by a blank line — neither nests inside
16
- * the other. A section appears only when its content is present. A
17
- * system-prompt amendment is folded into the protocol trailer before
18
- * wrapping, so it lands transparently inside `<session_protocol>`. The tag
16
+ * the other. A section appears only when its content is present. The tag
19
17
  * convention lives entirely here: profile `.md` files and trailer constants
20
18
  * carry no tags.
21
19
  *
20
+ * The `<session_protocol>` body is assembled from up to three fragments, in
21
+ * order of decreasing generality:
22
+ *
23
+ * 1. the role-invariant orchestration trailer (libeval-owned);
24
+ * 2. the profile's own hoisted `## Session Protocol` section, if present;
25
+ * 3. a run-specific amendment, if supplied.
26
+ *
27
+ * Fragment 2 is the convention-based hoist: a profile may carry a level-2
28
+ * `## Session Protocol` markdown heading whose body is the role's work
29
+ * routine. When present, that section is lifted out of `<agent_profile>` and
30
+ * folded into `<session_protocol>` next to the orchestration mechanics, so
31
+ * the harness comms protocol and the role's work routine read as one
32
+ * coherent block. The heading line itself is dropped — the tag already names
33
+ * the section. Profiles with no such heading are unaffected (the entire body
34
+ * stays in `<agent_profile>`).
35
+ *
22
36
  * Helpers:
23
37
  *
24
38
  * - `composeProfilePrompt(name, opts)` — profile + `claude_code` preset.
@@ -28,9 +42,9 @@
28
42
  * roles (supervisor, facilitator, discuss lead) that should only see
29
43
  * the orchestration instructions and optionally a profile body.
30
44
  *
31
- * - `composeSystemPrompt(opts)` — unified entry point. Folds `amend` into
32
- * the protocol section, then delegates to one of the above based on
33
- * `opts.role`.
45
+ * - `composeSystemPrompt(opts)` — unified entry point. Threads `amend` into
46
+ * the protocol section as the run-specific fragment, then delegates to one
47
+ * of the above based on `opts.role`.
34
48
  */
35
49
 
36
50
  import { join } from "node:path";
@@ -39,6 +53,17 @@ import { join } from "node:path";
39
53
  const AGENT_PROFILE_TAG = "agent_profile";
40
54
  const SESSION_PROTOCOL_TAG = "session_protocol";
41
55
 
56
+ /**
57
+ * A level-2 heading that names the profile's hoisted session-protocol
58
+ * section. Case-insensitive, tolerant of trailing whitespace, but the level
59
+ * is fixed at two `#` so a `### Session Protocol` subsection does not trip
60
+ * the hoist.
61
+ */
62
+ const SESSION_PROTOCOL_HEADING = /^##[ \t]+session protocol[ \t]*$/i;
63
+
64
+ /** A level-1 or level-2 heading — the boundary that ends a hoisted section. */
65
+ const SECTION_BOUNDARY = /^#{1,2}[ \t]+\S/;
66
+
42
67
  /** Wrap content in a semantic section tag, each on its own line. */
43
68
  function wrapSection(tag, content) {
44
69
  return `<${tag}>\n${content}\n</${tag}>`;
@@ -46,86 +71,148 @@ function wrapSection(tag, content) {
46
71
 
47
72
  /**
48
73
  * Assemble the parallel `<agent_profile>` / `<session_protocol>` sections.
49
- * Each section is emitted only when its content is non-empty; the two tags
50
- * are siblings joined by a blank line and never nest.
74
+ * The profile section is emitted only when `body` is non-empty. The protocol
75
+ * section is built by joining its fragments (in the order given) with a
76
+ * blank-line separator, dropping any that are empty, and is emitted only
77
+ * when at least one fragment survives. The two tags are siblings joined by a
78
+ * blank line and never nest.
51
79
  *
52
80
  * @param {object} parts
53
- * @param {string} [parts.body] - Profile body, already frontmatter-stripped.
54
- * @param {string} [parts.protocol] - Session protocol trailer, with any
55
- * amendment already folded in.
81
+ * @param {string} [parts.body] - Profile body, frontmatter-stripped and with
82
+ * any `## Session Protocol` section already hoisted out.
83
+ * @param {Array<string | undefined>} [parts.protocolParts] - Ordered session
84
+ * protocol fragments: trailer, hoisted profile section, run amendment.
56
85
  * @returns {string}
57
86
  */
58
- function assembleSections({ body, protocol }) {
87
+ function assembleSections({ body, protocolParts = [] }) {
59
88
  const sections = [];
60
89
  if (body) sections.push(wrapSection(AGENT_PROFILE_TAG, body));
90
+ const protocol = protocolParts.filter(Boolean).join("\n\n");
61
91
  if (protocol) sections.push(wrapSection(SESSION_PROTOCOL_TAG, protocol));
62
92
  return sections.join("\n\n");
63
93
  }
64
94
 
65
95
  /**
66
- * Read a profile `.md`, strip its frontmatter, and return the trimmed body.
67
- * Reads synchronously off the injected `runtime.fsSync` surface this
68
- * composer runs inside the synchronous SDK-option builders of the
69
- * supervisor / facilitator / discusser / judge factories, so it cannot go
70
- * async without an unbounded cascade.
96
+ * Split a frontmatter-stripped profile body into its persona and an optional
97
+ * hoisted `## Session Protocol` section. The section runs from its heading to
98
+ * the next level-1/level-2 heading (or end of body); the heading line is
99
+ * dropped. Anything before and after the section is rejoined into `persona`.
100
+ * When the body carries no `## Session Protocol` heading, the whole body is
101
+ * returned as `persona` and `protocol` is `undefined`.
102
+ *
103
+ * @param {string} body - Frontmatter-stripped, trimmed profile body.
104
+ * @returns {{ persona: string, protocol: string | undefined }}
105
+ */
106
+ function splitSessionProtocol(body) {
107
+ const lines = body.split("\n");
108
+ const start = lines.findIndex((line) => SESSION_PROTOCOL_HEADING.test(line));
109
+ if (start === -1) return { persona: body, protocol: undefined };
110
+
111
+ let end = lines.length;
112
+ for (let i = start + 1; i < lines.length; i++) {
113
+ if (SECTION_BOUNDARY.test(lines[i])) {
114
+ end = i;
115
+ break;
116
+ }
117
+ }
118
+
119
+ const protocol = lines
120
+ .slice(start + 1, end)
121
+ .join("\n")
122
+ .trim();
123
+ const before = lines.slice(0, start).join("\n").trim();
124
+ const after = lines.slice(end).join("\n").trim();
125
+ const persona = [before, after].filter(Boolean).join("\n\n");
126
+ return { persona, protocol: protocol || undefined };
127
+ }
128
+
129
+ /**
130
+ * Read a profile `.md`, strip its frontmatter, and split off any hoisted
131
+ * `## Session Protocol` section. Reads synchronously off the injected
132
+ * `runtime.fsSync` surface — this composer runs inside the synchronous
133
+ * SDK-option builders of the supervisor / facilitator / discusser / judge
134
+ * factories, so it cannot go async without an unbounded cascade.
71
135
  *
72
136
  * @param {string} name - Profile basename (no `.md` suffix)
73
137
  * @param {string} profilesDir - Directory containing `<name>.md`
74
138
  * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
75
- * @returns {string}
139
+ * @returns {{ persona: string, protocol: string | undefined }}
76
140
  */
77
- function readProfileBody(name, profilesDir, runtime) {
141
+ function readProfileSections(name, profilesDir, runtime) {
78
142
  const path = join(profilesDir, `${name}.md`);
79
143
  const raw = runtime.fsSync.readFileSync(path, "utf8");
80
- return stripFrontmatter(raw).trim();
144
+ return splitSessionProtocol(stripFrontmatter(raw).trim());
81
145
  }
82
146
 
83
147
  /**
84
148
  * Compose a `claude_code`-preset system prompt from a profile file. The
85
- * profile body is wrapped in `<agent_profile>`; an optional protocol trailer
86
- * is wrapped in a sibling `<session_protocol>`.
149
+ * persona is wrapped in `<agent_profile>`; the protocol trailer, the
150
+ * profile's hoisted `## Session Protocol` section, and any amendment are
151
+ * joined (in that order) into a sibling `<session_protocol>`.
87
152
  *
88
153
  * @param {string} name - Profile basename (no `.md` suffix)
89
154
  * @param {object} opts
90
155
  * @param {string} opts.profilesDir - Directory containing `<name>.md`
91
- * @param {string} [opts.trailer] - Session protocol, wrapped as a sibling
92
- * `<session_protocol>` section after a blank line
156
+ * @param {string} [opts.trailer] - Session protocol orchestration mechanics,
157
+ * the first fragment of the `<session_protocol>` section.
158
+ * @param {string} [opts.amend] - Run-specific amendment, the last fragment of
159
+ * the `<session_protocol>` section.
93
160
  * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
94
161
  * @returns {{type: "preset", preset: "claude_code", append: string}}
95
162
  */
96
- export function composeProfilePrompt(name, { profilesDir, trailer, runtime }) {
97
- const body = readProfileBody(name, profilesDir, runtime);
163
+ export function composeProfilePrompt(
164
+ name,
165
+ { profilesDir, trailer, amend, runtime },
166
+ ) {
167
+ const { persona, protocol } = readProfileSections(name, profilesDir, runtime);
98
168
  return {
99
169
  type: "preset",
100
170
  preset: "claude_code",
101
- append: assembleSections({ body, protocol: trailer }),
171
+ append: assembleSections({
172
+ body: persona,
173
+ protocolParts: [trailer, protocol, amend],
174
+ }),
102
175
  };
103
176
  }
104
177
 
105
178
  /**
106
179
  * Compose a plain-string system prompt for a lead role (no Claude Code
107
- * preset). The protocol trailer is wrapped in `<session_protocol>`; an
108
- * optional profile body is wrapped in a sibling `<agent_profile>` before it.
180
+ * preset). The protocol trailer, an optional profile's hoisted
181
+ * `## Session Protocol` section, and any amendment are joined into
182
+ * `<session_protocol>`; an optional persona is wrapped in a sibling
183
+ * `<agent_profile>` before it.
109
184
  *
110
185
  * @param {object} opts
111
186
  * @param {string} [opts.profile] - Profile basename (no `.md` suffix)
112
187
  * @param {string} [opts.profilesDir] - Directory containing profile files
113
188
  * @param {string} opts.trailer - Session protocol (orchestration instructions)
189
+ * @param {string} [opts.amend] - Run-specific amendment, the last fragment of
190
+ * the `<session_protocol>` section.
114
191
  * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
115
192
  * @returns {string}
116
193
  */
117
- export function composeLeadPrompt({ profile, profilesDir, trailer, runtime }) {
194
+ export function composeLeadPrompt({
195
+ profile,
196
+ profilesDir,
197
+ trailer,
198
+ amend,
199
+ runtime,
200
+ }) {
118
201
  if (!trailer) throw new Error("trailer is required");
119
- const body = profile
120
- ? readProfileBody(profile, profilesDir, runtime)
121
- : undefined;
122
- return assembleSections({ body, protocol: trailer });
202
+ const { persona, protocol } = profile
203
+ ? readProfileSections(profile, profilesDir, runtime)
204
+ : { persona: undefined, protocol: undefined };
205
+ return assembleSections({
206
+ body: persona,
207
+ protocolParts: [trailer, protocol, amend],
208
+ });
123
209
  }
124
210
 
125
211
  /**
126
- * Unified entry point for composing system prompts. Folds an optional
127
- * amendment into the protocol trailer so it lands inside
128
- * `<session_protocol>` then delegates by role.
212
+ * Unified entry point for composing system prompts. Threads an optional
213
+ * amendment through as the run-specific fragment of `<session_protocol>`
214
+ * (after the trailer and any hoisted profile section), then delegates by
215
+ * role.
129
216
  *
130
217
  * @param {object} opts
131
218
  * @param {"lead"|"agent"} opts.role - `"lead"` produces a plain string;
@@ -133,8 +220,8 @@ export function composeLeadPrompt({ profile, profilesDir, trailer, runtime }) {
133
220
  * @param {string} [opts.profile] - Profile basename
134
221
  * @param {string} [opts.profilesDir]
135
222
  * @param {string} opts.trailer - Session protocol (orchestration instructions)
136
- * @param {string} [opts.amend] - Caller-supplied amendment, appended inside
137
- * `<session_protocol>` after the trailer with a blank-line separator.
223
+ * @param {string} [opts.amend] - Caller-supplied amendment, the last fragment
224
+ * inside `<session_protocol>`, joined with a blank-line separator.
138
225
  * @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
139
226
  * @returns {string | {type: "preset", preset: "claude_code", append: string}}
140
227
  */
@@ -147,26 +234,21 @@ export function composeSystemPrompt({
147
234
  runtime,
148
235
  }) {
149
236
  if (!trailer) throw new Error("trailer is required");
150
- const protocol = amend ? `${trailer}\n\n${amend}` : trailer;
151
237
  if (role === "lead") {
152
- return composeLeadPrompt({
153
- profile,
154
- profilesDir,
155
- trailer: protocol,
156
- runtime,
157
- });
238
+ return composeLeadPrompt({ profile, profilesDir, trailer, amend, runtime });
158
239
  }
159
240
  if (profile) {
160
241
  return composeProfilePrompt(profile, {
161
242
  profilesDir,
162
- trailer: protocol,
243
+ trailer,
244
+ amend,
163
245
  runtime,
164
246
  });
165
247
  }
166
248
  return {
167
249
  type: "preset",
168
250
  preset: "claude_code",
169
- append: assembleSections({ protocol }),
251
+ append: assembleSections({ protocolParts: [trailer, amend] }),
170
252
  };
171
253
  }
172
254