@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 +6 -0
- package/dist/types/compaction/compaction.d.ts +15 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +60 -0
- package/src/compaction/compaction.ts +34 -3
- package/src/compaction/openai.ts +8 -25
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.
|
|
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.
|
|
36
|
-
"@gajae-code/natives": "0.11.
|
|
37
|
-
"@gajae-code/utils": "0.11.
|
|
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
|
-
|
|
1335
|
-
|
|
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
|
}
|
package/src/compaction/openai.ts
CHANGED
|
@@ -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
|
|
528
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|