@sayknow-cli/agent-core 0.3.11 → 0.3.13
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/package.json +10 -11
- package/src/agent-loop.ts +27 -1
- package/src/agent.ts +36 -0
- package/src/compaction/compaction.ts +184 -35
- package/src/image-placeholder-guard.ts +20 -0
- package/src/index.ts +1 -0
- package/dist/types/agent-loop.d.ts +0 -56
- package/dist/types/agent.d.ts +0 -391
- package/dist/types/append-only-context.d.ts +0 -137
- package/dist/types/compaction/branch-summarization.d.ts +0 -103
- package/dist/types/compaction/compaction.d.ts +0 -284
- package/dist/types/compaction/entries.d.ts +0 -109
- package/dist/types/compaction/errors.d.ts +0 -26
- package/dist/types/compaction/index.d.ts +0 -11
- package/dist/types/compaction/messages.d.ts +0 -61
- package/dist/types/compaction/openai.d.ts +0 -63
- package/dist/types/compaction/pruning.d.ts +0 -69
- package/dist/types/compaction/utils.d.ts +0 -32
- package/dist/types/compaction.d.ts +0 -1
- package/dist/types/harmony-leak.d.ts +0 -100
- package/dist/types/index.d.ts +0 -10
- package/dist/types/proxy.d.ts +0 -84
- package/dist/types/run-collector.d.ts +0 -196
- package/dist/types/telemetry.d.ts +0 -596
- package/dist/types/thinking.d.ts +0 -18
- package/dist/types/types.d.ts +0 -430
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/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.13",
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"state-management"
|
|
26
26
|
],
|
|
27
27
|
"main": "./src/index.ts",
|
|
28
|
-
"types": "./
|
|
28
|
+
"types": "./src/index.ts",
|
|
29
29
|
"scripts": {
|
|
30
30
|
"check": "biome check . && bun run check:types",
|
|
31
31
|
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
@@ -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.13",
|
|
39
|
+
"@sayknow-cli/natives": "0.3.13",
|
|
40
|
+
"@sayknow-cli/utils": "0.3.13",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
@@ -51,24 +51,23 @@
|
|
|
51
51
|
"files": [
|
|
52
52
|
"src",
|
|
53
53
|
"README.md",
|
|
54
|
-
"CHANGELOG.md"
|
|
55
|
-
"dist/types"
|
|
54
|
+
"CHANGELOG.md"
|
|
56
55
|
],
|
|
57
56
|
"exports": {
|
|
58
57
|
".": {
|
|
59
|
-
"types": "./
|
|
58
|
+
"types": "./src/index.ts",
|
|
60
59
|
"import": "./src/index.ts"
|
|
61
60
|
},
|
|
62
61
|
"./compaction": {
|
|
63
|
-
"types": "./
|
|
62
|
+
"types": "./src/compaction.ts",
|
|
64
63
|
"import": "./src/compaction.ts"
|
|
65
64
|
},
|
|
66
65
|
"./compaction/*": {
|
|
67
|
-
"types": "./
|
|
66
|
+
"types": "./src/compaction/*.ts",
|
|
68
67
|
"import": "./src/compaction/*.ts"
|
|
69
68
|
},
|
|
70
69
|
"./*": {
|
|
71
|
-
"types": "./
|
|
70
|
+
"types": "./src/*.ts",
|
|
72
71
|
"import": "./src/*.ts"
|
|
73
72
|
}
|
|
74
73
|
}
|
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
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { agentLoop, agentLoopContinue } from "./agent-loop";
|
|
24
24
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
25
25
|
import type { HarmonyAuditEvent } from "./harmony-leak";
|
|
26
|
+
import { assertImagePlaceholdersHavePayload } from "./image-placeholder-guard";
|
|
26
27
|
import type {
|
|
27
28
|
AgentContext,
|
|
28
29
|
AgentEvent,
|
|
@@ -35,6 +36,33 @@ import type {
|
|
|
35
36
|
ToolCallContext,
|
|
36
37
|
} from "./types";
|
|
37
38
|
|
|
39
|
+
function assertUserImagePlaceholdersHavePayload(messages: readonly AgentMessage[]): void {
|
|
40
|
+
for (const message of messages) {
|
|
41
|
+
if (!("role" in message) || message.role !== "user") continue;
|
|
42
|
+
const content = message.content;
|
|
43
|
+
if (typeof content === "string") {
|
|
44
|
+
assertImagePlaceholdersHavePayload(content, undefined);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(content)) continue;
|
|
48
|
+
const text = content
|
|
49
|
+
.filter(part => part.type === "text")
|
|
50
|
+
.map(part => part.text)
|
|
51
|
+
.join("\n");
|
|
52
|
+
assertImagePlaceholdersHavePayload(text, content);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
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
|
+
|
|
38
66
|
/**
|
|
39
67
|
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
|
40
68
|
*/
|
|
@@ -860,6 +888,7 @@ export class Agent {
|
|
|
860
888
|
* Delivered after current tool execution, skips remaining tools.
|
|
861
889
|
*/
|
|
862
890
|
steer(m: AgentMessage) {
|
|
891
|
+
assertUserImagePlaceholdersHavePayload([m]);
|
|
863
892
|
this.#steeringQueue.push(m);
|
|
864
893
|
}
|
|
865
894
|
|
|
@@ -872,6 +901,7 @@ export class Agent {
|
|
|
872
901
|
* other integration paths.
|
|
873
902
|
*/
|
|
874
903
|
followUp(m: AgentMessage, options?: { forceOneAtATime?: boolean }) {
|
|
904
|
+
assertUserImagePlaceholdersHavePayload([m]);
|
|
875
905
|
if (options?.forceOneAtATime) {
|
|
876
906
|
this.#followUpForceOneAtATime.add(m);
|
|
877
907
|
}
|
|
@@ -1118,6 +1148,8 @@ export class Agent {
|
|
|
1118
1148
|
promptOptions = imagesOrOptions as AgentPromptOptions | undefined;
|
|
1119
1149
|
}
|
|
1120
1150
|
|
|
1151
|
+
assertUserImagePlaceholdersHavePayload(msgs);
|
|
1152
|
+
|
|
1121
1153
|
await this.#runLoop(msgs, promptOptions);
|
|
1122
1154
|
}
|
|
1123
1155
|
|
|
@@ -1149,6 +1181,10 @@ export class Agent {
|
|
|
1149
1181
|
throw new Error("Cannot continue from message role: assistant");
|
|
1150
1182
|
}
|
|
1151
1183
|
|
|
1184
|
+
if (!canContinuePersistedHistory(messages)) {
|
|
1185
|
+
throw new Error("No messages to continue from");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1152
1188
|
await this.#runLoop(undefined);
|
|
1153
1189
|
}
|
|
1154
1190
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* and after compaction the session is reloaded.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as os from "node:os";
|
|
8
9
|
import {
|
|
9
10
|
type AssistantMessage,
|
|
10
11
|
Effort,
|
|
@@ -237,7 +238,13 @@ export function shouldCompact(
|
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
/** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
|
|
240
|
-
export type CompactionTriggerReason =
|
|
241
|
+
export type CompactionTriggerReason =
|
|
242
|
+
| "token"
|
|
243
|
+
| "heap"
|
|
244
|
+
| "retainedMemory"
|
|
245
|
+
| "providerBytes"
|
|
246
|
+
| "messageCount"
|
|
247
|
+
| "imageBytes";
|
|
241
248
|
|
|
242
249
|
/** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
|
|
243
250
|
export interface EmergencyCompactionSample {
|
|
@@ -249,6 +256,14 @@ export interface EmergencyCompactionSample {
|
|
|
249
256
|
messageCount: number;
|
|
250
257
|
/** Approximate inline image bytes in the provider context. */
|
|
251
258
|
imageBytes: number;
|
|
259
|
+
/** Bytes retained by session resident image sentinels; separate from provider-visible bytes. */
|
|
260
|
+
sessionResidentImageBytes?: number;
|
|
261
|
+
/** Bytes retained by non-provider materialized/session-local caches. */
|
|
262
|
+
materializedResidentBytes?: number;
|
|
263
|
+
/** Number of live TUI chat-container children. */
|
|
264
|
+
tuiChatChildren?: number;
|
|
265
|
+
/** Bytes retained by TUI render caches. */
|
|
266
|
+
tuiCachedRenderBytes?: number;
|
|
252
267
|
}
|
|
253
268
|
|
|
254
269
|
export interface EmergencyCompactionLimits {
|
|
@@ -256,6 +271,40 @@ export interface EmergencyCompactionLimits {
|
|
|
256
271
|
providerBytes: number;
|
|
257
272
|
messageCount: number;
|
|
258
273
|
imageBytes: number;
|
|
274
|
+
retainedMemoryBytes?: number;
|
|
275
|
+
retainedMemoryDiagnosticBytes?: number;
|
|
276
|
+
tuiChatChildren?: number;
|
|
277
|
+
tuiChatChildrenDiagnostic?: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const MAX_EMERGENCY_HEAP_FLOOR_BYTES = 1_536 * 1024 * 1024; // 1.5 GiB resident heap
|
|
281
|
+
const EMERGENCY_RETAINED_MEMORY_BYTES = 128 * 1024 * 1024;
|
|
282
|
+
const DIAGNOSTIC_RETAINED_MEMORY_BYTES = 64 * 1024 * 1024;
|
|
283
|
+
const EMERGENCY_TUI_CHAT_CHILDREN = 1000;
|
|
284
|
+
const DIAGNOSTIC_TUI_CHAT_CHILDREN = 700;
|
|
285
|
+
let retainedMemoryDiagnosticActive = false;
|
|
286
|
+
let tuiChatChildrenDiagnosticActive = false;
|
|
287
|
+
|
|
288
|
+
export function resetEmergencyRetainedMemoryDiagnosticsForTests(): void {
|
|
289
|
+
retainedMemoryDiagnosticActive = false;
|
|
290
|
+
tuiChatChildrenDiagnosticActive = false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.totalmem()): EmergencyCompactionLimits {
|
|
294
|
+
// Invalid or non-positive total memory (bad injection, exotic platform)
|
|
295
|
+
// must never disable the heap floor — fall back to the fixed 1.5 GiB cap.
|
|
296
|
+
const safeTotal =
|
|
297
|
+
Number.isFinite(totalMemoryBytes) && totalMemoryBytes > 0 ? totalMemoryBytes : Number.POSITIVE_INFINITY;
|
|
298
|
+
return {
|
|
299
|
+
heapUsedBytes: Math.min(MAX_EMERGENCY_HEAP_FLOOR_BYTES, Math.floor(0.5 * safeTotal)),
|
|
300
|
+
providerBytes: 24 * 1024 * 1024, // 24 MiB serialized provider context
|
|
301
|
+
messageCount: 4000,
|
|
302
|
+
imageBytes: 64 * 1024 * 1024, // 64 MiB inline image bytes
|
|
303
|
+
retainedMemoryBytes: EMERGENCY_RETAINED_MEMORY_BYTES,
|
|
304
|
+
retainedMemoryDiagnosticBytes: DIAGNOSTIC_RETAINED_MEMORY_BYTES,
|
|
305
|
+
tuiChatChildren: EMERGENCY_TUI_CHAT_CHILDREN,
|
|
306
|
+
tuiChatChildrenDiagnostic: DIAGNOSTIC_TUI_CHAT_CHILDREN,
|
|
307
|
+
};
|
|
259
308
|
}
|
|
260
309
|
|
|
261
310
|
/**
|
|
@@ -263,23 +312,43 @@ export interface EmergencyCompactionLimits {
|
|
|
263
312
|
* long session on weak hardware compacts before OOM even when token-based compaction is
|
|
264
313
|
* disabled or its threshold is set too high. They are NOT user-tunable down to zero.
|
|
265
314
|
*/
|
|
266
|
-
export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits =
|
|
267
|
-
heapUsedBytes: 1_536 * 1024 * 1024, // 1.5 GiB resident heap
|
|
268
|
-
providerBytes: 24 * 1024 * 1024, // 24 MiB serialized provider context
|
|
269
|
-
messageCount: 4000,
|
|
270
|
-
imageBytes: 64 * 1024 * 1024, // 64 MiB inline image bytes
|
|
271
|
-
};
|
|
315
|
+
export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits = resolveEmergencyCompactionLimits();
|
|
272
316
|
|
|
273
317
|
/**
|
|
274
|
-
* Returns the first emergency limit exceeded (heap > providerBytes > imageBytes > messageCount),
|
|
275
|
-
* or null when none is. Pure
|
|
318
|
+
* Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
|
|
319
|
+
* or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
|
|
276
320
|
* normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
|
|
277
321
|
*/
|
|
278
322
|
export function emergencyCompactionReason(
|
|
279
323
|
sample: EmergencyCompactionSample,
|
|
280
|
-
limits: EmergencyCompactionLimits =
|
|
324
|
+
limits: EmergencyCompactionLimits = resolveEmergencyCompactionLimits(),
|
|
281
325
|
): CompactionTriggerReason | null {
|
|
326
|
+
const retainedMemoryBytes = (sample.materializedResidentBytes ?? 0) + (sample.tuiCachedRenderBytes ?? 0);
|
|
327
|
+
const tuiChatChildren = sample.tuiChatChildren ?? 0;
|
|
328
|
+
const retainedDiagnostic =
|
|
329
|
+
retainedMemoryBytes >= (limits.retainedMemoryDiagnosticBytes ?? DIAGNOSTIC_RETAINED_MEMORY_BYTES);
|
|
330
|
+
const childDiagnostic = tuiChatChildren >= (limits.tuiChatChildrenDiagnostic ?? DIAGNOSTIC_TUI_CHAT_CHILDREN);
|
|
331
|
+
if (retainedDiagnostic && !retainedMemoryDiagnosticActive) {
|
|
332
|
+
logger.warn("Emergency compaction retained-memory diagnostic threshold crossed", {
|
|
333
|
+
retainedMemoryBytes,
|
|
334
|
+
limitBytes: limits.retainedMemoryDiagnosticBytes ?? DIAGNOSTIC_RETAINED_MEMORY_BYTES,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
if (childDiagnostic && !tuiChatChildrenDiagnosticActive) {
|
|
338
|
+
logger.warn("Emergency compaction TUI chat-child diagnostic threshold crossed", {
|
|
339
|
+
tuiChatChildren,
|
|
340
|
+
limit: limits.tuiChatChildrenDiagnostic ?? DIAGNOSTIC_TUI_CHAT_CHILDREN,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
retainedMemoryDiagnosticActive = retainedDiagnostic;
|
|
344
|
+
tuiChatChildrenDiagnosticActive = childDiagnostic;
|
|
345
|
+
|
|
282
346
|
if (sample.heapUsedBytes > limits.heapUsedBytes) return "heap";
|
|
347
|
+
if (
|
|
348
|
+
retainedMemoryBytes >= (limits.retainedMemoryBytes ?? EMERGENCY_RETAINED_MEMORY_BYTES) ||
|
|
349
|
+
tuiChatChildren >= (limits.tuiChatChildren ?? EMERGENCY_TUI_CHAT_CHILDREN)
|
|
350
|
+
)
|
|
351
|
+
return "retainedMemory";
|
|
283
352
|
if (sample.providerBytes > limits.providerBytes) return "providerBytes";
|
|
284
353
|
if (sample.imageBytes > limits.imageBytes) return "imageBytes";
|
|
285
354
|
if (sample.messageCount > limits.messageCount) return "messageCount";
|
|
@@ -336,7 +405,61 @@ function countCollectedMessageFragments(collected: { fragments: string[]; extra:
|
|
|
336
405
|
const HEURISTIC_BYTES_PER_TOKEN = 4;
|
|
337
406
|
|
|
338
407
|
/**
|
|
339
|
-
*
|
|
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
|
|
340
463
|
* token estimator: provider usage (see {@link calculatePromptTokens}) anchors
|
|
341
464
|
* the already-sent context, and this covers unsent/trailing deltas, per-entry
|
|
342
465
|
* budgeting, and display surfaces. Callers add a conservative inflation factor
|
|
@@ -344,24 +467,23 @@ const HEURISTIC_BYTES_PER_TOKEN = 4;
|
|
|
344
467
|
*/
|
|
345
468
|
export function estimateMessageTokensHeuristic(message: AgentMessage): number {
|
|
346
469
|
const { fragments, extra } = collectMessageFragments(message);
|
|
347
|
-
|
|
348
|
-
for (const fragment of fragments) {
|
|
349
|
-
bytes += fragment.length;
|
|
350
|
-
}
|
|
351
|
-
return extra + Math.ceil(bytes / HEURISTIC_BYTES_PER_TOKEN);
|
|
470
|
+
return extra + estimateTextTokensHeuristic(fragments);
|
|
352
471
|
}
|
|
353
472
|
|
|
354
473
|
/**
|
|
355
|
-
*
|
|
356
|
-
* counterpart of {@link estimateMessageTokensHeuristic}.
|
|
474
|
+
* Script-aware native-free token estimate for plain string fragments.
|
|
475
|
+
* Fragment-level counterpart of {@link estimateMessageTokensHeuristic}.
|
|
357
476
|
*/
|
|
358
477
|
export function estimateTextTokensHeuristic(fragments: string | readonly string[]): number {
|
|
359
|
-
|
|
360
|
-
let
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
485
|
}
|
|
364
|
-
return Math.ceil(
|
|
486
|
+
return Math.ceil(dense + Math.max(0, otherChars) / HEURISTIC_BYTES_PER_TOKEN);
|
|
365
487
|
}
|
|
366
488
|
|
|
367
489
|
/** Shared content walk for both the native and heuristic estimators. */
|
|
@@ -691,8 +813,8 @@ export interface SummaryOptions {
|
|
|
691
813
|
* on the very overflow the recovery was meant to absorb.
|
|
692
814
|
*
|
|
693
815
|
* The budget reserves the summary's own output tokens plus prompt/system/template
|
|
694
|
-
* overhead, and applies a conservative safety factor
|
|
695
|
-
*
|
|
816
|
+
* overhead, and applies a conservative safety factor for estimator error on
|
|
817
|
+
* dense text (the reason the original overflow was missed).
|
|
696
818
|
* Truncation keeps the head (origin/goals) and the tail (most recent state) and
|
|
697
819
|
* elides the middle; it is a last resort that only triggers when the input would
|
|
698
820
|
* otherwise not fit.
|
|
@@ -710,16 +832,43 @@ export function boundConversationTextForSummary(
|
|
|
710
832
|
const inputBudgetTokens = Math.floor(
|
|
711
833
|
(contextWindow - Math.max(0, outputMaxTokens) - OVERHEAD_TOKENS) * SAFETY_FACTOR,
|
|
712
834
|
);
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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 : "";
|
|
723
872
|
}
|
|
724
873
|
|
|
725
874
|
export async function generateSummary(
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "@sayknow-cli/ai";
|
|
2
|
+
|
|
3
|
+
export const IMAGE_PLACEHOLDER_ATTACHMENT_GUIDANCE =
|
|
4
|
+
"Image placeholder text was submitted without an image payload. Paste the image with #paste-image, attach it with @path/to/image.png, or save the image and provide the saved file path.";
|
|
5
|
+
|
|
6
|
+
const IMAGE_PLACEHOLDER_ONLY_PATTERN = /^\s*(?:\[image\s+\d+\]\s*)+$/i;
|
|
7
|
+
|
|
8
|
+
export function isImagePlaceholderOnlyText(text: string): boolean {
|
|
9
|
+
return IMAGE_PLACEHOLDER_ONLY_PATTERN.test(text);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function assertImagePlaceholdersHavePayload(
|
|
13
|
+
text: string,
|
|
14
|
+
content: readonly (TextContent | ImageContent)[] | undefined,
|
|
15
|
+
): void {
|
|
16
|
+
if (!isImagePlaceholderOnlyText(text)) return;
|
|
17
|
+
const hasImagePayload = content?.some(part => part.type === "image") ?? false;
|
|
18
|
+
if (hasImagePayload) return;
|
|
19
|
+
throw new Error(IMAGE_PLACEHOLDER_ATTACHMENT_GUIDANCE);
|
|
20
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent loop that works with AgentMessage throughout.
|
|
3
|
-
* Transforms to Message[] only at the LLM call boundary.
|
|
4
|
-
*/
|
|
5
|
-
import { type Context, EventStream } from "@sayknow-cli/ai";
|
|
6
|
-
import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
|
|
7
|
-
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
|
|
8
|
-
/**
|
|
9
|
-
* Start an agent loop with a new prompt message.
|
|
10
|
-
* The prompt is added to the context and events are emitted for it.
|
|
11
|
-
*/
|
|
12
|
-
export declare function agentLoop(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
|
|
13
|
-
/**
|
|
14
|
-
* Continue an agent loop from the current context without adding a new message.
|
|
15
|
-
* Used for retries - context already has user message or tool results.
|
|
16
|
-
*
|
|
17
|
-
* **Important:** The last message in context must convert to a `user` or `toolResult` message
|
|
18
|
-
* via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
|
|
19
|
-
* This cannot be validated here since `convertToLlm` is only called once per turn.
|
|
20
|
-
*/
|
|
21
|
-
export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
|
|
22
|
-
/**
|
|
23
|
-
* Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
|
|
24
|
-
* run-level telemetry/coverage rollup to the existing `AgentMessage[]`
|
|
25
|
-
* payload without changing the resolved type of `stream.result()`.
|
|
26
|
-
*/
|
|
27
|
-
export interface AgentLoopDetailedResult {
|
|
28
|
-
readonly messages: AgentMessage[];
|
|
29
|
-
readonly telemetry: AgentRunSummary | undefined;
|
|
30
|
-
readonly coverage: AgentRunCoverage | undefined;
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Convenience wrapper over {@link agentLoop} that exposes the run-level
|
|
34
|
-
* summary + coverage alongside the messages. The returned `stream` is the
|
|
35
|
-
* same `EventStream` callers already consume; `detailed()` awaits the
|
|
36
|
-
* stream's `agent_end` event and returns the additive fields.
|
|
37
|
-
*
|
|
38
|
-
* Existing `stream.result()` semantics are preserved — it still resolves to
|
|
39
|
-
* `AgentMessage[]`. Use {@link agentLoopDetailed} when you need the rollup;
|
|
40
|
-
* use {@link agentLoop} when you do not.
|
|
41
|
-
*/
|
|
42
|
-
export declare function agentLoopDetailed(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
|
|
43
|
-
readonly stream: EventStream<AgentEvent, AgentMessage[]>;
|
|
44
|
-
readonly detailed: () => Promise<AgentLoopDetailedResult>;
|
|
45
|
-
};
|
|
46
|
-
/**
|
|
47
|
-
* Like {@link agentLoopDetailed} but built on top of
|
|
48
|
-
* {@link agentLoopContinue}.
|
|
49
|
-
*/
|
|
50
|
-
export declare function agentLoopContinueDetailed(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
|
|
51
|
-
readonly stream: EventStream<AgentEvent, AgentMessage[]>;
|
|
52
|
-
readonly detailed: () => Promise<AgentLoopDetailedResult>;
|
|
53
|
-
};
|
|
54
|
-
export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
|
|
55
|
-
export declare const INTENT_FIELD = "_i";
|
|
56
|
-
export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"];
|