@sayknow-cli/agent-core 0.3.10 → 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.
@@ -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 and sampler-injected; the caller routes the result through the
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;
@@ -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.10",
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.10",
39
- "@sayknow-cli/natives": "0.3.10",
40
- "@sayknow-cli/utils": "0.3.10",
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 = "token" | "heap" | "providerBytes" | "messageCount" | "imageBytes";
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 and sampler-injected; the caller routes the result through the
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 = DEFAULT_EMERGENCY_COMPACTION_LIMITS,
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
@@ -7,6 +7,7 @@ export * from "./append-only-context";
7
7
  // Compaction
8
8
  export * from "./compaction";
9
9
  export * from "./harmony-leak";
10
+ export * from "./image-placeholder-guard";
10
11
  // Proxy utilities
11
12
  export * from "./proxy";
12
13
  // Run-level telemetry collector + aggregators