@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 +7 -1
- package/dist/types/agent-loop.d.ts +11 -2
- package/dist/types/compaction/compaction.d.ts +5 -2
- package/dist/types/types.d.ts +7 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +72 -1
- package/src/compaction/compaction.ts +12 -1
- package/src/compaction/utils.ts +6 -2
- package/src/types.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
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
|
*/
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
39
|
-
"@sayknow-cli/natives": "0.5.
|
|
40
|
-
"@sayknow-cli/utils": "0.5.
|
|
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";
|
package/src/compaction/utils.ts
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
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>;
|