@sayknow-cli/agent-core 0.3.12 → 0.3.15
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/CHANGELOG.md +8 -0
- package/dist/types/agent.d.ts +12 -0
- package/dist/types/compaction/compaction.d.ts +5 -5
- package/package.json +4 -4
- package/src/agent-loop.ts +27 -1
- package/src/agent.ts +35 -0
- package/src/compaction/compaction.ts +105 -25
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.10.0] - 2026-07-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The native-free token heuristic is now script-aware: common-BMP CJK characters (Hangul, unified/compat Han, Kana, CJK punctuation, full-width forms) are charged at 1 token each (measured o200k_base upper bound 0.96 tokens/char) and supplementary code points (surrogate pairs: rare Han extensions, emoji) at 1 token per code point, instead of chars/4 for everything. The old estimate undercounted Korean/CJK-heavy unsent context by 2–4x and could delay threshold compaction past the provider window; ASCII estimates are unchanged. `boundConversationTextForSummary` now derives its truncation cut from the text's own estimated token density, validates the complete assembled excerpt (elision marker included) against the estimator, and fails closed — bare marker only when the marker itself fits the budget, otherwise an empty excerpt, including when the computed input budget is non-positive — instead of assuming 4 chars/token and returning over-budget or unbounded text.
|
|
10
|
+
|
|
11
|
+
- A tool call for a name absent from the active tool set now appends a recovery hint pointing at `search_tool_bm25` (gated on a callable `search_tool_bm25`, matched by internal name or `customWireName`), so a model no longer abandons a discoverable tool such as `task` after a bare "Tool <name> not found"; the base error wording stays byte-for-byte stable when discovery is unavailable (#2042).
|
|
12
|
+
|
|
5
13
|
## [0.9.2] - 2026-07-09
|
|
6
14
|
|
|
7
15
|
### Fixed
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ import { type AssistantMessage, type AssistantMessageEvent, type CursorExecHandl
|
|
|
5
5
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
6
6
|
import type { HarmonyAuditEvent } from "./harmony-leak";
|
|
7
7
|
import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, StreamFn, ToolCallContext } from "./types";
|
|
8
|
+
/**
|
|
9
|
+
* Whether persisted history ends at a point where a new model turn can resume.
|
|
10
|
+
* Assistant-ended histories require an in-memory queued message and are handled
|
|
11
|
+
* separately by `Agent.continue()`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function canContinuePersistedHistory(messages: readonly AgentMessage[]): boolean;
|
|
8
14
|
export declare class AgentBusyError extends Error {
|
|
9
15
|
constructor(message?: string);
|
|
10
16
|
}
|
|
@@ -292,6 +298,7 @@ export declare class Agent {
|
|
|
292
298
|
get streamMaxRetries(): number | undefined;
|
|
293
299
|
set streamMaxRetries(value: number | undefined);
|
|
294
300
|
get state(): AgentState;
|
|
301
|
+
get contextRevision(): number;
|
|
295
302
|
get appendOnlyContext(): AppendOnlyContextManager | undefined;
|
|
296
303
|
setAppendOnlyContext(manager?: AppendOnlyContextManager): void;
|
|
297
304
|
subscribe(fn: (e: AgentEvent) => void): () => void;
|
|
@@ -315,6 +322,11 @@ export declare class Agent {
|
|
|
315
322
|
replaceMessages(ms: AgentMessage[]): void;
|
|
316
323
|
appendMessage(m: AgentMessage): void;
|
|
317
324
|
popMessage(): AgentMessage | undefined;
|
|
325
|
+
/**
|
|
326
|
+
* For callers that mutate committed messages or the system prompt in place
|
|
327
|
+
* outside Agent-owned mutators.
|
|
328
|
+
*/
|
|
329
|
+
touchContext(): void;
|
|
318
330
|
/**
|
|
319
331
|
* Queue a steering message to interrupt the agent mid-run.
|
|
320
332
|
* Delivered after current tool execution, skips remaining tools.
|
|
@@ -118,7 +118,7 @@ export declare function resolveThresholdTokens(contextWindow: number, settings:
|
|
|
118
118
|
*/
|
|
119
119
|
export declare const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
120
120
|
/**
|
|
121
|
-
* Native-free
|
|
121
|
+
* Native-free token estimate for a message. This is the only message
|
|
122
122
|
* token estimator: provider usage (see {@link calculatePromptTokens}) anchors
|
|
123
123
|
* the already-sent context, and this covers unsent/trailing deltas, per-entry
|
|
124
124
|
* budgeting, and display surfaces. Callers add a conservative inflation factor
|
|
@@ -126,8 +126,8 @@ export declare const IMAGE_TOKEN_ESTIMATE = 1200;
|
|
|
126
126
|
*/
|
|
127
127
|
export declare function estimateMessageTokensHeuristic(message: AgentMessage): number;
|
|
128
128
|
/**
|
|
129
|
-
*
|
|
130
|
-
* counterpart of {@link estimateMessageTokensHeuristic}.
|
|
129
|
+
* Script-aware native-free token estimate for plain string fragments.
|
|
130
|
+
* Fragment-level counterpart of {@link estimateMessageTokensHeuristic}.
|
|
131
131
|
*/
|
|
132
132
|
export declare function estimateTextTokensHeuristic(fragments: string | readonly string[]): number;
|
|
133
133
|
export declare function estimateEntryTokens(entry: SessionEntry): number;
|
|
@@ -207,8 +207,8 @@ export interface SummaryOptions {
|
|
|
207
207
|
* on the very overflow the recovery was meant to absorb.
|
|
208
208
|
*
|
|
209
209
|
* The budget reserves the summary's own output tokens plus prompt/system/template
|
|
210
|
-
* overhead, and applies a conservative safety factor
|
|
211
|
-
*
|
|
210
|
+
* overhead, and applies a conservative safety factor for estimator error on
|
|
211
|
+
* dense text (the reason the original overflow was missed).
|
|
212
212
|
* Truncation keeps the head (origin/goals) and the tail (most recent state) and
|
|
213
213
|
* elides the middle; it is a last resort that only triggers when the input would
|
|
214
214
|
* otherwise not fit.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/agent-core",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.15",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@sayknow-cli/ai": "0.3.
|
|
39
|
-
"@sayknow-cli/natives": "0.3.
|
|
40
|
-
"@sayknow-cli/utils": "0.3.
|
|
38
|
+
"@sayknow-cli/ai": "0.3.15",
|
|
39
|
+
"@sayknow-cli/natives": "0.3.15",
|
|
40
|
+
"@sayknow-cli/utils": "0.3.15",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -1092,6 +1092,17 @@ function emitAbortedAssistantMessage(
|
|
|
1092
1092
|
return abortedMessage;
|
|
1093
1093
|
}
|
|
1094
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* Match a tool against the model-visible call name. Tools emitted via OpenAI's
|
|
1097
|
+
* custom-tool path (e.g. `apply_patch` on GPT-5) arrive under their wire-level
|
|
1098
|
+
* name, which may differ from the harness-internal `name`, so dispatch and any
|
|
1099
|
+
* "is this tool callable" check must consider both. Internal `name` takes
|
|
1100
|
+
* precedence when a caller needs a single match.
|
|
1101
|
+
*/
|
|
1102
|
+
function toolMatchesCallName(tool: { name: string; customWireName?: string }, callName: string): boolean {
|
|
1103
|
+
return tool.name === callName || (tool.customWireName !== undefined && tool.customWireName === callName);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1095
1106
|
/**
|
|
1096
1107
|
* Execute tool calls from an assistant message.
|
|
1097
1108
|
*/
|
|
@@ -1273,7 +1284,22 @@ async function executeToolCalls(
|
|
|
1273
1284
|
`Re-issue the call with complete arguments, splitting the work into smaller steps if needed.`,
|
|
1274
1285
|
);
|
|
1275
1286
|
}
|
|
1276
|
-
if (!tool)
|
|
1287
|
+
if (!tool) {
|
|
1288
|
+
// A discoverable tool that hasn't been activated yet resolves to
|
|
1289
|
+
// undefined here. The model often "remembers" such a tool (e.g.
|
|
1290
|
+
// `task`) from earlier context and calls it by name without first
|
|
1291
|
+
// re-discovering it. Point it at tool discovery so it can activate
|
|
1292
|
+
// the tool and retry instead of giving up on the capability. The
|
|
1293
|
+
// base wording stays byte-for-byte stable for downstream consumers;
|
|
1294
|
+
// the period and hint are appended only when discovery is callable.
|
|
1295
|
+
const base = `Tool ${toolCall.name} not found`;
|
|
1296
|
+
const hasToolDiscovery = tools?.some(t => toolMatchesCallName(t, "search_tool_bm25")) ?? false;
|
|
1297
|
+
throw new Error(
|
|
1298
|
+
hasToolDiscovery
|
|
1299
|
+
? `${base}. If you are unsure whether this tool exists or how to use it, call \`search_tool_bm25\` to discover and activate the matching tool, then retry.`
|
|
1300
|
+
: base,
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1277
1303
|
|
|
1278
1304
|
let effectiveArgs: Record<string, unknown>;
|
|
1279
1305
|
try {
|
package/src/agent.ts
CHANGED
|
@@ -53,6 +53,16 @@ function assertUserImagePlaceholdersHavePayload(messages: readonly AgentMessage[
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Whether persisted history ends at a point where a new model turn can resume.
|
|
58
|
+
* Assistant-ended histories require an in-memory queued message and are handled
|
|
59
|
+
* separately by `Agent.continue()`.
|
|
60
|
+
*/
|
|
61
|
+
export function canContinuePersistedHistory(messages: readonly AgentMessage[]): boolean {
|
|
62
|
+
const lastMessage = messages.at(-1);
|
|
63
|
+
return lastMessage !== undefined && lastMessage.role !== "assistant";
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
/**
|
|
57
67
|
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
|
58
68
|
*/
|
|
@@ -286,6 +296,7 @@ export class Agent {
|
|
|
286
296
|
pendingToolCalls: new Set<string>(),
|
|
287
297
|
error: undefined,
|
|
288
298
|
};
|
|
299
|
+
#contextRevision = 0;
|
|
289
300
|
|
|
290
301
|
#listeners = new Set<(e: AgentEvent) => void>();
|
|
291
302
|
#abortController?: AbortController;
|
|
@@ -630,6 +641,10 @@ export class Agent {
|
|
|
630
641
|
return this.#state;
|
|
631
642
|
}
|
|
632
643
|
|
|
644
|
+
get contextRevision(): number {
|
|
645
|
+
return this.#contextRevision;
|
|
646
|
+
}
|
|
647
|
+
|
|
633
648
|
get appendOnlyContext(): AppendOnlyContextManager | undefined {
|
|
634
649
|
return this.#appendOnlyContext;
|
|
635
650
|
}
|
|
@@ -812,10 +827,12 @@ export class Agent {
|
|
|
812
827
|
// State mutators
|
|
813
828
|
setSystemPrompt(v: string[]) {
|
|
814
829
|
this.#state.systemPrompt = v;
|
|
830
|
+
this.#contextRevision++;
|
|
815
831
|
}
|
|
816
832
|
|
|
817
833
|
setModel(m: Model) {
|
|
818
834
|
this.#state.model = m;
|
|
835
|
+
this.#contextRevision++;
|
|
819
836
|
}
|
|
820
837
|
|
|
821
838
|
setThinkingLevel(l: Effort | undefined) {
|
|
@@ -848,10 +865,12 @@ export class Agent {
|
|
|
848
865
|
|
|
849
866
|
setTools(t: AgentTool<any>[]) {
|
|
850
867
|
this.#state.tools = t;
|
|
868
|
+
this.#contextRevision++;
|
|
851
869
|
}
|
|
852
870
|
|
|
853
871
|
replaceMessages(ms: AgentMessage[]) {
|
|
854
872
|
this.#state.messages = ms.slice();
|
|
873
|
+
this.#contextRevision++;
|
|
855
874
|
}
|
|
856
875
|
|
|
857
876
|
appendMessage(m: AgentMessage) {
|
|
@@ -859,12 +878,14 @@ export class Agent {
|
|
|
859
878
|
// N is O(N+M), not O(M*N). Consumers read state.messages fresh; run() snapshots
|
|
860
879
|
// via slice() at the API boundary, so no caller relies on per-append array identity.
|
|
861
880
|
this.#state.messages.push(m);
|
|
881
|
+
this.#contextRevision++;
|
|
862
882
|
}
|
|
863
883
|
|
|
864
884
|
popMessage(): AgentMessage | undefined {
|
|
865
885
|
const messages = this.#state.messages.slice(0, -1);
|
|
866
886
|
const removed = this.#state.messages.at(-1);
|
|
867
887
|
this.#state.messages = messages;
|
|
888
|
+
this.#contextRevision++;
|
|
868
889
|
|
|
869
890
|
if (removed && this.#state.streamMessage === removed) {
|
|
870
891
|
this.#state.streamMessage = null;
|
|
@@ -873,6 +894,14 @@ export class Agent {
|
|
|
873
894
|
return removed;
|
|
874
895
|
}
|
|
875
896
|
|
|
897
|
+
/**
|
|
898
|
+
* For callers that mutate committed messages or the system prompt in place
|
|
899
|
+
* outside Agent-owned mutators.
|
|
900
|
+
*/
|
|
901
|
+
touchContext(): void {
|
|
902
|
+
this.#contextRevision++;
|
|
903
|
+
}
|
|
904
|
+
|
|
876
905
|
/**
|
|
877
906
|
* Queue a steering message to interrupt the agent mid-run.
|
|
878
907
|
* Delivered after current tool execution, skips remaining tools.
|
|
@@ -1046,6 +1075,7 @@ export class Agent {
|
|
|
1046
1075
|
|
|
1047
1076
|
clearMessages() {
|
|
1048
1077
|
this.#state.messages = [];
|
|
1078
|
+
this.#contextRevision++;
|
|
1049
1079
|
}
|
|
1050
1080
|
|
|
1051
1081
|
abort() {
|
|
@@ -1084,6 +1114,7 @@ export class Agent {
|
|
|
1084
1114
|
|
|
1085
1115
|
reset() {
|
|
1086
1116
|
this.#state.messages = [];
|
|
1117
|
+
this.#contextRevision++;
|
|
1087
1118
|
this.#state.isStreaming = false;
|
|
1088
1119
|
this.#state.streamMessage = null;
|
|
1089
1120
|
this.#state.pendingToolCalls = new Set<string>();
|
|
@@ -1171,6 +1202,10 @@ export class Agent {
|
|
|
1171
1202
|
throw new Error("Cannot continue from message role: assistant");
|
|
1172
1203
|
}
|
|
1173
1204
|
|
|
1205
|
+
if (!canContinuePersistedHistory(messages)) {
|
|
1206
|
+
throw new Error("No messages to continue from");
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1174
1209
|
await this.#runLoop(undefined);
|
|
1175
1210
|
}
|
|
1176
1211
|
|
|
@@ -405,7 +405,61 @@ function countCollectedMessageFragments(collected: { fragments: string[]; extra:
|
|
|
405
405
|
const HEURISTIC_BYTES_PER_TOKEN = 4;
|
|
406
406
|
|
|
407
407
|
/**
|
|
408
|
-
*
|
|
408
|
+
* Token-dense character weight for the script-aware heuristic.
|
|
409
|
+
*
|
|
410
|
+
* Common-BMP CJK blocks (Hangul, unified/compat Han, Kana, CJK punctuation,
|
|
411
|
+
* full-width forms) tokenize at ~0.6–1.0 tokens per character under
|
|
412
|
+
* o200k-class BPE vocabularies (measured o200k_base: Hangul prose 0.604,
|
|
413
|
+
* spaceless Hangul 0.964, Han 0.793, Kana 0.740 tokens/char — versus the
|
|
414
|
+
* 0.25 the chars/4 heuristic assumes). Each such character is charged 1
|
|
415
|
+
* token: an upper bound for these measured blocks whose only failure mode is
|
|
416
|
+
* compacting slightly early, while undercounting risks overflowing the
|
|
417
|
+
* provider window.
|
|
418
|
+
*
|
|
419
|
+
* Surrogate code units are charged 0.5 each, i.e. 1 token per supplementary
|
|
420
|
+
* code point (supplementary Han extensions, emoji, and other astral chars).
|
|
421
|
+
* That is a floor rather than an upper bound — rare ideographs and emoji can
|
|
422
|
+
* cost several tokens — but it is strictly safer than the 0.5-per-pair the
|
|
423
|
+
* plain chars/4 rule produced.
|
|
424
|
+
*/
|
|
425
|
+
function tokenDenseCharWeight(text: string): { weight: number; units: number } {
|
|
426
|
+
let weight = 0;
|
|
427
|
+
let units = 0;
|
|
428
|
+
for (let i = 0; i < text.length; i++) {
|
|
429
|
+
const c = text.charCodeAt(i);
|
|
430
|
+
if (
|
|
431
|
+
(c >= 0x1100 && c <= 0x11ff) || // Hangul Jamo
|
|
432
|
+
(c >= 0x3000 && c <= 0x303f) || // CJK symbols & punctuation
|
|
433
|
+
(c >= 0x3040 && c <= 0x30ff) || // Hiragana & Katakana
|
|
434
|
+
(c >= 0x3130 && c <= 0x318f) || // Hangul compatibility Jamo
|
|
435
|
+
(c >= 0x3400 && c <= 0x4dbf) || // CJK ideographs extension A
|
|
436
|
+
(c >= 0x4e00 && c <= 0x9fff) || // CJK unified ideographs
|
|
437
|
+
(c >= 0xac00 && c <= 0xd7af) || // Hangul syllables
|
|
438
|
+
(c >= 0xf900 && c <= 0xfaff) || // CJK compatibility ideographs
|
|
439
|
+
(c >= 0xff00 && c <= 0xffef) // Half/full-width forms
|
|
440
|
+
) {
|
|
441
|
+
weight += 1;
|
|
442
|
+
units += 1;
|
|
443
|
+
} else if (c >= 0xd800 && c <= 0xdfff) {
|
|
444
|
+
// Surrogate half: a supplementary code point contributes two units.
|
|
445
|
+
weight += 0.5;
|
|
446
|
+
units += 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return { weight, units };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Script-aware native-free token estimate for a plain string fragment:
|
|
454
|
+
* token-dense characters cost ~1 token each, everything else chars/4.
|
|
455
|
+
*/
|
|
456
|
+
function estimateFragmentTokensHeuristic(fragment: string): { dense: number; otherChars: number } {
|
|
457
|
+
const { weight, units } = tokenDenseCharWeight(fragment);
|
|
458
|
+
return { dense: weight, otherChars: fragment.length - units };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Native-free token estimate for a message. This is the only message
|
|
409
463
|
* token estimator: provider usage (see {@link calculatePromptTokens}) anchors
|
|
410
464
|
* the already-sent context, and this covers unsent/trailing deltas, per-entry
|
|
411
465
|
* budgeting, and display surfaces. Callers add a conservative inflation factor
|
|
@@ -413,24 +467,23 @@ const HEURISTIC_BYTES_PER_TOKEN = 4;
|
|
|
413
467
|
*/
|
|
414
468
|
export function estimateMessageTokensHeuristic(message: AgentMessage): number {
|
|
415
469
|
const { fragments, extra } = collectMessageFragments(message);
|
|
416
|
-
|
|
417
|
-
for (const fragment of fragments) {
|
|
418
|
-
bytes += fragment.length;
|
|
419
|
-
}
|
|
420
|
-
return extra + Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
|
|
470
|
+
return extra + estimateTextTokensHeuristic(fragments);
|
|
421
471
|
}
|
|
422
472
|
|
|
423
473
|
/**
|
|
424
|
-
*
|
|
425
|
-
* counterpart of {@link estimateMessageTokensHeuristic}.
|
|
474
|
+
* Script-aware native-free token estimate for plain string fragments.
|
|
475
|
+
* Fragment-level counterpart of {@link estimateMessageTokensHeuristic}.
|
|
426
476
|
*/
|
|
427
477
|
export function estimateTextTokensHeuristic(fragments: string | readonly string[]): number {
|
|
428
|
-
|
|
429
|
-
let
|
|
430
|
-
|
|
431
|
-
|
|
478
|
+
const list = typeof fragments === "string" ? [fragments] : fragments;
|
|
479
|
+
let dense = 0;
|
|
480
|
+
let otherChars = 0;
|
|
481
|
+
for (const fragment of list) {
|
|
482
|
+
const counts = estimateFragmentTokensHeuristic(fragment);
|
|
483
|
+
dense += counts.dense;
|
|
484
|
+
otherChars += counts.otherChars;
|
|
432
485
|
}
|
|
433
|
-
return Math.ceil(
|
|
486
|
+
return Math.ceil(dense + Math.max(0, otherChars) / HEURISTIC_BYTES_PER_TOKEN);
|
|
434
487
|
}
|
|
435
488
|
|
|
436
489
|
/** Shared content walk for both the native and heuristic estimators. */
|
|
@@ -760,8 +813,8 @@ export interface SummaryOptions {
|
|
|
760
813
|
* on the very overflow the recovery was meant to absorb.
|
|
761
814
|
*
|
|
762
815
|
* The budget reserves the summary's own output tokens plus prompt/system/template
|
|
763
|
-
* overhead, and applies a conservative safety factor
|
|
764
|
-
*
|
|
816
|
+
* overhead, and applies a conservative safety factor for estimator error on
|
|
817
|
+
* dense text (the reason the original overflow was missed).
|
|
765
818
|
* Truncation keeps the head (origin/goals) and the tail (most recent state) and
|
|
766
819
|
* elides the middle; it is a last resort that only triggers when the input would
|
|
767
820
|
* otherwise not fit.
|
|
@@ -779,16 +832,43 @@ export function boundConversationTextForSummary(
|
|
|
779
832
|
const inputBudgetTokens = Math.floor(
|
|
780
833
|
(contextWindow - Math.max(0, outputMaxTokens) - OVERHEAD_TOKENS) * SAFETY_FACTOR,
|
|
781
834
|
);
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
835
|
+
const totalEstimatedTokens = estimateTextTokensHeuristic(conversationText);
|
|
836
|
+
const assemble = (head: string, tail: string): string => {
|
|
837
|
+
const elided = conversationText.length - head.length - tail.length;
|
|
838
|
+
return `${head}\n\n[... ${elided} characters of older conversation elided so this summarization request fits within the model context window ...]\n\n${tail}`;
|
|
839
|
+
};
|
|
840
|
+
const bareMarker = assemble("", "");
|
|
841
|
+
const fitsBudget = (candidate: string) => estimateTextTokensHeuristic(candidate) <= inputBudgetTokens;
|
|
842
|
+
if (inputBudgetTokens <= 0) {
|
|
843
|
+
// A window this small cannot fit any excerpt (not even the marker).
|
|
844
|
+
// Fail closed with an empty excerpt rather than submitting text into a
|
|
845
|
+
// request that is guaranteed to overflow.
|
|
846
|
+
return "";
|
|
847
|
+
}
|
|
848
|
+
if (totalEstimatedTokens <= inputBudgetTokens) return conversationText;
|
|
849
|
+
|
|
850
|
+
// Derive the character budget from the text's own measured token density
|
|
851
|
+
// instead of assuming 4 chars/token: a CJK-heavy conversation runs near
|
|
852
|
+
// 1 token/char, and a fixed 4-chars/token cut would overshoot the budget
|
|
853
|
+
// by up to ~4x — re-overflowing the very request this bound protects.
|
|
854
|
+
// Verify the complete assembled candidate (elision marker included)
|
|
855
|
+
// against the estimator and shrink until it fits.
|
|
856
|
+
const charsPerToken = conversationText.length / totalEstimatedTokens;
|
|
857
|
+
let budgetChars = Math.floor(inputBudgetTokens * charsPerToken);
|
|
858
|
+
for (let attempt = 0; attempt < 12 && budgetChars > 0; attempt++) {
|
|
859
|
+
const headChars = Math.floor(budgetChars * 0.35);
|
|
860
|
+
const tailChars = Math.max(0, budgetChars - headChars);
|
|
861
|
+
const head = conversationText.slice(0, headChars);
|
|
862
|
+
const tail = tailChars > 0 ? conversationText.slice(conversationText.length - tailChars) : "";
|
|
863
|
+
const assembled = assemble(head, tail);
|
|
864
|
+
if (fitsBudget(assembled)) return assembled;
|
|
865
|
+
budgetChars = Math.floor(budgetChars * 0.8);
|
|
866
|
+
}
|
|
867
|
+
// All attempts overshot (adversarially non-uniform density, or a budget
|
|
868
|
+
// smaller than the marker itself). Fail closed: return the bare marker
|
|
869
|
+
// only when it fits the budget, else an empty excerpt — never an
|
|
870
|
+
// over-budget result.
|
|
871
|
+
return fitsBudget(bareMarker) ? bareMarker : "";
|
|
792
872
|
}
|
|
793
873
|
|
|
794
874
|
export async function generateSummary(
|