@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 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.1",
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.1",
39
- "@sayknow-cli/natives": "0.3.1",
40
- "@sayknow-cli/utils": "0.3.1",
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;
@@ -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: ["H"], start, end, text };
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*|analysis|commentary|assistant|user|system|developer|tool|changedFiles|RTLU|Jsii(?:_commentary)?|\x4aapgolly)/;
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,