@gajae-code/agent-core 0.11.0 → 0.11.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,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.11.1] - 2026-07-16
6
+
7
+ ### Fixed
8
+
9
+ - Added a bounded, neutralize-only `invalid_prompt` circuit breaker to the agent loop (#2282). A poisoned-history rejection (`Request blocked (code=invalid_prompt)`) is a deterministic content fault: re-sending the same history re-triggers it, so uncontrolled session auto-retry would burn its budget re-poisoning the model. On the first `invalid_prompt` of a run, leaked reserved control tokens are neutralized in place across history (no item is ever dropped). If that changes the outgoing bytes, the turn is resent exactly once with the repaired history; if neutralization cannot change anything, the run fails fast immediately with no resend. The repaired history is persisted for a clean resume, the breaker fires at most once per run (budget = one repaired resend), and it is scoped to the non-managed session path since managed fallback owns its own retry policy.
10
+
5
11
  ## [0.10.2] - 2026-07-14
6
12
 
7
13
  ### Fixed
@@ -38,6 +38,19 @@ export interface CompactionSettings {
38
38
  remoteEnabled?: boolean;
39
39
  remoteEndpoint?: string;
40
40
  }
41
+ export type RemoteCompactionFallbackHealthEvent = {
42
+ kind: "success";
43
+ model: string;
44
+ provider: string;
45
+ } | {
46
+ kind: "fallback";
47
+ model: string;
48
+ provider: string;
49
+ error: string;
50
+ };
51
+ export interface RemoteCompactionFallbackHealthHooks {
52
+ recordRemoteCompactionFallback(event: RemoteCompactionFallbackHealthEvent): void;
53
+ }
41
54
  export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
42
55
  /**
43
56
  * Calculate total context tokens from usage.
@@ -194,6 +207,8 @@ export interface SummaryOptions {
194
207
  providerSessionState?: Map<string, ProviderSessionState>;
195
208
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
196
209
  preferWebsockets?: boolean;
210
+ /** Session-owned health sink for remote-compaction fallback transition logging. */
211
+ remoteCompactionFallbackHealth?: RemoteCompactionFallbackHealthHooks;
197
212
  }
198
213
  /**
199
214
  * Cap the serialized conversation fed to a summarization request so the request
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.11.0",
4
+ "version": "0.11.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.11.0",
36
- "@gajae-code/natives": "0.11.0",
37
- "@gajae-code/utils": "0.11.0",
35
+ "@gajae-code/ai": "0.11.1",
36
+ "@gajae-code/natives": "0.11.1",
37
+ "@gajae-code/utils": "0.11.1",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  validateToolArguments,
18
18
  zodToWireSchema,
19
19
  } from "@gajae-code/ai";
20
+ import { isInvalidPromptError, neutralizeReservedControlTokens } from "@gajae-code/ai/utils";
20
21
  import { sanitizeText } from "@gajae-code/utils";
21
22
  import {
22
23
  createHarmonyAuditEvent,
@@ -109,6 +110,39 @@ function managedRetryableFailure(failure: unknown): boolean {
109
110
  );
110
111
  }
111
112
 
113
+ /**
114
+ * Neutralize leaked reserved control tokens in-place across the outgoing
115
+ * history so a re-send no longer carries the poison that triggered
116
+ * `Request blocked (code=invalid_prompt)`. Only string text fields are
117
+ * rewritten; no history item is ever dropped or reordered. Returns whether any
118
+ * byte actually changed — the circuit breaker uses this to decide between a
119
+ * single repaired resend (changed) and immediate fail-fast (unchanged).
120
+ */
121
+ function repairInvalidPromptHistory(messages: AgentMessage[]): boolean {
122
+ let changed = false;
123
+ const repairString = (value: string): string => {
124
+ const next = neutralizeReservedControlTokens(value);
125
+ if (next !== value) changed = true;
126
+ return next;
127
+ };
128
+ for (const message of messages) {
129
+ const content = (message as { content?: unknown }).content;
130
+ if (typeof content === "string") {
131
+ (message as { content: string }).content = repairString(content);
132
+ } else if (Array.isArray(content)) {
133
+ for (const block of content) {
134
+ if (!block || typeof block !== "object") continue;
135
+ const record = block as Record<string, unknown>;
136
+ for (const key of ["text", "thinking"]) {
137
+ const value = record[key];
138
+ if (typeof value === "string") record[key] = repairString(value);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return changed;
144
+ }
145
+
112
146
  function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
113
147
  return {
114
148
  type: "retryable_discarded",
@@ -789,6 +823,9 @@ async function runLoopBody(
789
823
  // first iteration is skipped to avoid duplicating/racing it.
790
824
  let modelHasResponded = false;
791
825
  let harmonyTruncateResumeCount = 0;
826
+ // Fires at most one repaired resend per run for the poisoned-history
827
+ // `invalid_prompt` circuit breaker below.
828
+ let invalidPromptRepairAttempted = false;
792
829
 
793
830
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
794
831
  while (true) {
@@ -949,6 +986,29 @@ async function runLoopBody(
949
986
  continue;
950
987
  }
951
988
  }
989
+ // Session-level invalid_prompt circuit breaker (bounded, neutralize-only).
990
+ // A poisoned-history rejection (`Request blocked (code=invalid_prompt)`) is
991
+ // a deterministic content fault: re-sending the same history re-triggers it,
992
+ // so naive session auto-retry would burn its whole budget re-poisoning the
993
+ // model. On the first invalid_prompt of this run, neutralize leaked control
994
+ // tokens in history IN PLACE (never dropping items). If that changed the
995
+ // outgoing bytes, resend exactly once with the repaired history; if
996
+ // neutralization cannot change anything (nothing left to repair), fall
997
+ // through to terminal handling and fail fast. Budget = one repaired resend.
998
+ // Runs before the response is committed so the resend is a clean retry;
999
+ // managed fallback owns its own retry policy, so this is scoped to the
1000
+ // non-managed session path where uncontrolled auto-retry would recur.
1001
+ if (
1002
+ !config.fallbackManaged &&
1003
+ message.stopReason === "error" &&
1004
+ !invalidPromptRepairAttempted &&
1005
+ isInvalidPromptError(message)
1006
+ ) {
1007
+ invalidPromptRepairAttempted = true;
1008
+ if (repairInvalidPromptHistory(currentContext.messages)) {
1009
+ continue;
1010
+ }
1011
+ }
952
1012
  newMessages.push(message);
953
1013
  modelHasResponded = true;
954
1014
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
@@ -144,6 +144,18 @@ export interface CompactionSettings {
144
144
  remoteEndpoint?: string;
145
145
  }
146
146
 
147
+ export type RemoteCompactionFallbackHealthEvent =
148
+ | { kind: "success"; model: string; provider: string }
149
+ | { kind: "fallback"; model: string; provider: string; error: string };
150
+
151
+ export interface RemoteCompactionFallbackHealthHooks {
152
+ recordRemoteCompactionFallback(event: RemoteCompactionFallbackHealthEvent): void;
153
+ }
154
+
155
+ function isAbortError(error: unknown): boolean {
156
+ return error instanceof Error && error.name === "AbortError";
157
+ }
158
+
147
159
  export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
148
160
  enabled: true,
149
161
  strategy: "context-full",
@@ -799,6 +811,8 @@ export interface SummaryOptions {
799
811
  providerSessionState?: Map<string, ProviderSessionState>;
800
812
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
801
813
  preferWebsockets?: boolean;
814
+ /** Session-owned health sink for remote-compaction fallback transition logging. */
815
+ remoteCompactionFallbackHealth?: RemoteCompactionFallbackHealthHooks;
802
816
  }
803
817
 
804
818
  /**
@@ -1305,6 +1319,7 @@ export async function compact(
1305
1319
  sessionId: options?.sessionId,
1306
1320
  providerSessionState: options?.providerSessionState,
1307
1321
  preferWebsockets: options?.preferWebsockets,
1322
+ remoteCompactionFallbackHealth: options?.remoteCompactionFallbackHealth,
1308
1323
  };
1309
1324
 
1310
1325
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -1331,12 +1346,28 @@ export async function compact(
1331
1346
  { authCredentialType: options?.authCredentialType },
1332
1347
  );
1333
1348
  preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
1334
- } catch (err) {
1335
- logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1336
- error: err instanceof Error ? err.message : String(err),
1349
+ summaryOptions.remoteCompactionFallbackHealth?.recordRemoteCompactionFallback({
1350
+ kind: "success",
1337
1351
  model: model.id,
1338
1352
  provider: model.provider,
1339
1353
  });
1354
+ } catch (err) {
1355
+ if (signal?.aborted || isAbortError(err)) throw err;
1356
+ const error = err instanceof Error ? err.message : String(err);
1357
+ if (summaryOptions.remoteCompactionFallbackHealth) {
1358
+ summaryOptions.remoteCompactionFallbackHealth.recordRemoteCompactionFallback({
1359
+ kind: "fallback",
1360
+ error,
1361
+ model: model.id,
1362
+ provider: model.provider,
1363
+ });
1364
+ } else {
1365
+ logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1366
+ error,
1367
+ model: model.id,
1368
+ provider: model.provider,
1369
+ });
1370
+ }
1340
1371
  }
1341
1372
  }
1342
1373
  }
@@ -514,18 +514,14 @@ export async function requestOpenAiRemoteCompaction(
514
514
  });
515
515
 
516
516
  if (!response.ok) {
517
- const errorText = await response.text().catch(() => "");
518
- logger.warn("OpenAI remote compaction failed", {
519
- endpoint,
520
- status: response.status,
521
- statusText: response.statusText,
522
- errorText,
523
- });
524
517
  throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
525
518
  }
526
519
 
527
- const data = (await response.json()) as { output?: unknown[] } | undefined;
528
- const rawOutput = data?.output ?? [];
520
+ const data = (await response.json()) as { output?: unknown } | undefined;
521
+ if (!Array.isArray(data?.output)) {
522
+ throw new Error(`Remote compaction response malformed output (outputType=${typeof data?.output})`);
523
+ }
524
+ const rawOutput = data.output;
529
525
  const replacementHistory = rawOutput.filter(
530
526
  (item): item is Record<string, unknown> =>
531
527
  !!item && typeof item === "object" && shouldKeepOpenAiCompactOutputItem(item as Record<string, unknown>),
@@ -539,15 +535,9 @@ export async function requestOpenAiRemoteCompaction(
539
535
  const outputTypes = rawOutput.map(item =>
540
536
  typeof item === "object" && item !== null ? (item as Record<string, unknown>).type : typeof item,
541
537
  );
542
- logger.warn("Remote compaction response missing compaction item", {
543
- endpoint,
544
- model: model.id,
545
- provider: model.provider,
546
- rawOutputLength: rawOutput.length,
547
- outputTypes,
548
- replacementHistoryLength: replacementHistory.length,
549
- });
550
- throw new Error("Remote compaction response missing compaction item");
538
+ throw new Error(
539
+ `Remote compaction response missing compaction item (rawOutputLength=${rawOutput.length}, outputTypes=${outputTypes.join(",")}, replacementHistoryLength=${replacementHistory.length})`,
540
+ );
551
541
  }
552
542
  return { provider: model.provider, replacementHistory, compactionItem };
553
543
  }
@@ -572,13 +562,6 @@ export async function requestRemoteCompaction(
572
562
  });
573
563
 
574
564
  if (!response.ok) {
575
- const errorText = await response.text().catch(() => "");
576
- logger.warn("Remote compaction failed", {
577
- endpoint,
578
- status: response.status,
579
- statusText: response.statusText,
580
- errorText,
581
- });
582
565
  throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
583
566
  }
584
567