@gajae-code/agent-core 0.7.2 → 0.7.3
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 +11 -0
- package/dist/types/harmony-leak.d.ts +1 -1
- package/package.json +4 -4
- package/src/agent-loop.ts +58 -0
- package/src/harmony-leak.ts +27 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.3] - 2026-06-25
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added Composer evidence publication gates in the agent loop, so Composer-harness turns emit structured evidence under defined publication conditions (#1106).
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Wired the previously-dead GPT-5 harmony-leak detector into the streamed assistant-message path for openai-codex turns: recoverable tool-argument leaks are now recovered and everything else is routed through the existing abort-retry/audit loop, and the contaminated streamed message is removed (abort-retry) or replaced (truncate-resume) from working context so the model does not replay its own leak as history. Added detection of the leaked Anthropic-style `<invoke name="…">` envelope dialect that gpt-5.5 intermittently emits as visible assistant text instead of a native function call (#1069).
|
|
13
|
+
- Detect proxy-level context overflow from empty responses: some proxies (notably LiteLLM) return an empty `content: []` with `stopReason: "stop"` and fabricated near-zero usage when the upstream context window is exceeded; the agent loop now recognizes this pattern and promotes it to an error so the existing overflow/compaction recovery path fires instead of freezing the session as a clean completion (#1102).
|
|
14
|
+
- Hardened the Composer trace mutation classifier and its recovery-target guard (#1105).
|
|
15
|
+
|
|
5
16
|
## [0.7.2] - 2026-06-24
|
|
6
17
|
|
|
7
18
|
### Fixed
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { AssistantMessage, Model } from "@gajae-code/ai";
|
|
11
11
|
declare const SIGNAL_ORDER: readonly ["M", "C", "G", "S", "B", "R", "T"];
|
|
12
|
-
export type HarmonySignalClass = "H" | (typeof SIGNAL_ORDER)[number];
|
|
12
|
+
export type HarmonySignalClass = "H" | "I" | (typeof SIGNAL_ORDER)[number];
|
|
13
13
|
export type HarmonySurface = "assistant_text" | "assistant_thinking" | "tool_arg";
|
|
14
14
|
export interface HarmonySignal {
|
|
15
15
|
classes: HarmonySignalClass[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/agent-core",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.3",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@gajae-code/ai": "0.7.
|
|
39
|
-
"@gajae-code/natives": "0.7.
|
|
40
|
-
"@gajae-code/utils": "0.7.
|
|
38
|
+
"@gajae-code/ai": "0.7.3",
|
|
39
|
+
"@gajae-code/natives": "0.7.3",
|
|
40
|
+
"@gajae-code/utils": "0.7.3",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type AssistantMessageEvent,
|
|
8
8
|
type Context,
|
|
9
9
|
EventStream,
|
|
10
|
+
isContextOverflow,
|
|
10
11
|
isZodSchema,
|
|
11
12
|
streamSimple,
|
|
12
13
|
type ToolResultMessage,
|
|
@@ -17,9 +18,12 @@ import {
|
|
|
17
18
|
import { sanitizeText } from "@gajae-code/utils";
|
|
18
19
|
import {
|
|
19
20
|
createHarmonyAuditEvent,
|
|
21
|
+
detectHarmonyLeakInAssistantMessage,
|
|
22
|
+
extractHarmonyRemoved,
|
|
20
23
|
type HarmonyDetection,
|
|
21
24
|
type HarmonyRecoveredToolCall,
|
|
22
25
|
isHarmonyLeakMitigationTarget,
|
|
26
|
+
recoverHarmonyToolCall,
|
|
23
27
|
signalListLabel,
|
|
24
28
|
} from "./harmony-leak";
|
|
25
29
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
@@ -51,6 +55,15 @@ import type {
|
|
|
51
55
|
|
|
52
56
|
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
|
|
53
57
|
const ABORTED: unique symbol = Symbol("agent-loop-aborted");
|
|
58
|
+
/**
|
|
59
|
+
* Detect empty "successful" responses that indicate a proxy-level context
|
|
60
|
+
* overflow (e.g. LiteLLM returning `content: []`, `stopReason: "stop"`, and a
|
|
61
|
+
* fabricated near-zero usage). We delegate to {@link isContextOverflow} which
|
|
62
|
+
* has the threshold constant, so the detection logic stays in one place.
|
|
63
|
+
*/
|
|
64
|
+
function isEmptyResponseOverflow(message: AssistantMessage): boolean {
|
|
65
|
+
return isContextOverflow(message);
|
|
66
|
+
}
|
|
54
67
|
|
|
55
68
|
class HarmonyLeakInterruption extends Error {
|
|
56
69
|
constructor(
|
|
@@ -502,6 +515,20 @@ async function runLoopBody(
|
|
|
502
515
|
streamFn,
|
|
503
516
|
harmonyRetryAttempt,
|
|
504
517
|
);
|
|
518
|
+
// Post-stream harmony-leak detection. The mitigation scaffolding
|
|
519
|
+
// below (HarmonyLeakInterruption catch + retry/recover/audit,
|
|
520
|
+
// plus harmonyAbortController) existed but nothing invoked the
|
|
521
|
+
// detector, so leaks on openai-codex models were never caught.
|
|
522
|
+
// Detect on the completed message and route recoverable tool-arg
|
|
523
|
+
// leaks through recovery, everything else through abort-retry.
|
|
524
|
+
if (isHarmonyLeakMitigationTarget(config.model)) {
|
|
525
|
+
const detection = detectHarmonyLeakInAssistantMessage(message);
|
|
526
|
+
if (detection) {
|
|
527
|
+
const rec = recoverHarmonyToolCall(message, detection);
|
|
528
|
+
const removed = rec ? rec.removed : extractHarmonyRemoved(message, detection);
|
|
529
|
+
throw new HarmonyLeakInterruption(detection, removed, rec);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
505
532
|
harmonyRetryAttempt = 0;
|
|
506
533
|
harmonyTruncateResumeCount = 0;
|
|
507
534
|
} catch (err) {
|
|
@@ -516,6 +543,15 @@ async function runLoopBody(
|
|
|
516
543
|
harmonyTruncateResumeCount++;
|
|
517
544
|
recovered = err.recovered;
|
|
518
545
|
message = recovered.message;
|
|
546
|
+
// Replace the contaminated assistant message committed during
|
|
547
|
+
// streaming with the recovered (truncated) one so the retry
|
|
548
|
+
// sees clean history.
|
|
549
|
+
{
|
|
550
|
+
const idx = currentContext.messages.length - 1;
|
|
551
|
+
if (idx >= 0 && currentContext.messages[idx]?.role === "assistant") {
|
|
552
|
+
currentContext.messages[idx] = recovered.message;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
519
555
|
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
|
|
520
556
|
} else {
|
|
521
557
|
if (harmonyRetryAttempt >= 2) {
|
|
@@ -526,12 +562,34 @@ async function runLoopBody(
|
|
|
526
562
|
}
|
|
527
563
|
await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
|
|
528
564
|
harmonyRetryAttempt++;
|
|
565
|
+
// Drop the contaminated assistant message committed during
|
|
566
|
+
// streaming so the retry does not replay the model's own leak
|
|
567
|
+
// back to it as history.
|
|
568
|
+
{
|
|
569
|
+
const idx = currentContext.messages.length - 1;
|
|
570
|
+
if (idx >= 0 && currentContext.messages[idx]?.role === "assistant") {
|
|
571
|
+
currentContext.messages.splice(idx, 1);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
529
574
|
continue;
|
|
530
575
|
}
|
|
531
576
|
}
|
|
532
577
|
newMessages.push(message);
|
|
533
578
|
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
534
579
|
|
|
580
|
+
// Detect empty "successful" responses (stopReason "stop" + empty content).
|
|
581
|
+
// Some proxies (e.g. LiteLLM) return this when the upstream model's context
|
|
582
|
+
// window is exceeded, fabricating a near-zero usage instead of surfacing an
|
|
583
|
+
// error. Without this guard the agent loop treats the empty response as a
|
|
584
|
+
// natural turn completion and stops, leaving the user with a frozen session.
|
|
585
|
+
// Promote it to an error so the overflow/compaction recovery path can fire.
|
|
586
|
+
if (message.stopReason === "stop" && message.content.length === 0 && isEmptyResponseOverflow(message)) {
|
|
587
|
+
message.stopReason = "error";
|
|
588
|
+
message.errorMessage = message.errorMessage
|
|
589
|
+
? `${message.errorMessage} | Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)`
|
|
590
|
+
: "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
|
|
591
|
+
}
|
|
592
|
+
|
|
535
593
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
536
594
|
// Create placeholder tool results for any tool calls in the aborted message
|
|
537
595
|
// This maintains the tool_use/tool_result pairing that the API requires
|
package/src/harmony-leak.ts
CHANGED
|
@@ -14,6 +14,19 @@ import type { AssistantMessage, Model, ToolCall } from "@gajae-code/ai";
|
|
|
14
14
|
const MARKER_RE = /\bto=functions\.[A-Za-z_]\w*/g;
|
|
15
15
|
const HARMONY_RE = /<\|(start|end|channel|message|call|return)\|>/g;
|
|
16
16
|
|
|
17
|
+
// Leaked tool-call envelope (`I`): a structurally-committed Anthropic-style
|
|
18
|
+
// invoke block (an opening tag carrying a name attribute). openai-codex models
|
|
19
|
+
// use native function calling, so such an envelope appearing as visible
|
|
20
|
+
// assistant text / thinking is always a leaked tool call — a different dialect
|
|
21
|
+
// of the §1 phenomenon where the tool-call intent collapses into the content
|
|
22
|
+
// channel (frequently prefixed by a glitch token such as a bare `court` line).
|
|
23
|
+
// High precision: requires the opening tag AND a committed body (a parameter
|
|
24
|
+
// tag or a closing invoke tag) within a short window, so prose that merely
|
|
25
|
+
// mentions the tag does not trip. Like `H`, it trips on its own (outside code
|
|
26
|
+
// fences). Source spelled with `\s` so this module does not self-trip.
|
|
27
|
+
const INVOKE_OPEN_RE = /<invoke\s+name="[^"]+"\s*>/g;
|
|
28
|
+
const INVOKE_BODY_RE = /<parameter\s+name="|<\/invoke>/;
|
|
29
|
+
|
|
17
30
|
// Channel-word adjacency (`C`): channel/role name appearing immediately before the marker.
|
|
18
31
|
const CHANNEL_WORD_RE = /\b(?:analysis|commentary|assistant|user|system|developer|tool)\s+to=functions\./;
|
|
19
32
|
|
|
@@ -66,7 +79,7 @@ const RECOVERY_REGISTRY: Record<string, RecoveryConfig> = {
|
|
|
66
79
|
|
|
67
80
|
const SIGNAL_ORDER = ["M", "C", "G", "S", "B", "R", "T"] as const;
|
|
68
81
|
|
|
69
|
-
export type HarmonySignalClass = "H" | (typeof SIGNAL_ORDER)[number];
|
|
82
|
+
export type HarmonySignalClass = "H" | "I" | (typeof SIGNAL_ORDER)[number];
|
|
70
83
|
|
|
71
84
|
export type HarmonySurface = "assistant_text" | "assistant_thinking" | "tool_arg";
|
|
72
85
|
|
|
@@ -154,6 +167,17 @@ export function detectHarmonyLeak(
|
|
|
154
167
|
signals.push(makeSignal(["H"], start, start + match[0].length, match[0]));
|
|
155
168
|
}
|
|
156
169
|
|
|
170
|
+
for (const match of text.matchAll(INVOKE_OPEN_RE)) {
|
|
171
|
+
const start = match.index ?? 0;
|
|
172
|
+
if (isInsideFence(fences, start)) continue;
|
|
173
|
+
// Require a committed body nearby so a bare mention of the tag in prose
|
|
174
|
+
// does not trip; a real leaked envelope continues into parameters or a
|
|
175
|
+
// close tag.
|
|
176
|
+
const forward = text.slice(start, Math.min(text.length, start + 400));
|
|
177
|
+
if (!INVOKE_BODY_RE.test(forward)) continue;
|
|
178
|
+
signals.push(makeSignal(["I"], start, start + match[0].length, match[0]));
|
|
179
|
+
}
|
|
180
|
+
|
|
157
181
|
for (const match of text.matchAll(MARKER_RE)) {
|
|
158
182
|
const start = match.index ?? 0;
|
|
159
183
|
if (isInsideFence(fences, start)) continue;
|
|
@@ -306,7 +330,7 @@ export function createHarmonyAuditEvent(params: {
|
|
|
306
330
|
// ─── internals ──────────────────────────────────────────────────────────────
|
|
307
331
|
|
|
308
332
|
function makeSignal(classes: HarmonySignalClass[], start: number, end: number, text: string): HarmonySignal {
|
|
309
|
-
if (classes[0] === "H") return { classes: [
|
|
333
|
+
if (classes[0] === "H" || classes[0] === "I") return { classes: [classes[0]], start, end, text };
|
|
310
334
|
const sorted: HarmonySignalClass[] = [];
|
|
311
335
|
for (const cls of SIGNAL_ORDER) {
|
|
312
336
|
if (classes.includes(cls)) sorted.push(cls);
|
|
@@ -402,7 +426,7 @@ function sha8(text: string): string {
|
|
|
402
426
|
|
|
403
427
|
const PREVIEW_KEEP_RE = new RegExp(`[${SCRIPT_CLASS}\\s】【”“…」「、。]`, "u");
|
|
404
428
|
const PREVIEW_TOKEN_RE =
|
|
405
|
-
/^(?:to=functions\.[A-Za-z_]\w
|
|
429
|
+
/^(?:to=functions\.[A-Za-z_]\w*|<\/?invoke\b[^>]*>|<parameter\b[^>]*>|analysis|commentary|assistant|user|system|developer|tool|changedFiles|RTLU|Jsii(?:_commentary)?|\x4aapgolly)/;
|
|
406
430
|
|
|
407
431
|
/**
|
|
408
432
|
* Privacy-safe preview for the audit log: keeps marker/channel/glitch tokens,
|