@sayknow-cli/agent-core 0.5.1 → 0.5.6

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 CHANGED
@@ -2,7 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.12.0] - 2026-07-28
5
+ ## [0.5.6] - 2026-08-28
6
+
7
+ ## [0.5.3] - 2026-08-28
8
+
9
+ ### Fixed
10
+
11
+ - Compaction now serializes malformed persisted tool calls with null or missing arguments instead of throwing inside the recovery path, and long managed sessions trigger an emergency rewrite before their append-only transcript reaches the storage file-size ceiling.
6
12
 
7
13
  ## [0.11.11] - 2026-07-26
8
14
 
@@ -2,9 +2,9 @@
2
2
  * Agent loop that works with AgentMessage throughout.
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
- import { type Context, EventStream } from "@sayknow-cli/ai";
5
+ import { type AssistantMessage, type Context, EventStream, type TSchema } from "@sayknow-cli/ai";
6
6
  import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
7
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
7
+ import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool, StreamFn } from "./types";
8
8
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
9
9
  /**
10
10
  * Defensive caps for a provisional managed attempt. These are intentionally
@@ -13,6 +13,15 @@ import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn
13
13
  */
14
14
  export declare const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10000;
15
15
  export declare const MANAGED_ATTEMPT_MAX_STAGED_BYTES: number;
16
+ /**
17
+ * Validate provider-observed escaped arguments before permitting the only
18
+ * exception to the fail-closed tool boundary. The raw JSON must decode to the
19
+ * exact parsed arguments, every decoded non-ASCII scalar must be under a
20
+ * tool-declared display path, and the wire must not contain ASCII escapes.
21
+ */
22
+ export declare function displaySafeEscapedArguments(tool: AgentTool<TSchema> | undefined, toolCall: Extract<AssistantMessage["content"][number], {
23
+ type: "toolCall";
24
+ }>): boolean;
16
25
  /**
17
26
  * Start an agent loop with a new prompt message.
18
27
  * The prompt is added to the context and events are emitted for it.
@@ -80,7 +80,7 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
80
80
  */
81
81
  export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings, maxOutputTokens?: number): boolean;
82
82
  /** Reason a compaction was triggered. `token` is the normal user-configurable path; the rest are emergency floors. */
83
- export type CompactionTriggerReason = "token" | "heap" | "retainedMemory" | "providerBytes" | "messageCount" | "imageBytes";
83
+ export type CompactionTriggerReason = "token" | "heap" | "retainedMemory" | "transcriptFile" | "providerBytes" | "messageCount" | "imageBytes";
84
84
  /** A point-in-time resource sample. Supplied by an injectable sampler so tests never read real RSS. */
85
85
  export interface EmergencyCompactionSample {
86
86
  /** Resident heap bytes (e.g. process.memoryUsage().heapUsed). */
@@ -99,6 +99,8 @@ export interface EmergencyCompactionSample {
99
99
  tuiChatChildren?: number;
100
100
  /** Bytes retained by TUI render caches. */
101
101
  tuiCachedRenderBytes?: number;
102
+ /** On-disk JSONL transcript file size in bytes; 0/undefined when unknown. */
103
+ transcriptFileBytes?: number;
102
104
  }
103
105
  export interface EmergencyCompactionLimits {
104
106
  heapUsedBytes: number;
@@ -109,6 +111,7 @@ export interface EmergencyCompactionLimits {
109
111
  retainedMemoryDiagnosticBytes?: number;
110
112
  tuiChatChildren?: number;
111
113
  tuiChatChildrenDiagnostic?: number;
114
+ transcriptFileBytes?: number;
112
115
  }
113
116
  export declare function resetEmergencyRetainedMemoryDiagnosticsForTests(): void;
114
117
  export declare function resolveEmergencyCompactionLimits(totalMemoryBytes?: number): EmergencyCompactionLimits;
@@ -119,7 +122,7 @@ export declare function resolveEmergencyCompactionLimits(totalMemoryBytes?: numb
119
122
  */
120
123
  export declare const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits;
121
124
  /**
122
- * Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
125
+ * Returns the first emergency limit exceeded (heap > retainedMemory > transcriptFile > providerBytes > imageBytes > messageCount),
123
126
  * or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
124
127
  * normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
125
128
  */
@@ -461,6 +461,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
461
461
  * - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
462
462
  */
463
463
  intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
464
+ /**
465
+ * Argument fields (dotted paths into the arguments object) that render as
466
+ * pure display text. A corroborated `\uXXXX`-escaped non-ASCII payload may
467
+ * execute with a warning only when every decoded non-ASCII value is under
468
+ * one of these paths. IDs, metadata, and all undeclared fields fail closed.
469
+ */
470
+ displaySafeEscapedArgFields?: readonly string[];
464
471
  /** The main execution callback for this tool. */
465
472
  execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
466
473
  /** Optional custom rendering for tool call display (returns UI component) */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/agent-core",
4
- "version": "0.5.1",
4
+ "version": "0.5.6",
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.5.1",
39
- "@sayknow-cli/natives": "0.5.1",
40
- "@sayknow-cli/utils": "0.5.1",
38
+ "@sayknow-cli/ai": "0.5.6",
39
+ "@sayknow-cli/natives": "0.5.6",
40
+ "@sayknow-cli/utils": "0.5.6",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  zodToWireSchema,
22
22
  } from "@sayknow-cli/ai";
23
23
  import { isInvalidPromptError, neutralizeReservedControlTokens } from "@sayknow-cli/ai/utils";
24
- import { sanitizeText } from "@sayknow-cli/utils";
24
+ import { logger, sanitizeText } from "@sayknow-cli/utils";
25
25
  import {
26
26
  createHarmonyAuditEvent,
27
27
  detectHarmonyLeakInAssistantMessage,
@@ -113,6 +113,62 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
113
113
  * bounded too.
114
114
  */
115
115
  const MAX_CONSECUTIVE_MALFORMED_TURNS = 5;
116
+ /**
117
+ * Validate provider-observed escaped arguments before permitting the only
118
+ * exception to the fail-closed tool boundary. The raw JSON must decode to the
119
+ * exact parsed arguments, every decoded non-ASCII scalar must be under a
120
+ * tool-declared display path, and the wire must not contain ASCII escapes.
121
+ */
122
+ export function displaySafeEscapedArguments(
123
+ tool: AgentTool<TSchema> | undefined,
124
+ toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
125
+ ): boolean {
126
+ const fields = tool?.displaySafeEscapedArgFields;
127
+ const raw = toolCall.escapedNonAsciiArgumentsRaw;
128
+ if (!fields?.length || !raw) return false;
129
+
130
+ let decoded: unknown;
131
+ try {
132
+ decoded = JSON.parse(raw);
133
+ } catch {
134
+ return false;
135
+ }
136
+ if (JSON.stringify(decoded) !== JSON.stringify(toolCall.arguments)) return false;
137
+
138
+ const displayPaths = fields.map(field => field.split("."));
139
+ const path: string[] = [];
140
+ const isDisplayPath = () =>
141
+ displayPaths.some(
142
+ segments => segments.length === path.length && segments.every((segment, index) => path[index] === segment),
143
+ );
144
+ const walk = (value: unknown): boolean => {
145
+ if (typeof value === "string") {
146
+ return [...value].every(character => character.codePointAt(0)! < 0x80 || isDisplayPath());
147
+ }
148
+ if (Array.isArray(value)) return value.every(walk);
149
+ if (value && typeof value === "object") {
150
+ return Object.entries(value).every(([key, child]) => {
151
+ if (!/^[\x00-\x7f]*$/.test(key)) return false;
152
+ path.push(key);
153
+ const valid = walk(child);
154
+ path.pop();
155
+ return valid;
156
+ });
157
+ }
158
+ return true;
159
+ };
160
+ if (!walk(toolCall.arguments)) return false;
161
+
162
+ const escapes = [...raw.matchAll(/\\u([0-9a-fA-F]{4})/g)].map(match => Number.parseInt(match[1]!, 16));
163
+ return escapes.length > 0 && escapes.every(codeUnit => codeUnit >= 0x80);
164
+ }
165
+
166
+ function clearEscapedArgumentMetadata(
167
+ toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
168
+ ): void {
169
+ delete toolCall.escapedNonAsciiArguments;
170
+ delete toolCall.escapedNonAsciiArgumentsRaw;
171
+ }
116
172
  function managedContextOverflow(message: AssistantMessage, config: AgentLoopConfig): boolean {
117
173
  const transportFailure = managedTransportFailure(message);
118
174
  // Managed empty-stop responses may be repaired by the managed shell below; only
@@ -1541,6 +1597,7 @@ async function runLoopBody(
1541
1597
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
1542
1598
  const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
1543
1599
  const toolResults: ToolResultMessage[] = [];
1600
+ for (const toolCall of toolCalls) clearEscapedArgumentMetadata(toolCall);
1544
1601
  for (const toolCall of toolCalls) {
1545
1602
  const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
1546
1603
  currentContext.messages.push(result);
@@ -1571,6 +1628,7 @@ async function runLoopBody(
1571
1628
  let repeatedMalformedToolCall = false;
1572
1629
  if (hasMoreToolCalls) {
1573
1630
  if (wasRecoveryAttempt) {
1631
+ for (const toolCall of toolCalls) clearEscapedArgumentMetadata(toolCall);
1574
1632
  for (const toolCall of toolCalls) {
1575
1633
  const result = createAbortedToolResult(
1576
1634
  toolCall,
@@ -2195,6 +2253,19 @@ async function executeToolCalls(
2195
2253
 
2196
2254
  await runInActiveSpan(toolSpan, async () => {
2197
2255
  try {
2256
+ if (toolCall.escapedNonAsciiArguments) {
2257
+ const displaySafe = displaySafeEscapedArguments(tool, toolCall);
2258
+ clearEscapedArgumentMetadata(toolCall);
2259
+ if (!displaySafe) {
2260
+ record.argumentValidationFailed = true;
2261
+ throw new Error(
2262
+ "Tool call arguments contained unverified \\uXXXX-escaped non-ASCII text and were rejected.",
2263
+ );
2264
+ }
2265
+ logger.warn("agent: executing a tool-call whose display-safe arguments were \\uXXXX-escaped", {
2266
+ mode: config.fallbackManaged ? "managed" : "in_loop",
2267
+ });
2268
+ }
2198
2269
  if (toolCall.incompleteArguments) {
2199
2270
  record.argumentValidationFailed = true;
2200
2271
  // The provider flagged this call's argument JSON as truncated
@@ -253,6 +253,7 @@ export type CompactionTriggerReason =
253
253
  | "token"
254
254
  | "heap"
255
255
  | "retainedMemory"
256
+ | "transcriptFile"
256
257
  | "providerBytes"
257
258
  | "messageCount"
258
259
  | "imageBytes";
@@ -275,6 +276,8 @@ export interface EmergencyCompactionSample {
275
276
  tuiChatChildren?: number;
276
277
  /** Bytes retained by TUI render caches. */
277
278
  tuiCachedRenderBytes?: number;
279
+ /** On-disk JSONL transcript file size in bytes; 0/undefined when unknown. */
280
+ transcriptFileBytes?: number;
278
281
  }
279
282
 
280
283
  export interface EmergencyCompactionLimits {
@@ -286,6 +289,7 @@ export interface EmergencyCompactionLimits {
286
289
  retainedMemoryDiagnosticBytes?: number;
287
290
  tuiChatChildren?: number;
288
291
  tuiChatChildrenDiagnostic?: number;
292
+ transcriptFileBytes?: number;
289
293
  }
290
294
 
291
295
  const MAX_EMERGENCY_HEAP_FLOOR_BYTES = 1_536 * 1024 * 1024; // 1.5 GiB resident heap
@@ -293,6 +297,7 @@ const EMERGENCY_RETAINED_MEMORY_BYTES = 128 * 1024 * 1024;
293
297
  const DIAGNOSTIC_RETAINED_MEMORY_BYTES = 64 * 1024 * 1024;
294
298
  const EMERGENCY_TUI_CHAT_CHILDREN = 1000;
295
299
  const DIAGNOSTIC_TUI_CHAT_CHILDREN = 700;
300
+ const EMERGENCY_TRANSCRIPT_FILE_BYTES = 48 * 1024 * 1024; // 48 MiB (75% of the 64 MiB managed cap)
296
301
  let retainedMemoryDiagnosticActive = false;
297
302
  let tuiChatChildrenDiagnosticActive = false;
298
303
 
@@ -315,6 +320,7 @@ export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.t
315
320
  retainedMemoryDiagnosticBytes: DIAGNOSTIC_RETAINED_MEMORY_BYTES,
316
321
  tuiChatChildren: EMERGENCY_TUI_CHAT_CHILDREN,
317
322
  tuiChatChildrenDiagnostic: DIAGNOSTIC_TUI_CHAT_CHILDREN,
323
+ transcriptFileBytes: EMERGENCY_TRANSCRIPT_FILE_BYTES,
318
324
  };
319
325
  }
320
326
 
@@ -326,7 +332,7 @@ export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.t
326
332
  export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits = resolveEmergencyCompactionLimits();
327
333
 
328
334
  /**
329
- * Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
335
+ * Returns the first emergency limit exceeded (heap > retainedMemory > transcriptFile > providerBytes > imageBytes > messageCount),
330
336
  * or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
331
337
  * normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
332
338
  */
@@ -360,6 +366,11 @@ export function emergencyCompactionReason(
360
366
  tuiChatChildren >= (limits.tuiChatChildren ?? EMERGENCY_TUI_CHAT_CHILDREN)
361
367
  )
362
368
  return "retainedMemory";
369
+ if (
370
+ sample.transcriptFileBytes &&
371
+ sample.transcriptFileBytes > (limits.transcriptFileBytes ?? EMERGENCY_TRANSCRIPT_FILE_BYTES)
372
+ )
373
+ return "transcriptFile";
363
374
  if (sample.providerBytes > limits.providerBytes) return "providerBytes";
364
375
  if (sample.imageBytes > limits.imageBytes) return "imageBytes";
365
376
  if (sample.messageCount > limits.messageCount) return "messageCount";
@@ -147,8 +147,12 @@ export function serializeConversation(messages: Message[]): string {
147
147
  } else if (block.type === "thinking") {
148
148
  thinkingParts.push(block.thinking);
149
149
  } else if (block.type === "toolCall") {
150
- const args = block.arguments as Record<string, unknown>;
151
- const argsStr = Object.entries(args)
150
+ // `arguments` is typed non-null, but persisted history can carry a
151
+ // null/non-object payload from an aborted or malformed tool call.
152
+ // Summarization must never throw here: this runs inside compaction,
153
+ // which is itself the recovery path for context overflow.
154
+ const args = block.arguments as Record<string, unknown> | null | undefined;
155
+ const argsStr = Object.entries(args && typeof args === "object" && !Array.isArray(args) ? args : {})
152
156
  .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
153
157
  .join(", ");
154
158
  toolCalls.push(`${block.name}(${argsStr})`);
package/src/types.ts CHANGED
@@ -539,6 +539,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
539
539
  * - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
540
540
  */
541
541
  intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
542
+ /**
543
+ * Argument fields (dotted paths into the arguments object) that render as
544
+ * pure display text. A corroborated `\uXXXX`-escaped non-ASCII payload may
545
+ * execute with a warning only when every decoded non-ASCII value is under
546
+ * one of these paths. IDs, metadata, and all undeclared fields fail closed.
547
+ */
548
+ displaySafeEscapedArgFields?: readonly string[];
542
549
 
543
550
  /** The main execution callback for this tool. */
544
551
  execute: AgentToolExecFn<TParameters, TDetails, TTheme>;