@gajae-code/agent-core 0.14.2 → 0.15.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,18 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.14.2] - 2026-08-20
5
+ ## [0.15.0] - 2026-08-22
6
6
 
7
7
  ### Fixed
8
- - A steer that interrupts an in-flight tool is delivered instead of dropped, and the remaining abort-race gaps in steer-after-tool-interrupt are closed; a live subagent await ends when user steering arrives.
9
- - The escaped-non-ASCII em-dash exemption is bounded to display-only tools (#4627).
8
+ - Staged-payload sizing no longer materializes what it is bounding (#4602 fix-forward of the exact-head 078e22c0 review). All staging measurements now walk the JSON surface directly: exact byte counts come from a code-point walk (quotes, escapes, separators, delimiters, nulls, array holes, and keys all charged) instead of building the full `JSON.stringify` string plus its UTF-8 encoding, and lone surrogates are charged as the six-byte `\udXXX` escape JSON emits rather than their three-byte UTF-8 form, closing a ~2x undercount on surrogate-heavy strings. `structuredClone` is additionally preflighted by a clone-surface walk that never dispatches `toJSON`, accessors, or proxy traps: a live payload class whose compact `toJSON` hides an oversized own payload is rejected as the typed `local_buffer_overflow` at `overflow.preMeasure` — before the duplicate is allocated — instead of being cloned first and rejected at `overflow.staged`. Accessors are no longer invoked at all while sizing (a staged witness getter is read zero times), `undefined`-valued record properties are skipped exactly as `JSON.stringify` omits them, an unmeasurable assistant pair now fails closed like its `#stage` twin instead of being retained with a zero-byte charge, the `overflow.preMeasure` diagnostic reports the incoming event's real bounded size instead of a constant fabricated after `discard()`, and above-ceiling clamp warnings are logged once per distinct knob value with a bounded digest. |
9
+ - Provider safety-stop messages now retain their explicitly allowlisted `errorKind: "provider_safety_stop"` through managed assistant snapshots and remain terminal even when transport facts are present on a multi-model fallback chain, while provider payloads still cannot forge the runtime-owned local diagnostic kinds (#4777).
10
+ - A foreign error that self-declares a local failure kind no longer gets one either (#4618). `errorKind` and the structured `bufferOverflow` shape now come from a single identity-checked extractor (`managedLocalErrorDiagnostic`) used by both terminal-message producers — `managedFailureMessage` and the `Agent` run catch. Previously the shape was identity-gated but the label was not, so a provider or custom-stream failure carrying `errorKind: "local_buffer_overflow"` reached the parent receipt preview as `Local staging-buffer overflow; structured diagnostic unavailable.` and pointed whoever read it at the wrong subsystem.
11
+ - Local diagnostic authority fields are no longer foreign-settable through the managed snapshot shell (#4618). `managedAssistantShell` spreads the provider/stream message snapshot into the rebuilt assistant message; a payload that smuggled a local `errorKind` or `bufferOverflow` through that spread could masquerade as the runtime's own identity-checked diagnostic at the parent boundary. Local kinds and `bufferOverflow` remain stripped from the snapshot spread, while the provider-owned safety-stop kind is copied only through its explicit closed-literal guard.
12
+
13
+ - `Agent.waitForSteeringArrival(signal)` resolves when steering is queued without consuming it, so wait-style tools can end their observation early.
14
+ - Managed fallback provisional-buffer caps are now operator-configurable: `GJC_FALLBACK_MAX_STAGED_EVENTS` (default 10000, hard ceiling 2000000) and `GJC_FALLBACK_MAX_STAGED_BYTES` (default 16 MiB, hard ceiling 1 GiB) bound the events/bytes staged by the provisional staging transaction in both managed fallback and ordinary (non-managed lossless) sessions; in non-managed sessions the cap only decides how much reasoning buffers before the batch flushes and streams through. Values are read once per attempt; the trusted environment resolver ignores surrounding whitespace, while invalid or non-positive values fall back to the defaults, and values above the ceiling clamp to it with a warning so the staging guard stays bounded instead of trading a typed `local_buffer_overflow` for a process OOM. Every retained batch item — including the assistant message/event pair staged for streaming callbacks — is measured and charged against the caps BEFORE it is retained, so actual retention can never exceed the counted bounds, and the ceilings are set from total retained memory (2,000,000 events / 1 GiB) at values an ordinary host survives. The knobs resolve from trusted environment sources only (`$credentialEnv`, which excludes the repository `cwd/.env` overlay), so a project cannot weaken or weaponize the staging guard. Raise both to survive reasoning-heavy streaming in long-running sessions and `gjc team` workers (#4602, #4618).
15
+
16
+ ### Fixed
17
+ - The escaped-non-ASCII argument guard keeps its fail-closed terminal rejection and its unconditional two-resample budget for every tool and every field. After the budget is spent, one narrowly scoped exemption applies: a tool that enumerated its user-facing display fields (`displaySafeEscapedArgFields`; `ask` exempts only `questions.question` and `questions.options.label`) executes when every non-ASCII character lives inside those fields and is benign typographic punctuation (curated set: U+2014 em-dash). Escaped non-ASCII anywhere else — ids, deep-interview metadata, persisted records, non-ASCII object keys — and every other tool stays rejected terminally (#4627, reduced per both maintainer reviews: guard retained, exemption post-budget and field-scoped).
18
+
19
+ - Escaped-non-ASCII turn resamples are now steered instead of blind: each unmanaged resample carries a transient synthetic instruction naming the `\uXXXX` defect and demanding literal UTF-8, so a model that escapes deterministically (observed with Hangul-heavy `ask` payloads exhausting the whole resample budget every turn) has a reason to change its spelling on the retry. The instruction never lands in durable history, tools stay enabled, and the captured logical-turn tool choice is still replayed across the steered attempts; a pending one-shot malformed-tool-call recovery is never displaced by the steering. Managed fallback retries receive the same steering: the typed `escaped_arguments_discarded` outcome now reports whether the discarded attempt still lacked an instruction, and the session's retry continuation attaches the same transient message through the new `transientRecoveryMessage` prompt option, so coding-agent sessions (which run managed) also get exactly one steered re-request before the budget ends.
10
20
 
11
21
  ## [0.14.1] - 2026-08-18
12
22
  - 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.
23
+ - Managed fallback attempt snapshots no longer fail the whole run on benign provider shape variations: an assistant message whose `content` is a bare string, is missing, or is a primitive scalar (null/number/boolean) now degrades to an empty content array; staged `*_delta`/`*_end` events whose `delta`/`content` is missing or a primitive scalar degrade to an empty string; 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`. Object-shaped or other plain-object `content`/`delta` stays fail-closed under the named `shell.content`/`event.delta`/`event.content` diagnostic, as does sanitizer-sentinel string content (`[unserializable]`/`[accessor]`/`[truncated]`/`[Circular]`, which marks a non-cloneable original rather than provider string variance — degrading those would silently drop real tool-call or streamed 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.
13
24
 
14
25
  ### Added
15
26
 
16
27
  - `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.
28
+ - `ManagedAttemptBufferOverflowError` (`local_buffer_overflow`) now reports its full shape everywhere it can reach: the rejecting `stage`, which cap tripped (`exceeded: events|bytes|both`), the retained post-compaction staged event/byte counts, the rejected event's own serialized size, and both caps. The typed error carries this as a structured object, the terminal `AssistantMessage` carries an identity-checked `bufferOverflow` copy (only the module-private error class can attach it, so a foreign self-labeled error cannot), and the surfaced message keeps its stable prefix and appends the same shape-only values stating this is a local staging-buffer limit that reproduces on re-issue, not a provider or context-window failure. Previously every overflow surfaced as one static sentence with no way to tell an event-cap from a byte-cap trip or to distinguish it from a model-context problem (#4618).
17
29
 
18
30
  ## [0.14.0] - 2026-08-17
19
31
 
@@ -14,6 +14,71 @@ import { type AgentContext, type AgentEvent, type AgentLoopConfig, type AgentMes
14
14
  */
15
15
  export declare const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10000;
16
16
  export declare const MANAGED_ATTEMPT_MAX_STAGED_BYTES: number;
17
+ /**
18
+ * Hard ceilings for the operator overrides. The caps exist to bound memory, so
19
+ * an override may raise them only within a range that still leaves the guard
20
+ * meaningful — near-`MAX_SAFE_INTEGER` values would trade a typed, bounded
21
+ * `local_buffer_overflow` for a process OOM, which is strictly harder to
22
+ * diagnose. Above-ceiling overrides clamp to the ceiling with a warning
23
+ * instead of being honored.
24
+ *
25
+ * The ceilings are derived from a survivable PEAK-RSS budget, not from the
26
+ * counted-bytes number: peak resident memory holds the live payload, its
27
+ * detached snapshot, and the retained batch simultaneously, so it is a
28
+ * multiple of the counted bytes. Sizing itself is walk-based (no JSON string
29
+ * or UTF-8 copy is materialized to measure), which is why the factor below
30
+ * covers the live value plus one detached copy plus batch retention with
31
+ * headroom. The bytes ceiling is the peak budget divided by that multiplier,
32
+ * so an override at the ceiling still fits an ordinary host. The events
33
+ * ceiling is the object-count equivalent for the same budget at a
34
+ * conservative per-item floor.
35
+ */
36
+ export declare const MANAGED_STAGED_PEAK_RSS_BUDGET_BYTES: number;
37
+ export declare const MANAGED_STAGED_PEAK_RSS_FACTOR = 4;
38
+ export declare const MANAGED_ATTEMPT_STAGED_EVENTS_CEILING = 2000000;
39
+ export declare const MANAGED_ATTEMPT_STAGED_BYTES_CEILING: number;
40
+ /**
41
+ * Max events staged by a provisional managed-attempt transaction before it is
42
+ * rejected. Configurable via `GJC_FALLBACK_MAX_STAGED_EVENTS` (default
43
+ * `MANAGED_ATTEMPT_MAX_STAGED_EVENTS`, ceiling
44
+ * `MANAGED_ATTEMPT_STAGED_EVENTS_CEILING`). Read once per transaction so
45
+ * operators can raise the cap without a rebuild and tests can exercise the
46
+ * knob in-process. Values must be positive integers after the trusted
47
+ * resolver ignores surrounding whitespace; invalid or
48
+ * non-positive values fall back to the default, and values above the ceiling
49
+ * clamp to it with a warning.
50
+ *
51
+ * @internal
52
+ */
53
+ export declare function managedAttemptMaxStagedEvents(): number;
54
+ /**
55
+ * Max bytes staged by a provisional managed-attempt transaction before it is
56
+ * rejected. Configurable via `GJC_FALLBACK_MAX_STAGED_BYTES` (default
57
+ * `MANAGED_ATTEMPT_MAX_STAGED_BYTES`, ceiling
58
+ * `MANAGED_ATTEMPT_STAGED_BYTES_CEILING`). Read once per transaction; values
59
+ * must be positive integers after the trusted resolver ignores surrounding
60
+ * whitespace, anything else falls back to the
61
+ * default, and values above the ceiling clamp to it with a warning.
62
+ *
63
+ * @internal
64
+ */
65
+ export declare function managedAttemptMaxStagedBytes(): number;
66
+ /**
67
+ * Closed set of local-failure sites. A bounded diagnostic may name only these
68
+ * literals: the log is shape-only, so no caller-supplied or provider-derived
69
+ * string may ever reach it.
70
+ */
71
+ declare const MANAGED_LOCAL_FAILURE_STAGES: readonly ["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.preMeasure", "staging.overflow", "overflow.preMeasure", "overflow.staged"];
72
+ type ManagedLocalFailureStage = (typeof MANAGED_LOCAL_FAILURE_STAGES)[number];
73
+ /**
74
+ * How many times a single turn may be re-requested because its tool arguments
75
+ * arrived as `\uXXXX` escapes instead of literal UTF-8.
76
+ *
77
+ * The defect is a wire-format accident that resampling clears, so a small
78
+ * budget recovers the overwhelming majority of turns; past it the terminal
79
+ * per-call rejection takes over rather than spending the run on retries.
80
+ */
81
+ export declare const ESCAPED_NONASCII_RECOVERY_PROMPT: string;
17
82
  /**
18
83
  * Start an agent loop with a new prompt message.
19
84
  * The prompt is added to the context and events are emitted for it.
@@ -28,6 +93,46 @@ export declare function agentLoop(prompts: AgentMessage[], context: AgentContext
28
93
  * This cannot be validated here since `convertToLlm` is only called once per turn.
29
94
  */
30
95
  export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn, emitAgentStart?: boolean, initialScope?: AttemptScope): EventStream<AgentEvent, AgentMessage[]>;
96
+ /**
97
+ * Structured, shape-only overflow diagnostic carried on the terminal
98
+ * `AssistantMessage` of a managed run that died of a staging-buffer overflow.
99
+ * Every field is closed-vocabulary or numeric, so parent surfaces can render a
100
+ * trustworthy summary WITHOUT trusting the free-form `errorMessage` string
101
+ * (which a foreign, self-labeled error can still fill with arbitrary text).
102
+ */
103
+ export interface ManagedBufferOverflowDiagnostic {
104
+ stage: ManagedLocalFailureStage | "unknown";
105
+ exceeded: "events" | "bytes" | "both";
106
+ stagedEventCount: number;
107
+ stagedBytes: number;
108
+ incomingEventBytes: number;
109
+ maxStagedEvents: number;
110
+ maxStagedBytes: number;
111
+ }
112
+ /**
113
+ * The complete set of local-diagnostic authority fields a terminal
114
+ * `AssistantMessage` may carry. Produced only by
115
+ * {@link managedLocalErrorDiagnostic}, so `errorKind` and `bufferOverflow`
116
+ * always travel together from one identity check.
117
+ */
118
+ export interface ManagedLocalErrorDiagnostic {
119
+ errorKind: "local_snapshot_failure" | "local_buffer_overflow";
120
+ bufferOverflow?: ManagedBufferOverflowDiagnostic;
121
+ }
122
+ /**
123
+ * Single identity-checked source of local-failure authority. Returns
124
+ * `undefined` unless the error is genuinely `instanceof` one of this module's
125
+ * private local-failure classes — a foreign error that merely sets
126
+ * `errorKind: "local_buffer_overflow"` fails the identity check and receives
127
+ * NEITHER the kind nor the structured shape, so a provider or custom-stream
128
+ * failure can never be reported to the parent as a local staging-buffer
129
+ * overflow (#4618).
130
+ *
131
+ * Every producer of a terminal assistant message (`managedFailureMessage` and
132
+ * the `Agent` run catch) MUST derive both fields from this function instead of
133
+ * reading `errorKind`/`errorMessage` off the thrown value.
134
+ */
135
+ export declare function managedLocalErrorDiagnostic(error: unknown): ManagedLocalErrorDiagnostic | undefined;
31
136
  /**
32
137
  * Hard work budget for one degraded snapshot: every visited node AND every
33
138
  * enumerated own key is debited against this budget before it is processed
@@ -72,7 +177,20 @@ export declare const MANAGED_SNAPSHOT_MAX_NODES = 100000;
72
177
  * callers use the default budget via {@link managedAttemptSnapshot}.
73
178
  */
74
179
  export declare function sanitizedDetachedClone<T>(value: T, maxNodes?: number): T;
75
- export declare function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage): AssistantMessageEvent;
180
+ /**
181
+ * Exact serialized size of a staged snapshot, computed by walking the JSON
182
+ * surface. Replaces the previous `JSON.stringify` + `TextEncoder.encode`
183
+ * measurement, which materialized a full copy of the serialized value — and
184
+ * a second copy of its UTF-8 encoding — BEFORE the cap check could reject
185
+ * it: the budget-sized transient allocation the memory guard exists to
186
+ * prevent (exact-head 078e22c0 finding 1). Returns `undefined` when the
187
+ * value cannot be serialized, matching the previous measurement's failure
188
+ * mode so callers keep their sanitize fallbacks.
189
+ *
190
+ * @internal
191
+ */
192
+ export declare function managedSnapshotJsonByteLength(value: unknown): number | undefined;
193
+ export declare function managedAssistantEventSnapshot(event: AssistantMessageEvent, message: AssistantMessage, degradedFieldDiagnostics?: Set<string>): AssistantMessageEvent;
76
194
  /**
77
195
  * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
78
196
  * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
@@ -108,3 +226,4 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
108
226
  export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
109
227
  export declare const INTENT_FIELD = "_i";
110
228
  export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"];
229
+ export {};
@@ -1,7 +1,7 @@
1
1
  /** Agent class that uses the agent-loop directly.
2
2
  * No transport abstraction - calls streamSimple via the loop.
3
3
  */
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";
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, type UserMessage } from "@gajae-code/ai";
5
5
  import type { AppendOnlyContextManager } from "./append-only-context";
6
6
  import type { AttemptRunHandle, AttemptScope } from "./attempt-scope";
7
7
  import type { HarmonyAuditEvent } from "./harmony-leak";
@@ -177,6 +177,8 @@ export interface AgentOptions {
177
177
  appendOnlyContext?: AppendOnlyContextManager;
178
178
  }
179
179
  export interface AgentPromptOptions {
180
+ /** One-shot transient recovery instruction sent only to the provider for the next assistant request; never committed to durable history. */
181
+ transientRecoveryMessage?: UserMessage;
180
182
  toolChoice?: ToolChoice;
181
183
  /** Disable transport replay; fallback accounting is owned by the caller. */
182
184
  fallbackManaged?: boolean;
@@ -166,6 +166,14 @@ export type ManagedAttemptOutcome = {
166
166
  type: "escaped_arguments_discarded";
167
167
  /** The defective assistant turn; already removed from usable history by the loop. */
168
168
  message: AssistantMessage;
169
+ /**
170
+ * True when this discarded attempt had no transient steering instruction
171
+ * attached yet. A managed retry continuation should carry the escaped
172
+ * non-ASCII recovery instruction exactly once, so a deterministic
173
+ * escaper has a reason to change its spelling; the instruction never
174
+ * lands in durable history. Absent/false means steering already ran.
175
+ */
176
+ steeringPending?: boolean;
169
177
  scope?: AttemptScope;
170
178
  } | {
171
179
  type: "context_overflow_discarded";
@@ -315,10 +323,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
315
323
  /**
316
324
  * Invoked with the follow-up messages the loop dequeues for the next turn
317
325
  * (right after {@link getFollowUpMessages}). The consumer may use this to
318
- * attach per-turn state (e.g. a fresh owned-completion lineage) at actual
326
+ * attach per-turn state (e.g., a fresh owned-completion lineage) at actual
319
327
  * resume admission rather than when the message was merely queued.
320
328
  */
321
329
  onFollowUpConsumed?: (messages: AgentMessage[]) => void;
330
+ /**
331
+ * One-shot transient recovery instruction attached to the first assistant
332
+ * request of this loop invocation. Sent only to the provider (never committed
333
+ * to durable agent message history) so a caller-owned retry of a discarded
334
+ * attempt can name the defect it is retrying around.
335
+ */
336
+ transientRecoveryMessage?: UserMessage;
322
337
  /**
323
338
  * Supplies one bounded synthetic recovery instruction before the loop would
324
339
  * otherwise yield. Unlike a follow-up, it is sent only to the provider and
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.2",
4
+ "version": "0.15.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.14.2",
36
- "@gajae-code/natives": "0.14.2",
37
- "@gajae-code/utils": "0.14.2",
35
+ "@gajae-code/ai": "0.15.0",
36
+ "@gajae-code/natives": "0.15.0",
37
+ "@gajae-code/utils": "0.15.0",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
@@ -43,7 +43,7 @@
43
43
  "@types/bun": "^1.3.14"
44
44
  },
45
45
  "engines": {
46
- "bun": ">=1.3.14"
46
+ "bun": ">=1.4.0"
47
47
  },
48
48
  "files": [
49
49
  "src",