@gajae-code/agent-core 0.14.0 → 0.14.1

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,10 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.14.1] - 2026-08-18
6
+ - Compaction pruning no longer kills the turn when a persisted `toolCall.arguments` is `null`. Sessions written by an earlier cold-spill eviction path store `null` where the spill sentinel belongs, and the staleness index dereferenced that payload unguarded, so reloading such a session threw `null is not an object (evaluating 'args.path')` as a turn-fatal error instead of skipping the one unusable call. `ToolCall.arguments` is typed non-nullable, so no type check flagged the gap. Every read of a persisted argument bag — path extraction, `apply_patch` header parsing, idempotent-bash keys, and search target keys — now treats a non-object payload as absent. The original arguments are not lost: the eviction marker still names the blob and rehydration restores them.
7
+
8
+ ### Added
9
+
10
+ - `toolFailureEnvelope` / `isToolFailureEnvelope` / `ToolFailureEnvelope` name the result details the loop attaches when a tool call fails without the tool returning details of its own. The guard matches only that envelope, so a consumer can tell it apart from a tool that reports a `failureKind` alongside its own details before dereferencing a tool-owned detail shape.
11
+
5
12
  ## [0.14.0] - 2026-08-17
6
13
 
7
14
  ### Fixed
8
15
 
16
+ - Managed fallback no longer kills a long turn with `Managed fallback attempt exceeded the provisional event buffer limit`. Every staged streaming frame carries the whole accumulated partial (once as `message`, once as `assistantMessageEvent.partial`), so staged bytes grew quadratically with the response length and a reasoning-heavy turn of a few thousand tokens crossed the 16 MiB cap even though no single event came close to it. Reaching the cap now first reclaims the staged `*_delta` increments, whose complete value is re-published by the retained `*_end` and terminal `message_end`/`done` frames, and only a batch that still cannot fit fails. Attempt atomicity is unchanged: nothing is published early, so a discarded attempt stays unobservable, and a single oversized event keeps its pre-clone rejection with no provider-fallback authority.
9
17
  - Non-managed lossless response staging now commits its buffered lifecycle and switches to ordinary pass-through publication when the provisional event cap is reached, instead of turning a large reasoning-only response into a fatal `local_snapshot_failure`. Managed fallback attempts keep the strict bounded-buffer rejection required for atomic retry and provider-fallback isolation.
10
18
  - Managed snapshot machinery no longer fails runs on benign payload-class or readable-proxy roots: an assistant message or stream event whose fields live on prototype getters (which `structuredClone` drops — it copies only own enumerable properties) or behind a proxy whose gets are readable is repaired through the existing guarded-read path instead of throwing a deterministic `shell.role`/`event.unknownType`/`event.snapshot` local snapshot failure. The run-loop message_update replay also builds its event through the managed event snapshot instead of a naive `{ ...event }` spread, which silently dropped prototype-carried fields before the snapshot boundary could see them. Hostile shapes (throwing get traps, sentinel-marked degraded content, malformed non-string event types) keep their named fail-fast diagnostics with no retry authority.
11
19
 
@@ -5,7 +5,7 @@
5
5
  import { type AssistantMessage, type AssistantMessageEvent, type Context, EventStream } from "@gajae-code/ai";
6
6
  import type { AttemptScope } from "./attempt-scope";
7
7
  import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
8
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
8
+ import { type AgentContext, type AgentEvent, type AgentLoopConfig, type AgentMessage, type StreamFn } from "./types";
9
9
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
10
10
  /**
11
11
  * Defensive caps for a provisional managed attempt. These are intentionally
@@ -693,3 +693,25 @@ export type AgentEvent = {
693
693
  isError?: boolean;
694
694
  scope?: AttemptScope;
695
695
  };
696
+ /**
697
+ * Why a tool call failed when the loop — not the tool — produced the result.
698
+ *
699
+ * `argument_validation` means the call never dispatched: the arguments were
700
+ * rejected before `execute` ran. `execution` means `execute` threw.
701
+ */
702
+ export type ToolFailureKind = "argument_validation" | "execution";
703
+ /**
704
+ * The result details the loop attaches when a tool call fails without the tool
705
+ * returning its own details. It carries no tool-owned field, so a consumer that
706
+ * dereferences a tool's own detail shape must recognise it first.
707
+ */
708
+ export interface ToolFailureEnvelope {
709
+ failureKind: ToolFailureKind;
710
+ }
711
+ export declare function toolFailureEnvelope(kind: ToolFailureKind): ToolFailureEnvelope;
712
+ /**
713
+ * True only for the loop's own envelope. Tools that report a `failureKind`
714
+ * alongside their own details (`todo_write`, todo persistence) keep those fields,
715
+ * so their renderers still own the result and are left alone.
716
+ */
717
+ export declare function isToolFailureEnvelope(value: unknown): value is ToolFailureEnvelope;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.14.0",
4
+ "version": "0.14.1",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -32,9 +32,9 @@
32
32
  "fmt": "biome format --write ."
33
33
  },
34
34
  "dependencies": {
35
- "@gajae-code/ai": "0.14.0",
36
- "@gajae-code/natives": "0.14.0",
37
- "@gajae-code/utils": "0.14.0",
35
+ "@gajae-code/ai": "0.14.1",
36
+ "@gajae-code/natives": "0.14.1",
37
+ "@gajae-code/utils": "0.14.1",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -68,17 +68,18 @@ import {
68
68
  bindDispatchedToolIdentity,
69
69
  markNonDispatchedToolEvent,
70
70
  } from "./tool-dispatch-identity";
71
- import type {
72
- AgentContext,
73
- AgentEvent,
74
- AgentLoopConfig,
75
- AgentMessage,
76
- AgentTool,
77
- AgentToolContext,
78
- AgentToolResult,
79
- ManagedAttemptOutcome,
80
- StandaloneRunOwnership,
81
- StreamFn,
71
+ import {
72
+ type AgentContext,
73
+ type AgentEvent,
74
+ type AgentLoopConfig,
75
+ type AgentMessage,
76
+ type AgentTool,
77
+ type AgentToolContext,
78
+ type AgentToolResult,
79
+ type ManagedAttemptOutcome,
80
+ type StandaloneRunOwnership,
81
+ type StreamFn,
82
+ toolFailureEnvelope,
82
83
  } from "./types";
83
84
 
84
85
  // Capture the intrinsic before any tool/hook can replace `Reflect.apply`. Calling this
@@ -1204,9 +1205,28 @@ function warnManagedSnapshotFailure(
1204
1205
  * commits the transaction.
1205
1206
  */
1206
1207
  type ManagedAttemptBatchItem =
1207
- | { type: "event"; event: AgentEvent }
1208
+ | { type: "event"; event: AgentEvent; bytes?: number }
1208
1209
  | { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent };
1209
1210
 
1211
+ /**
1212
+ * Streaming increments whose complete value is re-published by the block's own
1213
+ * `*_end` frame and by the terminal `message_end` / `done` frames. Those
1214
+ * terminal frames are never reclaimed, so dropping the increments loses no
1215
+ * content — only the intermediate frames that carried it on the way there.
1216
+ */
1217
+ const MANAGED_SUPERSEDED_DELTA_EVENT_TYPES: ReadonlySet<string> = new Set([
1218
+ "text_delta",
1219
+ "thinking_delta",
1220
+ "reasoning_summary_delta",
1221
+ "toolcall_delta",
1222
+ ]);
1223
+
1224
+ function isSupersededStreamingDelta(item: ManagedAttemptBatchItem): boolean {
1225
+ if (item.type === "assistant_event") return MANAGED_SUPERSEDED_DELTA_EVENT_TYPES.has(item.event.type);
1226
+ if (item.event.type !== "message_update") return false;
1227
+ return MANAGED_SUPERSEDED_DELTA_EVENT_TYPES.has(item.event.assistantMessageEvent.type);
1228
+ }
1229
+
1210
1230
  class ManagedAttemptTransaction {
1211
1231
  #batch: ManagedAttemptBatchItem[] = [];
1212
1232
  #stagedEventCount = 0;
@@ -1369,6 +1389,48 @@ class ManagedAttemptTransaction {
1369
1389
  );
1370
1390
  }
1371
1391
 
1392
+ /**
1393
+ * Reclaim staged frames that later staged frames already supersede.
1394
+ *
1395
+ * Every staged streaming frame carries the WHOLE accumulated partial (once as
1396
+ * `message`, once as `assistantMessageEvent.partial`), so a turn that streams
1397
+ * N increments stages ~N * length bytes: quadratic in the response length. A
1398
+ * reasoning-heavy turn of a few thousand tokens therefore used to exhaust the
1399
+ * provisional cap and kill the whole run, even though the attempt itself was
1400
+ * healthy and the cap exists only to bound memory.
1401
+ *
1402
+ * Each `*_delta` increment is re-published in full by its block's `*_end`
1403
+ * frame and by the terminal `message_end` / `done` frames, and those are
1404
+ * retained, so dropping the increments reclaims the growth without inventing
1405
+ * or losing content. Nothing is published here: the batch stays
1406
+ * all-or-nothing, so a discarded attempt remains unobservable and the
1407
+ * fallback chain is still untouched.
1408
+ *
1409
+ * Returns whether anything was reclaimed, so the caller can re-test the cap
1410
+ * and keep failing fast on a single payload that cannot fit on its own.
1411
+ */
1412
+ #compactSupersededFrames(): boolean {
1413
+ if (this.#batch.length === 0) return false;
1414
+ const retained: ManagedAttemptBatchItem[] = [];
1415
+ let reclaimedBytes = 0;
1416
+ let reclaimedEvents = 0;
1417
+ for (const item of this.#batch) {
1418
+ if (!isSupersededStreamingDelta(item)) {
1419
+ retained.push(item);
1420
+ continue;
1421
+ }
1422
+ if (item.type === "event") {
1423
+ reclaimedBytes += item.bytes ?? 0;
1424
+ reclaimedEvents += 1;
1425
+ }
1426
+ }
1427
+ if (retained.length === this.#batch.length) return false;
1428
+ this.#batch = retained;
1429
+ this.#stagedBytes -= reclaimedBytes;
1430
+ this.#stagedEventCount -= reclaimedEvents;
1431
+ return true;
1432
+ }
1433
+
1372
1434
  #stage(event: AgentEvent): void {
1373
1435
  if (this.snapshotMode === "lossless") {
1374
1436
  const snapshot = this.#repairAssistantEvent(event);
@@ -1402,7 +1464,7 @@ class ManagedAttemptTransaction {
1402
1464
  this.push(detached);
1403
1465
  return;
1404
1466
  }
1405
- this.#batch.push({ type: "event", event: detached });
1467
+ this.#batch.push({ type: "event", event: detached, bytes: detachedBytes });
1406
1468
  this.#stagedEventCount++;
1407
1469
  this.#stagedBytes += detachedBytes;
1408
1470
  return;
@@ -1420,8 +1482,14 @@ class ManagedAttemptTransaction {
1420
1482
  bytes = undefined;
1421
1483
  }
1422
1484
  if (bytes !== undefined && this.#wouldOverflow(bytes)) {
1423
- this.discard();
1424
- throw new ManagedAttemptBufferOverflowError("overflow.preMeasure");
1485
+ // A long turn reaches the cap through accumulated streaming increments,
1486
+ // not through one oversized payload. Reclaim the superseded increments
1487
+ // first; only a batch that still cannot fit is a real local overflow.
1488
+ this.#compactSupersededFrames();
1489
+ if (this.#wouldOverflow(bytes)) {
1490
+ this.discard();
1491
+ throw new ManagedAttemptBufferOverflowError("overflow.preMeasure");
1492
+ }
1425
1493
  }
1426
1494
  const repaired = this.#repairAssistantEvent(event);
1427
1495
  const detailed = managedAttemptSnapshotDetailed(repaired);
@@ -1441,10 +1509,15 @@ class ManagedAttemptTransaction {
1441
1509
  throw new ManagedAttemptSnapshotError("staging.sanitize");
1442
1510
  }
1443
1511
  if (this.#wouldOverflow(bytes)) {
1444
- this.discard();
1445
- throw new ManagedAttemptBufferOverflowError("overflow.staged");
1512
+ this.#compactSupersededFrames();
1513
+ if (this.#wouldOverflow(bytes)) {
1514
+ this.discard();
1515
+ throw new ManagedAttemptBufferOverflowError("overflow.staged");
1516
+ }
1446
1517
  }
1447
- this.#batch.push({ type: "event", event: snapshot });
1518
+ // Retain each frame's accounted size so compaction can debit exactly what
1519
+ // it reclaims instead of re-measuring the whole batch.
1520
+ this.#batch.push({ type: "event", event: snapshot, bytes });
1448
1521
  this.#stagedEventCount += 1;
1449
1522
 
1450
1523
  this.#stagedBytes += bytes;
@@ -3518,9 +3591,7 @@ async function executeToolCalls(
3518
3591
  caughtError = e;
3519
3592
  result = {
3520
3593
  content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
3521
- details: {
3522
- failureKind: record.argumentValidationFailed ? "argument_validation" : "execution",
3523
- },
3594
+ details: toolFailureEnvelope(record.argumentValidationFailed ? "argument_validation" : "execution"),
3524
3595
  };
3525
3596
  isError = true;
3526
3597
  }
@@ -233,9 +233,25 @@ interface PrunedToolArgumentsSentinel {
233
233
 
234
234
  const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
235
235
 
236
+ /**
237
+ * A tool call's arguments, or `undefined` when the persisted payload is not an
238
+ * object.
239
+ *
240
+ * `ToolCall.arguments` is typed non-nullable, but sessions written by an older
241
+ * cold-spill eviction path carry `arguments: null` where the spill sentinel
242
+ * should be. Reading `.path` off that null threw a TypeError that surfaced as
243
+ * `null is not an object (evaluating 'args.path')` and killed the turn, so
244
+ * every reader of persisted arguments must treat them as untrusted.
245
+ */
246
+ function toolArguments(call: ToolCall): Record<string, unknown> | undefined {
247
+ const args = call.arguments;
248
+ return typeof args === "object" && args !== null ? args : undefined;
249
+ }
250
+
236
251
  /** Extract the file-path argument from a tool call, when the tool has one. */
237
252
  function toolCallPath(call: ToolCall): string | undefined {
238
- const args = call.arguments;
253
+ const args = toolArguments(call);
254
+ if (!args) return undefined;
239
255
  const path = args.path ?? args.file_path ?? args.filePath;
240
256
  return typeof path === "string" && path.length > 0 ? path : undefined;
241
257
  }
@@ -260,7 +276,7 @@ const APPLY_PATCH_HEADER = /^\*\*\* (?:((?:Add|Update|Delete) File)|(Move to)):
260
276
  function editToolPathGroups(call: ToolCall): string[][] {
261
277
  const path = toolCallPath(call);
262
278
  if (path !== undefined) return [[path]];
263
- const input = call.arguments.input;
279
+ const input = toolArguments(call)?.input;
264
280
  if (typeof input !== "string") return [];
265
281
  const groups: string[][] = [];
266
282
  for (const match of input.matchAll(APPLY_PATCH_HEADER)) {
@@ -422,11 +438,13 @@ const IDEMPOTENT_BASH_COMMAND =
422
438
 
423
439
  function normalizedIdempotentBashCommand(call: ToolCall): string | undefined {
424
440
  if (call.name !== "bash") return undefined;
425
- const command = call.arguments.command;
441
+ const args = toolArguments(call);
442
+ if (!args) return undefined;
443
+ const command = args.command;
426
444
  if (typeof command !== "string") return undefined;
427
445
  const normalized = command.trim().replace(/\s+/g, " ");
428
446
  if (/[;&|]/.test(normalized) || !IDEMPOTENT_BASH_COMMAND.test(normalized)) return undefined;
429
- return JSON.stringify([normalized, typeof call.arguments.cwd === "string" ? call.arguments.cwd : undefined]);
447
+ return JSON.stringify([normalized, typeof args.cwd === "string" ? args.cwd : undefined]);
430
448
  }
431
449
 
432
450
  function toolTargetKey(call: ToolCall): string | undefined {
@@ -434,13 +452,15 @@ function toolTargetKey(call: ToolCall): string | undefined {
434
452
  if (path !== undefined) return JSON.stringify([call.name, "path", path]);
435
453
  const command = normalizedIdempotentBashCommand(call);
436
454
  if (command !== undefined) return JSON.stringify([call.name, "command", command]);
437
- const pattern = call.arguments.pattern;
455
+ const args = toolArguments(call);
456
+ if (!args) return undefined;
457
+ const pattern = args.pattern;
438
458
  if (typeof pattern === "string" && pattern.length > 0) {
439
- const paths = call.arguments.paths;
459
+ const paths = args.paths;
440
460
  const pathList = Array.isArray(paths) ? paths.filter((p): p is string => typeof p === "string") : [];
441
- const skip = typeof call.arguments.skip === "number" ? call.arguments.skip : 0;
442
- const caseInsensitive = call.arguments.i === true;
443
- const gitignore = call.arguments.gitignore !== false;
461
+ const skip = typeof args.skip === "number" ? args.skip : 0;
462
+ const caseInsensitive = args.i === true;
463
+ const gitignore = args.gitignore !== false;
444
464
  return JSON.stringify([call.name, "pattern", pattern, pathList, skip, caseInsensitive, gitignore]);
445
465
  }
446
466
  return undefined;
package/src/types.ts CHANGED
@@ -777,3 +777,37 @@ export type AgentEvent =
777
777
  isError?: boolean;
778
778
  scope?: AttemptScope;
779
779
  };
780
+
781
+ /**
782
+ * Why a tool call failed when the loop — not the tool — produced the result.
783
+ *
784
+ * `argument_validation` means the call never dispatched: the arguments were
785
+ * rejected before `execute` ran. `execution` means `execute` threw.
786
+ */
787
+ export type ToolFailureKind = "argument_validation" | "execution";
788
+
789
+ /**
790
+ * The result details the loop attaches when a tool call fails without the tool
791
+ * returning its own details. It carries no tool-owned field, so a consumer that
792
+ * dereferences a tool's own detail shape must recognise it first.
793
+ */
794
+ export interface ToolFailureEnvelope {
795
+ failureKind: ToolFailureKind;
796
+ }
797
+
798
+ export function toolFailureEnvelope(kind: ToolFailureKind): ToolFailureEnvelope {
799
+ return { failureKind: kind };
800
+ }
801
+
802
+ /**
803
+ * True only for the loop's own envelope. Tools that report a `failureKind`
804
+ * alongside their own details (`todo_write`, todo persistence) keep those fields,
805
+ * so their renderers still own the result and are left alone.
806
+ */
807
+ export function isToolFailureEnvelope(value: unknown): value is ToolFailureEnvelope {
808
+ if (!value || typeof value !== "object") return false;
809
+ const keys = Object.keys(value);
810
+ if (keys.length !== 1 || keys[0] !== "failureKind") return false;
811
+ const kind = (value as ToolFailureEnvelope).failureKind;
812
+ return kind === "argument_validation" || kind === "execution";
813
+ }