@sayknow-cli/agent-core 0.3.1 → 0.3.4
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 +23 -0
- package/dist/types/compaction/pruning.d.ts +10 -0
- package/dist/types/harmony-leak.d.ts +2 -1
- package/package.json +4 -4
- package/src/agent-loop.ts +51 -0
- package/src/compaction/pruning.ts +165 -0
- package/src/harmony-leak.ts +32 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.7] - 2026-06-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Mitigate leaked Anthropic-style `<invoke name="…">` tool-call envelopes across providers, not only `openai-codex`, while keeping Codex `to=functions.*` harmony-header mitigation provider-scoped.
|
|
10
|
+
|
|
11
|
+
## [0.7.4] - 2026-06-27
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `pruneAssistantToolArguments`: an isolated pre-compaction pruning pass that redacts stale `edit`/`write`/`apply_patch`/`ast_edit` tool-call argument payloads only when every touched path group has a later successful mutation, preserving tool-call identity (id/name/customWireName/signatures/intent/path hints), protecting latest/failed/ambiguous calls, and reporting separate stats from tool-result pruning. Reduces pre-compaction context pressure and the resident footprint of superseded large edit arguments.
|
|
16
|
+
|
|
17
|
+
## [0.7.3] - 2026-06-25
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Added Composer evidence publication gates in the agent loop, so Composer-harness turns emit structured evidence under defined publication conditions (#1106).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- 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).
|
|
25
|
+
- 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).
|
|
26
|
+
- Hardened the Composer trace mutation classifier and its recovery-target guard (#1105).
|
|
27
|
+
|
|
5
28
|
## [0.7.2] - 2026-06-24
|
|
6
29
|
|
|
7
30
|
### Fixed
|
|
@@ -34,4 +34,14 @@ export interface PruneResult {
|
|
|
34
34
|
*/
|
|
35
35
|
prunedEntries: SessionMessageEntry[];
|
|
36
36
|
}
|
|
37
|
+
export interface AssistantArgumentPruneResult {
|
|
38
|
+
argumentPrunedCount: number;
|
|
39
|
+
argumentTokensSaved: number;
|
|
40
|
+
/**
|
|
41
|
+
* The mutated assistant message entries. Callers whose entry source returns
|
|
42
|
+
* materialized copies must write these back into their canonical store by id.
|
|
43
|
+
*/
|
|
44
|
+
prunedEntries: SessionMessageEntry[];
|
|
45
|
+
}
|
|
46
|
+
export declare function pruneAssistantToolArguments(entries: SessionEntry[], config?: PruneConfig): AssistantArgumentPruneResult;
|
|
37
47
|
export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { AssistantMessage, Model } from "@sayknow-cli/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[];
|
|
@@ -48,6 +48,7 @@ export interface HarmonyRecoveredToolCall {
|
|
|
48
48
|
* itself is cheap; the cost of missing a leak on a new model is not.
|
|
49
49
|
*/
|
|
50
50
|
export declare function isHarmonyLeakMitigationTarget(model: Model): boolean;
|
|
51
|
+
export declare function shouldMitigateHarmonyLeak(model: Model, detection: HarmonyDetection): boolean;
|
|
51
52
|
export declare function signalListLabel(signals: readonly HarmonySignal[]): string;
|
|
52
53
|
/**
|
|
53
54
|
* Detect harmony-protocol leakage in `text`. Returns undefined if clean.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/agent-core",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.4",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://github.com/jaybeyond/Sayknow_CLI",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@sayknow-cli/ai": "0.3.
|
|
39
|
-
"@sayknow-cli/natives": "0.3.
|
|
40
|
-
"@sayknow-cli/utils": "0.3.
|
|
38
|
+
"@sayknow-cli/ai": "0.3.4",
|
|
39
|
+
"@sayknow-cli/natives": "0.3.4",
|
|
40
|
+
"@sayknow-cli/utils": "0.3.4",
|
|
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,13 @@ import {
|
|
|
17
18
|
import { sanitizeText } from "@sayknow-cli/utils";
|
|
18
19
|
import {
|
|
19
20
|
createHarmonyAuditEvent,
|
|
21
|
+
detectHarmonyLeakInAssistantMessage,
|
|
22
|
+
extractHarmonyRemoved,
|
|
20
23
|
type HarmonyDetection,
|
|
21
24
|
type HarmonyRecoveredToolCall,
|
|
22
25
|
isHarmonyLeakMitigationTarget,
|
|
26
|
+
recoverHarmonyToolCall,
|
|
27
|
+
shouldMitigateHarmonyLeak,
|
|
23
28
|
signalListLabel,
|
|
24
29
|
} from "./harmony-leak";
|
|
25
30
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
@@ -51,6 +56,15 @@ import type {
|
|
|
51
56
|
|
|
52
57
|
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
|
|
53
58
|
const ABORTED: unique symbol = Symbol("agent-loop-aborted");
|
|
59
|
+
/**
|
|
60
|
+
* Detect empty "successful" responses that indicate a proxy-level context
|
|
61
|
+
* overflow (e.g. LiteLLM returning `content: []`, `stopReason: "stop"`, and a
|
|
62
|
+
* fabricated near-zero usage). We delegate to {@link isContextOverflow} which
|
|
63
|
+
* has the threshold constant, so the detection logic stays in one place.
|
|
64
|
+
*/
|
|
65
|
+
function isEmptyResponseOverflow(message: AssistantMessage): boolean {
|
|
66
|
+
return isContextOverflow(message);
|
|
67
|
+
}
|
|
54
68
|
|
|
55
69
|
class HarmonyLeakInterruption extends Error {
|
|
56
70
|
constructor(
|
|
@@ -502,6 +516,12 @@ async function runLoopBody(
|
|
|
502
516
|
streamFn,
|
|
503
517
|
harmonyRetryAttempt,
|
|
504
518
|
);
|
|
519
|
+
const detection = detectHarmonyLeakInAssistantMessage(message);
|
|
520
|
+
if (detection && shouldMitigateHarmonyLeak(config.model, detection)) {
|
|
521
|
+
const rec = recoverHarmonyToolCall(message, detection);
|
|
522
|
+
const removed = rec ? rec.removed : extractHarmonyRemoved(message, detection);
|
|
523
|
+
throw new HarmonyLeakInterruption(detection, removed, rec);
|
|
524
|
+
}
|
|
505
525
|
harmonyRetryAttempt = 0;
|
|
506
526
|
harmonyTruncateResumeCount = 0;
|
|
507
527
|
} catch (err) {
|
|
@@ -516,6 +536,15 @@ async function runLoopBody(
|
|
|
516
536
|
harmonyTruncateResumeCount++;
|
|
517
537
|
recovered = err.recovered;
|
|
518
538
|
message = recovered.message;
|
|
539
|
+
// Replace the contaminated assistant message committed during
|
|
540
|
+
// streaming with the recovered (truncated) one so the retry
|
|
541
|
+
// sees clean history.
|
|
542
|
+
{
|
|
543
|
+
const idx = currentContext.messages.length - 1;
|
|
544
|
+
if (idx >= 0 && currentContext.messages[idx]?.role === "assistant") {
|
|
545
|
+
currentContext.messages[idx] = recovered.message;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
519
548
|
await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
|
|
520
549
|
} else {
|
|
521
550
|
if (harmonyRetryAttempt >= 2) {
|
|
@@ -526,12 +555,34 @@ async function runLoopBody(
|
|
|
526
555
|
}
|
|
527
556
|
await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
|
|
528
557
|
harmonyRetryAttempt++;
|
|
558
|
+
// Drop the contaminated assistant message committed during
|
|
559
|
+
// streaming so the retry does not replay the model's own leak
|
|
560
|
+
// back to it as history.
|
|
561
|
+
{
|
|
562
|
+
const idx = currentContext.messages.length - 1;
|
|
563
|
+
if (idx >= 0 && currentContext.messages[idx]?.role === "assistant") {
|
|
564
|
+
currentContext.messages.splice(idx, 1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
529
567
|
continue;
|
|
530
568
|
}
|
|
531
569
|
}
|
|
532
570
|
newMessages.push(message);
|
|
533
571
|
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
534
572
|
|
|
573
|
+
// Detect empty "successful" responses (stopReason "stop" + empty content).
|
|
574
|
+
// Some proxies (e.g. LiteLLM) return this when the upstream model's context
|
|
575
|
+
// window is exceeded, fabricating a near-zero usage instead of surfacing an
|
|
576
|
+
// error. Without this guard the agent loop treats the empty response as a
|
|
577
|
+
// natural turn completion and stops, leaving the user with a frozen session.
|
|
578
|
+
// Promote it to an error so the overflow/compaction recovery path can fire.
|
|
579
|
+
if (message.stopReason === "stop" && message.content.length === 0 && isEmptyResponseOverflow(message)) {
|
|
580
|
+
message.stopReason = "error";
|
|
581
|
+
message.errorMessage = message.errorMessage
|
|
582
|
+
? `${message.errorMessage} | Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)`
|
|
583
|
+
: "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
|
|
584
|
+
}
|
|
585
|
+
|
|
535
586
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
536
587
|
// Create placeholder tool results for any tool calls in the aborted message
|
|
537
588
|
// This maintains the tool_use/tool_result pairing that the API requires
|
|
@@ -126,6 +126,24 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
|
126
126
|
return Math.max(0, tokens - noticeTokens);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export interface AssistantArgumentPruneResult {
|
|
130
|
+
argumentPrunedCount: number;
|
|
131
|
+
argumentTokensSaved: number;
|
|
132
|
+
/**
|
|
133
|
+
* The mutated assistant message entries. Callers whose entry source returns
|
|
134
|
+
* materialized copies must write these back into their canonical store by id.
|
|
135
|
+
*/
|
|
136
|
+
prunedEntries: SessionMessageEntry[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface PrunedToolArgumentsSentinel {
|
|
140
|
+
pruned: true;
|
|
141
|
+
reason: "stale_tool_arguments";
|
|
142
|
+
pathHints: string[];
|
|
143
|
+
originalChars: number;
|
|
144
|
+
prunedAt: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
const EDIT_TOOL_NAMES = new Set(["edit", "write", "apply_patch", "ast_edit"]);
|
|
130
148
|
|
|
131
149
|
/** Extract the file-path argument from a tool call, when the tool has one. */
|
|
@@ -170,6 +188,68 @@ function editToolPathGroups(call: ToolCall): string[][] {
|
|
|
170
188
|
}
|
|
171
189
|
return groups;
|
|
172
190
|
}
|
|
191
|
+
function pathGroupKey(group: string[]): string {
|
|
192
|
+
return JSON.stringify([...group].sort());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function pathHintsForGroups(groups: string[][]): string[] {
|
|
196
|
+
return [...new Set(groups.flat())].sort();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isPrunedToolArgumentsSentinel(value: unknown): value is PrunedToolArgumentsSentinel {
|
|
200
|
+
return (
|
|
201
|
+
typeof value === "object" &&
|
|
202
|
+
value !== null &&
|
|
203
|
+
(value as { pruned?: unknown; reason?: unknown }).pruned === true &&
|
|
204
|
+
(value as { pruned?: unknown; reason?: unknown }).reason === "stale_tool_arguments"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isEditToolCall(call: ToolCall): boolean {
|
|
209
|
+
return EDIT_TOOL_NAMES.has(call.name) || call.customWireName === "apply_patch";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface AssistantArgumentStalenessIndex {
|
|
213
|
+
latestSuccessfulMutationByPathGroup: Map<string, { index: number; callId: string }>;
|
|
214
|
+
failedCallIds: Set<string>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildAssistantArgumentStalenessIndex(entries: SessionEntry[]): AssistantArgumentStalenessIndex {
|
|
218
|
+
const callsById = new Map<string, ToolCall>();
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (entry.type !== "message") continue;
|
|
221
|
+
const message = entry.message as AgentMessage;
|
|
222
|
+
if (message.role !== "assistant") continue;
|
|
223
|
+
for (const content of message.content) {
|
|
224
|
+
if (content.type === "toolCall") callsById.set(content.id, content);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const latestSuccessfulMutationByPathGroup = new Map<string, { index: number; callId: string }>();
|
|
229
|
+
const failedCallIds = new Set<string>();
|
|
230
|
+
for (let i = 0; i < entries.length; i++) {
|
|
231
|
+
const message = getToolResultMessage(entries[i]);
|
|
232
|
+
if (!message) continue;
|
|
233
|
+
const call = callsById.get(message.toolCallId);
|
|
234
|
+
if (!call || !isEditToolCall(call)) continue;
|
|
235
|
+
const detailFiles = call.name === "ast_edit" ? resultDetailFiles(message) : [];
|
|
236
|
+
const groups = detailFiles.length > 0 ? detailFiles.map(file => [file]) : editToolPathGroups(call);
|
|
237
|
+
if (groups.length === 0) continue;
|
|
238
|
+
if (message.isError) {
|
|
239
|
+
failedCallIds.add(call.id);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const failed = failedEditPaths(message);
|
|
243
|
+
let mutated = false;
|
|
244
|
+
for (const group of groups) {
|
|
245
|
+
if (group.some(groupPath => failed.has(groupPath))) continue;
|
|
246
|
+
latestSuccessfulMutationByPathGroup.set(pathGroupKey(group), { index: i, callId: call.id });
|
|
247
|
+
mutated = true;
|
|
248
|
+
}
|
|
249
|
+
if (!mutated) failedCallIds.add(call.id);
|
|
250
|
+
}
|
|
251
|
+
return { latestSuccessfulMutationByPathGroup, failedCallIds };
|
|
252
|
+
}
|
|
173
253
|
|
|
174
254
|
/**
|
|
175
255
|
* Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
|
|
@@ -361,6 +441,91 @@ function buildStalenessIndex(entries: SessionEntry[]): StalenessIndex {
|
|
|
361
441
|
|
|
362
442
|
return { staleResultIndices };
|
|
363
443
|
}
|
|
444
|
+
export function pruneAssistantToolArguments(
|
|
445
|
+
entries: SessionEntry[],
|
|
446
|
+
config: PruneConfig = DEFAULT_PRUNE_CONFIG,
|
|
447
|
+
): AssistantArgumentPruneResult {
|
|
448
|
+
let accumulatedTokens = 0;
|
|
449
|
+
let argumentTokensSaved = 0;
|
|
450
|
+
const { latestSuccessfulMutationByPathGroup, failedCallIds } = buildAssistantArgumentStalenessIndex(entries);
|
|
451
|
+
const candidates: Array<{
|
|
452
|
+
entry: SessionMessageEntry;
|
|
453
|
+
call: ToolCall;
|
|
454
|
+
pathHints: string[];
|
|
455
|
+
originalChars: number;
|
|
456
|
+
savings: number;
|
|
457
|
+
}> = [];
|
|
458
|
+
|
|
459
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
460
|
+
const entry = entries[i];
|
|
461
|
+
if (entry.type !== "message") continue;
|
|
462
|
+
const message = entry.message as AgentMessage;
|
|
463
|
+
if (message.role !== "assistant") continue;
|
|
464
|
+
const entryTokens = estimateEntryTokens(entry);
|
|
465
|
+
const insideProtectWindow = accumulatedTokens < config.protectTokens;
|
|
466
|
+
accumulatedTokens += entryTokens;
|
|
467
|
+
for (const content of message.content) {
|
|
468
|
+
if (content.type !== "toolCall" || !isEditToolCall(content)) continue;
|
|
469
|
+
const argumentJson = JSON.stringify(content.arguments);
|
|
470
|
+
if (argumentJson === undefined) continue;
|
|
471
|
+
const originalChars = argumentJson.length;
|
|
472
|
+
if (isPrunedToolArgumentsSentinel(content.arguments)) continue;
|
|
473
|
+
if (insideProtectWindow || failedCallIds.has(content.id)) continue;
|
|
474
|
+
const groups = editToolPathGroups(content);
|
|
475
|
+
if (groups.length === 0) continue;
|
|
476
|
+
// Arguments are pruned as one indivisible payload, so require EVERY
|
|
477
|
+
// concrete path group to be stale from a later successful mutation.
|
|
478
|
+
// A group with no later success (failed/unknown/ambiguous) protects the
|
|
479
|
+
// whole call rather than dropping non-stale multi-file patch evidence.
|
|
480
|
+
const isStale =
|
|
481
|
+
groups.length > 0 &&
|
|
482
|
+
groups.every(group => {
|
|
483
|
+
const latest = latestSuccessfulMutationByPathGroup.get(pathGroupKey(group));
|
|
484
|
+
return latest !== undefined && latest.index > i && latest.callId !== content.id;
|
|
485
|
+
});
|
|
486
|
+
if (!isStale) continue;
|
|
487
|
+
const sentinelChars = JSON.stringify({
|
|
488
|
+
pruned: true,
|
|
489
|
+
reason: "stale_tool_arguments",
|
|
490
|
+
pathHints: pathHintsForGroups(groups),
|
|
491
|
+
originalChars,
|
|
492
|
+
prunedAt: 0,
|
|
493
|
+
} satisfies PrunedToolArgumentsSentinel).length;
|
|
494
|
+
candidates.push({
|
|
495
|
+
entry: entry as SessionMessageEntry,
|
|
496
|
+
call: content,
|
|
497
|
+
pathHints: pathHintsForGroups(groups),
|
|
498
|
+
originalChars,
|
|
499
|
+
savings: Math.max(0, Math.ceil((originalChars - sentinelChars) / 4)),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
for (const candidate of candidates) {
|
|
505
|
+
argumentTokensSaved += candidate.savings;
|
|
506
|
+
}
|
|
507
|
+
if (argumentTokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
508
|
+
return { argumentPrunedCount: 0, argumentTokensSaved: 0, prunedEntries: [] };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const prunedAt = Date.now();
|
|
512
|
+
const prunedEntries: SessionMessageEntry[] = [];
|
|
513
|
+
const prunedEntryIds = new Set<string>();
|
|
514
|
+
for (const candidate of candidates) {
|
|
515
|
+
candidate.call.arguments = {
|
|
516
|
+
pruned: true,
|
|
517
|
+
reason: "stale_tool_arguments",
|
|
518
|
+
pathHints: candidate.pathHints,
|
|
519
|
+
originalChars: candidate.originalChars,
|
|
520
|
+
prunedAt,
|
|
521
|
+
};
|
|
522
|
+
if (!prunedEntryIds.has(candidate.entry.id)) {
|
|
523
|
+
prunedEntries.push(candidate.entry);
|
|
524
|
+
prunedEntryIds.add(candidate.entry.id);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { argumentPrunedCount: candidates.length, argumentTokensSaved, prunedEntries };
|
|
528
|
+
}
|
|
364
529
|
|
|
365
530
|
export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
|
|
366
531
|
let accumulatedTokens = 0;
|
package/src/harmony-leak.ts
CHANGED
|
@@ -14,6 +14,19 @@ import type { AssistantMessage, Model, ToolCall } from "@sayknow-cli/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
|
|
|
@@ -114,6 +127,11 @@ export function isHarmonyLeakMitigationTarget(model: Model): boolean {
|
|
|
114
127
|
return model.provider === "openai-codex";
|
|
115
128
|
}
|
|
116
129
|
|
|
130
|
+
export function shouldMitigateHarmonyLeak(model: Model, detection: HarmonyDetection): boolean {
|
|
131
|
+
if (isHarmonyLeakMitigationTarget(model)) return true;
|
|
132
|
+
return detection.signals.some(signal => signal.classes.includes("I"));
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
export function signalListLabel(signals: readonly HarmonySignal[]): string {
|
|
118
136
|
const seen: string[] = [];
|
|
119
137
|
for (const signal of signals) {
|
|
@@ -154,6 +172,17 @@ export function detectHarmonyLeak(
|
|
|
154
172
|
signals.push(makeSignal(["H"], start, start + match[0].length, match[0]));
|
|
155
173
|
}
|
|
156
174
|
|
|
175
|
+
for (const match of text.matchAll(INVOKE_OPEN_RE)) {
|
|
176
|
+
const start = match.index ?? 0;
|
|
177
|
+
if (isInsideFence(fences, start)) continue;
|
|
178
|
+
// Require a committed body nearby so a bare mention of the tag in prose
|
|
179
|
+
// does not trip; a real leaked envelope continues into parameters or a
|
|
180
|
+
// close tag.
|
|
181
|
+
const forward = text.slice(start, Math.min(text.length, start + 400));
|
|
182
|
+
if (!INVOKE_BODY_RE.test(forward)) continue;
|
|
183
|
+
signals.push(makeSignal(["I"], start, start + match[0].length, match[0]));
|
|
184
|
+
}
|
|
185
|
+
|
|
157
186
|
for (const match of text.matchAll(MARKER_RE)) {
|
|
158
187
|
const start = match.index ?? 0;
|
|
159
188
|
if (isInsideFence(fences, start)) continue;
|
|
@@ -306,7 +335,7 @@ export function createHarmonyAuditEvent(params: {
|
|
|
306
335
|
// ─── internals ──────────────────────────────────────────────────────────────
|
|
307
336
|
|
|
308
337
|
function makeSignal(classes: HarmonySignalClass[], start: number, end: number, text: string): HarmonySignal {
|
|
309
|
-
if (classes[0] === "H") return { classes: [
|
|
338
|
+
if (classes[0] === "H" || classes[0] === "I") return { classes: [classes[0]], start, end, text };
|
|
310
339
|
const sorted: HarmonySignalClass[] = [];
|
|
311
340
|
for (const cls of SIGNAL_ORDER) {
|
|
312
341
|
if (classes.includes(cls)) sorted.push(cls);
|
|
@@ -402,7 +431,7 @@ function sha8(text: string): string {
|
|
|
402
431
|
|
|
403
432
|
const PREVIEW_KEEP_RE = new RegExp(`[${SCRIPT_CLASS}\\s】【”“…」「、。]`, "u");
|
|
404
433
|
const PREVIEW_TOKEN_RE =
|
|
405
|
-
/^(?:to=functions\.[A-Za-z_]\w
|
|
434
|
+
/^(?:to=functions\.[A-Za-z_]\w*|<\/?invoke\b[^>]*>|<parameter\b[^>]*>|analysis|commentary|assistant|user|system|developer|tool|changedFiles|RTLU|Jsii(?:_commentary)?|\x4aapgolly)/;
|
|
406
435
|
|
|
407
436
|
/**
|
|
408
437
|
* Privacy-safe preview for the audit log: keeps marker/channel/glitch tokens,
|