pi-context-guard-jev 0.1.0 → 0.2.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.
- package/README.md +198 -55
- package/docs/CACHE.md +135 -3
- package/docs/DESIGN.md +218 -30
- package/docs/JEV.md +95 -27
- package/docs/TESTING.md +95 -25
- package/package.json +2 -2
- package/src/cache-pin.ts +170 -0
- package/src/checkpoint.ts +436 -0
- package/src/config.ts +56 -2
- package/src/decide.ts +74 -0
- package/src/distill.ts +9 -2
- package/src/index.ts +243 -55
- package/src/items.ts +447 -0
- package/src/process.ts +176 -0
- package/src/recall.ts +63 -4
- package/src/run.ts +78 -27
- package/src/stats.ts +35 -5
- package/src/types.ts +7 -0
package/src/items.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-item judgments (docs/DESIGN.md → Small outputs, Old exchanges): small tool outputs and old
|
|
3
|
+
* exchanges are judged as whole items, many per Jev request, requests in parallel. A dropped small
|
|
4
|
+
* output becomes a one-line stub; an omitted exchange keeps a stub on its user prompt and omits every
|
|
5
|
+
* other entry (`replacement: null`).
|
|
6
|
+
*/
|
|
7
|
+
import { MARKER, formatChars, toolLabel } from "./render.ts";
|
|
8
|
+
import { supersededBy } from "./checkpoint.ts";
|
|
9
|
+
import { type Candidate, type CollectOptions, candidateOf, indexSpan, type RunHistory } from "./run.ts";
|
|
10
|
+
import {
|
|
11
|
+
type ClassifierQuestion,
|
|
12
|
+
type ClassifierRequest,
|
|
13
|
+
type ClassifyFn,
|
|
14
|
+
clip,
|
|
15
|
+
type ContextEditDraft,
|
|
16
|
+
type OmitDraft,
|
|
17
|
+
type ProjectedEntryLike,
|
|
18
|
+
textOf,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
|
|
21
|
+
export type ItemKind = "large" | "small" | "exchange";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------------------------
|
|
24
|
+
// Small outputs
|
|
25
|
+
// ---------------------------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface SmallItem extends Candidate {
|
|
28
|
+
/** Turn (0-based assistant message index in the run) of the call that produced the output. */
|
|
29
|
+
turn: number;
|
|
30
|
+
/** Why the output is probably out of date (see checkpoint.ts → supersededBy). */
|
|
31
|
+
superseded?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SmallOptions extends CollectOptions {
|
|
35
|
+
smallResultMinChars: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tool results of the run span `span` (entries after the run's user message) between
|
|
40
|
+
* `smallResultMinChars` and `minResultChars`, under the same candidate rules as large outputs. With
|
|
41
|
+
* `youngest`, only outputs whose call was made in a turn ≤ youngest (the mid-run age rule).
|
|
42
|
+
*/
|
|
43
|
+
export function collectSmall(span: readonly ProjectedEntryLike[], options: SmallOptions, judged: ReadonlySet<string>, youngest = Number.POSITIVE_INFINITY): SmallItem[] {
|
|
44
|
+
const { toolCalls } = indexSpan(span);
|
|
45
|
+
const calls = [...toolCalls.values()];
|
|
46
|
+
const items: SmallItem[] = [];
|
|
47
|
+
for (const entry of span) {
|
|
48
|
+
const candidate = candidateOf(entry, toolCalls, { ...options, minResultChars: options.smallResultMinChars });
|
|
49
|
+
if (!candidate || candidate.text.length >= options.minResultChars || judged.has(candidate.entryId)) continue;
|
|
50
|
+
const turn = candidate.turn ?? 0;
|
|
51
|
+
if (turn > youngest) continue;
|
|
52
|
+
const { toolCallId: _id, turn: _turn, ...plain } = candidate;
|
|
53
|
+
// Skip outputs whose stub would not be shorter.
|
|
54
|
+
if (smallStub(plain).length >= plain.text.length) continue;
|
|
55
|
+
const superseded = supersededBy({ toolName: plain.toolName, args: plain.args, turn }, calls);
|
|
56
|
+
items.push({ ...plain, turn, ...(superseded ? { superseded } : {}) });
|
|
57
|
+
}
|
|
58
|
+
return items;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const smallStub = (item: Pick<Candidate, "entryId" | "toolName" | "args" | "text">): string =>
|
|
62
|
+
`${MARKER} Omitted the output of ${toolLabel(item)} (${formatChars(item.text.length)} chars): judged no longer needed. Full output: recall({"entryId":"${item.entryId}"}).`;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------------------------
|
|
65
|
+
// Old exchanges
|
|
66
|
+
// ---------------------------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export interface ExchangeItem {
|
|
69
|
+
/** Session entry id of the user prompt that starts the exchange (the stub goes here). */
|
|
70
|
+
entryId: string;
|
|
71
|
+
/** Every other editable entry of the exchange (they get `replacement: null`). */
|
|
72
|
+
omitIds: string[];
|
|
73
|
+
prompt: string;
|
|
74
|
+
/** The last assistant text of the exchange. */
|
|
75
|
+
answer: string;
|
|
76
|
+
/** toolLabel()s of the tool calls, in order. */
|
|
77
|
+
toolCalls: string[];
|
|
78
|
+
/** Model-visible characters of the exchange's editable entries (what the omission removes). */
|
|
79
|
+
chars: number;
|
|
80
|
+
/** Messages in the exchange. */
|
|
81
|
+
messages: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const EXCHANGE_STUB_PREFIX = `${MARKER} Omitted an earlier exchange`;
|
|
85
|
+
|
|
86
|
+
/** Editable entries per Pi's context_edit rules (user, assistant, tool result, custom message). */
|
|
87
|
+
const isEditable = (entry: ProjectedEntryLike): boolean => {
|
|
88
|
+
if (entry.sourceEntry.type === "custom_message") return true;
|
|
89
|
+
if (entry.sourceEntry.type !== "message") return false;
|
|
90
|
+
const role = entry.messages[0]?.role;
|
|
91
|
+
return role === "user" || role === "assistant" || role === "toolResult";
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const entryChars = (entry: ProjectedEntryLike): number =>
|
|
95
|
+
entry.messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length), 0);
|
|
96
|
+
|
|
97
|
+
/** Exchanges smaller than this (model-visible characters) are never judged: the saving is not worth an edit. */
|
|
98
|
+
export const EXCHANGE_MIN_CHARS = 2_000;
|
|
99
|
+
|
|
100
|
+
const hasImage = (entry: ProjectedEntryLike): boolean =>
|
|
101
|
+
[...entry.messages, ...(entry.sourceEntry.message ? [entry.sourceEntry.message] : [])].some(
|
|
102
|
+
(m) => Array.isArray(m.content) && m.content.some((b) => b.type === "image"),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* True when the model sees something other than the raw entry and the difference is not ours: another
|
|
107
|
+
* extension (or Pi's overflow recovery) replaced or omitted it. `recall` returns raw entries, so whatever
|
|
108
|
+
* that edit put in place could not be recovered after the exchange is omitted.
|
|
109
|
+
*/
|
|
110
|
+
const editedByOthers = (entry: ProjectedEntryLike): boolean => {
|
|
111
|
+
const raw = entry.sourceEntry.message;
|
|
112
|
+
if (!raw) return false;
|
|
113
|
+
if (entry.messages.length === 0) return true;
|
|
114
|
+
const projected = entry.messages[0];
|
|
115
|
+
if (projected === raw) return false;
|
|
116
|
+
const text = textOf(projected);
|
|
117
|
+
return !text.startsWith(MARKER) && (text !== textOf(raw) || JSON.stringify(projected.content) !== JSON.stringify(raw.content));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Old exchanges: from each user prompt up to the next one, before the current run's user message at
|
|
122
|
+
* `runStart`, without the last `keepRecent` exchanges. Only exchanges that completed (their last
|
|
123
|
+
* assistant message has no tool calls), that are not already omitted, and that were not judged in this
|
|
124
|
+
* run (`judged`, user entry ids). Compaction and branch summaries are never part of an exchange (they
|
|
125
|
+
* come before the first user message, or are not editable and stay in place).
|
|
126
|
+
*/
|
|
127
|
+
export function collectExchanges(entries: readonly ProjectedEntryLike[], runStart: number, keepRecent: number, judged: ReadonlySet<string>): ExchangeItem[] {
|
|
128
|
+
const starts: number[] = [];
|
|
129
|
+
for (let i = 0; i < runStart; i++) if (entries[i].messages.some((m) => m.role === "user")) starts.push(i);
|
|
130
|
+
const exchanges: ExchangeItem[] = [];
|
|
131
|
+
starts.slice(0, Math.max(0, starts.length - keepRecent)).forEach((start, k) => {
|
|
132
|
+
const end = k + 1 < starts.length ? starts[k + 1] : runStart;
|
|
133
|
+
const span = entries.slice(start, end);
|
|
134
|
+
const user = span[0];
|
|
135
|
+
if (!isEditable(user) || judged.has(user.sourceEntry.id)) return;
|
|
136
|
+
const promptText = textOf(user.messages[0]);
|
|
137
|
+
if (promptText.startsWith(MARKER)) return; // already omitted
|
|
138
|
+
// Recoverability: recall returns raw entries as text, so no images and no foreign edits.
|
|
139
|
+
if (span.some((e) => hasImage(e) || editedByOthers(e))) return;
|
|
140
|
+
const assistants = span.filter((e) => e.messages[0]?.role === "assistant");
|
|
141
|
+
const lastAssistant = assistants.at(-1)?.messages[0];
|
|
142
|
+
if (!lastAssistant || (Array.isArray(lastAssistant.content) && lastAssistant.content.some((b) => b.type === "toolCall"))) return;
|
|
143
|
+
const toolCalls: string[] = [];
|
|
144
|
+
for (const a of assistants) {
|
|
145
|
+
const content = a.messages[0].content;
|
|
146
|
+
if (!Array.isArray(content)) continue;
|
|
147
|
+
for (const block of content) {
|
|
148
|
+
if (block.type === "toolCall") toolCalls.push(toolLabel({ toolName: String(block.name ?? "tool"), args: block.arguments as Record<string, unknown> | undefined }));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const answer = [...assistants].reverse().map((a) => textOf(a.messages[0]).trim()).find(Boolean) ?? "";
|
|
152
|
+
const editable = span.filter(isEditable);
|
|
153
|
+
const item: ExchangeItem = {
|
|
154
|
+
entryId: user.sourceEntry.id,
|
|
155
|
+
omitIds: editable.slice(1).map((e) => e.sourceEntry.id),
|
|
156
|
+
prompt: promptText,
|
|
157
|
+
answer,
|
|
158
|
+
toolCalls,
|
|
159
|
+
chars: editable.reduce((n, e) => n + entryChars(e), 0),
|
|
160
|
+
messages: span.reduce((n, e) => n + e.messages.length, 0),
|
|
161
|
+
};
|
|
162
|
+
// Net saving: the stub must be clearly shorter than what it replaces.
|
|
163
|
+
if (item.chars < EXCHANGE_MIN_CHARS || exchangeStub(item).length >= item.chars) return;
|
|
164
|
+
exchanges.push(item);
|
|
165
|
+
});
|
|
166
|
+
return exchanges;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function exchangeStub(item: Pick<ExchangeItem, "entryId" | "prompt" | "messages" | "chars">): string {
|
|
170
|
+
const prompt = item.prompt.replace(/\s+/g, " ").trim();
|
|
171
|
+
const short = prompt.length > 120 ? `${prompt.slice(0, 119)}…` : prompt;
|
|
172
|
+
const tokens = formatChars(Math.round(item.chars / 4));
|
|
173
|
+
return `${EXCHANGE_STUB_PREFIX} judged unrelated to the current work: "${short}" (${item.messages} messages, ~${tokens} tokens). Full exchange: recall({"entryId":"${item.entryId}"}).`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function exchangeEdits(item: ExchangeItem): (ContextEditDraft | OmitDraft)[] {
|
|
177
|
+
return [
|
|
178
|
+
{ type: "context_edit", targetId: item.entryId, replacement: { content: [{ type: "text", text: exchangeStub(item) }] } },
|
|
179
|
+
...item.omitIds.map((id) => ({ type: "context_edit" as const, targetId: id, replacement: null })),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------------------------
|
|
184
|
+
// Requests
|
|
185
|
+
// ---------------------------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** What Jev knows about the current work (both phases). */
|
|
188
|
+
export interface WorkContext {
|
|
189
|
+
question: string;
|
|
190
|
+
history: RunHistory;
|
|
191
|
+
/** Run end: the final answer. */
|
|
192
|
+
answer?: string;
|
|
193
|
+
/** Mid-run: the latest assistant text and earlier notes. */
|
|
194
|
+
latest?: string;
|
|
195
|
+
notes?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const SMALL_OUTPUT_LIMIT = 4_000;
|
|
199
|
+
export const EXCHANGE_PROMPT_LIMIT = 600;
|
|
200
|
+
export const EXCHANGE_ANSWER_LIMIT = 800;
|
|
201
|
+
export const EXCHANGE_CALLS_LIMIT = 20;
|
|
202
|
+
|
|
203
|
+
const workState = (work: WorkContext): Record<string, unknown> => {
|
|
204
|
+
const state: Record<string, unknown> = {};
|
|
205
|
+
const history = work.history;
|
|
206
|
+
if (history.summary || history.firstRequest || history.exchanges.length > 0) {
|
|
207
|
+
const earlier: Record<string, unknown> = {};
|
|
208
|
+
if (history.summary) earlier.summary = history.summary;
|
|
209
|
+
if (history.firstRequest) earlier.first_request = history.firstRequest;
|
|
210
|
+
if (history.exchanges.length > 0) earlier.recent_exchanges = history.exchanges.map((e) => (e.assistant ? { user: e.user, assistant: e.assistant } : { user: e.user }));
|
|
211
|
+
state.earlier_conversation = earlier;
|
|
212
|
+
}
|
|
213
|
+
state.user_question = clip(work.question, 4_000);
|
|
214
|
+
if (work.answer !== undefined) state.final_answer = clip(work.answer, 6_000);
|
|
215
|
+
else {
|
|
216
|
+
const progress: Record<string, string> = {};
|
|
217
|
+
if (work.latest?.trim()) progress.latest_note = clip(work.latest, 2_000);
|
|
218
|
+
if (work.notes?.trim()) progress.earlier_notes = clip(work.notes, 2_000);
|
|
219
|
+
if (Object.keys(progress).length > 0) state.agent_progress = progress;
|
|
220
|
+
}
|
|
221
|
+
return state;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const itemLabel = (index: number): string => `item_${index + 1}`;
|
|
225
|
+
|
|
226
|
+
/** One bool per small output: will the agent still need it? */
|
|
227
|
+
export function buildSmallRequest(work: WorkContext, items: readonly SmallItem[]): ClassifierRequest {
|
|
228
|
+
const finished = work.answer !== undefined;
|
|
229
|
+
const state: Record<string, unknown> = {
|
|
230
|
+
situation: finished
|
|
231
|
+
? "A coding agent answered user_question. To do so it called tools; items are the outputs of some of those calls. Outputs judged not needed are replaced by a one-line note and can be fetched again."
|
|
232
|
+
: "A coding agent is still working on user_question; it has not finished yet. Items are outputs of its earlier tool calls. Outputs judged not needed are replaced by a one-line note and can be fetched again.",
|
|
233
|
+
...workState(work),
|
|
234
|
+
items: Object.fromEntries(
|
|
235
|
+
items.map((item, i) => [
|
|
236
|
+
itemLabel(i),
|
|
237
|
+
{
|
|
238
|
+
tool: toolLabel(item),
|
|
239
|
+
...(item.isError ? { status: "failed" } : {}),
|
|
240
|
+
...(item.superseded ? { superseded: item.superseded } : {}),
|
|
241
|
+
output: clip(item.text, SMALL_OUTPUT_LIMIT),
|
|
242
|
+
},
|
|
243
|
+
]),
|
|
244
|
+
),
|
|
245
|
+
};
|
|
246
|
+
const questions: Record<string, ClassifierQuestion> = {};
|
|
247
|
+
items.forEach((_, i) => {
|
|
248
|
+
const label = itemLabel(i);
|
|
249
|
+
questions[label] = {
|
|
250
|
+
type: "bool",
|
|
251
|
+
instructions: finished
|
|
252
|
+
? `Does ${label} contain anything that final_answer relies on, or that a likely follow-up question would need? An output marked superseded is out of date: a newer version exists, so it is not needed.`
|
|
253
|
+
: `To finish user_question, will the agent still need the output in ${label}? Output that is out of date (marked superseded) or that the agent has already acted on and moved past is not needed.`,
|
|
254
|
+
criteria: { true: `Keep ${label}`, false: `${label} is no longer needed` },
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
return { state, questions };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const exchangeLabel = (index: number): string => `exchange_${index + 1}`;
|
|
261
|
+
|
|
262
|
+
/** One bool per old exchange: is it still relevant to the current work? */
|
|
263
|
+
export function buildExchangeRequest(work: WorkContext, items: readonly ExchangeItem[]): ClassifierRequest {
|
|
264
|
+
const state: Record<string, unknown> = {
|
|
265
|
+
situation:
|
|
266
|
+
"A coding agent works with a user in one long session. The exchanges are earlier, finished parts of the session: a user " +
|
|
267
|
+
"prompt and everything the agent did for it. An exchange judged unrelated to the current work is replaced by a one-line " +
|
|
268
|
+
"note and can be fetched again; the agent then no longer sees its details. user_question is the current work.",
|
|
269
|
+
...workState(work),
|
|
270
|
+
exchanges: Object.fromEntries(
|
|
271
|
+
items.map((item, i) => [
|
|
272
|
+
exchangeLabel(i),
|
|
273
|
+
{
|
|
274
|
+
user_prompt: clip(item.prompt, EXCHANGE_PROMPT_LIMIT),
|
|
275
|
+
final_answer: clip(item.answer, EXCHANGE_ANSWER_LIMIT),
|
|
276
|
+
tool_calls: item.toolCalls.length > EXCHANGE_CALLS_LIMIT ? [...item.toolCalls.slice(0, EXCHANGE_CALLS_LIMIT), `… ${item.toolCalls.length - EXCHANGE_CALLS_LIMIT} more`] : item.toolCalls,
|
|
277
|
+
size: `${item.messages} messages, about ${formatChars(Math.round(item.chars / 4))} tokens`,
|
|
278
|
+
},
|
|
279
|
+
]),
|
|
280
|
+
),
|
|
281
|
+
};
|
|
282
|
+
const questions: Record<string, ClassifierQuestion> = {};
|
|
283
|
+
items.forEach((_, i) => {
|
|
284
|
+
const label = exchangeLabel(i);
|
|
285
|
+
questions[label] = {
|
|
286
|
+
type: "bool",
|
|
287
|
+
instructions: `Is ${label} still relevant to the current work (user_question and what the agent is doing for it)? Relevant means the agent may need its details: the same files, task, decisions or facts.`,
|
|
288
|
+
criteria: { true: `${label} is still relevant`, false: `${label} is unrelated to the current work` },
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
return { state, questions };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------------------------
|
|
295
|
+
// Shared limiter
|
|
296
|
+
// ---------------------------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/** Runs at most `limit` tasks at once; shared by every item kind so `concurrency` is the total. */
|
|
299
|
+
export function createLimiter(limit: number): <T>(task: () => Promise<T>) => Promise<T> {
|
|
300
|
+
let active = 0;
|
|
301
|
+
const queue: (() => void)[] = [];
|
|
302
|
+
const next = () => {
|
|
303
|
+
if (active >= limit) return;
|
|
304
|
+
const start = queue.shift();
|
|
305
|
+
if (start) start();
|
|
306
|
+
};
|
|
307
|
+
return <T>(task: () => Promise<T>) =>
|
|
308
|
+
new Promise<T>((resolve, reject) => {
|
|
309
|
+
queue.push(() => {
|
|
310
|
+
active++;
|
|
311
|
+
task()
|
|
312
|
+
.then(resolve, reject)
|
|
313
|
+
.finally(() => {
|
|
314
|
+
active--;
|
|
315
|
+
next();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
next();
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------------------------
|
|
323
|
+
// Judging in parallel batches
|
|
324
|
+
// ---------------------------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
export interface JudgeOptions {
|
|
327
|
+
timeoutMs: number;
|
|
328
|
+
concurrency: number;
|
|
329
|
+
/** Most characters of item text per request. */
|
|
330
|
+
maxRequestChars: number;
|
|
331
|
+
/** Most items per request. */
|
|
332
|
+
maxItemsPerRequest: number;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface JudgeOutcome {
|
|
336
|
+
/** P(true) per item index; undefined when Jev gave no answer (error, timeout). */
|
|
337
|
+
probabilities: (number | undefined)[];
|
|
338
|
+
/** Why an item has no probability. */
|
|
339
|
+
failures: Map<number, string>;
|
|
340
|
+
requests: number;
|
|
341
|
+
inputTokens: number;
|
|
342
|
+
costUsd: number;
|
|
343
|
+
ms: number;
|
|
344
|
+
timedOut: boolean;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Group consecutive items into requests that respect both limits. */
|
|
348
|
+
export function batchItems<T>(items: readonly T[], sizeOf: (item: T) => number, maxChars: number, maxItems: number): number[][] {
|
|
349
|
+
const batches: number[][] = [];
|
|
350
|
+
let current: number[] = [];
|
|
351
|
+
let size = 0;
|
|
352
|
+
items.forEach((item, i) => {
|
|
353
|
+
const s = sizeOf(item);
|
|
354
|
+
if (current.length > 0 && (size + s > maxChars || current.length >= maxItems)) {
|
|
355
|
+
batches.push(current);
|
|
356
|
+
current = [];
|
|
357
|
+
size = 0;
|
|
358
|
+
}
|
|
359
|
+
current.push(i);
|
|
360
|
+
size += s;
|
|
361
|
+
});
|
|
362
|
+
if (current.length > 0) batches.push(current);
|
|
363
|
+
return batches;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Ask Jev about `items` in batches (`build(batchItems)` makes one request, whose questions are
|
|
368
|
+
* `label(position in batch)`), at most `concurrency` requests at once, within `timeoutMs`. Errors and
|
|
369
|
+
* timeouts leave the probability undefined (= keep).
|
|
370
|
+
*/
|
|
371
|
+
export async function judgeItems<T>(
|
|
372
|
+
items: readonly T[],
|
|
373
|
+
sizeOf: (item: T) => number,
|
|
374
|
+
build: (batch: T[]) => ClassifierRequest,
|
|
375
|
+
label: (index: number) => string,
|
|
376
|
+
classify: ClassifyFn,
|
|
377
|
+
options: JudgeOptions,
|
|
378
|
+
parentSignal?: AbortSignal,
|
|
379
|
+
now: () => number = Date.now,
|
|
380
|
+
): Promise<JudgeOutcome> {
|
|
381
|
+
const started = now();
|
|
382
|
+
const outcome: JudgeOutcome = { probabilities: items.map(() => undefined), failures: new Map(), requests: 0, inputTokens: 0, costUsd: 0, ms: 0, timedOut: false };
|
|
383
|
+
if (items.length === 0) return outcome;
|
|
384
|
+
const batches = batchItems(items, sizeOf, options.maxRequestChars, options.maxItemsPerRequest);
|
|
385
|
+
const budget = new AbortController();
|
|
386
|
+
const timer = setTimeout(() => budget.abort(new Error("context-guard time budget exceeded")), options.timeoutMs);
|
|
387
|
+
const signal = parentSignal ? AbortSignal.any([parentSignal, budget.signal]) : budget.signal;
|
|
388
|
+
let next = 0;
|
|
389
|
+
const fail = (batch: number[], why: string) => {
|
|
390
|
+
for (const i of batch) outcome.failures.set(i, why);
|
|
391
|
+
};
|
|
392
|
+
const runner = async () => {
|
|
393
|
+
while (next < batches.length && !signal.aborted) {
|
|
394
|
+
const batch = batches[next++];
|
|
395
|
+
outcome.requests++;
|
|
396
|
+
try {
|
|
397
|
+
const response = await raceAbort(classify(build(batch.map((i) => items[i])), signal), signal);
|
|
398
|
+
if (response.usage) {
|
|
399
|
+
outcome.inputTokens += response.usage.input;
|
|
400
|
+
outcome.costUsd += response.usage.cost.total;
|
|
401
|
+
}
|
|
402
|
+
if (response.stopReason !== "stop") {
|
|
403
|
+
fail(batch, budget.signal.aborted ? "timeout" : "error");
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
batch.forEach((itemIndex, k) => {
|
|
407
|
+
const answer = response.answers[label(k)];
|
|
408
|
+
if (answer?.type === "bool") outcome.probabilities[itemIndex] = answer.probability;
|
|
409
|
+
else outcome.failures.set(itemIndex, "no-answer");
|
|
410
|
+
});
|
|
411
|
+
} catch {
|
|
412
|
+
fail(batch, budget.signal.aborted ? "timeout" : "error");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
try {
|
|
417
|
+
await Promise.all(Array.from({ length: Math.min(options.concurrency, batches.length) }, runner));
|
|
418
|
+
} finally {
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
}
|
|
421
|
+
outcome.timedOut = budget.signal.aborted;
|
|
422
|
+
items.forEach((_, i) => {
|
|
423
|
+
if (outcome.probabilities[i] === undefined && !outcome.failures.has(i)) outcome.failures.set(i, "timeout");
|
|
424
|
+
});
|
|
425
|
+
outcome.ms = now() - started;
|
|
426
|
+
return outcome;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
430
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
431
|
+
return new Promise<T>((resolve, reject) => {
|
|
432
|
+
const onAbort = () => reject(signal.reason);
|
|
433
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
434
|
+
promise.then(
|
|
435
|
+
(v) => {
|
|
436
|
+
signal.removeEventListener("abort", onAbort);
|
|
437
|
+
resolve(v);
|
|
438
|
+
},
|
|
439
|
+
(e) => {
|
|
440
|
+
signal.removeEventListener("abort", onAbort);
|
|
441
|
+
reject(e);
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
package/src/process.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One pass over every kind of item (docs/DESIGN.md → Items): large outputs (chunked, `distillRun`),
|
|
3
|
+
* small outputs and old exchanges (whole items, `judgeItems`), all in parallel, merged into one list of
|
|
4
|
+
* edits and one record.
|
|
5
|
+
*/
|
|
6
|
+
import { type DistillOptions, type DistillOutcome, distillRun, type ResultRecord } from "./distill.ts";
|
|
7
|
+
import {
|
|
8
|
+
buildExchangeRequest,
|
|
9
|
+
buildSmallRequest,
|
|
10
|
+
createLimiter,
|
|
11
|
+
type ExchangeItem,
|
|
12
|
+
exchangeEdits,
|
|
13
|
+
exchangeLabel,
|
|
14
|
+
exchangeStub,
|
|
15
|
+
type ItemKind,
|
|
16
|
+
itemLabel,
|
|
17
|
+
judgeItems,
|
|
18
|
+
|
|
19
|
+
type SmallItem,
|
|
20
|
+
smallStub,
|
|
21
|
+
type WorkContext,
|
|
22
|
+
} from "./items.ts";
|
|
23
|
+
import { toolLabel } from "./render.ts";
|
|
24
|
+
import type { RunInfo } from "./run.ts";
|
|
25
|
+
import type { ClassifyFn, ContextEditDraft, OmitDraft } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
export interface ProcessOptions extends DistillOptions {
|
|
28
|
+
/** Keep a small output when P(still needed) reaches this. */
|
|
29
|
+
smallKeepThreshold: number;
|
|
30
|
+
/** Omit an exchange only when P(still relevant) is below this. */
|
|
31
|
+
exchangeOmitThreshold: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ItemRecord extends ResultRecord {
|
|
35
|
+
kind: ItemKind;
|
|
36
|
+
/** Omitted exchanges: the exact entry ids that got `replacement: null` (recall shows exactly these). */
|
|
37
|
+
omitted?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProcessOutcome {
|
|
41
|
+
edits: (ContextEditDraft | OmitDraft)[];
|
|
42
|
+
records: ItemRecord[];
|
|
43
|
+
savedChars: number;
|
|
44
|
+
requests: number;
|
|
45
|
+
inputTokens: number;
|
|
46
|
+
costUsd: number;
|
|
47
|
+
ms: number;
|
|
48
|
+
timedOut: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const p2 = (p: number | undefined): string => (p === undefined ? "?" : p.toFixed(2).replace(/^0/, ""));
|
|
52
|
+
|
|
53
|
+
export async function processItems(
|
|
54
|
+
run: RunInfo,
|
|
55
|
+
small: readonly SmallItem[],
|
|
56
|
+
exchanges: readonly ExchangeItem[],
|
|
57
|
+
work: WorkContext,
|
|
58
|
+
classify: ClassifyFn,
|
|
59
|
+
options: ProcessOptions,
|
|
60
|
+
signal?: AbortSignal,
|
|
61
|
+
now: () => number = Date.now,
|
|
62
|
+
): Promise<ProcessOutcome> {
|
|
63
|
+
const started = now();
|
|
64
|
+
// One limiter for every kind: `concurrency` is the total number of Jev requests in flight.
|
|
65
|
+
const limit = createLimiter(Math.max(1, options.concurrency));
|
|
66
|
+
const limited: ClassifyFn = (request, s) => limit(() => classify(request, s));
|
|
67
|
+
const judge = { timeoutMs: options.timeoutMs, concurrency: options.concurrency, maxRequestChars: options.maxSegmentChars, maxItemsPerRequest: options.maxChunksPerSegment };
|
|
68
|
+
const empty: DistillOutcome = { edits: [], records: [], savedChars: 0, requests: 0, inputTokens: 0, costUsd: 0, ms: 0, timedOut: false };
|
|
69
|
+
const [large, smallOutcome, exchangeOutcome] = await Promise.all([
|
|
70
|
+
run.candidates.length > 0 ? distillRun(run, limited, options, signal, now) : Promise.resolve(empty),
|
|
71
|
+
judgeItems(small, (i) => Math.min(i.text.length, 4_000) + 200, (batch) => buildSmallRequest(work, batch), itemLabel, limited, judge, signal, now),
|
|
72
|
+
judgeItems(exchanges, (e) => Math.min(e.prompt.length, 600) + Math.min(e.answer.length, 800) + e.toolCalls.length * 60 + 200, (batch) => buildExchangeRequest(work, batch), exchangeLabel, limited, judge, signal, now),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const outcome: ProcessOutcome = {
|
|
76
|
+
edits: [...large.edits],
|
|
77
|
+
records: large.records.map((r) => ({ ...r, kind: "large" as const })),
|
|
78
|
+
savedChars: large.savedChars,
|
|
79
|
+
requests: large.requests + smallOutcome.requests + exchangeOutcome.requests,
|
|
80
|
+
inputTokens: large.inputTokens + smallOutcome.inputTokens + exchangeOutcome.inputTokens,
|
|
81
|
+
costUsd: large.costUsd + smallOutcome.costUsd + exchangeOutcome.costUsd,
|
|
82
|
+
ms: 0,
|
|
83
|
+
timedOut: large.timedOut || smallOutcome.timedOut || exchangeOutcome.timedOut,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
small.forEach((item, i) => {
|
|
87
|
+
const p = smallOutcome.probabilities[i];
|
|
88
|
+
const failure = smallOutcome.failures.get(i);
|
|
89
|
+
const drop = p !== undefined && p < options.smallKeepThreshold;
|
|
90
|
+
const stub = smallStub(item);
|
|
91
|
+
const record: ItemRecord = {
|
|
92
|
+
kind: "small",
|
|
93
|
+
entryId: item.entryId,
|
|
94
|
+
tool: item.toolName,
|
|
95
|
+
label: toolLabel(item),
|
|
96
|
+
outcome: drop ? "removed" : "kept",
|
|
97
|
+
reason: failure ?? (drop ? "not-needed" : "needed"),
|
|
98
|
+
beforeChars: item.text.length,
|
|
99
|
+
afterChars: drop ? stub.length : item.text.length,
|
|
100
|
+
keptLines: 0,
|
|
101
|
+
totalLines: 0,
|
|
102
|
+
chunks: 1,
|
|
103
|
+
requests: p === undefined ? 0 : 1,
|
|
104
|
+
jev: [`p${p2(p)}${failure ? ` ${failure}` : ""}`],
|
|
105
|
+
};
|
|
106
|
+
outcome.records.push(record);
|
|
107
|
+
if (drop) {
|
|
108
|
+
outcome.edits.push({ type: "context_edit", targetId: item.entryId, replacement: { content: [{ type: "text", text: stub }] } });
|
|
109
|
+
outcome.savedChars += item.text.length - stub.length;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
exchanges.forEach((item, i) => {
|
|
114
|
+
const p = exchangeOutcome.probabilities[i];
|
|
115
|
+
const failure = exchangeOutcome.failures.get(i);
|
|
116
|
+
const omit = p !== undefined && p < options.exchangeOmitThreshold;
|
|
117
|
+
const stub = exchangeStub(item);
|
|
118
|
+
outcome.records.push({
|
|
119
|
+
kind: "exchange",
|
|
120
|
+
entryId: item.entryId,
|
|
121
|
+
tool: "exchange",
|
|
122
|
+
label: `"${item.prompt.replace(/\s+/g, " ").slice(0, 60)}"`,
|
|
123
|
+
outcome: omit ? "removed" : "kept",
|
|
124
|
+
reason: failure ?? (omit ? "unrelated" : "relevant"),
|
|
125
|
+
beforeChars: item.chars,
|
|
126
|
+
afterChars: omit ? stub.length : item.chars,
|
|
127
|
+
keptLines: 0,
|
|
128
|
+
totalLines: item.messages,
|
|
129
|
+
chunks: 1,
|
|
130
|
+
requests: p === undefined ? 0 : 1,
|
|
131
|
+
jev: [`p${p2(p)}${failure ? ` ${failure}` : ""}`],
|
|
132
|
+
});
|
|
133
|
+
if (omit) {
|
|
134
|
+
outcome.records.at(-1)!.omitted = item.omitIds;
|
|
135
|
+
outcome.edits.push(...exchangeEdits(item));
|
|
136
|
+
outcome.savedChars += item.chars - stub.length;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
outcome.ms = now() - started;
|
|
141
|
+
return outcome;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
/** Net characters the outcome's exchange omissions remove, and the entry ids they edit. */
|
|
147
|
+
export function exchangeOmissions(outcome: ProcessOutcome): { savedChars: number; ids: Set<string> } {
|
|
148
|
+
const ids = new Set<string>();
|
|
149
|
+
let savedChars = 0;
|
|
150
|
+
for (const r of outcome.records) {
|
|
151
|
+
if (r.kind !== "exchange" || r.outcome !== "removed") continue;
|
|
152
|
+
savedChars += r.beforeChars - r.afterChars;
|
|
153
|
+
ids.add(r.entryId);
|
|
154
|
+
for (const id of r.omitted ?? []) ids.add(id);
|
|
155
|
+
}
|
|
156
|
+
return { savedChars, ids };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Take the exchange omissions back out of `outcome` (the break-even gate deferred them): their edits are
|
|
161
|
+
* dropped and their records say `kept` with reason `deferred`, so they stay eligible and are judged again
|
|
162
|
+
* at a later run end, against the work of that run.
|
|
163
|
+
*/
|
|
164
|
+
export function deferExchanges(outcome: ProcessOutcome): void {
|
|
165
|
+
const { savedChars, ids } = exchangeOmissions(outcome);
|
|
166
|
+
if (ids.size === 0) return;
|
|
167
|
+
outcome.edits = outcome.edits.filter((e) => !ids.has(e.targetId));
|
|
168
|
+
outcome.savedChars -= savedChars;
|
|
169
|
+
for (const r of outcome.records) {
|
|
170
|
+
if (r.kind !== "exchange" || r.outcome !== "removed") continue;
|
|
171
|
+
r.outcome = "kept";
|
|
172
|
+
r.reason = "deferred";
|
|
173
|
+
r.afterChars = r.beforeChars;
|
|
174
|
+
delete r.omitted;
|
|
175
|
+
}
|
|
176
|
+
}
|