@sayknow-cli/agent-core 0.3.1 → 0.3.2
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 +17 -0
- package/dist/types/compaction/pruning.d.ts +10 -0
- package/dist/types/harmony-leak.d.ts +1 -1
- package/package.json +4 -4
- package/src/agent-loop.ts +58 -0
- package/src/compaction/pruning.ts +165 -0
- package/src/harmony-leak.ts +27 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.4] - 2026-06-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
## [0.7.3] - 2026-06-25
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added Composer evidence publication gates in the agent loop, so Composer-harness turns emit structured evidence under defined publication conditions (#1106).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- 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).
|
|
19
|
+
- 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).
|
|
20
|
+
- Hardened the Composer trace mutation classifier and its recovery-target guard (#1105).
|
|
21
|
+
|
|
5
22
|
## [0.7.2] - 2026-06-24
|
|
6
23
|
|
|
7
24
|
### 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[];
|
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.2",
|
|
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.2",
|
|
39
|
+
"@sayknow-cli/natives": "0.3.2",
|
|
40
|
+
"@sayknow-cli/utils": "0.3.2",
|
|
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 "@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,
|
|
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
|
|
@@ -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
|
|
|
@@ -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,
|