pi-context-guard-jev 0.1.0 → 0.3.1
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 +98 -253
- package/docs/CACHE.md +139 -3
- package/docs/DESIGN.md +263 -39
- package/docs/JEV.md +95 -27
- package/docs/REFERENCE.md +164 -0
- package/docs/RESULTS.md +146 -0
- package/docs/TESTING.md +116 -27
- package/package.json +2 -2
- package/src/cache-pin.ts +170 -0
- package/src/checkpoint.ts +459 -0
- package/src/config.ts +63 -2
- package/src/decide.ts +74 -0
- package/src/distill.ts +9 -2
- package/src/images.ts +89 -0
- package/src/index.ts +299 -58
- package/src/items.ts +446 -0
- package/src/process.ts +213 -0
- package/src/recall.ts +98 -6
- package/src/render.ts +2 -0
- package/src/run.ts +143 -32
- package/src/size.ts +114 -0
- package/src/stats.ts +77 -15
- package/src/types.ts +9 -0
package/src/config.ts
CHANGED
|
@@ -46,8 +46,45 @@ export interface GuardConfig {
|
|
|
46
46
|
* against the ongoing work too. 0 sends no earlier conversation at all (no summary either).
|
|
47
47
|
*/
|
|
48
48
|
historyExchanges: number;
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Add Anthropic cache breakpoints: at the previous user question, and after context edits at a
|
|
51
|
+
* read point before the first edited output (see docs/CACHE.md).
|
|
52
|
+
*/
|
|
50
53
|
pinAnthropicCache: boolean;
|
|
54
|
+
/** Mid-run checkpoints: judge older tool outputs while a long run is still going. */
|
|
55
|
+
midRun: boolean;
|
|
56
|
+
/** A tool output is judged mid-run only once its turn is at least this many turns old. */
|
|
57
|
+
midRunMinAgeTurns: number;
|
|
58
|
+
/** Run a checkpoint only when the not-yet-judged eligible outputs total at least this (characters). */
|
|
59
|
+
midRunBatchChars: number;
|
|
60
|
+
/** Batch size for OpenAI Codex models, whose first request after an edit reads (almost) nothing from cache. */
|
|
61
|
+
midRunBatchCharsOpenAI: number;
|
|
62
|
+
/** Keep a chunk at a checkpoint when P(still needed) reaches this. */
|
|
63
|
+
midRunChunkKeepThreshold: number;
|
|
64
|
+
/** Skip a checkpoint unless its one-time prompt-cache rewrite is likely to pay off (docs/CACHE.md). */
|
|
65
|
+
midRunBreakEven: boolean;
|
|
66
|
+
/** Tool results from this size up to `minResultChars` are judged as whole items (0 turns this off). */
|
|
67
|
+
smallResultMinChars: number;
|
|
68
|
+
/** Keep a small output when P(still needed) reaches this. */
|
|
69
|
+
smallKeepThreshold: number;
|
|
70
|
+
/** Omit old exchanges judged unrelated to the current work. */
|
|
71
|
+
pruneExchanges: boolean;
|
|
72
|
+
/** The last this many exchanges before the current prompt are never omitted. */
|
|
73
|
+
keepRecentExchanges: number;
|
|
74
|
+
/** Omit an exchange only when P(still relevant) is below this. */
|
|
75
|
+
exchangeOmitThreshold: number;
|
|
76
|
+
/**
|
|
77
|
+
* Gate exchange omission at run end by the break-even rule (and `exchangeMinSavingChars`); deferred
|
|
78
|
+
* exchanges stay eligible and accumulate. `false` always omits unrelated exchanges.
|
|
79
|
+
*/
|
|
80
|
+
exchangeBreakEven: boolean;
|
|
81
|
+
/** A pass omits exchanges only if they save at least this many characters together (when that costs a cache rewrite). */
|
|
82
|
+
exchangeMinSavingChars: number;
|
|
83
|
+
/**
|
|
84
|
+
* Remove images from tool results once they are this many turns old (assistant messages after the
|
|
85
|
+
* result); `recall` shows them again. Images in user messages are always kept. 0 turns this off.
|
|
86
|
+
*/
|
|
87
|
+
imageKeepTurns: number;
|
|
51
88
|
}
|
|
52
89
|
|
|
53
90
|
export const DEFAULT_CONFIG: GuardConfig = {
|
|
@@ -69,6 +106,20 @@ export const DEFAULT_CONFIG: GuardConfig = {
|
|
|
69
106
|
distillErrors: true,
|
|
70
107
|
historyExchanges: 3,
|
|
71
108
|
pinAnthropicCache: true,
|
|
109
|
+
midRun: true,
|
|
110
|
+
midRunMinAgeTurns: 4,
|
|
111
|
+
midRunBatchChars: 60_000,
|
|
112
|
+
midRunBatchCharsOpenAI: 60_000,
|
|
113
|
+
midRunChunkKeepThreshold: 0.6,
|
|
114
|
+
midRunBreakEven: true,
|
|
115
|
+
smallResultMinChars: 400,
|
|
116
|
+
smallKeepThreshold: 0.45,
|
|
117
|
+
pruneExchanges: true,
|
|
118
|
+
keepRecentExchanges: 2,
|
|
119
|
+
exchangeOmitThreshold: 0.2,
|
|
120
|
+
exchangeBreakEven: true,
|
|
121
|
+
exchangeMinSavingChars: 8_000,
|
|
122
|
+
imageKeepTurns: 3,
|
|
72
123
|
};
|
|
73
124
|
|
|
74
125
|
const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
|
|
@@ -84,9 +135,19 @@ const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
|
|
|
84
135
|
maxSegmentChars: [2_000, 200_000],
|
|
85
136
|
maxChunksPerSegment: [2, 60],
|
|
86
137
|
historyExchanges: [0, 10],
|
|
138
|
+
midRunMinAgeTurns: [1, 100],
|
|
139
|
+
midRunBatchChars: [0, 10_000_000],
|
|
140
|
+
midRunBatchCharsOpenAI: [0, 10_000_000],
|
|
141
|
+
midRunChunkKeepThreshold: [0, 1],
|
|
142
|
+
smallResultMinChars: [0, 10_000_000],
|
|
143
|
+
smallKeepThreshold: [0, 1],
|
|
144
|
+
keepRecentExchanges: [0, 100],
|
|
145
|
+
exchangeOmitThreshold: [0, 1],
|
|
146
|
+
exchangeMinSavingChars: [0, 10_000_000],
|
|
147
|
+
imageKeepTurns: [0, 1_000],
|
|
87
148
|
};
|
|
88
149
|
|
|
89
|
-
const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges"]);
|
|
150
|
+
const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges", "midRunMinAgeTurns", "midRunBatchChars", "midRunBatchCharsOpenAI", "smallResultMinChars", "keepRecentExchanges", "exchangeMinSavingChars", "imageKeepTurns"]);
|
|
90
151
|
|
|
91
152
|
/** Merge untrusted JSON over the defaults, keeping only well-typed, in-range values. */
|
|
92
153
|
export function normalizeConfig(raw: unknown): GuardConfig {
|
package/src/decide.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* can only name the most important chunk; the per-chunk yes/no questions add the others.
|
|
12
12
|
*/
|
|
13
13
|
import type { Chunk } from "./chunk.ts";
|
|
14
|
+
import type { CheckpointCandidate, CheckpointInfo } from "./checkpoint.ts";
|
|
15
|
+
import { recentNotes } from "./checkpoint.ts";
|
|
14
16
|
import { type Candidate, hasHistory, type RunHistory, type RunInfo } from "./run.ts";
|
|
15
17
|
import { type ClassifierQuestion, type ClassifierRequest, type ClassifierResponse, clip } from "./types.ts";
|
|
16
18
|
|
|
@@ -121,6 +123,78 @@ export function buildRequest(
|
|
|
121
123
|
return { state, questions };
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
const PROGRESS_LIMIT = 2_000;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Request for a mid-run checkpoint: there is no final answer yet, so Jev judges whether the agent
|
|
130
|
+
* will still need the output to finish the task (docs/JEV.md → Checkpoint wording). The run-end
|
|
131
|
+
* wording in `buildRequest` is untouched.
|
|
132
|
+
*/
|
|
133
|
+
export function buildCheckpointRequest(
|
|
134
|
+
info: Pick<CheckpointInfo, "question" | "history" | "latest" | "notes">,
|
|
135
|
+
candidate: CheckpointCandidate,
|
|
136
|
+
segment: readonly Chunk[],
|
|
137
|
+
segmentIndex: number,
|
|
138
|
+
segmentCount: number,
|
|
139
|
+
totalLines: number,
|
|
140
|
+
): ClassifierRequest {
|
|
141
|
+
const withHistory = hasHistory(info.history);
|
|
142
|
+
const state: Record<string, unknown> = {
|
|
143
|
+
situation:
|
|
144
|
+
"A coding agent is still working on user_question; it has not finished yet. This is the output of one of its earlier tool " +
|
|
145
|
+
"calls, split into chunks. From now on the agent will only see the chunks we keep, plus a note that the rest was removed " +
|
|
146
|
+
"and can be fetched again. later_tool_calls shows what the agent did after this call." +
|
|
147
|
+
(withHistory ? " earlier_conversation shows the session before this task." : ""),
|
|
148
|
+
};
|
|
149
|
+
if (withHistory) state.earlier_conversation = describeHistory(info.history);
|
|
150
|
+
state.user_question = clip(info.question, QUESTION_LIMIT);
|
|
151
|
+
const progress: Record<string, string> = {};
|
|
152
|
+
if (info.latest.trim()) progress.latest_note = clip(info.latest, PROGRESS_LIMIT);
|
|
153
|
+
if (info.notes.trim()) progress.earlier_notes = recentNotes(info.notes, PROGRESS_LIMIT);
|
|
154
|
+
if (Object.keys(progress).length > 0) state.agent_progress = progress;
|
|
155
|
+
state.later_tool_calls = candidate.laterCalls.length > 0 ? candidate.laterCalls : "none yet";
|
|
156
|
+
if (candidate.superseded) state.superseded = candidate.superseded;
|
|
157
|
+
state.tool = candidate.toolName;
|
|
158
|
+
if (candidate.isError) state.tool_status = "failed (the tool reported an error, e.g. a non-zero exit code).";
|
|
159
|
+
const args = describeArgs(candidate.args);
|
|
160
|
+
if (args) state.tool_arguments = args;
|
|
161
|
+
state.output_size = `${totalLines} lines${segmentCount > 1 ? `; this is part ${segmentIndex + 1} of ${segmentCount}` : ""}`;
|
|
162
|
+
state.chunks = Object.fromEntries(segment.map((chunk) => [chunkLabel(chunk), chunk.text]));
|
|
163
|
+
|
|
164
|
+
const scope = segmentCount > 1 ? "this part of the tool output" : "the tool output";
|
|
165
|
+
const focusCriteria: Record<string, string> = {
|
|
166
|
+
whole: `Most of ${scope} will still be needed`,
|
|
167
|
+
none: `Nothing in ${scope} will be needed again`,
|
|
168
|
+
};
|
|
169
|
+
for (const chunk of segment) {
|
|
170
|
+
const label = chunkLabel(chunk);
|
|
171
|
+
focusCriteria[label] = `A few chunks will still be needed; ${label} is the most important of them`;
|
|
172
|
+
}
|
|
173
|
+
const questions: Record<string, ClassifierQuestion> = {
|
|
174
|
+
keep_whole: {
|
|
175
|
+
type: "bool",
|
|
176
|
+
instructions: `To finish user_question, will the agent still need all of ${scope}, so that removing any chunk would lose information it is likely to use again?`,
|
|
177
|
+
criteria: { true: "Keep everything", false: "Some chunks will not be needed again and can be removed" },
|
|
178
|
+
},
|
|
179
|
+
focus: {
|
|
180
|
+
type: "choice",
|
|
181
|
+
instructions:
|
|
182
|
+
`Will the rest of the agent's work draw on most of ${scope}, or on a few chunks? If a few, pick the most important chunk. ` +
|
|
183
|
+
"If nothing in it will be needed again (for example because it is out of date or the agent has moved past it), pick none.",
|
|
184
|
+
criteria: focusCriteria,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
for (const chunk of segment) {
|
|
188
|
+
const label = chunkLabel(chunk);
|
|
189
|
+
questions[label] = {
|
|
190
|
+
type: "bool",
|
|
191
|
+
instructions: `To finish user_question, will the agent still need the lines in ${label}? Lines that are out of date or that the agent has already acted on and moved past are not needed.`,
|
|
192
|
+
criteria: { true: `Keep ${label}`, false: `${label} will not be needed again` },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { state, questions };
|
|
196
|
+
}
|
|
197
|
+
|
|
124
198
|
/** Basenames and paths mentioned in the answer, for the citation safety net. */
|
|
125
199
|
export function citedFiles(answer: string, chunks: readonly Chunk[]): Set<number> {
|
|
126
200
|
const keep = new Set<number>();
|
package/src/distill.ts
CHANGED
|
@@ -8,7 +8,10 @@ import { type Chunk, chunkOutput, DEFAULT_CHUNK_OPTIONS, segmentChunks } from ".
|
|
|
8
8
|
import { buildRequest, citedFiles, type DecideThresholds, interpret, type SegmentDecision } from "./decide.ts";
|
|
9
9
|
import { renderReplacement, toolLabel } from "./render.ts";
|
|
10
10
|
import type { Candidate, RunInfo } from "./run.ts";
|
|
11
|
-
import type { ClassifierResponse, ClassifyFn, ContextEditDraft } from "./types.ts";
|
|
11
|
+
import type { ClassifierRequest, ClassifierResponse, ClassifyFn, ContextEditDraft } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/** Builds the Jev request for one segment; defaults to the run-end request (`buildRequest`). */
|
|
14
|
+
export type RequestBuilder = (candidate: Candidate, segment: readonly Chunk[], segmentIndex: number, segmentCount: number, totalLines: number) => ClassifierRequest;
|
|
12
15
|
|
|
13
16
|
export interface DistillOptions extends DecideThresholds {
|
|
14
17
|
minRunChars: number;
|
|
@@ -18,6 +21,8 @@ export interface DistillOptions extends DecideThresholds {
|
|
|
18
21
|
concurrency: number;
|
|
19
22
|
maxSegmentChars: number;
|
|
20
23
|
maxChunksPerSegment: number;
|
|
24
|
+
/** Mid-run checkpoints pass their own request builder. */
|
|
25
|
+
requestBuilder?: RequestBuilder;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export type ResultOutcome = "distilled" | "removed" | "kept";
|
|
@@ -128,7 +133,9 @@ export async function distillRun(
|
|
|
128
133
|
try {
|
|
129
134
|
await pool(tasks.filter((task) => !task.decision), options.concurrency, signal, async (task) => {
|
|
130
135
|
const candidate = run.candidates[task.candidate];
|
|
131
|
-
const request =
|
|
136
|
+
const request = options.requestBuilder
|
|
137
|
+
? options.requestBuilder(candidate, task.segment, task.segmentIndex, task.segmentCount, task.totalLines)
|
|
138
|
+
: buildRequest(run, candidate, task.segment, task.segmentIndex, task.segmentCount, task.totalLines);
|
|
132
139
|
outcome.requests++;
|
|
133
140
|
try {
|
|
134
141
|
// Race the request against the budget: a provider that ignores the signal must not
|
package/src/images.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Old images (docs/DESIGN.md → Images). A model looks at a screenshot once: it describes or acts on it in
|
|
3
|
+
* its next message and goes on. Every later request still sends the image (about w×h/750 tokens each), so
|
|
4
|
+
* long runs with screenshots pile up context. Images in tool results are removed once they are
|
|
5
|
+
* `imageKeepTurns` turns old (assistant messages after the result). No Jev request is needed: Jev reads
|
|
6
|
+
* text only, and age is what decides. The result's text stays; a stub in front says what was removed, and
|
|
7
|
+
* `recall` returns the images. Images in user messages are always kept.
|
|
8
|
+
*/
|
|
9
|
+
import { MARKER, formatChars, toolLabel } from "./render.ts";
|
|
10
|
+
import { indexSpan } from "./run.ts";
|
|
11
|
+
import { contentChars, imageInfo } from "./size.ts";
|
|
12
|
+
import { type Block, type MessageLike, type ProjectedEntryLike, textOf } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export interface ImageItem {
|
|
15
|
+
/** Session entry id of the tool result. */
|
|
16
|
+
entryId: string;
|
|
17
|
+
toolName: string;
|
|
18
|
+
args?: Record<string, unknown>;
|
|
19
|
+
/** Position in the projection. */
|
|
20
|
+
index: number;
|
|
21
|
+
/** Assistant messages after the result. */
|
|
22
|
+
age: number;
|
|
23
|
+
images: number;
|
|
24
|
+
/** Estimated input tokens of the removed images. */
|
|
25
|
+
imageTokens: number;
|
|
26
|
+
/** Model-visible size before and after, in text characters (size.ts). */
|
|
27
|
+
beforeChars: number;
|
|
28
|
+
afterChars: number;
|
|
29
|
+
/** The new content: the stub, then the result's text blocks. */
|
|
30
|
+
replacement: { type: "text"; text: string }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const imageBlocks = (message: MessageLike | undefined): Block[] => (Array.isArray(message?.content) ? message.content.filter((b) => b.type === "image") : []);
|
|
34
|
+
|
|
35
|
+
/** True for a tool result whose images were removed by us (the raw entry has images, the model sees none). */
|
|
36
|
+
export const imagesRemoved = (projected: MessageLike | undefined, raw: MessageLike | undefined): boolean =>
|
|
37
|
+
projected?.role === "toolResult" && imageBlocks(raw).length > 0 && imageBlocks(projected).length === 0 && textOf(projected).startsWith(MARKER);
|
|
38
|
+
|
|
39
|
+
export function imageStub(item: Pick<ImageItem, "entryId" | "images" | "imageTokens" | "age">, sizes: string[]): string {
|
|
40
|
+
const what = item.images === 1 ? "1 image" : `${item.images} images`;
|
|
41
|
+
const dims = sizes.length > 0 ? `${sizes.slice(0, 3).join(", ")}${sizes.length > 3 ? ", …" : ""}; ` : "";
|
|
42
|
+
const them = item.images === 1 ? "it" : "them";
|
|
43
|
+
return `${MARKER} Removed ${what} from this output (${dims}~${formatChars(item.imageTokens)} tokens), ${item.age} turns old. recall({"entryId":"${item.entryId}"}) shows ${them} again.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tool results in `entries` (the whole projection, earlier runs included) with images that are at least
|
|
48
|
+
* `keepTurns` turns old. Skipped: results another extension changed (recall returns the raw entry, so
|
|
49
|
+
* their edit could not be restored) and results without images in the model's view.
|
|
50
|
+
*/
|
|
51
|
+
export function collectImages(entries: readonly ProjectedEntryLike[], keepTurns: number): ImageItem[] {
|
|
52
|
+
if (keepTurns <= 0) return [];
|
|
53
|
+
const { toolCalls } = indexSpan(entries);
|
|
54
|
+
// Assistant messages after each position.
|
|
55
|
+
const after = new Array<number>(entries.length + 1).fill(0);
|
|
56
|
+
for (let i = entries.length - 1; i >= 0; i--) after[i] = after[i + 1] + entries[i].messages.filter((m) => m.role === "assistant").length;
|
|
57
|
+
const items: ImageItem[] = [];
|
|
58
|
+
entries.forEach((entry, index) => {
|
|
59
|
+
const message = entry.messages[0];
|
|
60
|
+
if (entry.messages.length !== 1 || message?.role !== "toolResult" || !Array.isArray(message.content)) return;
|
|
61
|
+
const images = imageBlocks(message);
|
|
62
|
+
if (images.length === 0) return;
|
|
63
|
+
const age = after[index + 1];
|
|
64
|
+
if (age < keepTurns) return;
|
|
65
|
+
const raw = entry.sourceEntry.message;
|
|
66
|
+
if (raw && raw !== message && (textOf(raw) !== textOf(message) || imageBlocks(raw).length !== images.length)) return;
|
|
67
|
+
const call = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
|
|
68
|
+
const infos = images.map(imageInfo);
|
|
69
|
+
const item = {
|
|
70
|
+
entryId: entry.sourceEntry.id,
|
|
71
|
+
toolName: message.toolName ?? call?.name ?? "tool",
|
|
72
|
+
...(call?.args ? { args: call.args } : {}),
|
|
73
|
+
index,
|
|
74
|
+
age,
|
|
75
|
+
images: images.length,
|
|
76
|
+
imageTokens: infos.reduce((n, i) => n + i.tokens, 0),
|
|
77
|
+
};
|
|
78
|
+
const sizes = infos.flatMap((i) => (i.width && i.height ? [`${i.width}×${i.height}`] : []));
|
|
79
|
+
const replacement = [
|
|
80
|
+
{ type: "text" as const, text: imageStub(item, sizes) },
|
|
81
|
+
...message.content.filter((b) => b.type === "text").map((b) => ({ type: "text" as const, text: b.text ?? "" })),
|
|
82
|
+
];
|
|
83
|
+
items.push({ ...item, beforeChars: contentChars(message.content), afterChars: contentChars(replacement), replacement });
|
|
84
|
+
});
|
|
85
|
+
return items;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const imageLabel = (item: Pick<ImageItem, "toolName" | "args" | "images">): string =>
|
|
89
|
+
`${toolLabel({ toolName: item.toolName, args: item.args })} (${item.images === 1 ? "1 image" : `${item.images} images`})`;
|