@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.
- package/dist/cjs/graphs/Graph.cjs +47 -13
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/graphs/MultiAgentGraph.cjs +56 -6
- package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
- package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
- package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
- package/dist/cjs/instrumentation.cjs +18 -48
- package/dist/cjs/instrumentation.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +174 -29
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/langfuseConfig.cjs +12 -0
- package/dist/cjs/langfuseConfig.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
- package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
- package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
- package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
- package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
- package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
- package/dist/cjs/langfuseTraceShaping.cjs +121 -4
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/init.cjs +3 -3
- package/dist/cjs/llm/invoke.cjs +5 -5
- package/dist/cjs/llm/openai/index.cjs +1 -1
- package/dist/cjs/main.cjs +10 -10
- package/dist/cjs/messages/format.cjs +124 -15
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/injected.cjs +10 -1
- package/dist/cjs/messages/injected.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +13 -1
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/prompts/activityLabel.cjs +51 -11
- package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
- package/dist/cjs/run.cjs +54 -24
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/messageSerialization.cjs +6 -0
- package/dist/cjs/session/messageSerialization.cjs.map +1 -1
- package/dist/cjs/stream.cjs +21 -10
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +60 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +253 -24
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/handlers.cjs +1 -1
- package/dist/cjs/tools/search/tool.cjs +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
- package/dist/cjs/utils/index.cjs +2 -2
- package/dist/esm/graphs/Graph.mjs +48 -14
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/graphs/MultiAgentGraph.mjs +56 -6
- package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
- package/dist/esm/hitl/askUserQuestion.mjs +3 -2
- package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
- package/dist/esm/instrumentation.mjs +18 -48
- package/dist/esm/instrumentation.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +176 -28
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/langfuseConfig.mjs +10 -1
- package/dist/esm/langfuseConfig.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeContext.mjs +21 -3
- package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
- package/dist/esm/langfuseRuntimeScope.mjs +39 -10
- package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
- package/dist/esm/langfuseSpanRegistry.mjs +91 -0
- package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
- package/dist/esm/langfuseTraceShaping.mjs +121 -4
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/init.mjs +2 -2
- package/dist/esm/llm/invoke.mjs +5 -5
- package/dist/esm/llm/openai/index.mjs +1 -1
- package/dist/esm/main.mjs +8 -8
- package/dist/esm/messages/format.mjs +124 -15
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/injected.mjs +10 -1
- package/dist/esm/messages/injected.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +13 -1
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/prompts/activityLabel.mjs +51 -11
- package/dist/esm/prompts/activityLabel.mjs.map +1 -1
- package/dist/esm/run.mjs +54 -24
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/messageSerialization.mjs +6 -0
- package/dist/esm/session/messageSerialization.mjs.map +1 -1
- package/dist/esm/stream.mjs +21 -10
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +60 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +254 -25
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/handlers.mjs +1 -1
- package/dist/esm/tools/search/tool.mjs +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
- package/dist/esm/utils/index.mjs +2 -2
- package/dist/types/graphs/Graph.d.ts +19 -0
- package/dist/types/hitl/askUserQuestion.d.ts +11 -1
- package/dist/types/langfuse.d.ts +16 -8
- package/dist/types/langfuseConfig.d.ts +6 -0
- package/dist/types/langfuseRuntimeContext.d.ts +27 -1
- package/dist/types/langfuseRuntimeScope.d.ts +17 -2
- package/dist/types/langfuseSpanRegistry.d.ts +17 -0
- package/dist/types/langfuseTraceShaping.d.ts +2 -1
- package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
- package/dist/types/messages/format.d.ts +9 -8
- package/dist/types/prompts/activityLabel.d.ts +8 -1
- package/dist/types/run.d.ts +1 -1
- package/dist/types/session/types.d.ts +1 -0
- package/dist/types/tools/ToolNode.d.ts +7 -1
- package/dist/types/types/activityLabel.d.ts +8 -0
- package/dist/types/types/hitl.d.ts +8 -0
- package/dist/types/types/stream.d.ts +19 -0
- package/dist/types/types/tools.d.ts +30 -0
- package/package.json +7 -4
- package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
- package/src/graphs/Graph.ts +69 -20
- package/src/graphs/MultiAgentGraph.ts +74 -6
- package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
- package/src/hitl/askUserQuestion.ts +14 -1
- package/src/instrumentation.ts +35 -77
- package/src/langfuse.ts +320 -43
- package/src/langfuseConfig.ts +24 -0
- package/src/langfuseRuntimeContext.ts +43 -1
- package/src/langfuseRuntimeScope.ts +94 -21
- package/src/langfuseSpanRegistry.ts +131 -0
- package/src/langfuseTraceShaping.ts +194 -7
- package/src/llm/anthropic/utils/message_inputs.ts +70 -19
- package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
- package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
- package/src/llm/bedrock/utils/message_inputs.ts +32 -7
- package/src/messages/format.ts +222 -50
- package/src/messages/formatAgentMessages.test.ts +308 -6
- package/src/messages/injected.test.ts +18 -1
- package/src/messages/injected.ts +8 -1
- package/src/messages/prune.ts +12 -1
- package/src/prompts/activityLabel.ts +67 -2
- package/src/run.ts +86 -46
- package/src/scripts/activity-labels/captured.json +56 -0
- package/src/scripts/activity-labels/checks.cjs +205 -0
- package/src/scripts/activity-labels/corpus.cjs +473 -0
- package/src/scripts/activity-labels/report.cjs +203 -0
- package/src/scripts/activity-labels/rescore.cjs +102 -0
- package/src/scripts/activity-labels/run.ts +705 -0
- package/src/scripts/activity-labels/variants.ts +71 -0
- package/src/session/messageSerialization.ts +12 -1
- package/src/session/types.ts +1 -0
- package/src/specs/activity-label-prompt.test.ts +109 -0
- package/src/specs/agent-handoffs.test.ts +306 -0
- package/src/specs/langfuse-callbacks.test.ts +456 -0
- package/src/specs/langfuse-routing.integration.test.ts +138 -1
- package/src/specs/langfuse-span-registry.test.ts +70 -0
- package/src/specs/langfuse-trace-shaping.test.ts +294 -0
- package/src/specs/prune.test.ts +38 -1
- package/src/stream.ts +70 -6
- package/src/summarization/__tests__/node.test.ts +188 -0
- package/src/summarization/node.ts +72 -0
- package/src/tools/ToolNode.ts +400 -9
- package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
- package/src/tools/__tests__/hitl.test.ts +58 -0
- package/src/types/activityLabel.ts +8 -0
- package/src/types/hitl.ts +8 -0
- package/src/types/stream.ts +20 -0
- 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
|
+
});
|