@librechat/agents 3.3.7 → 3.3.9

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 (168) hide show
  1. package/dist/cjs/graphs/Graph.cjs +47 -13
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +56 -6
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  6. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  7. package/dist/cjs/instrumentation.cjs +18 -48
  8. package/dist/cjs/instrumentation.cjs.map +1 -1
  9. package/dist/cjs/langfuse.cjs +174 -29
  10. package/dist/cjs/langfuse.cjs.map +1 -1
  11. package/dist/cjs/langfuseConfig.cjs +12 -0
  12. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  13. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  14. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  16. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  17. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  18. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  19. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  20. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  21. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  22. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  24. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/init.cjs +3 -3
  26. package/dist/cjs/llm/invoke.cjs +5 -5
  27. package/dist/cjs/llm/openai/index.cjs +1 -1
  28. package/dist/cjs/main.cjs +10 -10
  29. package/dist/cjs/messages/format.cjs +124 -15
  30. package/dist/cjs/messages/format.cjs.map +1 -1
  31. package/dist/cjs/messages/injected.cjs +10 -1
  32. package/dist/cjs/messages/injected.cjs.map +1 -1
  33. package/dist/cjs/messages/prune.cjs +13 -1
  34. package/dist/cjs/messages/prune.cjs.map +1 -1
  35. package/dist/cjs/prompts/activityLabel.cjs +51 -11
  36. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  37. package/dist/cjs/run.cjs +54 -24
  38. package/dist/cjs/run.cjs.map +1 -1
  39. package/dist/cjs/session/messageSerialization.cjs +6 -0
  40. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  41. package/dist/cjs/stream.cjs +21 -10
  42. package/dist/cjs/stream.cjs.map +1 -1
  43. package/dist/cjs/summarization/node.cjs +60 -0
  44. package/dist/cjs/summarization/node.cjs.map +1 -1
  45. package/dist/cjs/tools/ToolNode.cjs +253 -24
  46. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  47. package/dist/cjs/tools/handlers.cjs +1 -1
  48. package/dist/cjs/tools/search/tool.cjs +1 -1
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  50. package/dist/cjs/utils/index.cjs +2 -2
  51. package/dist/esm/graphs/Graph.mjs +48 -14
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +56 -6
  54. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  55. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  56. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  57. package/dist/esm/instrumentation.mjs +18 -48
  58. package/dist/esm/instrumentation.mjs.map +1 -1
  59. package/dist/esm/langfuse.mjs +176 -28
  60. package/dist/esm/langfuse.mjs.map +1 -1
  61. package/dist/esm/langfuseConfig.mjs +10 -1
  62. package/dist/esm/langfuseConfig.mjs.map +1 -1
  63. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  64. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  65. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  66. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  67. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  68. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  69. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  70. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  71. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  72. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  73. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  74. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  75. package/dist/esm/llm/init.mjs +2 -2
  76. package/dist/esm/llm/invoke.mjs +5 -5
  77. package/dist/esm/llm/openai/index.mjs +1 -1
  78. package/dist/esm/main.mjs +8 -8
  79. package/dist/esm/messages/format.mjs +124 -15
  80. package/dist/esm/messages/format.mjs.map +1 -1
  81. package/dist/esm/messages/injected.mjs +10 -1
  82. package/dist/esm/messages/injected.mjs.map +1 -1
  83. package/dist/esm/messages/prune.mjs +13 -1
  84. package/dist/esm/messages/prune.mjs.map +1 -1
  85. package/dist/esm/prompts/activityLabel.mjs +51 -11
  86. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  87. package/dist/esm/run.mjs +54 -24
  88. package/dist/esm/run.mjs.map +1 -1
  89. package/dist/esm/session/messageSerialization.mjs +6 -0
  90. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  91. package/dist/esm/stream.mjs +21 -10
  92. package/dist/esm/stream.mjs.map +1 -1
  93. package/dist/esm/summarization/node.mjs +60 -0
  94. package/dist/esm/summarization/node.mjs.map +1 -1
  95. package/dist/esm/tools/ToolNode.mjs +254 -25
  96. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  97. package/dist/esm/tools/handlers.mjs +1 -1
  98. package/dist/esm/tools/search/tool.mjs +1 -1
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  100. package/dist/esm/utils/index.mjs +2 -2
  101. package/dist/types/graphs/Graph.d.ts +19 -0
  102. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  103. package/dist/types/langfuse.d.ts +16 -8
  104. package/dist/types/langfuseConfig.d.ts +6 -0
  105. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  106. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  107. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  108. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  109. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  110. package/dist/types/messages/format.d.ts +9 -8
  111. package/dist/types/prompts/activityLabel.d.ts +8 -1
  112. package/dist/types/run.d.ts +1 -1
  113. package/dist/types/session/types.d.ts +1 -0
  114. package/dist/types/tools/ToolNode.d.ts +7 -1
  115. package/dist/types/types/activityLabel.d.ts +8 -0
  116. package/dist/types/types/hitl.d.ts +8 -0
  117. package/dist/types/types/stream.d.ts +19 -0
  118. package/dist/types/types/tools.d.ts +30 -0
  119. package/package.json +7 -4
  120. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  121. package/src/graphs/Graph.ts +69 -20
  122. package/src/graphs/MultiAgentGraph.ts +74 -6
  123. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  124. package/src/hitl/askUserQuestion.ts +14 -1
  125. package/src/instrumentation.ts +35 -77
  126. package/src/langfuse.ts +320 -43
  127. package/src/langfuseConfig.ts +24 -0
  128. package/src/langfuseRuntimeContext.ts +43 -1
  129. package/src/langfuseRuntimeScope.ts +94 -21
  130. package/src/langfuseSpanRegistry.ts +131 -0
  131. package/src/langfuseTraceShaping.ts +194 -7
  132. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  133. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  134. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  135. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  136. package/src/messages/format.ts +222 -50
  137. package/src/messages/formatAgentMessages.test.ts +308 -6
  138. package/src/messages/injected.test.ts +18 -1
  139. package/src/messages/injected.ts +8 -1
  140. package/src/messages/prune.ts +12 -1
  141. package/src/prompts/activityLabel.ts +67 -2
  142. package/src/run.ts +86 -46
  143. package/src/scripts/activity-labels/captured.json +56 -0
  144. package/src/scripts/activity-labels/checks.cjs +205 -0
  145. package/src/scripts/activity-labels/corpus.cjs +473 -0
  146. package/src/scripts/activity-labels/report.cjs +203 -0
  147. package/src/scripts/activity-labels/rescore.cjs +102 -0
  148. package/src/scripts/activity-labels/run.ts +705 -0
  149. package/src/scripts/activity-labels/variants.ts +71 -0
  150. package/src/session/messageSerialization.ts +12 -1
  151. package/src/session/types.ts +1 -0
  152. package/src/specs/activity-label-prompt.test.ts +109 -0
  153. package/src/specs/agent-handoffs.test.ts +306 -0
  154. package/src/specs/langfuse-callbacks.test.ts +456 -0
  155. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  156. package/src/specs/langfuse-span-registry.test.ts +70 -0
  157. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  158. package/src/specs/prune.test.ts +38 -1
  159. package/src/stream.ts +70 -6
  160. package/src/summarization/__tests__/node.test.ts +188 -0
  161. package/src/summarization/node.ts +72 -0
  162. package/src/tools/ToolNode.ts +400 -9
  163. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  164. package/src/tools/__tests__/hitl.test.ts +58 -0
  165. package/src/types/activityLabel.ts +8 -0
  166. package/src/types/hitl.ts +8 -0
  167. package/src/types/stream.ts +20 -0
  168. package/src/types/tools.ts +35 -1
@@ -0,0 +1,705 @@
1
+ /* eslint-disable no-console */
2
+ /**
3
+ * SDK-side activity-label eval runner, ported from LibreChat #14527
4
+ * (scripts/activity-labels/). corpus and report are byte-identical to the
5
+ * LibreChat originals so results compare across repos; checks carries one
6
+ * tool-echo normalization fix pending backport (see checks.cjs header).
7
+ * The user prompt is rendered by the REAL `buildActivityLabelPrompt` from
8
+ * `src/prompts/activityLabel.ts` instead of a hand-port. That is the point
9
+ * of the SDK version: a change to the builder (sections, truncation,
10
+ * framing) is measured as it will ship.
11
+ *
12
+ * Replays each case against the production wire shape (system = variant
13
+ * instruction, user = built prompt, max_tokens 256). Multi-step cases run
14
+ * serially and chain each generated label into the next step's
15
+ * `previousLabels`, exercising the builder's own sanitation and 3-label cap.
16
+ *
17
+ * Usage (from the repo root):
18
+ * npm run label:eval -- [--variants sdk-default,host-shipped]
19
+ * [--cases sandbox-probe-run,fib-rapid] [--samples 3] [--model id]
20
+ * [--concurrency 6] [--dry]
21
+ *
22
+ * Needs ANTHROPIC_API_KEY (env or repo .env). Results land in
23
+ * src/scripts/activity-labels/results/ (gitignored): a timestamped JSON of
24
+ * every record plus latest.md. The aggregate table is a regression guard;
25
+ * the per-case tables read by eye are the real instrument.
26
+ */
27
+ import fs from 'fs';
28
+ import path from 'path';
29
+ import crypto from 'crypto';
30
+ import { fileURLToPath } from 'url';
31
+ import { createRequire } from 'module';
32
+ import type { ActivityLabelToolEntry } from '@/types/activityLabel';
33
+ import type { Variant } from './variants';
34
+ import { buildActivityLabelPrompt } from '@/prompts/activityLabel';
35
+ import { variants } from './variants';
36
+
37
+ /** The subset of an entry the echo checker reads; verbatim captured steps
38
+ * only recover tool names, not full entries. */
39
+ type EchoEntry = Pick<ActivityLabelToolEntry, 'toolName'>;
40
+
41
+ type LabelCheck = {
42
+ flags: string[];
43
+ wordCount: number;
44
+ firstWord?: string;
45
+ maxOverlap?: number;
46
+ };
47
+
48
+ /** The row shape aggregate() actually returns (report.cjs) — typed
49
+ * explicitly so the reporting boundary is validated even though the
50
+ * producer is CJS. avgWords and costUsd are pre-formatted strings
51
+ * ('7.2' / '—', '0.0428' / 'n/a'). */
52
+ type AggregateRow = {
53
+ variant: string;
54
+ steps: number;
55
+ errors: number;
56
+ flagCounts: Readonly<Record<string, number>>;
57
+ distinctOpeners: number;
58
+ topOpener: string;
59
+ avgWords: string;
60
+ meanLatencyMs: number;
61
+ inputTokens: number;
62
+ outputTokens: number;
63
+ costUsd: string;
64
+ };
65
+
66
+ const require = createRequire(import.meta.url);
67
+ const HARNESS_DIR = path.dirname(fileURLToPath(import.meta.url));
68
+ const { cases, stepEntries } = require('./corpus.cjs') as {
69
+ cases: CorpusCase[];
70
+ stepEntries: (step: CorpusStep) => EchoEntry[];
71
+ };
72
+ const { checkLabel } = require('./checks.cjs') as {
73
+ checkLabel: (
74
+ label: string,
75
+ context: { entries: EchoEntry[]; previousLabels: string[] }
76
+ ) => LabelCheck;
77
+ };
78
+ const { aggregate, markdownReport } = require('./report.cjs') as {
79
+ aggregate: (records: RunRecord[], model: string) => AggregateRow[];
80
+ markdownReport: (input: {
81
+ records: RunRecord[];
82
+ aggregates: AggregateRow[];
83
+ runCases: CorpusCase[];
84
+ variantNames: string[];
85
+ model: string;
86
+ samples: number;
87
+ }) => string;
88
+ };
89
+
90
+ const ROOT = path.resolve(HARNESS_DIR, '..', '..', '..');
91
+ const RESULTS_DIR = path.join(HARNESS_DIR, 'results');
92
+ const CHAR_LIMIT = 600;
93
+ const MAX_TOKENS = 256;
94
+ /** Per-attempt deadline: a connection an intermediary leaves open would
95
+ * otherwise pin its worker forever — pool() awaits every worker, so one
96
+ * hung request would keep the whole run from writing its report. Labels
97
+ * take ~1s; 60s is generous headroom before the retry path takes over. */
98
+ const ATTEMPT_TIMEOUT_MS = 60_000;
99
+
100
+ type CorpusStep = {
101
+ id?: string;
102
+ verbatim?: string;
103
+ productionLabel?: string;
104
+ payload?: {
105
+ entries: ActivityLabelToolEntry[];
106
+ thinkingExcerpts?: string[];
107
+ lastAssistantText?: string;
108
+ };
109
+ };
110
+
111
+ type CorpusCase = { id: string; notes?: string; steps: CorpusStep[] };
112
+
113
+ type RunArgs = {
114
+ samples: number;
115
+ concurrency: number;
116
+ model: string;
117
+ dry: boolean;
118
+ variants?: string[];
119
+ cases?: string[];
120
+ };
121
+
122
+ /** A value-taking option must not consume a following flag (`--model
123
+ * --dry` would silently disable dry-run mode) or run off the end of
124
+ * argv (an undefined model is omitted from the request body and the
125
+ * whole sweep 400s per task). */
126
+ function optionValue(argv: string[], index: number, flag: string): string {
127
+ const value = argv[index];
128
+ if (value == null || value === '' || value.startsWith('--')) {
129
+ throw new Error(`${flag} requires a value`);
130
+ }
131
+ return value;
132
+ }
133
+
134
+ function parseArgs(argv: string[]): RunArgs {
135
+ const args: RunArgs = {
136
+ samples: 1,
137
+ concurrency: 6,
138
+ model: 'claude-haiku-4-5',
139
+ dry: false,
140
+ };
141
+ for (let i = 0; i < argv.length; i++) {
142
+ const key = argv[i];
143
+ if (key === '--dry') {
144
+ args.dry = true;
145
+ } else if (key === '--variants') {
146
+ args.variants = optionValue(argv, ++i, key).split(',');
147
+ } else if (key === '--cases') {
148
+ args.cases = optionValue(argv, ++i, key).split(',');
149
+ } else if (key === '--samples') {
150
+ args.samples = Number(optionValue(argv, ++i, key));
151
+ } else if (key === '--model') {
152
+ args.model = optionValue(argv, ++i, key);
153
+ } else if (key === '--concurrency') {
154
+ args.concurrency = Number(optionValue(argv, ++i, key));
155
+ } else {
156
+ /** A mistyped `--dryy` must not fall through to a fully billed
157
+ * default sweep. */
158
+ throw new Error(`unknown option: ${key}`);
159
+ }
160
+ }
161
+ if (!Number.isInteger(args.samples) || args.samples < 1) {
162
+ throw new Error(
163
+ `--samples must be a positive integer, got ${args.samples}`
164
+ );
165
+ }
166
+ if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
167
+ throw new Error(
168
+ `--concurrency must be a positive integer, got ${args.concurrency}`
169
+ );
170
+ }
171
+ return args;
172
+ }
173
+
174
+ function loadKey(): string {
175
+ if (process.env.ANTHROPIC_API_KEY) {
176
+ return process.env.ANTHROPIC_API_KEY;
177
+ }
178
+ const envPath = path.join(ROOT, '.env');
179
+ const line = fs.existsSync(envPath)
180
+ ? fs
181
+ .readFileSync(envPath, 'utf8')
182
+ .split('\n')
183
+ .find((entry) => entry.startsWith('ANTHROPIC_API_KEY='))
184
+ : undefined;
185
+ if (!line) {
186
+ throw new Error(
187
+ `ANTHROPIC_API_KEY not set and not found in ${envPath}.\n` +
188
+ 'Pass it inline: ANTHROPIC_API_KEY=sk-… npm run label:eval'
189
+ );
190
+ }
191
+ return line
192
+ .slice('ANTHROPIC_API_KEY='.length)
193
+ .trim()
194
+ .replace(/^["']|["']$/g, '');
195
+ }
196
+
197
+ /** The CURRENT builder framing (shipped in #363). Framing-hypothesis
198
+ * variants substitute against these markers; when a hypothesis ships,
199
+ * update them and the variant becomes the baseline. */
200
+ const BUILDER_ENTRIES_HEADING =
201
+ 'What it called, and what came back (do not restate these):';
202
+ const BUILDER_TERMINAL_LINE = 'Header:';
203
+ const BUILDER_TERMINAL = `\n\n${BUILDER_TERMINAL_LINE}`;
204
+
205
+ /**
206
+ * Renders the continuity section through the REAL builder (empty batch +
207
+ * previousLabels yields `<section>\n\n<terminal>`) so verbatim captured
208
+ * steps get exactly the sanitation and 3-label cap production applies —
209
+ * not a reimplementation of it. The terminal is derived from an empty
210
+ * render rather than assumed, so this keeps working if the builder ever
211
+ * renames `Label:` — the exact change the framing variants evaluate.
212
+ */
213
+ function continuitySection(previousLabels: string[]): string | null {
214
+ if (previousLabels.length === 0) {
215
+ return null;
216
+ }
217
+ const terminal = buildActivityLabelPrompt({
218
+ entries: [],
219
+ charLimit: CHAR_LIMIT,
220
+ });
221
+ const rendered = buildActivityLabelPrompt({
222
+ entries: [],
223
+ charLimit: CHAR_LIMIT,
224
+ previousLabels,
225
+ });
226
+ return rendered === terminal
227
+ ? null
228
+ : rendered.slice(0, -(terminal.length + '\n\n'.length));
229
+ }
230
+
231
+ /**
232
+ * Applies a framing hypothesis as marker-exact substitutions on a built
233
+ * prompt. Strict: the heading is rewritten when the prompt STARTS with the
234
+ * entries section (a bare batch with no previous labels, intent, or
235
+ * excerpts puts it first) or when the section-boundary marker occurs
236
+ * exactly once; the terminal only when it is the final line. A prompt
237
+ * failing every check is returned unchanged, so a framing variant degrades
238
+ * to the control rather than corrupting the sample — note the captured
239
+ * verbatim prompts predate #363's framing and take exactly that path.
240
+ */
241
+ function applyFraming(
242
+ prompt: string,
243
+ { entriesHeading, terminal }: Pick<Variant, 'entriesHeading' | 'terminal'>
244
+ ): string {
245
+ let text = prompt;
246
+ if (entriesHeading != null && entriesHeading !== BUILDER_ENTRIES_HEADING) {
247
+ const lead = `${BUILDER_ENTRIES_HEADING}\n`;
248
+ const marker = `\n\n${BUILDER_ENTRIES_HEADING}\n`;
249
+ const first = text.indexOf(marker);
250
+ if (text.startsWith(lead)) {
251
+ text = `${entriesHeading}\n` + text.slice(lead.length);
252
+ } else if (first !== -1 && text.indexOf(marker, first + 1) === -1) {
253
+ text =
254
+ text.slice(0, first) +
255
+ `\n\n${entriesHeading}\n` +
256
+ text.slice(first + marker.length);
257
+ }
258
+ }
259
+ if (
260
+ terminal != null &&
261
+ terminal !== BUILDER_TERMINAL_LINE &&
262
+ text.endsWith(BUILDER_TERMINAL)
263
+ ) {
264
+ text = text.slice(0, -BUILDER_TERMINAL_LINE.length) + terminal;
265
+ }
266
+ return text;
267
+ }
268
+
269
+ function renderStepPrompt(
270
+ step: CorpusStep,
271
+ variant: Variant,
272
+ previousLabels: string[] | null
273
+ ): string {
274
+ let prompt: string;
275
+ if (step.verbatim != null) {
276
+ const section =
277
+ previousLabels != null ? continuitySection(previousLabels) : null;
278
+ prompt = section != null ? `${section}\n\n${step.verbatim}` : step.verbatim;
279
+ } else {
280
+ prompt = buildActivityLabelPrompt({
281
+ entries: step.payload?.entries ?? [],
282
+ thinkingExcerpts: step.payload?.thinkingExcerpts,
283
+ lastAssistantText: step.payload?.lastAssistantText,
284
+ charLimit: CHAR_LIMIT,
285
+ previousLabels: previousLabels ?? undefined,
286
+ });
287
+ }
288
+ return applyFraming(prompt, variant);
289
+ }
290
+
291
+ type LabelSuccess = {
292
+ label: string;
293
+ latencyMs: number;
294
+ inputTokens: number;
295
+ outputTokens: number;
296
+ };
297
+
298
+ type LabelFailure = {
299
+ error: string;
300
+ latencyMs: number;
301
+ /** Present when the failure still billed tokens (e.g. a 200 whose
302
+ * label normalized to empty) — without them an all-empty variant
303
+ * would report $0. */
304
+ inputTokens?: number;
305
+ outputTokens?: number;
306
+ };
307
+
308
+ /** Error diagnostics land in Markdown table cells that escape only
309
+ * pipes — a multiline gateway body would split the row. */
310
+ function singleLine(text: string): string {
311
+ return text.replace(/\s+/g, ' ').trim();
312
+ }
313
+
314
+ type LabelResult = LabelSuccess | LabelFailure;
315
+
316
+ /** An error no retry or later task can recover from — thrown through the
317
+ * pool so the run stops instead of recording it per task. */
318
+ class FatalRunError extends Error {}
319
+
320
+ /**
321
+ * One label request with three attempts. The whole attempt — fetch AND the
322
+ * body reads, which reject on a connection reset after headers arrive —
323
+ * sits inside one try, so any transport rejection is retried like a
324
+ * 429/500/529 instead of escaping to the top level, where a single flake
325
+ * would discard every completed record of an otherwise finished run.
326
+ * `latencyMs` spans the whole sequence including backoff — the cost a
327
+ * variant actually paid.
328
+ */
329
+ async function requestLabel({
330
+ apiKey,
331
+ model,
332
+ instruction,
333
+ prompt,
334
+ }: {
335
+ apiKey: string;
336
+ model: string;
337
+ instruction: string;
338
+ prompt: string;
339
+ }): Promise<LabelResult> {
340
+ const started = Date.now();
341
+ let lastError = 'exhausted retries';
342
+ for (let attempt = 1; attempt <= 3; attempt++) {
343
+ try {
344
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
345
+ method: 'POST',
346
+ signal: AbortSignal.timeout(ATTEMPT_TIMEOUT_MS),
347
+ headers: {
348
+ 'content-type': 'application/json',
349
+ 'x-api-key': apiKey,
350
+ 'anthropic-version': '2023-06-01',
351
+ },
352
+ body: JSON.stringify({
353
+ model,
354
+ max_tokens: MAX_TOKENS,
355
+ system: instruction,
356
+ messages: [{ role: 'user', content: prompt }],
357
+ }),
358
+ });
359
+ if (response.ok) {
360
+ const json = (await response.json()) as {
361
+ content?: Array<{ text?: string }>;
362
+ usage?: { input_tokens?: number; output_tokens?: number };
363
+ };
364
+ /** Same normalization as the production extractor (src/run.ts): a
365
+ * label renders as a single row and re-enters later prompts as
366
+ * continuity context, so newlines must not survive here either —
367
+ * and a raw newline would break the per-case Markdown tables. */
368
+ const label = (json.content ?? [])
369
+ .map((block) => block.text ?? '')
370
+ .join('')
371
+ .replace(/\s+/g, ' ')
372
+ .trim()
373
+ .replace(/^["']|["']$/g, '');
374
+ if (label.length === 0) {
375
+ /** Production's extractLabel returns {} for an empty normalized
376
+ * label and commits no header — grading it as a len:0 step
377
+ * would count an outcome users never see. */
378
+ return {
379
+ error: 'empty label — production would commit no header',
380
+ latencyMs: Date.now() - started,
381
+ inputTokens: json.usage?.input_tokens ?? 0,
382
+ outputTokens: json.usage?.output_tokens ?? 0,
383
+ };
384
+ }
385
+ return {
386
+ label,
387
+ latencyMs: Date.now() - started,
388
+ inputTokens: json.usage?.input_tokens ?? 0,
389
+ outputTokens: json.usage?.output_tokens ?? 0,
390
+ };
391
+ }
392
+ /** Statuses where every remaining task would send the same doomed
393
+ * request — bad credentials (401/403) or a nonexistent --model
394
+ * (404). Classified BEFORE the body read, and with the read
395
+ * guarded, so a dropped error-body stream cannot demote a
396
+ * definitive failure to a retryable transport error. */
397
+ if ([401, 403, 404].includes(response.status)) {
398
+ const detail = await response.text().catch(() => '');
399
+ throw new FatalRunError(
400
+ `run-fatal HTTP ${response.status} (bad credentials or unknown model): ${singleLine(detail).slice(0, 160)}`
401
+ );
402
+ }
403
+ const body = await response.text();
404
+ lastError = `HTTP ${response.status}: ${singleLine(body).slice(0, 160)}`;
405
+ /** Transient statuses: rate limit (429), Anthropic 500/529
406
+ * (overloaded), and completed gateway failures (408/502/503/504)
407
+ * — a brief proxy outage must not bias an arm as an error. */
408
+ if (
409
+ attempt < 3 &&
410
+ [408, 429, 500, 502, 503, 504, 529].includes(response.status)
411
+ ) {
412
+ const retryAfter = Number(response.headers.get('retry-after'));
413
+ const waitMs =
414
+ Number.isFinite(retryAfter) && retryAfter > 0
415
+ ? retryAfter * 1000
416
+ : attempt * 2000;
417
+ await new Promise((resolve) =>
418
+ setTimeout(resolve, Math.min(waitMs, 15000))
419
+ );
420
+ continue;
421
+ }
422
+ break;
423
+ } catch (error) {
424
+ if (error instanceof FatalRunError) {
425
+ throw error;
426
+ }
427
+ lastError = `request failed: ${singleLine(error instanceof Error ? error.message : String(error))}`;
428
+ if (attempt < 3) {
429
+ await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
430
+ continue;
431
+ }
432
+ break;
433
+ }
434
+ }
435
+ return { error: lastError, latencyMs: Date.now() - started };
436
+ }
437
+
438
+ type RecordBase = {
439
+ variant: string;
440
+ sample: number;
441
+ caseId: string;
442
+ stepId: string;
443
+ };
444
+
445
+ type DryRunRecord = RecordBase & { prompt: string };
446
+
447
+ type ErrorRecord = RecordBase & {
448
+ error: string;
449
+ latencyMs?: number;
450
+ inputTokens?: number;
451
+ outputTokens?: number;
452
+ };
453
+
454
+ type LabelRecord = RecordBase & {
455
+ label: string;
456
+ production?: string;
457
+ flags: string[];
458
+ wordCount: number;
459
+ firstWord?: string;
460
+ latencyMs: number;
461
+ inputTokens: number;
462
+ outputTokens: number;
463
+ };
464
+
465
+ type RunRecord = DryRunRecord | ErrorRecord | LabelRecord;
466
+
467
+ /** One case chain: steps serial, labels feeding forward. */
468
+ async function runCase({
469
+ apiKey,
470
+ model,
471
+ variant,
472
+ sample,
473
+ testCase,
474
+ dry,
475
+ records,
476
+ }: {
477
+ apiKey: string;
478
+ model: string;
479
+ variant: Variant;
480
+ sample: number;
481
+ testCase: CorpusCase;
482
+ dry: boolean;
483
+ records: RunRecord[];
484
+ }): Promise<void> {
485
+ const chain: string[] = [];
486
+ for (const step of testCase.steps) {
487
+ const prompt = renderStepPrompt(
488
+ step,
489
+ variant,
490
+ variant.usePreviousLabels ? chain : null
491
+ );
492
+ const stepId = step.id ?? testCase.id;
493
+ if (dry) {
494
+ records.push({
495
+ variant: variant.name,
496
+ sample,
497
+ caseId: testCase.id,
498
+ stepId,
499
+ prompt,
500
+ });
501
+ /** Seed the chain so later dry prompts render the Previous-headers
502
+ * section a live continuity run would — the captured production
503
+ * label when the step has one, a marked placeholder otherwise.
504
+ * Rendering inspection only; nothing is graded in dry mode. */
505
+ chain.push(step.productionLabel ?? `[dry placeholder] ${stepId}`);
506
+ continue;
507
+ }
508
+ const result = await requestLabel({
509
+ apiKey,
510
+ model,
511
+ instruction: variant.instruction,
512
+ prompt,
513
+ });
514
+ if ('error' in result) {
515
+ records.push({
516
+ variant: variant.name,
517
+ sample,
518
+ caseId: testCase.id,
519
+ stepId,
520
+ error: result.error,
521
+ latencyMs: result.latencyMs,
522
+ ...(result.inputTokens != null && {
523
+ inputTokens: result.inputTokens,
524
+ outputTokens: result.outputTokens,
525
+ }),
526
+ });
527
+ continue;
528
+ }
529
+ const { flags, wordCount, firstWord } = checkLabel(result.label, {
530
+ entries: stepEntries(step),
531
+ previousLabels: chain,
532
+ });
533
+ chain.push(result.label);
534
+ records.push({
535
+ variant: variant.name,
536
+ sample,
537
+ caseId: testCase.id,
538
+ stepId,
539
+ label: result.label,
540
+ production: step.productionLabel,
541
+ flags,
542
+ wordCount,
543
+ firstWord,
544
+ latencyMs: result.latencyMs,
545
+ inputTokens: result.inputTokens,
546
+ outputTokens: result.outputTokens,
547
+ });
548
+ }
549
+ }
550
+
551
+ async function pool(
552
+ tasks: Array<() => Promise<void>>,
553
+ size: number
554
+ ): Promise<void> {
555
+ /** Shared index instead of queue.shift(): shifting reindexes the whole
556
+ * remaining array per task, quadratic over a large sweep. */
557
+ let next = 0;
558
+ const workers = Array.from(
559
+ { length: Math.min(size, tasks.length) },
560
+ async () => {
561
+ while (next < tasks.length) {
562
+ const task = tasks[next];
563
+ next += 1;
564
+ await task();
565
+ }
566
+ }
567
+ );
568
+ await Promise.all(workers);
569
+ }
570
+
571
+ /** A typo in a selection must fail up front, not silently drop the
572
+ * requested column and complete a billed run without its control; a
573
+ * duplicate would schedule the same paid arm twice and merge both runs
574
+ * under one aggregate row. */
575
+ function resolveSelection<T>(
576
+ requested: string[] | undefined,
577
+ available: T[],
578
+ nameOf: (item: T) => string,
579
+ flag: string
580
+ ): T[] {
581
+ if (requested == null) {
582
+ return available;
583
+ }
584
+ const byName = new Map(available.map((item) => [nameOf(item), item]));
585
+ const unknown = requested.filter((name) => !byName.has(name));
586
+ if (unknown.length > 0) {
587
+ throw new Error(
588
+ `unknown ${flag}: ${unknown.join(', ')} (available: ${[...byName.keys()].join(', ')})`
589
+ );
590
+ }
591
+ const duplicates = requested.filter(
592
+ (name, index) => requested.indexOf(name) !== index
593
+ );
594
+ if (duplicates.length > 0) {
595
+ throw new Error(
596
+ `duplicate ${flag} selection: ${[...new Set(duplicates)].join(', ')}`
597
+ );
598
+ }
599
+ return requested.map((name) => byName.get(name)!);
600
+ }
601
+
602
+ (async () => {
603
+ const args = parseArgs(process.argv.slice(2));
604
+ const runVariants = resolveSelection(
605
+ args.variants,
606
+ variants,
607
+ (variant) => variant.name,
608
+ '--variants'
609
+ );
610
+ const runCases = resolveSelection(args.cases, cases, (c) => c.id, '--cases');
611
+ if (runVariants.length === 0 || runCases.length === 0) {
612
+ throw new Error('nothing selected — check --variants / --cases names');
613
+ }
614
+ const apiKey = args.dry ? '' : loadKey();
615
+ const records: RunRecord[] = [];
616
+ const tasks: Array<() => Promise<void>> = [];
617
+ /** Variants innermost, so adjacent queue slots cycle through the arms:
618
+ * a variant-major queue would correlate variant identity with elapsed
619
+ * run time, letting later arms inherit rate-limit or provider-load
620
+ * conditions the first arm never saw. */
621
+ for (let sample = 1; sample <= args.samples; sample++) {
622
+ for (const testCase of runCases) {
623
+ for (const variant of runVariants) {
624
+ tasks.push(() =>
625
+ runCase({
626
+ apiKey,
627
+ model: args.model,
628
+ variant,
629
+ sample,
630
+ testCase,
631
+ dry: args.dry,
632
+ records,
633
+ })
634
+ );
635
+ }
636
+ }
637
+ }
638
+ const totalSteps = runCases.reduce((sum, c) => sum + c.steps.length, 0);
639
+ console.log(
640
+ `${args.dry ? 'DRY RUN — rendering only' : `model ${args.model}`} · ${runVariants.length} variants × ${args.samples} samples × ${runCases.length} cases (${totalSteps} steps each pass)`
641
+ );
642
+ const started = Date.now();
643
+ await pool(tasks, args.concurrency);
644
+ console.log(`done in ${((Date.now() - started) / 1000).toFixed(1)}s\n`);
645
+
646
+ if (args.dry) {
647
+ /** Every rendered prompt is inspectable, not just the first three —
648
+ * a multi-step case's later prompts (the ones that demonstrate the
649
+ * three-label continuity cap) live past the console preview. */
650
+ fs.mkdirSync(RESULTS_DIR, { recursive: true });
651
+ const dryPath = path.join(RESULTS_DIR, 'dry-latest.md');
652
+ fs.writeFileSync(
653
+ dryPath,
654
+ (records as DryRunRecord[])
655
+ .map(
656
+ (record) =>
657
+ `## ${record.variant} / ${record.caseId} / ${record.stepId}\n\n\`\`\`\n${record.prompt}\n\`\`\`\n`
658
+ )
659
+ .join('\n')
660
+ );
661
+ for (const record of records.slice(0, 3) as DryRunRecord[]) {
662
+ console.log(
663
+ `--- ${record.variant} / ${record.caseId} / ${record.stepId} ---`
664
+ );
665
+ console.log(record.prompt);
666
+ console.log('');
667
+ }
668
+ console.log(
669
+ `rendered ${records.length} prompts (showing 3; all in ${path.relative(process.cwd(), dryPath)})`
670
+ );
671
+ return;
672
+ }
673
+
674
+ const aggregates = aggregate(records, args.model);
675
+ const variantNames = runVariants.map((variant) => variant.name);
676
+ const report = markdownReport({
677
+ records,
678
+ aggregates,
679
+ runCases,
680
+ variantNames,
681
+ model: args.model,
682
+ samples: args.samples,
683
+ });
684
+ fs.mkdirSync(RESULTS_DIR, { recursive: true });
685
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
686
+ /** Lets the rescorer refuse a stored run whose corpus has since
687
+ * drifted (renamed steps, changed tool names). */
688
+ const corpusFingerprint = crypto
689
+ .createHash('sha256')
690
+ .update(JSON.stringify(cases))
691
+ .digest('hex');
692
+ fs.writeFileSync(
693
+ path.join(RESULTS_DIR, `${stamp}.json`),
694
+ JSON.stringify({ args, corpusFingerprint, records }, null, 2)
695
+ );
696
+ fs.writeFileSync(path.join(RESULTS_DIR, 'latest.md'), report);
697
+
698
+ console.log(report.split('## Per-case')[0]);
699
+ console.log(
700
+ 'full per-case tables: src/scripts/activity-labels/results/latest.md'
701
+ );
702
+ })().catch((error: Error) => {
703
+ console.error('ERR', error.message);
704
+ process.exit(1);
705
+ });