@gajae-code/agent-core 0.13.2 → 0.14.0

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,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.14.0] - 2026-08-17
6
+
7
+ ### Fixed
8
+
9
+ - 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
+ - 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
+
12
+ - Managed fallback now validates and byte-measures the detached event snapshot rather than trusting the live payload's JSON result. Custom payload classes whose prototype `toJSON()` hides bigint state are sanitized after `structuredClone` removes that serializer, so every accepted snapshot stays detached, JSON-serializable, and bounded; residual typed `local_snapshot_failure` diagnostics remain outside provider fallback authority and surface without deterministic retry amplification.
13
+ - Managed fallback buffer overflows now retain a typed `local_buffer_overflow` error kind on the terminal assistant message, so session retry policy surfaces them immediately without provider-fallback attribution instead of admitting them to the bounded `unknown` retry class.
14
+ - Managed local-failure diagnostics: `ManagedAttemptSnapshotError` and `ManagedAttemptBufferOverflowError` now carry a stable `stage` discriminator naming the exact rejecting site (`shell.role`, `shell.content`, `event.snapshot`, `event.contentIndex`, `event.delta`, `event.content`, `event.toolcall`, `event.done.reason`, `event.error.reason`, `event.unknownType`, `staging.losslessSnapshot`, `staging.measure`, `staging.sanitize`, `staging.overflow`, `overflow.preMeasure`, `overflow.staged`), and the run-loop failure boundary emits ONE bounded shape-only `logger.warn` per stream invocation (stage, error kind, model, provider, snapshot mode, staged event count/bytes, and content block count for the content stage). The diagnostic is gated on the module-private local error identities and its stage is whitelisted against the closed vocabulary, so neither a foreign error that self-labels a local failure kind nor an in-module regression can route arbitrary text into the log; it never records raw text, thinking, tool arguments, or any provider payload, and the user-facing message string is unchanged so session-side classification keeps matching. Previously all 14 rejecting sites shared one static message, leaving no way to identify which provider shape a normalizer must be taught to accept.
15
+ - A turn whose tool arguments arrive flagged `escapedNonAsciiArguments` is now resampled instead of being reported as a tool failure: the defective assistant turn is dropped from history and the request is re-issued, up to twice per turn, before the terminal per-call rejection takes over. Hand-spelled `\uXXXX` arguments decode into valid-looking but silently wrong text (observed as garbled Hangul in `ask` prompts) and no post-parse repair can recover them, but the defect is a wire-format accident that resampling clears - surfacing it as a tool error instead burned the whole turn and fed the literal escape syntax back into the context the model samples from next. Scoped to the non-managed session path, matching the existing `invalid_prompt` and reasoning-content repairs; managed fallback keeps owning its own retry policy.
16
+ - Visible-text Harmony leak retries now close the already-published assistant lifecycle with an empty aborted terminal stripped of raw provider payload before contaminated history is removed and a replacement request begins, preventing both orphaned streaming updates and leaked control text in durable history or replay.
17
+ - Unmanaged escaped-non-ASCII resampling now stages a detached, provider-metadata-preserving assistant lifecycle until validation, publishes live safety updates before dispatch, and defers terminal `message_end` publication until subscriber-triggered cancellation is resolved so persisted assistant state and aborted tool-result pairing cannot disagree.
18
+ - The agent loop still rejects a tool call flagged `escapedNonAsciiArguments` before execution once the resample budget is spent, with a retryable error telling the model to re-issue the call writing non-ASCII characters literally.
19
+ - Managed fallback attempt snapshots no longer fail the whole run on benign provider shape variations: an assistant message whose `content` is a bare string or is missing now degrades to an empty content array, and staged assistant events with out-of-vocabulary `done`/`error` reasons or an unknown string `type` degrade to schema-valid values instead of throwing a non-retryable `ManagedAttemptSnapshotError`. This matches the closed `StopReason` vocabulary already normalized elsewhere in the shell. Object-shaped and other exotic non-array `content` stays fail-closed under the named `shell.content` diagnostic, as does sanitizer-sentinel string content (`[unserializable]`/`[accessor]`/`[truncated]`/`[Circular]`, which mark a non-cloneable original value such as a proxy-wrapped content array rather than provider string variance — degrading those would silently drop real content behind a successful empty turn), and hostile inputs keep failing fast with no retry authority: a live proxy root, a throwing `get`/`getOwnPropertyDescriptor` trap, and a non-string event `type` all remain local snapshot failures.
20
+
21
+ ### Added
22
+
23
+ - Reassignable `onFollowUpConsumed` hook on `Agent`: invoked with the follow-up messages the loop dequeues for the next turn, so consumers can attach per-turn state (e.g. a fresh owned-completion lineage) at actual resume admission.
24
+ - `AgentPromptOptions.onRunAccepted` now receives a typed acceptance payload containing `consumedQueuedMessages`, allowing consumers to bind ownership and other per-message state only to the queued messages actually claimed by the accepted run.
25
+ ## [0.13.3] - 2026-08-15
26
+
27
+ ### Fixed
28
+ - Emergency compaction now considers managed transcript file size so sessions compact before the managed per-file limit (#4411).
29
+ - Managed runs discard assistant turns whose tool calls carried `\uXXXX`-escaped arguments and report them through the typed `escaped_arguments_discarded` outcome instead of executing unverifiable text; unmanaged runs reject such calls per-call with an actionable error (#4515).
30
+
5
31
  ## [0.13.2] - 2026-08-13
6
32
 
7
33
  ## [0.13.1] - 2026-08-11
@@ -31,6 +57,7 @@
31
57
  ### Fixed
32
58
 
33
59
  - An aborted run whose tool ignores its `AbortSignal` now terminates on its own (#3894). `Promise.allSettled` waited on the unresolved call forever, so the turn only ended when the session's force-abort budget expired; the loop now emits a synthetic aborted result for the outstanding calls and `waitForIdle` settles immediately. Session dispose consequently reaches idle through the cooperative path instead of force-invalidating the run.
60
+
34
61
  ### Changed
35
62
 
36
63
  - Telemetry configured with `spans: false` now skips span and attribute construction while preserving usage and cost hooks.
@@ -2,7 +2,7 @@
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 "@gajae-code/ai";
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
8
  import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
@@ -72,6 +72,7 @@ export declare const MANAGED_SNAPSHOT_MAX_NODES = 100000;
72
72
  * callers use the default budget via {@link managedAttemptSnapshot}.
73
73
  */
74
74
  export declare function sanitizedDetachedClone<T>(value: T, maxNodes?: number): T;
75
+ export declare function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent;
75
76
  /**
76
77
  * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
77
78
  * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { type AssistantMessage, type AssistantMessageEvent, type CursorExecHandlers, type CursorToolResultHandler, type Effort, type ImageContent, type Message, type Model, type ProviderSessionState, type ServiceTier, type SimpleStreamOptions, type ThinkingBudgets, type ToolChoice } from "@gajae-code/ai";
5
5
  import type { AppendOnlyContextManager } from "./append-only-context";
6
- import type { AttemptScope } from "./attempt-scope";
6
+ import type { AttemptRunHandle, AttemptScope } from "./attempt-scope";
7
7
  import type { HarmonyAuditEvent } from "./harmony-leak";
8
8
  import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, ManagedLogicalRunId, RunCancellationDomainBridge, RunResourceLedger, RunTerminalRequest, StreamFn, ToolCallContext } from "./types";
9
9
  /**
@@ -162,6 +162,8 @@ export interface AgentOptions {
162
162
  * message are emitted. See {@link AgentLoopConfig.afterToolCall} for full semantics.
163
163
  */
164
164
  afterToolCall?: AgentLoopConfig["afterToolCall"];
165
+ /** Invoked with the follow-up messages dequeued for the next turn (reassignable). */
166
+ onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"];
165
167
  /**
166
168
  * Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's
167
169
  * GenAI-semantic-convention spans using the global tracer provider. See
@@ -181,8 +183,9 @@ export interface AgentPromptOptions {
181
183
  /** Continue a cooperative maintenance checkpoint under its existing logical run and cancellation domain. */
182
184
  maintenanceContinuation?: boolean;
183
185
  /** Called synchronously after this invocation claims the agent run, before asynchronous provider work. */
184
- /** Receives the immutable run handle as the first callback argument. */
185
- onRunAccepted?: (...args: any[]) => void;
186
+ onRunAccepted?: (handle: AttemptRunHandle, acceptance: {
187
+ consumedQueuedMessages: readonly AgentMessage[];
188
+ }) => void;
186
189
  /** Called once immediately before every managed upstream request. */
187
190
  nextFallbackAttempt?: AgentLoopConfig["nextFallbackAttempt"];
188
191
  /** Called after a managed upstream request is accepted and committed. */
@@ -224,6 +227,8 @@ export declare class Agent {
224
227
  * message emission. Reassign at any time to swap the implementation.
225
228
  */
226
229
  afterToolCall?: AgentLoopConfig["afterToolCall"];
230
+ /** Invoked with the follow-up messages dequeued for the next turn. Reassign at any time. */
231
+ onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"];
227
232
  constructor(opts?: AgentOptions);
228
233
  /**
229
234
  * Get the current session ID used for provider caching.
@@ -344,9 +349,22 @@ export declare class Agent {
344
349
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void;
345
350
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
346
351
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
352
+ setProvisionalAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
347
353
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
348
354
  setShouldPause(fn: AgentLoopConfig["shouldPause"] | undefined): void;
349
355
  setMaintainContext(fn: AgentLoopConfig["maintainContext"] | undefined): void;
356
+ /**
357
+ * Publish an event produced OUTSIDE the agent loop (a provider that executed the tool
358
+ * itself, a host bridge, a replay).
359
+ *
360
+ * Identity is the PRODUCER's to prove: whoever dispatched the call binds the tool object
361
+ * it actually ran (see `bindDispatchedToolIdentity`) before handing the event here, and
362
+ * that binding is never touched from this side. Re-resolving `event.toolName` against the
363
+ * mutable current tool list would let a mid-run `setTools`, MCP reload, or plain name
364
+ * collision overwrite a proven object with one that never ran — and would invent an
365
+ * identity for replays and host bridges that never executed an AgentTool at all. An
366
+ * unbound external event stays unbound; unproven provenance is `custom`.
367
+ */
350
368
  emitExternalEvent(event: AgentEvent): void;
351
369
  createExternalEventEmitterForCurrentRun(): ((event: AgentEvent) => void) | undefined;
352
370
  setSystemPrompt(v: string[]): void;
@@ -425,8 +443,21 @@ export declare class Agent {
425
443
  popLastFollowUp(): AgentMessage | undefined;
426
444
  removeFollowUpAt(index: number): AgentMessage | undefined;
427
445
  moveFollowUp(fromIndex: number, toIndex: number): boolean;
428
- /** Remove queued steering+follow-up messages matching `predicate`, preserving order of the rest. */
429
- removeQueuedMessages(predicate: (message: AgentMessage) => boolean): {
446
+ /**
447
+ * Remove ALL queued STEERING messages without touching the follow-up queue.
448
+ * Used by the terminal-abort path to purge steering queued for the aborted
449
+ * turn (the loop may exit on the abort signal without polling it); the
450
+ * follow-up queue is preserved because it may carry owned-completion
451
+ * resumes that must still deliver.
452
+ */
453
+ clearSteeringMessages(): void;
454
+ /**
455
+ * Remove queued steering/follow-up messages matching `predicate`, preserving
456
+ * order of the rest. `scope` restricts the removal to one queue — the
457
+ * terminal-abort steering purge must not wipe the follow-up queue, which
458
+ * the owned-completion resume policy preserves.
459
+ */
460
+ removeQueuedMessages(predicate: (message: AgentMessage) => boolean, scope?: "both" | "steering" | "followUp"): {
430
461
  steering: number;
431
462
  followUp: number;
432
463
  total: number;
@@ -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
  */
@@ -9,4 +9,5 @@ export * from "./run-collector";
9
9
  export * from "./run-resource-ledger";
10
10
  export * from "./telemetry";
11
11
  export * from "./thinking";
12
+ export * from "./tool-dispatch-identity";
12
13
  export * from "./types";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Record, at the producer boundary, the tool object this event was emitted for.
3
+ *
4
+ * A call with no resolved tool (an unknown name the loop is about to reject, or a call
5
+ * aborted before dispatch) binds nothing: there is no object, so there is nothing to prove.
6
+ */
7
+ export declare function bindDispatchedToolIdentity(event: object, tool: object | undefined): void;
8
+ /** The tool object this event was actually dispatched to, if its producer bound one. */
9
+ export declare function dispatchedToolIdentity(event: object): object | undefined;
10
+ /** Mark an event as pairing-only: emitted for a call the loop never dispatched. */
11
+ export declare function markNonDispatchedToolEvent(event: object): void;
12
+ /** Whether this exact event was synthesized for a call that never ran. */
13
+ export declare function isNonDispatchedToolEvent(event: object): boolean;
14
+ /**
15
+ * Active tool a call name dispatches to. Tools emitted via OpenAI's custom-tool path
16
+ * (e.g. `apply_patch` on GPT-5) come back under their wire-level name, which may differ
17
+ * from the harness-internal `name`. Match on either, preferring `name` for determinism if
18
+ * both somehow collide.
19
+ *
20
+ * This is the single dispatch-matching rule: execution, external-event identity binding,
21
+ * and every "is this tool callable" check must agree, or a label can describe a tool the
22
+ * call would never have reached.
23
+ */
24
+ export declare function activeToolForCallName<T extends {
25
+ name: string;
26
+ customWireName?: string;
27
+ }>(tools: ReadonlyArray<T> | undefined, callName: string): T | undefined;
@@ -162,6 +162,11 @@ export type ManagedAttemptOutcome = {
162
162
  transportFailure?: TransportFailureFacts;
163
163
  };
164
164
  scope?: AttemptScope;
165
+ } | {
166
+ type: "escaped_arguments_discarded";
167
+ /** The defective assistant turn; already removed from usable history by the loop. */
168
+ message: AssistantMessage;
169
+ scope?: AttemptScope;
165
170
  } | {
166
171
  type: "context_overflow_discarded";
167
172
  message: AssistantMessage;
@@ -296,6 +301,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
296
301
  * continues with another turn.
297
302
  */
298
303
  getFollowUpMessages?: () => Promise<AgentMessage[]>;
304
+ /**
305
+ * Invoked with the follow-up messages the loop dequeues for the next turn
306
+ * (right after {@link getFollowUpMessages}). The consumer may use this to
307
+ * attach per-turn state (e.g. a fresh owned-completion lineage) at actual
308
+ * resume admission rather than when the message was merely queued.
309
+ */
310
+ onFollowUpConsumed?: (messages: AgentMessage[]) => void;
299
311
  /**
300
312
  * Supplies one bounded synthetic recovery instruction before the loop would
301
313
  * otherwise yield. Unlike a follow-up, it is sent only to the provider and
@@ -376,6 +388,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
376
388
  * Callers may abort synchronously to stop consuming buffered provider events.
377
389
  */
378
390
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
391
+ /** Observe unmanaged provisional assistant deltas before public publication. */
392
+ onProvisionalAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
393
+ /** True when the host consumes provisional assistant events for live safety checks. */
394
+ hasProvisionalAssistantMessageEventConsumer?: boolean;
379
395
  /** Called for non-content tool-choice incapability stream events. */
380
396
  onToolChoiceIncapability?: (event: Extract<AssistantMessageEvent, {
381
397
  type: "toolChoiceIncapability";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.13.2",
4
+ "version": "0.14.0",
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.13.2",
36
- "@gajae-code/natives": "0.13.2",
37
- "@gajae-code/utils": "0.13.2",
35
+ "@gajae-code/ai": "0.14.0",
36
+ "@gajae-code/natives": "0.14.0",
37
+ "@gajae-code/utils": "0.14.0",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {