clementine-agent 1.18.206 → 1.18.208
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/dist/agent/run-agent.js
CHANGED
|
@@ -428,8 +428,25 @@ export async function runAgent(prompt, opts) {
|
|
|
428
428
|
},
|
|
429
429
|
});
|
|
430
430
|
},
|
|
431
|
+
onLargeWrite: (info) => {
|
|
432
|
+
writeEvent({
|
|
433
|
+
kind: 'tool_result',
|
|
434
|
+
ts: new Date().toISOString(),
|
|
435
|
+
sessionId,
|
|
436
|
+
toolUseId: info.toolUseId,
|
|
437
|
+
toolResult: {
|
|
438
|
+
successful: true,
|
|
439
|
+
_clementine_large_write_guard: true,
|
|
440
|
+
tool: info.toolName,
|
|
441
|
+
filePath: info.filePath,
|
|
442
|
+
contentBytes: info.contentBytes,
|
|
443
|
+
...(info.archivePath ? { archivePath: info.archivePath } : {}),
|
|
444
|
+
message: 'Large Write content restored after placeholder Write to protect parent context.',
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
},
|
|
431
448
|
})
|
|
432
|
-
: { hooks: {}, stats: { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0 } };
|
|
449
|
+
: { hooks: {}, stats: { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0, largeWrites: 0 } };
|
|
433
450
|
// ── Tool-call dedup hook (1.18.173) ─────────────────────────────────
|
|
434
451
|
// Breaks the "re-fetch after compaction" loop that crashed the
|
|
435
452
|
// imessage-triage cron on 2026-05-11 (4× identical tool calls →
|
|
@@ -854,12 +871,13 @@ export async function runAgent(prompt, opts) {
|
|
|
854
871
|
finalTextChars: finalText.length,
|
|
855
872
|
// 1.18.169 — tool-output guard summary, surfaced for observability.
|
|
856
873
|
// Non-zero `compressed` means the guard kept the SDK from thrashing.
|
|
857
|
-
guard: guard.stats.inspected > 0 ? {
|
|
874
|
+
guard: (guard.stats.inspected > 0 || guard.stats.largeWrites > 0) ? {
|
|
858
875
|
inspected: guard.stats.inspected,
|
|
859
876
|
compressed: guard.stats.compressed,
|
|
860
877
|
bytesShed: guard.stats.bytesShed,
|
|
861
878
|
compactions: guard.stats.compactions,
|
|
862
879
|
ceilingHits: guard.stats.ceilingHits,
|
|
880
|
+
largeWrites: guard.stats.largeWrites,
|
|
863
881
|
} : undefined,
|
|
864
882
|
// 1.18.173 — tool-call dedup summary. Non-zero warned/blocked means
|
|
865
883
|
// the model tried to re-fetch identical data (typically a
|
|
@@ -198,6 +198,12 @@ export function extractRecipients(input) {
|
|
|
198
198
|
function extractSubject(input) {
|
|
199
199
|
return firstString(input.subject, input.title);
|
|
200
200
|
}
|
|
201
|
+
function extractFilePath(input, raw) {
|
|
202
|
+
return firstString(input.file_path, input.filePath, input.path, input.target_path, input.targetPath)
|
|
203
|
+
?? (raw && typeof raw === 'object'
|
|
204
|
+
? firstString(raw.filePath, raw.file_path, raw.path)
|
|
205
|
+
: undefined);
|
|
206
|
+
}
|
|
201
207
|
function extractProviderLogId(raw) {
|
|
202
208
|
if (!raw || typeof raw !== 'object')
|
|
203
209
|
return undefined;
|
|
@@ -305,9 +311,11 @@ export function formatOverflowRecoveryMessage(summary) {
|
|
|
305
311
|
function formatDetailedCall(call) {
|
|
306
312
|
const recipients = extractRecipients(call.input);
|
|
307
313
|
const subject = extractSubject(call.input);
|
|
314
|
+
const filePath = extractFilePath(call.input, call.result?.raw);
|
|
308
315
|
const logId = call.result ? extractProviderLogId(call.result.raw) : undefined;
|
|
309
316
|
const parts = [
|
|
310
317
|
toolKindLabel(call.toolName),
|
|
318
|
+
filePath ? `file ${filePath}` : undefined,
|
|
311
319
|
recipients.length ? `to ${recipients.join(', ')}` : undefined,
|
|
312
320
|
subject ? `subject "${subject}"` : undefined,
|
|
313
321
|
call.result ? statusPhrase(call) : 'started, no confirmation',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tool-output-guard —
|
|
3
|
-
* so the SDK's auto-compactor can
|
|
2
|
+
* tool-output-guard — SDK hooks that bound per-call tool output size and
|
|
3
|
+
* out-of-band very large artifact writes so the SDK's auto-compactor can
|
|
4
|
+
* never thrash on runaway MCP results or generated files.
|
|
4
5
|
*
|
|
5
6
|
* Why this exists
|
|
6
7
|
* ───────────────
|
|
@@ -20,8 +21,10 @@
|
|
|
20
21
|
*
|
|
21
22
|
* The fix is the canonical Anthropic primitive: a `PostToolUse` hook that
|
|
22
23
|
* returns `hookSpecificOutput.updatedToolOutput` to replace the result
|
|
23
|
-
* before it reaches the model.
|
|
24
|
-
*
|
|
24
|
+
* before it reaches the model. A companion `PreToolUse` hook handles large
|
|
25
|
+
* `Write` inputs by swapping the native call to a small placeholder; the
|
|
26
|
+
* `PostToolUse` hook then restores the real artifact on disk after the
|
|
27
|
+
* placeholder write succeeds.
|
|
25
28
|
*
|
|
26
29
|
* Design properties
|
|
27
30
|
* ─────────────────
|
|
@@ -70,6 +73,8 @@ export interface GuardRunStats {
|
|
|
70
73
|
bytesShed: number;
|
|
71
74
|
/** Number of SDK auto-compactions observed for this run. */
|
|
72
75
|
compactions: number;
|
|
76
|
+
/** Large file writes completed out-of-band before reaching the SDK context. */
|
|
77
|
+
largeWrites: number;
|
|
73
78
|
}
|
|
74
79
|
/**
|
|
75
80
|
* Approximate the byte size of a tool_response as it will appear in the
|
|
@@ -138,6 +143,15 @@ export interface GuardHookOptions {
|
|
|
138
143
|
ceilingHit: boolean;
|
|
139
144
|
archivePath: string | null;
|
|
140
145
|
}) => void;
|
|
146
|
+
/** Optional callback fired when a large Write input is completed
|
|
147
|
+
* out-of-band by the guard before the native Write tool runs. */
|
|
148
|
+
onLargeWrite?: (info: {
|
|
149
|
+
toolName: string;
|
|
150
|
+
toolUseId: string;
|
|
151
|
+
filePath: string;
|
|
152
|
+
contentBytes: number;
|
|
153
|
+
archivePath: string | null;
|
|
154
|
+
}) => void;
|
|
141
155
|
/** Optional source of the current cumulative context-usage ratio
|
|
142
156
|
* (cache_read + input) / window. Returns a number in [0,1]. The
|
|
143
157
|
* guard calls this once per tool result to adapt the cap. When
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tool-output-guard —
|
|
3
|
-
* so the SDK's auto-compactor can
|
|
2
|
+
* tool-output-guard — SDK hooks that bound per-call tool output size and
|
|
3
|
+
* out-of-band very large artifact writes so the SDK's auto-compactor can
|
|
4
|
+
* never thrash on runaway MCP results or generated files.
|
|
4
5
|
*
|
|
5
6
|
* Why this exists
|
|
6
7
|
* ───────────────
|
|
@@ -20,8 +21,10 @@
|
|
|
20
21
|
*
|
|
21
22
|
* The fix is the canonical Anthropic primitive: a `PostToolUse` hook that
|
|
22
23
|
* returns `hookSpecificOutput.updatedToolOutput` to replace the result
|
|
23
|
-
* before it reaches the model.
|
|
24
|
-
*
|
|
24
|
+
* before it reaches the model. A companion `PreToolUse` hook handles large
|
|
25
|
+
* `Write` inputs by swapping the native call to a small placeholder; the
|
|
26
|
+
* `PostToolUse` hook then restores the real artifact on disk after the
|
|
27
|
+
* placeholder write succeeds.
|
|
25
28
|
*
|
|
26
29
|
* Design properties
|
|
27
30
|
* ─────────────────
|
|
@@ -45,7 +48,7 @@
|
|
|
45
48
|
* continues. Telemetry must never break execution.
|
|
46
49
|
*/
|
|
47
50
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
48
|
-
import { join } from 'node:path';
|
|
51
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
49
52
|
import pino from 'pino';
|
|
50
53
|
import { BASE_DIR, TOOL_OUTPUT_GUARD } from '../config.js';
|
|
51
54
|
const logger = pino({ name: 'clementine.tool-output-guard' });
|
|
@@ -60,7 +63,7 @@ export function defaultGuardConfig() {
|
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
function freshStats() {
|
|
63
|
-
return { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0 };
|
|
66
|
+
return { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0, largeWrites: 0 };
|
|
64
67
|
}
|
|
65
68
|
// ── Size estimation ───────────────────────────────────────────────────
|
|
66
69
|
/**
|
|
@@ -97,6 +100,26 @@ const VERBOSE_FIELDS = [
|
|
|
97
100
|
'body', 'html', 'html_body', 'htmlBody', 'bodyHtml', 'content', 'text', 'snippet',
|
|
98
101
|
'message', 'transcript', 'raw', 'rawBody', 'rawMessage', 'contentText', 'plainText',
|
|
99
102
|
];
|
|
103
|
+
const LARGE_WRITE_INPUT_BYTES = 8_000;
|
|
104
|
+
const LARGE_WRITE_PLACEHOLDER = '[Clementine large-write guard placeholder: full content is restored after native Write succeeds.]';
|
|
105
|
+
function writeArchiveFile(baseDir, runId, toolUseId, toolName, suffix, payload) {
|
|
106
|
+
try {
|
|
107
|
+
const dir = join(baseDir, 'tool-archive', runId);
|
|
108
|
+
mkdirSync(dir, { recursive: true });
|
|
109
|
+
const safeName = toolName.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
|
|
110
|
+
const safeSuffix = suffix.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 30);
|
|
111
|
+
const file = join(dir, `${safeName}__${toolUseId}${safeSuffix ? `__${safeSuffix}` : ''}.json`);
|
|
112
|
+
const body = typeof payload === 'string'
|
|
113
|
+
? payload
|
|
114
|
+
: JSON.stringify(payload, null, 2);
|
|
115
|
+
writeFileSync(file, body, 'utf8');
|
|
116
|
+
return file;
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
logger.debug({ err, toolName, runId }, 'tool-output-guard: archive write failed (non-fatal)');
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
100
123
|
/** First attempt: trim the list inside the response down to head + tail items. */
|
|
101
124
|
function tryListShrink(value, ctx) {
|
|
102
125
|
if (Array.isArray(value)) {
|
|
@@ -280,21 +303,7 @@ function formatBytes(n) {
|
|
|
280
303
|
* Returns the absolute path, or null on any failure (archive is opt-in
|
|
281
304
|
* convenience — never blocks compression). */
|
|
282
305
|
function archivePayload(baseDir, runId, toolUseId, toolName, payload) {
|
|
283
|
-
|
|
284
|
-
const dir = join(baseDir, 'tool-archive', runId);
|
|
285
|
-
mkdirSync(dir, { recursive: true });
|
|
286
|
-
const safeName = toolName.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
|
|
287
|
-
const file = join(dir, `${safeName}__${toolUseId}.json`);
|
|
288
|
-
const body = typeof payload === 'string'
|
|
289
|
-
? payload
|
|
290
|
-
: JSON.stringify(payload, null, 2);
|
|
291
|
-
writeFileSync(file, body, 'utf8');
|
|
292
|
-
return file;
|
|
293
|
-
}
|
|
294
|
-
catch (err) {
|
|
295
|
-
logger.debug({ err, toolName, runId }, 'tool-output-guard: archive write failed (non-fatal)');
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
306
|
+
return writeArchiveFile(baseDir, runId, toolUseId, toolName, '', payload);
|
|
298
307
|
}
|
|
299
308
|
// ── Adaptive cap computation ──────────────────────────────────────────
|
|
300
309
|
/**
|
|
@@ -375,6 +384,26 @@ export function compressToolOutput(_toolName, rawOutput, ctx) {
|
|
|
375
384
|
passthrough: false,
|
|
376
385
|
};
|
|
377
386
|
}
|
|
387
|
+
function asRecord(value) {
|
|
388
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
389
|
+
? value
|
|
390
|
+
: {};
|
|
391
|
+
}
|
|
392
|
+
function largeWriteInput(input) {
|
|
393
|
+
const obj = asRecord(input);
|
|
394
|
+
const filePath = typeof obj.file_path === 'string' ? obj.file_path.trim() : '';
|
|
395
|
+
const content = typeof obj.content === 'string' ? obj.content : '';
|
|
396
|
+
if (!filePath || !content || !isAbsolute(filePath))
|
|
397
|
+
return null;
|
|
398
|
+
const contentBytes = estimateBytes(content);
|
|
399
|
+
if (contentBytes <= LARGE_WRITE_INPUT_BYTES)
|
|
400
|
+
return null;
|
|
401
|
+
return { filePath, content, contentBytes };
|
|
402
|
+
}
|
|
403
|
+
function writeLargeFileOutOfBand(filePath, content) {
|
|
404
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
405
|
+
writeFileSync(filePath, content, 'utf8');
|
|
406
|
+
}
|
|
378
407
|
/**
|
|
379
408
|
* Build the hook handles that runAgent will hand to the SDK.
|
|
380
409
|
*
|
|
@@ -391,6 +420,52 @@ export function buildGuardHooks(opts) {
|
|
|
391
420
|
return { hooks: {}, stats };
|
|
392
421
|
}
|
|
393
422
|
const config = opts.config ?? defaultGuardConfig();
|
|
423
|
+
const pendingLargeWrites = new Map();
|
|
424
|
+
const preToolUse = async (input, toolUseID) => {
|
|
425
|
+
if (input.hook_event_name !== 'PreToolUse') {
|
|
426
|
+
return {};
|
|
427
|
+
}
|
|
428
|
+
const evt = input;
|
|
429
|
+
const toolName = String(evt.tool_name ?? 'unknown');
|
|
430
|
+
if (toolName !== 'Write')
|
|
431
|
+
return {};
|
|
432
|
+
const toolUseId = String(toolUseID ?? evt.tool_use_id ?? 'unknown');
|
|
433
|
+
const large = largeWriteInput(evt.tool_input);
|
|
434
|
+
if (!large)
|
|
435
|
+
return {};
|
|
436
|
+
const archivePath = writeArchiveFile(opts.archiveBaseDir ?? BASE_DIR, opts.runId, toolUseId, toolName, 'input', evt.tool_input);
|
|
437
|
+
pendingLargeWrites.set(toolUseId, {
|
|
438
|
+
filePath: large.filePath,
|
|
439
|
+
content: large.content,
|
|
440
|
+
contentBytes: large.contentBytes,
|
|
441
|
+
archivePath,
|
|
442
|
+
});
|
|
443
|
+
stats.bytesShed += Math.max(0, large.contentBytes - 400);
|
|
444
|
+
logger.info({
|
|
445
|
+
toolName,
|
|
446
|
+
toolUseId,
|
|
447
|
+
filePath: large.filePath,
|
|
448
|
+
contentBytes: large.contentBytes,
|
|
449
|
+
archivePath,
|
|
450
|
+
}, 'tool-output-guard: staged large Write with placeholder input');
|
|
451
|
+
const reason = [
|
|
452
|
+
`Clementine large-write guard staged ${formatBytes(large.contentBytes)} for ${large.filePath}.`,
|
|
453
|
+
archivePath ? `Full original Write input archived at ${archivePath}.` : undefined,
|
|
454
|
+
'The native Write will use a small placeholder, then Clementine will restore the full content after the write succeeds. Continue with the remaining requested steps after this tool result.',
|
|
455
|
+
].filter(Boolean).join(' ');
|
|
456
|
+
return {
|
|
457
|
+
hookSpecificOutput: {
|
|
458
|
+
hookEventName: 'PreToolUse',
|
|
459
|
+
permissionDecision: 'allow',
|
|
460
|
+
permissionDecisionReason: reason,
|
|
461
|
+
additionalContext: reason,
|
|
462
|
+
updatedInput: {
|
|
463
|
+
file_path: large.filePath,
|
|
464
|
+
content: LARGE_WRITE_PLACEHOLDER,
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
};
|
|
394
469
|
const postToolUse = async (input, toolUseID) => {
|
|
395
470
|
// We only react to PostToolUse — the hook list is keyed by event,
|
|
396
471
|
// but the callback signature is shared, so guard the cast.
|
|
@@ -402,6 +477,65 @@ export function buildGuardHooks(opts) {
|
|
|
402
477
|
const toolUseId = String(toolUseID ?? evt.tool_use_id ?? 'unknown');
|
|
403
478
|
const rawOutput = evt.tool_response;
|
|
404
479
|
stats.inspected += 1;
|
|
480
|
+
const pendingLargeWrite = toolName === 'Write' ? pendingLargeWrites.get(toolUseId) : undefined;
|
|
481
|
+
if (pendingLargeWrite) {
|
|
482
|
+
pendingLargeWrites.delete(toolUseId);
|
|
483
|
+
try {
|
|
484
|
+
writeLargeFileOutOfBand(pendingLargeWrite.filePath, pendingLargeWrite.content);
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
logger.warn({
|
|
488
|
+
err,
|
|
489
|
+
toolName,
|
|
490
|
+
toolUseId,
|
|
491
|
+
filePath: pendingLargeWrite.filePath,
|
|
492
|
+
contentBytes: pendingLargeWrite.contentBytes,
|
|
493
|
+
archivePath: pendingLargeWrite.archivePath,
|
|
494
|
+
}, 'tool-output-guard: failed to restore large Write content after placeholder write');
|
|
495
|
+
return {
|
|
496
|
+
hookSpecificOutput: {
|
|
497
|
+
hookEventName: 'PostToolUse',
|
|
498
|
+
additionalContext: [
|
|
499
|
+
`Clementine could not restore the staged ${formatBytes(pendingLargeWrite.contentBytes)} Write content to ${pendingLargeWrite.filePath}.`,
|
|
500
|
+
pendingLargeWrite.archivePath ? `The original input is archived at ${pendingLargeWrite.archivePath}.` : undefined,
|
|
501
|
+
'Retry by writing the file in a smaller way or ask the owner for the archive path.',
|
|
502
|
+
].filter(Boolean).join(' '),
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
stats.largeWrites += 1;
|
|
507
|
+
logger.info({
|
|
508
|
+
toolName,
|
|
509
|
+
toolUseId,
|
|
510
|
+
filePath: pendingLargeWrite.filePath,
|
|
511
|
+
contentBytes: pendingLargeWrite.contentBytes,
|
|
512
|
+
archivePath: pendingLargeWrite.archivePath,
|
|
513
|
+
}, 'tool-output-guard: restored large Write content after placeholder write');
|
|
514
|
+
if (opts.onLargeWrite) {
|
|
515
|
+
try {
|
|
516
|
+
opts.onLargeWrite({
|
|
517
|
+
toolName,
|
|
518
|
+
toolUseId,
|
|
519
|
+
filePath: pendingLargeWrite.filePath,
|
|
520
|
+
contentBytes: pendingLargeWrite.contentBytes,
|
|
521
|
+
archivePath: pendingLargeWrite.archivePath,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
catch { /* best-effort */ }
|
|
525
|
+
}
|
|
526
|
+
const restoredMessage = [
|
|
527
|
+
`File created successfully at: ${pendingLargeWrite.filePath}`,
|
|
528
|
+
`(Clementine restored ${formatBytes(pendingLargeWrite.contentBytes)} of large generated content after native Write used a placeholder to protect context.)`,
|
|
529
|
+
pendingLargeWrite.archivePath ? `Original Write input archived at: ${pendingLargeWrite.archivePath}` : undefined,
|
|
530
|
+
].filter(Boolean).join(' ');
|
|
531
|
+
return {
|
|
532
|
+
hookSpecificOutput: {
|
|
533
|
+
hookEventName: 'PostToolUse',
|
|
534
|
+
additionalContext: `The full file content is present at ${pendingLargeWrite.filePath}. Do not rewrite it; continue with verification, deployment, or the next requested step.`,
|
|
535
|
+
updatedToolOutput: restoredMessage,
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
405
539
|
const usageRatio = Math.max(opts.usageRatio ? safeRatio(opts.usageRatio) : 0, stats.compactions > 0 ? 0.75 : 0);
|
|
406
540
|
const { softCap } = resolveCap(toolName, config, usageRatio);
|
|
407
541
|
const originalBytes = estimateBytes(rawOutput);
|
|
@@ -474,6 +608,7 @@ export function buildGuardHooks(opts) {
|
|
|
474
608
|
};
|
|
475
609
|
return {
|
|
476
610
|
hooks: {
|
|
611
|
+
PreToolUse: [{ hooks: [preToolUse] }],
|
|
477
612
|
PostToolUse: [{ hooks: [postToolUse] }],
|
|
478
613
|
PreCompact: [{ hooks: [preCompact] }],
|
|
479
614
|
PostCompact: [{ hooks: [postCompact] }],
|