@sayknow-cli/agent-core 0.3.11 → 0.3.12
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/dist/types/compaction/compaction.d.ts +17 -3
- package/dist/types/image-placeholder-guard.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +4 -4
- package/src/agent.ts +22 -0
- package/src/compaction/compaction.ts +79 -10
- package/src/image-placeholder-guard.ts +20 -0
- package/src/index.ts +1 -0
|
@@ -67,7 +67,7 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
|
|
|
67
67
|
*/
|
|
68
68
|
export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): boolean;
|
|
69
69
|
/** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
|
|
70
|
-
export type CompactionTriggerReason = "token" | "heap" | "providerBytes" | "messageCount" | "imageBytes";
|
|
70
|
+
export type CompactionTriggerReason = "token" | "heap" | "retainedMemory" | "providerBytes" | "messageCount" | "imageBytes";
|
|
71
71
|
/** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
|
|
72
72
|
export interface EmergencyCompactionSample {
|
|
73
73
|
/** Resident heap bytes (e.g. process.memoryUsage().heapUsed). */
|
|
@@ -78,13 +78,27 @@ export interface EmergencyCompactionSample {
|
|
|
78
78
|
messageCount: number;
|
|
79
79
|
/** Approximate inline image bytes in the provider context. */
|
|
80
80
|
imageBytes: number;
|
|
81
|
+
/** Bytes retained by session resident image sentinels; separate from provider-visible bytes. */
|
|
82
|
+
sessionResidentImageBytes?: number;
|
|
83
|
+
/** Bytes retained by non-provider materialized/session-local caches. */
|
|
84
|
+
materializedResidentBytes?: number;
|
|
85
|
+
/** Number of live TUI chat-container children. */
|
|
86
|
+
tuiChatChildren?: number;
|
|
87
|
+
/** Bytes retained by TUI render caches. */
|
|
88
|
+
tuiCachedRenderBytes?: number;
|
|
81
89
|
}
|
|
82
90
|
export interface EmergencyCompactionLimits {
|
|
83
91
|
heapUsedBytes: number;
|
|
84
92
|
providerBytes: number;
|
|
85
93
|
messageCount: number;
|
|
86
94
|
imageBytes: number;
|
|
95
|
+
retainedMemoryBytes?: number;
|
|
96
|
+
retainedMemoryDiagnosticBytes?: number;
|
|
97
|
+
tuiChatChildren?: number;
|
|
98
|
+
tuiChatChildrenDiagnostic?: number;
|
|
87
99
|
}
|
|
100
|
+
export declare function resetEmergencyRetainedMemoryDiagnosticsForTests(): void;
|
|
101
|
+
export declare function resolveEmergencyCompactionLimits(totalMemoryBytes?: number): EmergencyCompactionLimits;
|
|
88
102
|
/**
|
|
89
103
|
* Non-disableable emergency floors. These sit well above normal usage and exist so a
|
|
90
104
|
* long session on weak hardware compacts before OOM even when token-based compaction is
|
|
@@ -92,8 +106,8 @@ export interface EmergencyCompactionLimits {
|
|
|
92
106
|
*/
|
|
93
107
|
export declare const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits;
|
|
94
108
|
/**
|
|
95
|
-
* Returns the first emergency limit exceeded (heap > providerBytes > imageBytes > messageCount),
|
|
96
|
-
* or null when none is. Pure
|
|
109
|
+
* Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
|
|
110
|
+
* or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
|
|
97
111
|
* normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
|
|
98
112
|
*/
|
|
99
113
|
export declare function emergencyCompactionReason(sample: EmergencyCompactionSample, limits?: EmergencyCompactionLimits): CompactionTriggerReason | null;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ImageContent, TextContent } from "@sayknow-cli/ai";
|
|
2
|
+
export declare const IMAGE_PLACEHOLDER_ATTACHMENT_GUIDANCE = "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.";
|
|
3
|
+
export declare function isImagePlaceholderOnlyText(text: string): boolean;
|
|
4
|
+
export declare function assertImagePlaceholdersHavePayload(text: string, content: readonly (TextContent | ImageContent)[] | undefined): void;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./agent-loop";
|
|
|
3
3
|
export * from "./append-only-context";
|
|
4
4
|
export * from "./compaction";
|
|
5
5
|
export * from "./harmony-leak";
|
|
6
|
+
export * from "./image-placeholder-guard";
|
|
6
7
|
export * from "./proxy";
|
|
7
8
|
export * from "./run-collector";
|
|
8
9
|
export * from "./telemetry";
|
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.12",
|
|
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.12",
|
|
39
|
+
"@sayknow-cli/natives": "0.3.12",
|
|
40
|
+
"@sayknow-cli/utils": "0.3.12",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
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,23 @@ 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
|
+
|
|
38
56
|
/**
|
|
39
57
|
* Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
|
|
40
58
|
*/
|
|
@@ -860,6 +878,7 @@ export class Agent {
|
|
|
860
878
|
* Delivered after current tool execution, skips remaining tools.
|
|
861
879
|
*/
|
|
862
880
|
steer(m: AgentMessage) {
|
|
881
|
+
assertUserImagePlaceholdersHavePayload([m]);
|
|
863
882
|
this.#steeringQueue.push(m);
|
|
864
883
|
}
|
|
865
884
|
|
|
@@ -872,6 +891,7 @@ export class Agent {
|
|
|
872
891
|
* other integration paths.
|
|
873
892
|
*/
|
|
874
893
|
followUp(m: AgentMessage, options?: { forceOneAtATime?: boolean }) {
|
|
894
|
+
assertUserImagePlaceholdersHavePayload([m]);
|
|
875
895
|
if (options?.forceOneAtATime) {
|
|
876
896
|
this.#followUpForceOneAtATime.add(m);
|
|
877
897
|
}
|
|
@@ -1118,6 +1138,8 @@ export class Agent {
|
|
|
1118
1138
|
promptOptions = imagesOrOptions as AgentPromptOptions | undefined;
|
|
1119
1139
|
}
|
|
1120
1140
|
|
|
1141
|
+
assertUserImagePlaceholdersHavePayload(msgs);
|
|
1142
|
+
|
|
1121
1143
|
await this.#runLoop(msgs, promptOptions);
|
|
1122
1144
|
}
|
|
1123
1145
|
|
|
@@ -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";
|
|
@@ -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