clementine-agent 1.18.205 → 1.18.207
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/agent-definitions.js +3 -1
- package/dist/agent/run-agent.js +20 -2
- package/dist/agent/run-summary.d.ts +3 -0
- package/dist/agent/run-summary.js +85 -2
- package/dist/agent/tool-output-guard.d.ts +19 -4
- package/dist/agent/tool-output-guard.js +224 -22
- package/dist/config.js +4 -1
- package/package.json +1 -1
|
@@ -61,7 +61,7 @@ const RESEARCHER_PROMPT = [
|
|
|
61
61
|
'',
|
|
62
62
|
'## Output discipline',
|
|
63
63
|
'',
|
|
64
|
-
'Return a ONE-PARAGRAPH summary in the format the parent specified. Never raw tool output, never full lists, never unbounded data dumps. If a tool returns 50KB of JSON, extract only the requested fields and discard the rest — your job is to compress.',
|
|
64
|
+
'Return a ONE-PARAGRAPH summary in the format the parent specified. Hard limit: 250 words unless the parent explicitly asks for a longer artifact. Never raw tool output, never full lists, never unbounded data dumps. If a tool returns 50KB of JSON, extract only the requested fields and discard the rest — your job is to compress.',
|
|
65
65
|
'',
|
|
66
66
|
'If you cannot find the requested data, say so in one line. Do not speculate.',
|
|
67
67
|
].join('\n');
|
|
@@ -106,6 +106,8 @@ const DISCOVERY_PROMPT = [
|
|
|
106
106
|
'Recommendation: <which path the orchestrator should fetch next, if any>',
|
|
107
107
|
'```',
|
|
108
108
|
'',
|
|
109
|
+
'Hard output limit: 250 words. Return decision-grade handoff only: paths, counts, sizes, and the next file/action the orchestrator should inspect. Do NOT return a full project report, long markdown excerpts, repeated file inventories, or narrative analysis. If the parent needs detail, point it at the exact file path to read next.',
|
|
110
|
+
'',
|
|
109
111
|
'If nothing matches, say so in one line.',
|
|
110
112
|
'',
|
|
111
113
|
'You are bounded by max 15 turns. Use them wisely — list, scope, summarize, return.',
|
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 completed out-of-band; native Write tool denied 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
|
|
@@ -31,6 +31,9 @@ export interface RunSummary {
|
|
|
31
31
|
failedSideEffects: SideEffectCall[];
|
|
32
32
|
pendingSideEffects: SideEffectCall[];
|
|
33
33
|
unknownEffectCalls: SideEffectCall[];
|
|
34
|
+
successfulDelegations: SideEffectCall[];
|
|
35
|
+
failedDelegations: SideEffectCall[];
|
|
36
|
+
pendingDelegations: SideEffectCall[];
|
|
34
37
|
readOnlyCount: number;
|
|
35
38
|
errors: Array<{
|
|
36
39
|
runId: string;
|
|
@@ -66,11 +66,23 @@ export function summarizeRunSideEffects(runIds, eventLog = new EventLog()) {
|
|
|
66
66
|
const failedSideEffects = [];
|
|
67
67
|
const pendingSideEffects = [];
|
|
68
68
|
const unknownEffectCalls = [];
|
|
69
|
+
const successfulDelegations = [];
|
|
70
|
+
const failedDelegations = [];
|
|
71
|
+
const pendingDelegations = [];
|
|
69
72
|
let readOnlyCount = 0;
|
|
70
73
|
for (const call of events.filter(isToolCall)) {
|
|
71
74
|
const verdict = classifyToolCall(call.toolName, asInput(call.toolInput));
|
|
72
75
|
const result = resultForToolUse(events, call.toolUseId);
|
|
73
76
|
const item = makeCall(call, result, verdict);
|
|
77
|
+
if (call.toolName === 'Agent') {
|
|
78
|
+
if (!result)
|
|
79
|
+
pendingDelegations.push(item);
|
|
80
|
+
else if (item.result?.successful)
|
|
81
|
+
successfulDelegations.push(item);
|
|
82
|
+
else
|
|
83
|
+
failedDelegations.push(item);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
74
86
|
if (verdict.kind === 'read_only') {
|
|
75
87
|
readOnlyCount += 1;
|
|
76
88
|
continue;
|
|
@@ -110,6 +122,9 @@ export function summarizeRunSideEffects(runIds, eventLog = new EventLog()) {
|
|
|
110
122
|
failedSideEffects,
|
|
111
123
|
pendingSideEffects,
|
|
112
124
|
unknownEffectCalls,
|
|
125
|
+
successfulDelegations,
|
|
126
|
+
failedDelegations,
|
|
127
|
+
pendingDelegations,
|
|
113
128
|
readOnlyCount,
|
|
114
129
|
errors,
|
|
115
130
|
...(lastAssistantText ? { lastAssistantText } : {}),
|
|
@@ -120,7 +135,10 @@ export function hasOperationalActivity(summary) {
|
|
|
120
135
|
return summary.successfulSideEffects.length > 0
|
|
121
136
|
|| summary.failedSideEffects.length > 0
|
|
122
137
|
|| summary.pendingSideEffects.length > 0
|
|
123
|
-
|| summary.unknownEffectCalls.length > 0
|
|
138
|
+
|| summary.unknownEffectCalls.length > 0
|
|
139
|
+
|| summary.successfulDelegations.length > 0
|
|
140
|
+
|| summary.failedDelegations.length > 0
|
|
141
|
+
|| summary.pendingDelegations.length > 0;
|
|
124
142
|
}
|
|
125
143
|
function toolKindLabel(toolName) {
|
|
126
144
|
const lower = toolName.toLowerCase();
|
|
@@ -180,6 +198,12 @@ export function extractRecipients(input) {
|
|
|
180
198
|
function extractSubject(input) {
|
|
181
199
|
return firstString(input.subject, input.title);
|
|
182
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
|
+
}
|
|
183
207
|
function extractProviderLogId(raw) {
|
|
184
208
|
if (!raw || typeof raw !== 'object')
|
|
185
209
|
return undefined;
|
|
@@ -206,6 +230,41 @@ function recipientPreview(calls, max = 3) {
|
|
|
206
230
|
function formatGroupedLines(prefix, calls) {
|
|
207
231
|
return groupCounts(calls).map((group) => `- ${group.count} ${group.label} ${prefix}${recipientPreview(group.calls)}`);
|
|
208
232
|
}
|
|
233
|
+
function collectResultText(value) {
|
|
234
|
+
if (typeof value === 'string')
|
|
235
|
+
return value;
|
|
236
|
+
if (Array.isArray(value))
|
|
237
|
+
return value.map(collectResultText).filter(Boolean).join('\n');
|
|
238
|
+
if (!value || typeof value !== 'object')
|
|
239
|
+
return '';
|
|
240
|
+
const obj = value;
|
|
241
|
+
return ['text', 'content', 'result', 'message']
|
|
242
|
+
.map((key) => collectResultText(obj[key]))
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.join('\n');
|
|
245
|
+
}
|
|
246
|
+
function extractAgentArchivePath(text) {
|
|
247
|
+
return text.match(/Full payload archived at `([^`]+)`/)?.[1]
|
|
248
|
+
?? text.match(/Full output:\s*([^\n]+)/)?.[1]?.trim();
|
|
249
|
+
}
|
|
250
|
+
function extractAgentId(text) {
|
|
251
|
+
return text.match(/\bagentId:\s*([a-zA-Z0-9_-]+)/)?.[1];
|
|
252
|
+
}
|
|
253
|
+
function formatDelegationCall(call, status) {
|
|
254
|
+
const description = firstString(call.input.description, call.input.task, call.input.prompt)?.slice(0, 120);
|
|
255
|
+
const subagentType = firstString(call.input.subagent_type, call.input.subagentType);
|
|
256
|
+
const resultText = call.result ? collectResultText(call.result.raw) : '';
|
|
257
|
+
const agentId = extractAgentId(resultText);
|
|
258
|
+
const archivePath = extractAgentArchivePath(resultText);
|
|
259
|
+
const pieces = [
|
|
260
|
+
subagentType ? `${subagentType} subagent` : 'subagent',
|
|
261
|
+
status,
|
|
262
|
+
description ? `for "${description}"` : undefined,
|
|
263
|
+
agentId ? `agentId ${agentId}` : undefined,
|
|
264
|
+
archivePath ? `archive ${archivePath}` : undefined,
|
|
265
|
+
].filter(Boolean);
|
|
266
|
+
return `- ${pieces.join(' · ')}`;
|
|
267
|
+
}
|
|
209
268
|
export function formatOverflowRecoveryMessage(summary) {
|
|
210
269
|
const lines = [
|
|
211
270
|
'That run hit the context limit after some work had already happened.',
|
|
@@ -216,12 +275,28 @@ export function formatOverflowRecoveryMessage(summary) {
|
|
|
216
275
|
lines.push(...formatGroupedLines('completed', summary.successfulSideEffects));
|
|
217
276
|
lines.push('');
|
|
218
277
|
}
|
|
219
|
-
if (summary.
|
|
278
|
+
if (summary.successfulDelegations.length > 0) {
|
|
279
|
+
lines.push('Delegated work completed before overflow:');
|
|
280
|
+
for (const call of summary.successfulDelegations.slice(0, 5))
|
|
281
|
+
lines.push(formatDelegationCall(call, 'completed'));
|
|
282
|
+
if (summary.successfulDelegations.length > 5)
|
|
283
|
+
lines.push(`- ...and ${summary.successfulDelegations.length - 5} more completed subagent calls`);
|
|
284
|
+
lines.push('');
|
|
285
|
+
}
|
|
286
|
+
if (summary.failedSideEffects.length > 0
|
|
287
|
+
|| summary.pendingSideEffects.length > 0
|
|
288
|
+
|| summary.unknownEffectCalls.length > 0
|
|
289
|
+
|| summary.failedDelegations.length > 0
|
|
290
|
+
|| summary.pendingDelegations.length > 0) {
|
|
220
291
|
lines.push('Needs attention:');
|
|
221
292
|
if (summary.failedSideEffects.length > 0)
|
|
222
293
|
lines.push(...formatGroupedLines('failed', summary.failedSideEffects));
|
|
223
294
|
if (summary.pendingSideEffects.length > 0)
|
|
224
295
|
lines.push(...formatGroupedLines('started, no confirmation', summary.pendingSideEffects));
|
|
296
|
+
for (const call of summary.failedDelegations.slice(0, 5))
|
|
297
|
+
lines.push(formatDelegationCall(call, 'failed'));
|
|
298
|
+
for (const call of summary.pendingDelegations.slice(0, 5))
|
|
299
|
+
lines.push(formatDelegationCall(call, 'started, no confirmation'));
|
|
225
300
|
if (summary.unknownEffectCalls.length > 0)
|
|
226
301
|
lines.push(`- ${summary.unknownEffectCalls.length} tool call(s) had unknown external effect`);
|
|
227
302
|
lines.push('');
|
|
@@ -236,9 +311,11 @@ export function formatOverflowRecoveryMessage(summary) {
|
|
|
236
311
|
function formatDetailedCall(call) {
|
|
237
312
|
const recipients = extractRecipients(call.input);
|
|
238
313
|
const subject = extractSubject(call.input);
|
|
314
|
+
const filePath = extractFilePath(call.input, call.result?.raw);
|
|
239
315
|
const logId = call.result ? extractProviderLogId(call.result.raw) : undefined;
|
|
240
316
|
const parts = [
|
|
241
317
|
toolKindLabel(call.toolName),
|
|
318
|
+
filePath ? `file ${filePath}` : undefined,
|
|
242
319
|
recipients.length ? `to ${recipients.join(', ')}` : undefined,
|
|
243
320
|
subject ? `subject "${subject}"` : undefined,
|
|
244
321
|
call.result ? statusPhrase(call) : 'started, no confirmation',
|
|
@@ -261,6 +338,12 @@ export function buildContinuationPrompt(summary, originalRequest) {
|
|
|
261
338
|
lines.push(`- ...and ${summary.successfulSideEffects.length - 80} more completed side effects in the event log.`);
|
|
262
339
|
lines.push('');
|
|
263
340
|
}
|
|
341
|
+
if (summary.successfulDelegations.length > 0) {
|
|
342
|
+
lines.push('Completed delegated work. Do not repeat discovery/research already done unless the archive is insufficient:');
|
|
343
|
+
for (const call of summary.successfulDelegations.slice(0, 20))
|
|
344
|
+
lines.push(formatDelegationCall(call, 'completed'));
|
|
345
|
+
lines.push('');
|
|
346
|
+
}
|
|
264
347
|
if (summary.failedSideEffects.length > 0) {
|
|
265
348
|
lines.push('Failed side effects that may need retry or reconciliation:');
|
|
266
349
|
for (const call of summary.failedSideEffects.slice(0, 30))
|
|
@@ -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,9 @@
|
|
|
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 writing the artifact to disk before the native tool can
|
|
26
|
+
* echo a giant file body into the parent conversation.
|
|
25
27
|
*
|
|
26
28
|
* Design properties
|
|
27
29
|
* ─────────────────
|
|
@@ -70,6 +72,8 @@ export interface GuardRunStats {
|
|
|
70
72
|
bytesShed: number;
|
|
71
73
|
/** Number of SDK auto-compactions observed for this run. */
|
|
72
74
|
compactions: number;
|
|
75
|
+
/** Large file writes completed out-of-band before reaching the SDK context. */
|
|
76
|
+
largeWrites: number;
|
|
73
77
|
}
|
|
74
78
|
/**
|
|
75
79
|
* Approximate the byte size of a tool_response as it will appear in the
|
|
@@ -80,6 +84,8 @@ export interface GuardRunStats {
|
|
|
80
84
|
export declare function estimateBytes(value: unknown): number;
|
|
81
85
|
interface CompressionContext {
|
|
82
86
|
toolName: string;
|
|
87
|
+
toolUseId?: string;
|
|
88
|
+
toolInput?: unknown;
|
|
83
89
|
archivePath: string | null;
|
|
84
90
|
cap: number;
|
|
85
91
|
}
|
|
@@ -136,6 +142,15 @@ export interface GuardHookOptions {
|
|
|
136
142
|
ceilingHit: boolean;
|
|
137
143
|
archivePath: string | null;
|
|
138
144
|
}) => void;
|
|
145
|
+
/** Optional callback fired when a large Write input is completed
|
|
146
|
+
* out-of-band by the guard before the native Write tool runs. */
|
|
147
|
+
onLargeWrite?: (info: {
|
|
148
|
+
toolName: string;
|
|
149
|
+
toolUseId: string;
|
|
150
|
+
filePath: string;
|
|
151
|
+
contentBytes: number;
|
|
152
|
+
archivePath: string | null;
|
|
153
|
+
}) => void;
|
|
139
154
|
/** Optional source of the current cumulative context-usage ratio
|
|
140
155
|
* (cache_read + input) / window. Returns a number in [0,1]. The
|
|
141
156
|
* 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,9 @@
|
|
|
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 writing the artifact to disk before the native tool can
|
|
26
|
+
* echo a giant file body into the parent conversation.
|
|
25
27
|
*
|
|
26
28
|
* Design properties
|
|
27
29
|
* ─────────────────
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
* continues. Telemetry must never break execution.
|
|
46
48
|
*/
|
|
47
49
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
48
|
-
import { join } from 'node:path';
|
|
50
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
49
51
|
import pino from 'pino';
|
|
50
52
|
import { BASE_DIR, TOOL_OUTPUT_GUARD } from '../config.js';
|
|
51
53
|
const logger = pino({ name: 'clementine.tool-output-guard' });
|
|
@@ -60,7 +62,7 @@ export function defaultGuardConfig() {
|
|
|
60
62
|
};
|
|
61
63
|
}
|
|
62
64
|
function freshStats() {
|
|
63
|
-
return { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0 };
|
|
65
|
+
return { inspected: 0, compressed: 0, ceilingHits: 0, bytesShed: 0, compactions: 0, largeWrites: 0 };
|
|
64
66
|
}
|
|
65
67
|
// ── Size estimation ───────────────────────────────────────────────────
|
|
66
68
|
/**
|
|
@@ -97,6 +99,25 @@ const VERBOSE_FIELDS = [
|
|
|
97
99
|
'body', 'html', 'html_body', 'htmlBody', 'bodyHtml', 'content', 'text', 'snippet',
|
|
98
100
|
'message', 'transcript', 'raw', 'rawBody', 'rawMessage', 'contentText', 'plainText',
|
|
99
101
|
];
|
|
102
|
+
const LARGE_WRITE_INPUT_BYTES = 8_000;
|
|
103
|
+
function writeArchiveFile(baseDir, runId, toolUseId, toolName, suffix, payload) {
|
|
104
|
+
try {
|
|
105
|
+
const dir = join(baseDir, 'tool-archive', runId);
|
|
106
|
+
mkdirSync(dir, { recursive: true });
|
|
107
|
+
const safeName = toolName.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
|
|
108
|
+
const safeSuffix = suffix.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 30);
|
|
109
|
+
const file = join(dir, `${safeName}__${toolUseId}${safeSuffix ? `__${safeSuffix}` : ''}.json`);
|
|
110
|
+
const body = typeof payload === 'string'
|
|
111
|
+
? payload
|
|
112
|
+
: JSON.stringify(payload, null, 2);
|
|
113
|
+
writeFileSync(file, body, 'utf8');
|
|
114
|
+
return file;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
logger.debug({ err, toolName, runId }, 'tool-output-guard: archive write failed (non-fatal)');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
100
121
|
/** First attempt: trim the list inside the response down to head + tail items. */
|
|
101
122
|
function tryListShrink(value, ctx) {
|
|
102
123
|
if (Array.isArray(value)) {
|
|
@@ -117,6 +138,101 @@ function tryListShrink(value, ctx) {
|
|
|
117
138
|
}
|
|
118
139
|
return null;
|
|
119
140
|
}
|
|
141
|
+
function collectTextFragments(value) {
|
|
142
|
+
if (typeof value === 'string')
|
|
143
|
+
return [value];
|
|
144
|
+
if (Array.isArray(value))
|
|
145
|
+
return value.flatMap((item) => collectTextFragments(item));
|
|
146
|
+
if (!value || typeof value !== 'object')
|
|
147
|
+
return [];
|
|
148
|
+
const obj = value;
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const key of ['text', 'content', 'result', 'message']) {
|
|
151
|
+
const v = obj[key];
|
|
152
|
+
if (typeof v === 'string')
|
|
153
|
+
out.push(v);
|
|
154
|
+
else if (Array.isArray(v) || (v && typeof v === 'object'))
|
|
155
|
+
out.push(...collectTextFragments(v));
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function objectField(value, key) {
|
|
160
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
161
|
+
? typeof value[key] === 'string'
|
|
162
|
+
? String(value[key]).trim()
|
|
163
|
+
: undefined
|
|
164
|
+
: undefined;
|
|
165
|
+
}
|
|
166
|
+
function extractAgentId(text) {
|
|
167
|
+
return text.match(/\bagentId:\s*([a-zA-Z0-9_-]+)/)?.[1];
|
|
168
|
+
}
|
|
169
|
+
function extractUsageLine(text) {
|
|
170
|
+
const match = text.match(/<usage>[\s\S]*?(?:<\/usage>|$)/);
|
|
171
|
+
return match?.[0]?.replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
172
|
+
}
|
|
173
|
+
function stripAgentBoilerplate(text) {
|
|
174
|
+
return text
|
|
175
|
+
.replace(/agentId:\s*[a-zA-Z0-9_-]+[\s\S]*$/i, '')
|
|
176
|
+
.replace(/<usage>[\s\S]*$/i, '')
|
|
177
|
+
.replace(/^\s*(perfect|great|okay|ok)[.!]?\s+now\s+i\s+have[^\n]*\n+/i, '')
|
|
178
|
+
.trim();
|
|
179
|
+
}
|
|
180
|
+
function compactMarkdownLines(text) {
|
|
181
|
+
const lines = stripAgentBoilerplate(text)
|
|
182
|
+
.split(/\r?\n/)
|
|
183
|
+
.map((line) => line.trim())
|
|
184
|
+
.filter((line) => line && line !== '---' && line !== '```');
|
|
185
|
+
const keep = [];
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
if (keep.length >= 26)
|
|
188
|
+
break;
|
|
189
|
+
if (/^#{1,4}\s/.test(line) || /^[-*]\s/.test(line) || /^\d+\.\s/.test(line) || /^[A-Z][^:]{2,60}:/.test(line)) {
|
|
190
|
+
keep.push(line);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (keep.length < 8 && line.length <= 220)
|
|
194
|
+
keep.push(line);
|
|
195
|
+
}
|
|
196
|
+
return keep.join('\n');
|
|
197
|
+
}
|
|
198
|
+
function fitUnderBytes(text, maxBytes) {
|
|
199
|
+
if (estimateBytes(text) <= maxBytes)
|
|
200
|
+
return text;
|
|
201
|
+
const marker = '\n\n[...compact handoff truncated; read the archived Agent result for full detail.]';
|
|
202
|
+
let head = text.slice(0, Math.max(200, maxBytes - estimateBytes(marker) - 200));
|
|
203
|
+
while (head.length > 200 && estimateBytes(head + marker) > maxBytes) {
|
|
204
|
+
head = head.slice(0, Math.floor(head.length * 0.9));
|
|
205
|
+
}
|
|
206
|
+
return `${head.trimEnd()}${marker}`;
|
|
207
|
+
}
|
|
208
|
+
function tryAgentShrink(value, ctx) {
|
|
209
|
+
if (ctx.toolName !== 'Agent')
|
|
210
|
+
return null;
|
|
211
|
+
const fragments = collectTextFragments(value);
|
|
212
|
+
const text = fragments.join('\n\n').trim();
|
|
213
|
+
if (!text)
|
|
214
|
+
return null;
|
|
215
|
+
const subagentType = objectField(ctx.toolInput, 'subagent_type');
|
|
216
|
+
const description = objectField(ctx.toolInput, 'description');
|
|
217
|
+
const agentId = extractAgentId(text);
|
|
218
|
+
const usage = extractUsageLine(text);
|
|
219
|
+
const summary = compactMarkdownLines(text);
|
|
220
|
+
const archive = archiveHint(ctx, 'full Agent result');
|
|
221
|
+
const lines = [
|
|
222
|
+
'[Clementine compacted this Agent result to protect the parent chat context.]',
|
|
223
|
+
subagentType ? `Subagent: ${subagentType}` : undefined,
|
|
224
|
+
description ? `Task: ${description}` : undefined,
|
|
225
|
+
agentId ? `agentId: ${agentId}` : undefined,
|
|
226
|
+
usage,
|
|
227
|
+
archive,
|
|
228
|
+
'',
|
|
229
|
+
'Decision-grade handoff:',
|
|
230
|
+
summary || fitUnderBytes(stripAgentBoilerplate(text), Math.max(1_000, Math.floor(ctx.cap * 0.6))),
|
|
231
|
+
'',
|
|
232
|
+
'Use this handoff to continue. Read the archived result only if the missing detail is necessary.',
|
|
233
|
+
].filter((line) => typeof line === 'string' && line.length > 0);
|
|
234
|
+
return fitUnderBytes(lines.join('\n'), ctx.cap);
|
|
235
|
+
}
|
|
120
236
|
function shrinkArray(arr, ctx) {
|
|
121
237
|
if (arr.length <= 6) {
|
|
122
238
|
// Don't trim short lists; the bloat is somewhere else (likely a fat body).
|
|
@@ -185,21 +301,7 @@ function formatBytes(n) {
|
|
|
185
301
|
* Returns the absolute path, or null on any failure (archive is opt-in
|
|
186
302
|
* convenience — never blocks compression). */
|
|
187
303
|
function archivePayload(baseDir, runId, toolUseId, toolName, payload) {
|
|
188
|
-
|
|
189
|
-
const dir = join(baseDir, 'tool-archive', runId);
|
|
190
|
-
mkdirSync(dir, { recursive: true });
|
|
191
|
-
const safeName = toolName.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 80);
|
|
192
|
-
const file = join(dir, `${safeName}__${toolUseId}.json`);
|
|
193
|
-
const body = typeof payload === 'string'
|
|
194
|
-
? payload
|
|
195
|
-
: JSON.stringify(payload, null, 2);
|
|
196
|
-
writeFileSync(file, body, 'utf8');
|
|
197
|
-
return file;
|
|
198
|
-
}
|
|
199
|
-
catch (err) {
|
|
200
|
-
logger.debug({ err, toolName, runId }, 'tool-output-guard: archive write failed (non-fatal)');
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
304
|
+
return writeArchiveFile(baseDir, runId, toolUseId, toolName, '', payload);
|
|
203
305
|
}
|
|
204
306
|
// ── Adaptive cap computation ──────────────────────────────────────────
|
|
205
307
|
/**
|
|
@@ -244,6 +346,19 @@ export function compressToolOutput(_toolName, rawOutput, ctx) {
|
|
|
244
346
|
if (originalBytes <= ctx.cap) {
|
|
245
347
|
return { output: rawOutput, bytesShed: 0, ceilingHit: false, passthrough: true };
|
|
246
348
|
}
|
|
349
|
+
// Agent tool results are subagent handoffs to the parent orchestrator.
|
|
350
|
+
// Preserve the decision-grade summary and archive the full result instead
|
|
351
|
+
// of letting a verbose report refill the parent context after compaction.
|
|
352
|
+
const agentShrunk = tryAgentShrink(rawOutput, ctx);
|
|
353
|
+
if (agentShrunk !== null) {
|
|
354
|
+
const bytes = estimateBytes(agentShrunk);
|
|
355
|
+
return {
|
|
356
|
+
output: agentShrunk,
|
|
357
|
+
bytesShed: Math.max(0, originalBytes - bytes),
|
|
358
|
+
ceilingHit: originalBytes > ctx.cap * 2,
|
|
359
|
+
passthrough: false,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
247
362
|
// Pass 1: list-shape shrink (preserves structure).
|
|
248
363
|
const shrunk1 = tryListShrink(rawOutput, ctx);
|
|
249
364
|
if (shrunk1 !== null) {
|
|
@@ -267,6 +382,26 @@ export function compressToolOutput(_toolName, rawOutput, ctx) {
|
|
|
267
382
|
passthrough: false,
|
|
268
383
|
};
|
|
269
384
|
}
|
|
385
|
+
function asRecord(value) {
|
|
386
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
387
|
+
? value
|
|
388
|
+
: {};
|
|
389
|
+
}
|
|
390
|
+
function largeWriteInput(input) {
|
|
391
|
+
const obj = asRecord(input);
|
|
392
|
+
const filePath = typeof obj.file_path === 'string' ? obj.file_path.trim() : '';
|
|
393
|
+
const content = typeof obj.content === 'string' ? obj.content : '';
|
|
394
|
+
if (!filePath || !content || !isAbsolute(filePath))
|
|
395
|
+
return null;
|
|
396
|
+
const contentBytes = estimateBytes(content);
|
|
397
|
+
if (contentBytes <= LARGE_WRITE_INPUT_BYTES)
|
|
398
|
+
return null;
|
|
399
|
+
return { filePath, content, contentBytes };
|
|
400
|
+
}
|
|
401
|
+
function writeLargeFileOutOfBand(filePath, content) {
|
|
402
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
403
|
+
writeFileSync(filePath, content, 'utf8');
|
|
404
|
+
}
|
|
270
405
|
/**
|
|
271
406
|
* Build the hook handles that runAgent will hand to the SDK.
|
|
272
407
|
*
|
|
@@ -283,6 +418,71 @@ export function buildGuardHooks(opts) {
|
|
|
283
418
|
return { hooks: {}, stats };
|
|
284
419
|
}
|
|
285
420
|
const config = opts.config ?? defaultGuardConfig();
|
|
421
|
+
const preToolUse = async (input, toolUseID) => {
|
|
422
|
+
if (input.hook_event_name !== 'PreToolUse') {
|
|
423
|
+
return {};
|
|
424
|
+
}
|
|
425
|
+
const evt = input;
|
|
426
|
+
const toolName = String(evt.tool_name ?? 'unknown');
|
|
427
|
+
if (toolName !== 'Write')
|
|
428
|
+
return {};
|
|
429
|
+
const toolUseId = String(toolUseID ?? evt.tool_use_id ?? 'unknown');
|
|
430
|
+
const large = largeWriteInput(evt.tool_input);
|
|
431
|
+
if (!large)
|
|
432
|
+
return {};
|
|
433
|
+
const archivePath = writeArchiveFile(opts.archiveBaseDir ?? BASE_DIR, opts.runId, toolUseId, toolName, 'input', evt.tool_input);
|
|
434
|
+
try {
|
|
435
|
+
writeLargeFileOutOfBand(large.filePath, large.content);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
logger.warn({
|
|
439
|
+
err,
|
|
440
|
+
toolName,
|
|
441
|
+
toolUseId,
|
|
442
|
+
filePath: large.filePath,
|
|
443
|
+
contentBytes: large.contentBytes,
|
|
444
|
+
}, 'tool-output-guard: large Write out-of-band write failed; allowing native tool');
|
|
445
|
+
return {};
|
|
446
|
+
}
|
|
447
|
+
stats.largeWrites += 1;
|
|
448
|
+
stats.bytesShed += Math.max(0, large.contentBytes - 400);
|
|
449
|
+
logger.info({
|
|
450
|
+
toolName,
|
|
451
|
+
toolUseId,
|
|
452
|
+
filePath: large.filePath,
|
|
453
|
+
contentBytes: large.contentBytes,
|
|
454
|
+
archivePath,
|
|
455
|
+
}, 'tool-output-guard: completed large Write out-of-band');
|
|
456
|
+
if (opts.onLargeWrite) {
|
|
457
|
+
try {
|
|
458
|
+
opts.onLargeWrite({
|
|
459
|
+
toolName,
|
|
460
|
+
toolUseId,
|
|
461
|
+
filePath: large.filePath,
|
|
462
|
+
contentBytes: large.contentBytes,
|
|
463
|
+
archivePath,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
catch { /* best-effort */ }
|
|
467
|
+
}
|
|
468
|
+
const reason = [
|
|
469
|
+
`Clementine large-write guard already wrote ${formatBytes(large.contentBytes)} to ${large.filePath}.`,
|
|
470
|
+
archivePath ? `Full original Write input archived at ${archivePath}.` : undefined,
|
|
471
|
+
'Do not retry Write. Treat the file creation as complete and continue with the remaining requested steps, such as verification or deploy.',
|
|
472
|
+
].filter(Boolean).join(' ');
|
|
473
|
+
return {
|
|
474
|
+
hookSpecificOutput: {
|
|
475
|
+
hookEventName: 'PreToolUse',
|
|
476
|
+
permissionDecision: 'deny',
|
|
477
|
+
permissionDecisionReason: reason,
|
|
478
|
+
additionalContext: reason,
|
|
479
|
+
updatedInput: {
|
|
480
|
+
file_path: large.filePath,
|
|
481
|
+
content: `[Clementine large-write guard wrote the full ${formatBytes(large.contentBytes)} content out-of-band. ${archivePath ? `Original input: ${archivePath}` : 'Original input was not archived.'}]`,
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
};
|
|
286
486
|
const postToolUse = async (input, toolUseID) => {
|
|
287
487
|
// We only react to PostToolUse — the hook list is keyed by event,
|
|
288
488
|
// but the callback signature is shared, so guard the cast.
|
|
@@ -294,7 +494,7 @@ export function buildGuardHooks(opts) {
|
|
|
294
494
|
const toolUseId = String(toolUseID ?? evt.tool_use_id ?? 'unknown');
|
|
295
495
|
const rawOutput = evt.tool_response;
|
|
296
496
|
stats.inspected += 1;
|
|
297
|
-
const usageRatio = opts.usageRatio ? safeRatio(opts.usageRatio) : 0;
|
|
497
|
+
const usageRatio = Math.max(opts.usageRatio ? safeRatio(opts.usageRatio) : 0, stats.compactions > 0 ? 0.75 : 0);
|
|
298
498
|
const { softCap } = resolveCap(toolName, config, usageRatio);
|
|
299
499
|
const originalBytes = estimateBytes(rawOutput);
|
|
300
500
|
if (originalBytes <= softCap) {
|
|
@@ -307,6 +507,7 @@ export function buildGuardHooks(opts) {
|
|
|
307
507
|
const archivePath = archivePayload(opts.archiveBaseDir ?? BASE_DIR, opts.runId, toolUseId, toolName, rawOutput);
|
|
308
508
|
const outcome = compressToolOutput(toolName, rawOutput, {
|
|
309
509
|
toolName,
|
|
510
|
+
toolInput: evt.tool_input,
|
|
310
511
|
toolUseId,
|
|
311
512
|
archivePath,
|
|
312
513
|
cap: softCap,
|
|
@@ -365,6 +566,7 @@ export function buildGuardHooks(opts) {
|
|
|
365
566
|
};
|
|
366
567
|
return {
|
|
367
568
|
hooks: {
|
|
569
|
+
PreToolUse: [{ hooks: [preToolUse] }],
|
|
368
570
|
PostToolUse: [{ hooks: [postToolUse] }],
|
|
369
571
|
PreCompact: [{ hooks: [preCompact] }],
|
|
370
572
|
PostCompact: [{ hooks: [postCompact] }],
|
package/dist/config.js
CHANGED
|
@@ -552,7 +552,10 @@ export const TOOL_OUTPUT_GUARD = {
|
|
|
552
552
|
softLimitBytes: getEnvOrJsonNumber('TOOL_OUTPUT_GUARD_SOFT_BYTES', json.toolOutputGuard?.softLimitBytes, 30_000),
|
|
553
553
|
hardLimitBytes: getEnvOrJsonNumber('TOOL_OUTPUT_GUARD_HARD_BYTES', json.toolOutputGuard?.hardLimitBytes, 200_000),
|
|
554
554
|
adaptive: boolEnv('TOOL_OUTPUT_GUARD_ADAPTIVE', json.toolOutputGuard?.adaptive, true),
|
|
555
|
-
|
|
555
|
+
// Agent results are especially dangerous: even a "medium" subagent report
|
|
556
|
+
// refills the parent orchestrator after compaction. Keep the handoff tight;
|
|
557
|
+
// the full result is archived by tool-output-guard.
|
|
558
|
+
perTool: { Agent: 4_000, ...(json.toolOutputGuard?.perTool ?? {}) },
|
|
556
559
|
};
|
|
557
560
|
export const DEFAULT_MODEL_TIER = (getEnvOrJson('DEFAULT_MODEL_TIER', json.models?.default, 'sonnet'));
|
|
558
561
|
export const MODEL = MODELS[DEFAULT_MODEL_TIER] ?? MODELS.sonnet;
|