@corenel/harness 0.7.4 → 0.8.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.
Files changed (35) hide show
  1. package/dist/conversation.d.ts +15 -0
  2. package/dist/conversation.d.ts.map +1 -1
  3. package/dist/conversation.js +13 -1
  4. package/dist/conversation.js.map +1 -1
  5. package/dist/core/compactHistory.d.ts +84 -0
  6. package/dist/core/compactHistory.d.ts.map +1 -0
  7. package/dist/core/compactHistory.js +191 -0
  8. package/dist/core/compactHistory.js.map +1 -0
  9. package/dist/core/loop.d.ts +25 -0
  10. package/dist/core/loop.d.ts.map +1 -1
  11. package/dist/core/loop.js +99 -1
  12. package/dist/core/loop.js.map +1 -1
  13. package/dist/core/summarize.d.ts +10 -0
  14. package/dist/core/summarize.d.ts.map +1 -0
  15. package/dist/core/summarize.js +66 -0
  16. package/dist/core/summarize.js.map +1 -0
  17. package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
  18. package/dist/prompts/library/defaults.generated.js +35 -6
  19. package/dist/prompts/library/defaults.generated.js.map +1 -1
  20. package/dist/sessions/checkpointStore.d.ts +12 -0
  21. package/dist/sessions/checkpointStore.d.ts.map +1 -0
  22. package/dist/sessions/checkpointStore.js +48 -0
  23. package/dist/sessions/checkpointStore.js.map +1 -0
  24. package/dist/sessions/fileSessionStore.d.ts +4 -0
  25. package/dist/sessions/fileSessionStore.d.ts.map +1 -1
  26. package/dist/sessions/fileSessionStore.js +28 -2
  27. package/dist/sessions/fileSessionStore.js.map +1 -1
  28. package/dist/tools/builtins.d.ts.map +1 -1
  29. package/dist/tools/builtins.js +2 -0
  30. package/dist/tools/builtins.js.map +1 -1
  31. package/dist/tools/project.d.ts +11 -0
  32. package/dist/tools/project.d.ts.map +1 -0
  33. package/dist/tools/project.js +133 -0
  34. package/dist/tools/project.js.map +1 -0
  35. package/package.json +1 -1
@@ -14,5 +14,20 @@ export interface ConversationTurn {
14
14
  messages: ChatCompletionMessageParam[];
15
15
  /** Token/cost/latency for the assistant response, once the turn settles. */
16
16
  stats?: TurnStats;
17
+ /** Set on a COMPACTION SEAM: the checkpoint body the summarizer produced for
18
+ * the stretch this seam replaced. Carried on the turn rather than left to a
19
+ * file because the question a seam raises ("what did it keep?") is a question
20
+ * about the conversation in front of you, and because the file only exists
21
+ * for a run that had a session to write beside. Absent on every other turn. */
22
+ checkpoint?: string;
17
23
  }
24
+ /** Is this turn a COMPACTION SEAM rather than something said?
25
+ *
26
+ * Recognized by the marker the harness writes into the window, so a surface
27
+ * cannot look for one string while the loop writes another. Lives here, beside
28
+ * the type, because three consumers need it now -- the transcript draws the
29
+ * seam, the context panel counts seams and must keep them OUT of the window it
30
+ * reports (a seam is an unsent note; counting it would inflate the very number
31
+ * the panel exists to give), and the runtime writes them. */
32
+ export declare function isCheckpointTurn(t: ConversationTurn): boolean;
18
33
  //# sourceMappingURL=conversation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../conversation.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,mCAAmC,CAAC;AAEpF,qFAAqF;AACrF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;+EAC+E;AAC/E,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,4EAA4E;IAC5E,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB"}
1
+ {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../conversation.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,mCAAmC,CAAC;AAGpF,qFAAqF;AACrF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;+EAC+E;AAC/E,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,4EAA4E;IAC5E,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;mFAI+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;6DAO6D;AAC7D,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAG7D"}
@@ -1,2 +1,14 @@
1
- export {};
1
+ import { CHECKPOINT_MARKER } from './core/compactHistory.js';
2
+ /** Is this turn a COMPACTION SEAM rather than something said?
3
+ *
4
+ * Recognized by the marker the harness writes into the window, so a surface
5
+ * cannot look for one string while the loop writes another. Lives here, beside
6
+ * the type, because three consumers need it now -- the transcript draws the
7
+ * seam, the context panel counts seams and must keep them OUT of the window it
8
+ * reports (a seam is an unsent note; counting it would inflate the very number
9
+ * the panel exists to give), and the runtime writes them. */
10
+ export function isCheckpointTurn(t) {
11
+ const first = t.messages[0];
12
+ return typeof first?.content === 'string' && first.content.startsWith(CHECKPOINT_MARKER);
13
+ }
2
14
  //# sourceMappingURL=conversation.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"conversation.js","sourceRoot":"","sources":["../conversation.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"conversation.js","sourceRoot":"","sources":["../conversation.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AA0B1D;;;;;;;6DAO6D;AAC7D,MAAM,UAAU,gBAAgB,CAAC,CAAmB;IAClD,MAAM,KAAK,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IAC5B,OAAO,OAAO,KAAK,EAAE,OAAO,KAAK,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,iBAAiB,CAAC,CAAC;AAC3F,CAAC"}
@@ -0,0 +1,84 @@
1
+ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions';
2
+ export interface CompactHistoryOptions {
3
+ /** Summarizes the collapsed stretch. Injected so the partitioning is testable
4
+ * without a model call. */
5
+ summarize: (collapsed: ChatCompletionMessageParam[]) => Promise<string>;
6
+ /** Produces the one-line referent for a preserved user message: what it was
7
+ * answering. `answered` is the nearest preceding assistant turn, or null when
8
+ * the user opened the conversation. Omit to preserve user messages bare. */
9
+ anchor?: (user: ChatCompletionMessageParam, answered: ChatCompletionMessageParam | null) => Promise<string>;
10
+ /** Byte size past which a user message's own content is offloaded rather than
11
+ * kept inline. Requires `offload`. */
12
+ offloadOverBytes?: number;
13
+ /** Records oversized user content and returns the pointer to leave in its
14
+ * place — the same treatment rung 1 gives an oversized tool result. */
15
+ offload?: (content: string) => Promise<string>;
16
+ }
17
+ export interface CompactHistoryResult {
18
+ /** The rebuilt message array to send from here on. */
19
+ messages: ChatCompletionMessageParam[];
20
+ /** The durable checkpoint body, destined for a file in the project. */
21
+ checkpoint: string;
22
+ }
23
+ /** The marker every checkpoint message starts with, so a later pass can
24
+ * recognize one it already wrote instead of collapsing it again. */
25
+ export declare const CHECKPOINT_MARKER = "[checkpoint] ";
26
+ /** The marker every anchor message starts with. An anchor is context ABOUT the
27
+ * user message that follows it, not something the assistant said. */
28
+ export declare const ANCHOR_MARKER = "[anchor] ";
29
+ /** Window fill ratio at which history compaction becomes worth its cost.
30
+ * High on purpose: see the one-shot reasoning at the top of this file. */
31
+ export declare const COMPACT_AT_FILL = 0.86;
32
+ /** Whether compacting now is worth rewriting the cached prefix. Two gates: the
33
+ * window is genuinely close to full, AND there is bulk to remove. A compaction
34
+ * that reduces nothing still invalidates the whole prefix, so "nothing to
35
+ * collapse" is a refusal, not a no-op. */
36
+ export declare function shouldCompact(fill: number, messages: ChatCompletionMessageParam[]): boolean;
37
+ /** Fill must fall back below this before compaction re-arms. */
38
+ export declare const REARM_BELOW_FILL = 0.6;
39
+ /** How much the window must refill, ABOVE where the last compaction left it,
40
+ * for another compaction to be worth its cost. */
41
+ export declare const REARM_ON_REGROWTH = 0.1;
42
+ /** The latch's state between turns.
43
+ *
44
+ * `floor` is the fill measured just after the last compaction -- how far down
45
+ * it actually got. Null means either nothing has compacted yet or the next
46
+ * usage reading has not arrived, and the gate falls back to the absolute rule
47
+ * until it has. It is deliberately taken from a REAL reading rather than
48
+ * estimateFill: it is compared against real readings, and mixing an estimate
49
+ * into one side of that comparison would bias every re-arm after it. */
50
+ export interface CompactGate {
51
+ armed: boolean;
52
+ floor: number | null;
53
+ }
54
+ /** The gate's state the moment a compaction has run: shut, and waiting for the
55
+ * next usage reading to tell it how far down that compaction got. */
56
+ export declare function afterCompaction(): CompactGate;
57
+ /** Window fill ratio read from the messages themselves.
58
+ *
59
+ * For the one turn where no usage reading exists: `fill` comes from the
60
+ * PREVIOUS response, so it is 0 on turn 1 and the gate could never fire before
61
+ * the first request. A run resuming an already-oversized conversation
62
+ * therefore put it on the wire uncompacted and took the fatal 400 immediately
63
+ * -- the case that needs rung 2 most was the case it could not run in.
64
+ *
65
+ * Reads low: the request also carries tool schemas, which are not visible
66
+ * here. That is the right direction to be wrong in, since the cost of reading
67
+ * high is a compaction nobody needed. */
68
+ export declare function estimateFill(messages: ChatCompletionMessageParam[], contextWindow: number | undefined): number;
69
+ export declare function compactionDecision(fill: number, messages: ChatCompletionMessageParam[], gate: CompactGate): {
70
+ compact: boolean;
71
+ next: CompactGate;
72
+ };
73
+ export declare function compactHistory(messages: ChatCompletionMessageParam[], opts: CompactHistoryOptions): Promise<CompactHistoryResult>;
74
+ /** An anchor or checkpoint a compaction wrote — bookkeeping ABOUT the
75
+ * conversation rather than part of it. Exported because a caller that has to
76
+ * tell "what this run added" from "what a rewrite inserted" cannot do it by
77
+ * position: a rewrite puts fresh objects into the array at arbitrary places. */
78
+ export declare const isCompactionMarker: typeof isOwnMarker;
79
+ /** An anchor or checkpoint this module wrote on an earlier pass. Dropped and
80
+ * rewritten each time rather than accumulated: the newest checkpoint already
81
+ * subsumes what the older ones recorded. */
82
+ declare function isOwnMarker(m: ChatCompletionMessageParam): boolean;
83
+ export {};
84
+ //# sourceMappingURL=compactHistory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compactHistory.d.ts","sourceRoot":"","sources":["../../core/compactHistory.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,mCAAmC,CAAC;AAEpF,MAAM,WAAW,qBAAqB;IACpC;+BAC2B;IAC3B,SAAS,EAAE,CAAC,SAAS,EAAE,0BAA0B,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACxE;;gFAE4E;IAC5E,MAAM,CAAC,EAAE,CACP,IAAI,EAAE,0BAA0B,EAChC,QAAQ,EAAE,0BAA0B,GAAG,IAAI,KACxC,OAAO,CAAC,MAAM,CAAC,CAAC;IACrB;0CACsC;IACtC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;2EACuE;IACvE,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,oBAAoB;IACnC,sDAAsD;IACtD,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,uEAAuE;IACvE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;oEACoE;AACpE,eAAO,MAAM,iBAAiB,kBAAkB,CAAC;AAEjD;qEACqE;AACrE,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC;0EAC0E;AAC1E,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC;;;0CAG0C;AAC1C,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,0BAA0B,EAAE,GAAG,OAAO,CAG3F;AAED,gEAAgE;AAChE,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC;kDACkD;AAClD,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC;;;;;;;wEAOwE;AACxE,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED;qEACqE;AACrE,wBAAgB,eAAe,IAAI,WAAW,CAE7C;AAyBD;;;;;;;;;;yCAUyC;AACzC,wBAAgB,YAAY,CAAC,QAAQ,EAAE,0BAA0B,EAAE,EAAE,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAK9G;AA+BD,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,0BAA0B,EAAE,EACtC,IAAI,EAAE,WAAW,GAChB;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,WAAW,CAAA;CAAE,CAQzC;AAED,wBAAsB,cAAc,CAClC,QAAQ,EAAE,0BAA0B,EAAE,EACtC,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAAC,oBAAoB,CAAC,CAyC/B;AAeD;;;gFAGgF;AAChF,eAAO,MAAM,kBAAkB,oBAAc,CAAC;AAE9C;;4CAE4C;AAC5C,iBAAS,WAAW,CAAC,CAAC,EAAE,0BAA0B,GAAG,OAAO,CAG3D"}
@@ -0,0 +1,191 @@
1
+ /* Rung 2 of the context ladder: one-shot history compaction.
2
+ *
3
+ * Rung 1 (compression.ts) offloads ONE oversized tool result at a time and is
4
+ * lossless. This is the other half: when the window is genuinely close to full,
5
+ * collapse the conversation's bulk in a single structural step.
6
+ *
7
+ * It is deliberately NOT incremental. Every form of history compression
8
+ * invalidates the provider's cached prefix — the cache is a prefix, so touching
9
+ * any byte of it recomputes everything after it at full price. A compactor that
10
+ * trims a little each turn therefore pays full reprocessing every turn and saves
11
+ * almost nothing. One shot, high threshold, large payoff. */
12
+ /** The marker every checkpoint message starts with, so a later pass can
13
+ * recognize one it already wrote instead of collapsing it again. */
14
+ export const CHECKPOINT_MARKER = '[checkpoint] ';
15
+ /** The marker every anchor message starts with. An anchor is context ABOUT the
16
+ * user message that follows it, not something the assistant said. */
17
+ export const ANCHOR_MARKER = '[anchor] ';
18
+ /** Window fill ratio at which history compaction becomes worth its cost.
19
+ * High on purpose: see the one-shot reasoning at the top of this file. */
20
+ export const COMPACT_AT_FILL = 0.86;
21
+ /** Whether compacting now is worth rewriting the cached prefix. Two gates: the
22
+ * window is genuinely close to full, AND there is bulk to remove. A compaction
23
+ * that reduces nothing still invalidates the whole prefix, so "nothing to
24
+ * collapse" is a refusal, not a no-op. */
25
+ export function shouldCompact(fill, messages) {
26
+ if (fill < COMPACT_AT_FILL)
27
+ return false;
28
+ return messages.some((m) => m.role !== 'system' && m.role !== 'user');
29
+ }
30
+ /** Fill must fall back below this before compaction re-arms. */
31
+ export const REARM_BELOW_FILL = 0.6;
32
+ /** How much the window must refill, ABOVE where the last compaction left it,
33
+ * for another compaction to be worth its cost. */
34
+ export const REARM_ON_REGROWTH = 0.1;
35
+ /** The gate's state the moment a compaction has run: shut, and waiting for the
36
+ * next usage reading to tell it how far down that compaction got. */
37
+ export function afterCompaction() {
38
+ return { armed: false, floor: null };
39
+ }
40
+ /* ~4 characters per token, matching estimateFactTokens in memory/tier.ts and
41
+ * the chars/4 fallback in @corenel/ui-kit. A BPE count is not worth a
42
+ * dependency for a number whose whole job is to be approximately right. */
43
+ function messageChars(m) {
44
+ let n = 0;
45
+ const content = m.content;
46
+ if (typeof content === 'string')
47
+ n += content.length;
48
+ else if (Array.isArray(content)) {
49
+ for (const part of content) {
50
+ const text = part.text;
51
+ if (typeof text === 'string')
52
+ n += text.length;
53
+ }
54
+ }
55
+ /* Tool-call arguments ARE the bulk of an agent conversation and live beside
56
+ * `content`, not in it. Leaving them out would make this read near zero on
57
+ * exactly the histories it exists to catch. */
58
+ const calls = m.tool_calls;
59
+ if (Array.isArray(calls)) {
60
+ for (const c of calls)
61
+ n += (c.function?.name?.length ?? 0) + (c.function?.arguments?.length ?? 0);
62
+ }
63
+ return n;
64
+ }
65
+ /** Window fill ratio read from the messages themselves.
66
+ *
67
+ * For the one turn where no usage reading exists: `fill` comes from the
68
+ * PREVIOUS response, so it is 0 on turn 1 and the gate could never fire before
69
+ * the first request. A run resuming an already-oversized conversation
70
+ * therefore put it on the wire uncompacted and took the fatal 400 immediately
71
+ * -- the case that needs rung 2 most was the case it could not run in.
72
+ *
73
+ * Reads low: the request also carries tool schemas, which are not visible
74
+ * here. That is the right direction to be wrong in, since the cost of reading
75
+ * high is a compaction nobody needed. */
76
+ export function estimateFill(messages, contextWindow) {
77
+ if (!contextWindow || contextWindow <= 0)
78
+ return 0;
79
+ let chars = 0;
80
+ for (const m of messages)
81
+ chars += messageChars(m);
82
+ return Math.min(1, chars / 4 / contextWindow);
83
+ }
84
+ /* The gate, with hysteresis.
85
+ *
86
+ * `shouldCompact` alone says "the window is full and there is bulk" — which
87
+ * stays TRUE on the turn after a compaction, and the one after that. A real
88
+ * run against a live model compacted over and over for exactly that reason,
89
+ * paying a full cached-prefix rewrite each time: the trickle failure this
90
+ * module's own header argues against, reintroduced by the missing edge.
91
+ *
92
+ * So the gate is a latch, not a predicate. It fires once at COMPACT_AT_FILL,
93
+ * disarms, and re-arms only once another compaction would be worth its cost.
94
+ *
95
+ * RE-ARMING ON PROGRESS, NOT ON A MARK. The first version re-armed only below
96
+ * REARM_BELOW_FILL, which assumed a compaction always drains past that mark.
97
+ * Nothing guarantees it: this codebase keeps EVERY user message verbatim, so a
98
+ * compaction's floor is system + all user turns + the checkpoint, and in a long
99
+ * conversation with large pastes that clears 0.6 easily. Landing anywhere in
100
+ * [0.6, 0.86) left the latch stuck SHUT FOREVER -- fill only grows from there,
101
+ * so it could never fall back under the mark, and the run compacted exactly
102
+ * once before marching into the wall it was supposed to prevent. Silently: a
103
+ * gate that never fires looks identical to a conversation that never got big.
104
+ *
105
+ * So the question is not "how full is the window" but "has it grown enough
106
+ * since the last compaction to have something new to collapse". Growth of
107
+ * REARM_ON_REGROWTH past that floor means yes, whatever the absolute level.
108
+ * The drained-window rule is kept alongside it: it is the cheapest possible
109
+ * signal and stays correct. Between the marks, with nothing new accumulated,
110
+ * the gate still does nothing -- which remains the right answer, because a
111
+ * second compaction so soon has almost nothing left to remove and would charge
112
+ * a full prefix rewrite to discover that. */
113
+ export function compactionDecision(fill, messages, gate) {
114
+ if (!gate.armed) {
115
+ const drained = fill < REARM_BELOW_FILL;
116
+ const regrown = gate.floor !== null && fill >= gate.floor + REARM_ON_REGROWTH;
117
+ return { compact: false, next: { ...gate, armed: drained || regrown } };
118
+ }
119
+ if (!shouldCompact(fill, messages))
120
+ return { compact: false, next: gate };
121
+ return { compact: true, next: afterCompaction() };
122
+ }
123
+ export async function compactHistory(messages, opts) {
124
+ // A trailing assistant message that has asked for tools but not yet been
125
+ // answered is held back from the collapse: the loop appends its `tool`
126
+ // results next, and those results need this parent to exist or the provider
127
+ // rejects the request with a fatal 400.
128
+ const tail = messages.length ? messages[messages.length - 1] : undefined;
129
+ const pending = tail && isUnansweredToolCall(tail) ? tail : null;
130
+ const body = pending ? messages.slice(0, -1) : messages;
131
+ const system = body.filter((m) => m.role === 'system');
132
+ const collapsed = body.filter((m) => m.role !== 'system' && m.role !== 'user');
133
+ const kept = [];
134
+ let answered = null;
135
+ for (const m of body) {
136
+ /* Skip THIS MODULE'S OWN messages, both as anchor candidates and as
137
+ * things to re-anchor. An anchor is an assistant message with string
138
+ * content, so a naive walk picks up the previous compaction's anchors and
139
+ * anchors those — a real run produced
140
+ * "[anchor] answering: [anchor] answering: [anchor] answering: ...".
141
+ * These are bookkeeping about the conversation, not part of it. */
142
+ if (isOwnMarker(m))
143
+ continue;
144
+ if (m.role === 'assistant' && typeof m.content === 'string')
145
+ answered = m;
146
+ if (m.role !== 'user')
147
+ continue;
148
+ if (opts.anchor) {
149
+ kept.push({ role: 'assistant', content: `${ANCHOR_MARKER}${await opts.anchor(m, answered)}` });
150
+ }
151
+ kept.push(await offloadIfOversized(m, opts));
152
+ }
153
+ const summary = await opts.summarize(collapsed);
154
+ return {
155
+ messages: [
156
+ ...system,
157
+ { role: 'assistant', content: `${CHECKPOINT_MARKER}${summary}` },
158
+ ...kept,
159
+ ...(pending ? [pending] : []),
160
+ ],
161
+ checkpoint: summary,
162
+ };
163
+ }
164
+ /** A user turn's DECISION text is small and stays verbatim; a pasted log is
165
+ * bulk and gets rung 1's treatment. Anything that is not oversized plain text
166
+ * is returned untouched. */
167
+ async function offloadIfOversized(m, opts) {
168
+ const limit = opts.offloadOverBytes;
169
+ if (!opts.offload || limit === undefined)
170
+ return m;
171
+ if (typeof m.content !== 'string' || m.content.length <= limit)
172
+ return m;
173
+ return { ...m, content: await opts.offload(m.content) };
174
+ }
175
+ /** An anchor or checkpoint a compaction wrote — bookkeeping ABOUT the
176
+ * conversation rather than part of it. Exported because a caller that has to
177
+ * tell "what this run added" from "what a rewrite inserted" cannot do it by
178
+ * position: a rewrite puts fresh objects into the array at arbitrary places. */
179
+ export const isCompactionMarker = isOwnMarker;
180
+ /** An anchor or checkpoint this module wrote on an earlier pass. Dropped and
181
+ * rewritten each time rather than accumulated: the newest checkpoint already
182
+ * subsumes what the older ones recorded. */
183
+ function isOwnMarker(m) {
184
+ if (m.role !== 'assistant' || typeof m.content !== 'string')
185
+ return false;
186
+ return m.content.startsWith(ANCHOR_MARKER) || m.content.startsWith(CHECKPOINT_MARKER);
187
+ }
188
+ function isUnansweredToolCall(m) {
189
+ return m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
190
+ }
191
+ //# sourceMappingURL=compactHistory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compactHistory.js","sourceRoot":"","sources":["../../core/compactHistory.ts"],"names":[],"mappings":"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"}
@@ -40,6 +40,17 @@ export interface RunAgentArgs {
40
40
  describePermissionDenial?: (toolName: string) => string;
41
41
  /** Context-compression config (rung-1 offload). Omit to disable. */
42
42
  compression?: CompressionConfig;
43
+ /** Rung-2 history compaction. Omit and history is never compacted — the run
44
+ * then behaves exactly as it did before this existed, which is to say it
45
+ * runs until the provider rejects an oversized request with a fatal 400.
46
+ * `summarize` is a host concern because it costs a model call; the host also
47
+ * decides where `onCheckpoint` persists the body (a file the user can edit,
48
+ * not an invisible blob). */
49
+ compaction?: {
50
+ summarize: (collapsed: ChatCompletionMessageParam[]) => Promise<string>;
51
+ anchor?: (user: ChatCompletionMessageParam, answered: ChatCompletionMessageParam | null) => Promise<string>;
52
+ onCheckpoint?: (checkpoint: string) => void | Promise<void>;
53
+ };
43
54
  /** Intra-run self-healing budget (stream-retry + tool-steer). Omit for defaults. */
44
55
  recovery?: Partial<RecoveryConfig>;
45
56
  /** Optional output-token ceiling forwarded to the model on every turn (reasoning
@@ -73,6 +84,20 @@ export interface RunAgentResult {
73
84
  text: string;
74
85
  usage: Usage;
75
86
  stopReason: string;
87
+ /** How many messages at the TAIL of `messages` this run added.
88
+ *
89
+ * A caller stitching the new messages onto its own transcript cannot compute
90
+ * this from what it sent: rung-2 compaction rewrites `messages` in place and
91
+ * the rewrite is SHORTER than the input -- that is the point of it -- so an
92
+ * absolute offset into the result runs off the end and the caller silently
93
+ * keeps nothing. The browser runtime did exactly that, and dropped the reply
94
+ * of every run that compacted.
95
+ *
96
+ * Counted from the tail by object identity, so no future `messages.push` can
97
+ * forget to maintain it. A run whose earlier messages were collapsed into a
98
+ * checkpoint reports only what survives: those messages are genuinely gone
99
+ * from the array, and the seam in the transcript is what stands in for them. */
100
+ appended: number;
76
101
  }
77
102
  /** The most tools one request may carry.
78
103
  *
@@ -1 +1 @@
1
- {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../core/loop.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,KAAK,EAAE,0BAA0B,EAAsB,MAAM,mCAAmC,CAAC;AACxG,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,KAAK,EAA4B,MAAM,mBAAmB,CAAC;AAE7G,OAAO,EACY,KAAK,cAAc,EAErC,MAAM,YAAY,CAAC;AAMpB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAA0D,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAIhH,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;kDAC8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB;;;kFAG8E;IAC9E,aAAa,CAAC,EAAE,SAAS,EAAE,CAAC;IAC5B;oFACgF;IAChF,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,OAAO,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B;;;;;;;;6EAQyE;IACzE,wBAAwB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;IACxD,oEAAoE;IACpE,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,oFAAoF;IACpF,QAAQ,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;IACnC;0FACsF;IACtF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;yEAOqE;IACrE,aAAa,CAAC,EAAE,MAAM,0BAA0B,EAAE,CAAC;IACnD;;4DAEwD;IACxD,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC;IACpC;;;0EAGsE;IACtE,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IACpD,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,KAAK,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;gEAWgE;AAChE,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CAwa1E"}
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../core/loop.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,KAAK,EAAE,0BAA0B,EAAsB,MAAM,mCAAmC,CAAC;AACxG,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,KAAK,EAA4B,MAAM,mBAAmB,CAAC;AAE7G,OAAO,EACY,KAAK,cAAc,EAErC,MAAM,YAAY,CAAC;AAMpB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAA0D,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAKhH,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;kDAC8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,KAAK,EAAE,SAAS,EAAE,CAAC;IACnB;;;kFAG8E;IAC9E,aAAa,CAAC,EAAE,SAAS,EAAE,CAAC;IAC5B;oFACgF;IAChF,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,OAAO,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B;;;;;;;;6EAQyE;IACzE,wBAAwB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;IACxD,oEAAoE;IACpE,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC;;;;;iCAK6B;IAC7B,UAAU,CAAC,EAAE;QACX,SAAS,EAAE,CAAC,SAAS,EAAE,0BAA0B,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;QACxE,MAAM,CAAC,EAAE,CACP,IAAI,EAAE,0BAA0B,EAChC,QAAQ,EAAE,0BAA0B,GAAG,IAAI,KACxC,OAAO,CAAC,MAAM,CAAC,CAAC;QACrB,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KAC7D,CAAC;IACF,oFAAoF;IACpF,QAAQ,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;IACnC;0FACsF;IACtF,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;yEAOqE;IACrE,aAAa,CAAC,EAAE,MAAM,0BAA0B,EAAE,CAAC;IACnD;;4DAEwD;IACxD,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC;IACpC;;;0EAGsE;IACtE,iBAAiB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IACpD,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,0BAA0B,EAAE,CAAC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,KAAK,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;;oFAYgF;IAChF,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;gEAWgE;AAChE,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC,wBAAsB,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,cAAc,CAAC,CAugB1E"}
package/dist/core/loop.js CHANGED
@@ -6,6 +6,7 @@ import { checkFeature } from '../guards.js';
6
6
  import { STANDARD_POLICY } from '../guardrail/builtins.js';
7
7
  import { toolAddress } from '../tools/namespaces.js';
8
8
  import { effectiveThreshold, formatOffloadRef, isOffloadPointer } from '../compression.js';
9
+ import { compactHistory, compactionDecision, estimateFill, isCompactionMarker } from './compactHistory.js';
9
10
  import { fillToolsMarker } from '../prompts/toolsMarker.js';
10
11
  import { createToolLoader, serverIndex } from '../tools/toolSearch.js';
11
12
  /** The most tools one request may carry.
@@ -29,6 +30,10 @@ export async function runAgent(args) {
29
30
  const ctxWindow = compression?.contextWindow;
30
31
  const compressionStrategy = compression?.strategy ?? 'eager';
31
32
  let fill = 0; // window fill ratio; drives the adaptive offload threshold
33
+ /* Compaction is a LATCH, not a per-turn predicate -- see compactionDecision.
34
+ * Armed at the start of a run; disarmed by a compaction; re-armed once the
35
+ * window has drained, or has refilled past where that compaction left it. */
36
+ let compactGate = { armed: true, floor: null };
32
37
  // 'deferred' strategy only: results offloaded THIS turn, awaiting the ONE
33
38
  // streamCompletion call that sends them in full (the turn right after
34
39
  // they're produced — the model that asked for the result reads it). As soon
@@ -54,6 +59,24 @@ export async function runAgent(args) {
54
59
  signal.addEventListener('abort', () => { turnAc?.abort(); });
55
60
  args.registerInterrupt?.(() => { interrupted = true; turnAc?.abort(); });
56
61
  const messages = [...args.messages];
62
+ /* The messages that came IN, by identity. Everything this run appends is a
63
+ * fresh object, and everything that survives a compaction from the input is
64
+ * the same object, so walking back from the tail until an input message
65
+ * appears is an exact count of what this run added -- maintained by nothing,
66
+ * and therefore impossible for a later `push` site to get wrong. */
67
+ const incoming = new Set(args.messages);
68
+ const appendedFromTail = () => {
69
+ let n = 0;
70
+ for (let i = messages.length - 1; i >= 0; i--) {
71
+ const m = messages[i];
72
+ // A compaction's own checkpoint/anchor, and the system prompt unshifted
73
+ // below, are fresh objects too -- and neither is something this run said.
74
+ if (incoming.has(m) || m.role === 'system' || isCompactionMarker(m))
75
+ break;
76
+ n++;
77
+ }
78
+ return n;
79
+ };
57
80
  /* DEFERRED TOOLS: sent only once found. The loader owns the loaded set;
58
81
  * `activeTools` is what one request may carry right now -- the eager tools,
59
82
  * `tool_search`, then the loaded ones, capped. Eager first so a full loaded
@@ -138,6 +161,75 @@ export async function runAgent(args) {
138
161
  emit({ type: 'injected', content: typeof m.content === 'string' ? m.content : '' });
139
162
  }
140
163
  }
164
+ /* Rung-2 history compaction, BEFORE the request is built so the compacted
165
+ * array is what goes on the wire. `fill` is last turn's prompt/window
166
+ * ratio; shouldCompact refuses below the threshold and refuses again when
167
+ * there is nothing collapsible, because a compaction that removes nothing
168
+ * still rewrites the cached prefix and re-bills everything after it.
169
+ * Rebuilt in place: callers (and `res.messages`) hold this array. */
170
+ /* On turn 1 there is no usage reading yet, so `fill` is 0 and the gate
171
+ * could not fire before the first request -- which is precisely how a run
172
+ * resuming an already-oversized conversation put it on the wire and took
173
+ * the fatal 400. Fall back to reading the window off the messages. Only as
174
+ * a fallback: a real reading is always better than an estimate that cannot
175
+ * see the tool schemas. */
176
+ const gateFill = fill || estimateFill(messages, ctxWindow);
177
+ const gate = compactionDecision(gateFill, messages, compactGate);
178
+ compactGate = gate.next;
179
+ if (args.compaction && gate.compact) {
180
+ const before = messages.length;
181
+ /* A summarizer failure is the same shape as a persist failure: the window
182
+ * is full, the model call meant to compress it did not come back, and the
183
+ * honest response is to carry on UNCOMPACTED rather than end the run on
184
+ * the recovery path. The turn then proceeds with the history it had,
185
+ * which is exactly what would have happened without compaction at all. */
186
+ try {
187
+ const out = await compactHistory(messages, {
188
+ summarize: args.compaction.summarize,
189
+ anchor: args.compaction.anchor,
190
+ offloadOverBytes: compression && compression.mode !== 'off'
191
+ ? effectiveThreshold(compression.mode, gateFill)
192
+ : undefined,
193
+ offload: toolCtx.recall
194
+ // Recorded under 'user' rather than a tool name: the RecallStore is
195
+ // indifferent to who authored the bulk, but the transcript should not
196
+ // claim a tool produced what a person pasted.
197
+ ? async (content) => formatOffloadRef(await toolCtx.recall.record('user', content))
198
+ : undefined,
199
+ });
200
+ messages.splice(0, messages.length, ...out.messages);
201
+ /* The 'deferred' strategy holds ABSOLUTE indices into `messages`, to
202
+ * rewrite those entries once the model has seen them in full. The splice
203
+ * above just invalidated every one of them, and the rewrite pass read
204
+ * `.role` off the resulting undefined -- a crash in the interactive
205
+ * CLI's shipping configuration ('deferred' + compaction). Nothing is
206
+ * lost by dropping them: the tool results they point at were collapsed
207
+ * into the checkpoint and are not in `messages` any more. */
208
+ deferredOffloads.length = 0;
209
+ /* The compaction has ALREADY happened by this point. Failing to RECORD it
210
+ * must not take the run with it -- checkpointStore.append deliberately
211
+ * rejects rather than risk clobbering an existing CHECKPOINT.md, and a
212
+ * read-only workspace rejects too. Dying here would lose the whole
213
+ * conversation in order to save nothing. */
214
+ try {
215
+ await args.compaction.onCheckpoint?.(out.checkpoint);
216
+ }
217
+ catch (e) {
218
+ alog('checkpoint persist failed', e instanceof Error ? e.message : String(e));
219
+ }
220
+ emit({ type: 'checkpoint', collapsed: before - messages.length, checkpoint: out.checkpoint });
221
+ }
222
+ catch (e) {
223
+ /* Compaction failed (most likely the summarizer's own model call). The
224
+ * turn proceeds with the history it had -- exactly what would have
225
+ * happened had compaction never been configured. Re-arm so a later turn
226
+ * may try again rather than giving up on the run for good. */
227
+ /* The floor is left as it was: it describes where the last SUCCESSFUL
228
+ * compaction landed, and a failed attempt moved nothing. */
229
+ compactGate = { ...compactGate, armed: true };
230
+ alog('compaction failed', e instanceof Error ? e.message : String(e));
231
+ }
232
+ }
141
233
  // Turn-budget guardrail (gstate.turn = completed turns).
142
234
  gstate.turn = turn - 1;
143
235
  const turnGate = evaluateTurnStart(policy, gstate);
@@ -263,6 +355,12 @@ export async function runAgent(args) {
263
355
  usage.completionTokens += streamed.usage.completionTokens;
264
356
  if (ctxWindow && ctxWindow > 0 && streamed.usage.promptTokens) {
265
357
  fill = Math.min(1, streamed.usage.promptTokens / ctxWindow);
358
+ /* The first real reading after a compaction is how far down that
359
+ * compaction actually got, and re-arming is judged against it from here
360
+ * on. Measured, never estimated: it is compared with later readings of
361
+ * the same kind. */
362
+ if (!compactGate.armed && compactGate.floor === null)
363
+ compactGate = { armed: false, floor: fill };
266
364
  }
267
365
  emit({ type: 'usage', usage: streamed.usage });
268
366
  // Commit the assistant message to history (with tool_calls if any).
@@ -454,7 +552,7 @@ export async function runAgent(args) {
454
552
  }
455
553
  alog('done', stopReason, 'tokens', usage);
456
554
  emit({ type: 'done', reason: stopReason });
457
- return { messages, text, usage, stopReason };
555
+ return { messages, text, usage, stopReason, appended: appendedFromTail() };
458
556
  }
459
557
  /** 'deferred' strategy: compact any ALREADY-oversized tool result already
460
558
  * sitting in the incoming history — i.e. produced by an earlier `runAgent()`