@felan-ai/felan 0.24.2 → 0.24.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/NOTICE +4 -0
- package/README.md +85 -0
- package/dist/acp/interactions.d.ts +22 -0
- package/dist/acp/interactions.d.ts.map +1 -0
- package/dist/acp/interactions.js +352 -0
- package/dist/acp/interactions.js.map +1 -0
- package/dist/acp/login.d.ts +39 -0
- package/dist/acp/login.d.ts.map +1 -0
- package/dist/acp/login.js +500 -0
- package/dist/acp/login.js.map +1 -0
- package/dist/acp/server.d.ts +25 -0
- package/dist/acp/server.d.ts.map +1 -0
- package/dist/acp/server.js +96 -0
- package/dist/acp/server.js.map +1 -0
- package/dist/acp/session-registry.d.ts +25 -0
- package/dist/acp/session-registry.d.ts.map +1 -0
- package/dist/acp/session-registry.js +450 -0
- package/dist/acp/session-registry.js.map +1 -0
- package/dist/acp/session-updates.d.ts +44 -0
- package/dist/acp/session-updates.d.ts.map +1 -0
- package/dist/acp/session-updates.js +1076 -0
- package/dist/acp/session-updates.js.map +1 -0
- package/dist/acp/stdio.d.ts +13 -0
- package/dist/acp/stdio.d.ts.map +1 -0
- package/dist/acp/stdio.js +55 -0
- package/dist/acp/stdio.js.map +1 -0
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.d.ts.map +1 -1
- package/dist/cli-main.js +31 -0
- package/dist/cli-main.js.map +1 -1
- package/dist/command-autocomplete.d.ts +3 -0
- package/dist/command-autocomplete.d.ts.map +1 -0
- package/dist/command-autocomplete.js +10 -0
- package/dist/command-autocomplete.js.map +1 -0
- package/dist/cwd-command.d.ts.map +1 -1
- package/dist/cwd-command.js +2 -4
- package/dist/cwd-command.js.map +1 -1
- package/dist/restart-command.d.ts.map +1 -1
- package/dist/restart-command.js +2 -4
- package/dist/restart-command.js.map +1 -1
- package/dist/runtime.d.ts +8 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +15 -1
- package/dist/runtime.js.map +1 -1
- package/dist/subagents/host.d.ts +4 -0
- package/dist/subagents/host.d.ts.map +1 -1
- package/dist/subagents/host.js +6 -1
- package/dist/subagents/host.js.map +1 -1
- package/package.json +12 -11
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import { RequestError, methods, } from '@agentclientprotocol/sdk';
|
|
2
|
+
import { sessionEntryToContextMessages, } from '@felan-ai/agent-core';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync, statSync, } from 'node:fs';
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
6
|
+
const MAX_PROMPT_BLOCKS = 128;
|
|
7
|
+
const MAX_PROMPT_SERIALIZED_BYTES = 512 * 1024;
|
|
8
|
+
const MAX_PROMPT_BLOCK_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_PROMPT_BYTES = 256 * 1024;
|
|
10
|
+
const MAX_RESOURCE_FIELD_BYTES = 16 * 1024;
|
|
11
|
+
const MAX_ASSISTANT_TURN_BYTES = 1024 * 1024;
|
|
12
|
+
const MAX_TURN_UPDATES = 10_000;
|
|
13
|
+
const MAX_TURN_TOOL_CALLS = 1_024;
|
|
14
|
+
const MAX_SESSION_TOOL_CALL_IDS = 100_000;
|
|
15
|
+
const MAX_MESSAGE_CONTENT_PARTS = 2_048;
|
|
16
|
+
const MAX_UPDATE_TEXT_BYTES = 64 * 1024;
|
|
17
|
+
const MAX_REPLAY_TEXT_BYTES = 256 * 1024;
|
|
18
|
+
const MAX_REPLAY_ENTRIES = 100_000;
|
|
19
|
+
const MAX_REPLAY_UPDATES = 100_000;
|
|
20
|
+
const MAX_REPLAY_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
const MAX_REPLAY_TOOL_CALLS = 10_000;
|
|
22
|
+
const MAX_RAW_VALUE_BYTES = 64 * 1024;
|
|
23
|
+
const MAX_RAW_STRING_BYTES = 20 * 1024;
|
|
24
|
+
const MAX_RAW_DEPTH = 8;
|
|
25
|
+
const MAX_RAW_ENTRIES = 100;
|
|
26
|
+
const MAX_TOOL_CONTENT_ITEMS = 32;
|
|
27
|
+
const MAX_TOOL_CONTENT_BYTES = 128 * 1024;
|
|
28
|
+
const MAX_TOOL_TITLE_BYTES = 512;
|
|
29
|
+
const MAX_TOOL_ID_BYTES = 512;
|
|
30
|
+
const MAX_MESSAGE_ID_BYTES = 512;
|
|
31
|
+
const MAX_LOCATION_BYTES = 4 * 1024;
|
|
32
|
+
const MAX_LOCATIONS = 8;
|
|
33
|
+
const MAX_DIFF_FILE_BYTES = 256 * 1024;
|
|
34
|
+
const MAX_ERROR_BYTES = 4 * 1024;
|
|
35
|
+
const MAX_ERROR_SCAN_BYTES = 64 * 1024;
|
|
36
|
+
const TRUNCATION_MARKER = '\n… [truncated]';
|
|
37
|
+
const SENSITIVE_KEYS = new Set([
|
|
38
|
+
'accesstoken',
|
|
39
|
+
'apikey',
|
|
40
|
+
'authorization',
|
|
41
|
+
'clientsecret',
|
|
42
|
+
'cookie',
|
|
43
|
+
'password',
|
|
44
|
+
'privatekey',
|
|
45
|
+
'refreshtoken',
|
|
46
|
+
'secret',
|
|
47
|
+
'token',
|
|
48
|
+
]);
|
|
49
|
+
export function prepareAcpPrompt(content) {
|
|
50
|
+
let serializedBytes;
|
|
51
|
+
try {
|
|
52
|
+
serializedBytes = Buffer.byteLength(JSON.stringify(content));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw RequestError.invalidParams(undefined, 'Prompt content must be serializable');
|
|
56
|
+
}
|
|
57
|
+
if (serializedBytes > MAX_PROMPT_SERIALIZED_BYTES) {
|
|
58
|
+
throw RequestError.invalidParams({ maxBytes: MAX_PROMPT_SERIALIZED_BYTES }, 'Prompt payload exceeds the maximum encoded size');
|
|
59
|
+
}
|
|
60
|
+
if (content.length > MAX_PROMPT_BLOCKS) {
|
|
61
|
+
throw RequestError.invalidParams({ maxBlocks: MAX_PROMPT_BLOCKS }, 'Prompt contains too many content blocks');
|
|
62
|
+
}
|
|
63
|
+
const blocks = [];
|
|
64
|
+
const modelParts = [];
|
|
65
|
+
let totalBytes = 0;
|
|
66
|
+
for (const part of content) {
|
|
67
|
+
if (part.type === 'text') {
|
|
68
|
+
assertBoundedText(part.text, MAX_PROMPT_BLOCK_BYTES, 'Prompt text block');
|
|
69
|
+
assertNoNul(part.text, 'Prompt text block');
|
|
70
|
+
blocks.push({ type: 'text', text: part.text });
|
|
71
|
+
modelParts.push(part.text);
|
|
72
|
+
totalBytes += Buffer.byteLength(part.text);
|
|
73
|
+
}
|
|
74
|
+
else if (part.type === 'resource_link') {
|
|
75
|
+
const name = boundedResourceField(part.name, 'Resource name');
|
|
76
|
+
const uri = boundedResourceField(part.uri, 'Resource URI');
|
|
77
|
+
const description = part.description === undefined || part.description === null
|
|
78
|
+
? undefined
|
|
79
|
+
: boundedResourceField(part.description, 'Resource description');
|
|
80
|
+
const title = part.title === undefined || part.title === null
|
|
81
|
+
? undefined
|
|
82
|
+
: boundedResourceField(part.title, 'Resource title');
|
|
83
|
+
const mimeType = part.mimeType === undefined || part.mimeType === null
|
|
84
|
+
? undefined
|
|
85
|
+
: boundedResourceField(part.mimeType, 'Resource MIME type');
|
|
86
|
+
const safe = {
|
|
87
|
+
type: 'resource_link',
|
|
88
|
+
name,
|
|
89
|
+
uri,
|
|
90
|
+
...(description === undefined ? {} : { description }),
|
|
91
|
+
...(title === undefined ? {} : { title }),
|
|
92
|
+
...(mimeType === undefined ? {} : { mimeType }),
|
|
93
|
+
...(typeof part.size === 'number' && Number.isSafeInteger(part.size) && part.size >= 0
|
|
94
|
+
? { size: part.size }
|
|
95
|
+
: {}),
|
|
96
|
+
};
|
|
97
|
+
blocks.push(safe);
|
|
98
|
+
const modelText = formatResourceLink(safe);
|
|
99
|
+
modelParts.push(modelText);
|
|
100
|
+
totalBytes += Buffer.byteLength(modelText);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
throw RequestError.invalidParams({ contentType: part.type }, `Unsupported prompt content type: ${part.type}`);
|
|
104
|
+
}
|
|
105
|
+
if (totalBytes > MAX_PROMPT_BYTES) {
|
|
106
|
+
throw RequestError.invalidParams({ maxBytes: MAX_PROMPT_BYTES }, 'Prompt exceeds the maximum encoded size');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const text = modelParts.join('\n').trim();
|
|
110
|
+
if (!text)
|
|
111
|
+
throw RequestError.invalidParams(undefined, 'Prompt must contain text or a resource link');
|
|
112
|
+
if (Buffer.byteLength(text) > MAX_PROMPT_BYTES) {
|
|
113
|
+
throw RequestError.invalidParams({ maxBytes: MAX_PROMPT_BYTES }, 'Prompt exceeds the maximum encoded size');
|
|
114
|
+
}
|
|
115
|
+
return { text, blocks };
|
|
116
|
+
}
|
|
117
|
+
export class AcpToolCallIdRegistry {
|
|
118
|
+
#used = new Set();
|
|
119
|
+
#latest = new Map();
|
|
120
|
+
claim(id, scope = '') {
|
|
121
|
+
if (this.#used.size >= MAX_SESSION_TOOL_CALL_IDS) {
|
|
122
|
+
throw RequestError.internalError({ maxToolCalls: MAX_SESSION_TOOL_CALL_IDS }, 'Session contains too many tool calls');
|
|
123
|
+
}
|
|
124
|
+
const preferred = wireToolCallId(id);
|
|
125
|
+
if (!this.#used.has(preferred)) {
|
|
126
|
+
this.#used.add(preferred);
|
|
127
|
+
this.#latest.set(toolCallScopeKey(scope, id), preferred);
|
|
128
|
+
return preferred;
|
|
129
|
+
}
|
|
130
|
+
let sequence = 1;
|
|
131
|
+
while (true) {
|
|
132
|
+
const candidate = `tool-${createHash('sha256')
|
|
133
|
+
.update(`${id}\0${sequence}`)
|
|
134
|
+
.digest('hex')
|
|
135
|
+
.slice(0, 32)}`;
|
|
136
|
+
if (!this.#used.has(candidate)) {
|
|
137
|
+
this.#used.add(candidate);
|
|
138
|
+
this.#latest.set(toolCallScopeKey(scope, id), candidate);
|
|
139
|
+
return candidate;
|
|
140
|
+
}
|
|
141
|
+
sequence += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
current(id, scope = '') {
|
|
145
|
+
return this.#latest.get(toolCallScopeKey(scope, id));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function toolCallScopeKey(scope, id) {
|
|
149
|
+
return `${scope}\0${id}`;
|
|
150
|
+
}
|
|
151
|
+
export function createAcpToolCallUpdate(cwd, toolCallId, name, args) {
|
|
152
|
+
const safeName = truncateUtf8(name, MAX_TOOL_TITLE_BYTES * 2);
|
|
153
|
+
const metadata = toolMetadata(cwd, safeName, args);
|
|
154
|
+
return {
|
|
155
|
+
toolCallId,
|
|
156
|
+
title: metadata.title,
|
|
157
|
+
kind: metadata.kind,
|
|
158
|
+
...(metadata.locations.length === 0 ? {} : { locations: metadata.locations }),
|
|
159
|
+
rawInput: boundToolInput(cwd, name, args),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export class AcpPromptUpdateStream {
|
|
163
|
+
#sessionId;
|
|
164
|
+
#cwd;
|
|
165
|
+
#client;
|
|
166
|
+
#isActive;
|
|
167
|
+
#turnPrefix;
|
|
168
|
+
#tools = new Map();
|
|
169
|
+
#toolCallIds;
|
|
170
|
+
#tail = Promise.resolve();
|
|
171
|
+
#failure;
|
|
172
|
+
#messageSequence = 0;
|
|
173
|
+
#assistantMessageId;
|
|
174
|
+
#assistantStreamedIndexes = new Set();
|
|
175
|
+
#remainingAssistantBytes = MAX_ASSISTANT_TURN_BYTES;
|
|
176
|
+
#expectPromptUser = true;
|
|
177
|
+
#queuedUpdates = 0;
|
|
178
|
+
constructor(options) {
|
|
179
|
+
this.#sessionId = options.sessionId;
|
|
180
|
+
this.#cwd = options.cwd;
|
|
181
|
+
this.#client = options.client;
|
|
182
|
+
this.#isActive = options.isActive;
|
|
183
|
+
this.#turnPrefix = wireMessageId(`${options.sessionId}:turn:${options.turn}`);
|
|
184
|
+
this.#toolCallIds = options.toolCallIds ?? new AcpToolCallIdRegistry();
|
|
185
|
+
}
|
|
186
|
+
emitPrompt(blocks) {
|
|
187
|
+
const messageId = wireMessageId(`${this.#turnPrefix}:user:0`);
|
|
188
|
+
for (const content of blocks) {
|
|
189
|
+
this.#enqueue({ sessionUpdate: 'user_message_chunk', content, messageId });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
handle(event) {
|
|
193
|
+
try {
|
|
194
|
+
switch (event.type) {
|
|
195
|
+
case 'message_start':
|
|
196
|
+
if (event.message.role === 'assistant')
|
|
197
|
+
this.#startAssistantMessage();
|
|
198
|
+
else if (event.message.role === 'user')
|
|
199
|
+
this.#handleUserMessage(event.message.content);
|
|
200
|
+
break;
|
|
201
|
+
case 'message_update':
|
|
202
|
+
if (event.message.role === 'assistant') {
|
|
203
|
+
this.#handleAssistantUpdate(event.message, event.assistantMessageEvent);
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
case 'message_end':
|
|
207
|
+
if (event.message.role === 'assistant')
|
|
208
|
+
this.#finishAssistantMessage(event.message);
|
|
209
|
+
else if (event.message.role === 'toolResult') {
|
|
210
|
+
this.#finishTool(event.message.toolCallId, event.message.toolName, event.message, event.message.isError);
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
case 'tool_execution_start':
|
|
214
|
+
this.#startTool(event.toolCallId, event.toolName, event.args);
|
|
215
|
+
break;
|
|
216
|
+
case 'tool_execution_update':
|
|
217
|
+
this.#updateTool(event.toolCallId, event.toolName, event.args, event.partialResult);
|
|
218
|
+
break;
|
|
219
|
+
case 'tool_execution_end':
|
|
220
|
+
this.#finishTool(event.toolCallId, event.toolName, event.result, event.isError);
|
|
221
|
+
break;
|
|
222
|
+
default:
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
this.#failure ??= error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finish(cancelled) {
|
|
231
|
+
const message = cancelled ? 'Operation cancelled' : 'Tool did not complete';
|
|
232
|
+
for (const state of this.#tools.values()) {
|
|
233
|
+
if (state.status === 'terminal')
|
|
234
|
+
continue;
|
|
235
|
+
state.status = 'terminal';
|
|
236
|
+
this.#enqueue({
|
|
237
|
+
sessionUpdate: 'tool_call_update',
|
|
238
|
+
toolCallId: state.wireId,
|
|
239
|
+
status: 'failed',
|
|
240
|
+
content: textToolContent(message),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async flush() {
|
|
245
|
+
await this.#tail;
|
|
246
|
+
if (this.#failure !== undefined)
|
|
247
|
+
throw this.#failure;
|
|
248
|
+
}
|
|
249
|
+
#handleUserMessage(content) {
|
|
250
|
+
if (this.#expectPromptUser) {
|
|
251
|
+
this.#expectPromptUser = false;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const messageId = this.#nextMessageId('user');
|
|
255
|
+
const parts = typeof content === 'string' ? [content] : content
|
|
256
|
+
.filter((part) => (part.type === 'text' && typeof part.text === 'string'))
|
|
257
|
+
.map(({ text }) => text);
|
|
258
|
+
for (const text of parts) {
|
|
259
|
+
this.#enqueue({
|
|
260
|
+
sessionUpdate: 'user_message_chunk',
|
|
261
|
+
content: { type: 'text', text: truncateUtf8(text, MAX_UPDATE_TEXT_BYTES) },
|
|
262
|
+
messageId,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
#startAssistantMessage() {
|
|
267
|
+
this.#assistantMessageId = this.#nextMessageId('assistant');
|
|
268
|
+
this.#assistantStreamedIndexes = new Set();
|
|
269
|
+
}
|
|
270
|
+
#handleAssistantUpdate(message, update) {
|
|
271
|
+
const messageId = this.#assistantMessageId ?? this.#nextMessageId('assistant');
|
|
272
|
+
this.#assistantMessageId = messageId;
|
|
273
|
+
if (update.type === 'text_delta' || update.type === 'thinking_delta') {
|
|
274
|
+
const kind = update.type === 'text_delta' ? 'text' : 'thinking';
|
|
275
|
+
if (kind === 'thinking' && isRedactedThinking(message, update.contentIndex))
|
|
276
|
+
return;
|
|
277
|
+
this.#assistantStreamedIndexes.add(`${kind}:${update.contentIndex}`);
|
|
278
|
+
this.#emitAssistantChunk(kind, update.delta, messageId);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (update.type === 'text_end' || update.type === 'thinking_end') {
|
|
282
|
+
const kind = update.type === 'text_end' ? 'text' : 'thinking';
|
|
283
|
+
if (kind === 'thinking' && isRedactedThinking(message, update.contentIndex))
|
|
284
|
+
return;
|
|
285
|
+
const key = `${kind}:${update.contentIndex}`;
|
|
286
|
+
if (!this.#assistantStreamedIndexes.has(key)) {
|
|
287
|
+
this.#assistantStreamedIndexes.add(key);
|
|
288
|
+
this.#emitAssistantChunk(kind, update.content, messageId);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (update.type === 'toolcall_end') {
|
|
293
|
+
this.#ensureTool(update.toolCall.id, update.toolCall.name, update.toolCall.arguments, true);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
#finishAssistantMessage(message) {
|
|
297
|
+
const messageId = this.#assistantMessageId ?? this.#nextMessageId('assistant');
|
|
298
|
+
for (let index = 0; index < message.content.length; index += 1) {
|
|
299
|
+
const part = message.content[index];
|
|
300
|
+
if (part.type === 'text') {
|
|
301
|
+
if (!this.#assistantStreamedIndexes.has(`text:${index}`)) {
|
|
302
|
+
this.#emitAssistantChunk('text', part.text, messageId);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else if (part.type === 'thinking') {
|
|
306
|
+
if (!part.redacted && !this.#assistantStreamedIndexes.has(`thinking:${index}`)) {
|
|
307
|
+
this.#emitAssistantChunk('thinking', part.thinking, messageId);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
this.#ensureTool(part.id, part.name, part.arguments, true);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (message.stopReason === 'aborted' || message.stopReason === 'error') {
|
|
315
|
+
const failure = message.stopReason === 'aborted'
|
|
316
|
+
? 'Operation cancelled'
|
|
317
|
+
: sanitizeAcpErrorMessage(message.errorMessage ?? 'Tool execution failed');
|
|
318
|
+
for (const state of this.#tools.values()) {
|
|
319
|
+
if (state.status === 'terminal')
|
|
320
|
+
continue;
|
|
321
|
+
state.status = 'terminal';
|
|
322
|
+
this.#enqueue({
|
|
323
|
+
sessionUpdate: 'tool_call_update',
|
|
324
|
+
toolCallId: state.wireId,
|
|
325
|
+
status: 'failed',
|
|
326
|
+
content: textToolContent(failure),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
this.#assistantMessageId = undefined;
|
|
331
|
+
this.#assistantStreamedIndexes.clear();
|
|
332
|
+
}
|
|
333
|
+
#emitAssistantChunk(kind, text, messageId) {
|
|
334
|
+
if (!text || this.#remainingAssistantBytes <= 0)
|
|
335
|
+
return;
|
|
336
|
+
const limit = Math.min(MAX_UPDATE_TEXT_BYTES, this.#remainingAssistantBytes);
|
|
337
|
+
const truncated = Buffer.byteLength(text) > limit;
|
|
338
|
+
const bounded = truncateUtf8(text, limit);
|
|
339
|
+
this.#remainingAssistantBytes = truncated
|
|
340
|
+
? 0
|
|
341
|
+
: this.#remainingAssistantBytes - Buffer.byteLength(bounded);
|
|
342
|
+
if (!bounded)
|
|
343
|
+
return;
|
|
344
|
+
this.#enqueue({
|
|
345
|
+
sessionUpdate: kind === 'text' ? 'agent_message_chunk' : 'agent_thought_chunk',
|
|
346
|
+
content: { type: 'text', text: bounded },
|
|
347
|
+
messageId,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
#ensureTool(toolCallId, name, args, newOccurrence = false) {
|
|
351
|
+
const existing = this.#tools.get(toolCallId);
|
|
352
|
+
if (existing && (!newOccurrence || existing.status !== 'terminal'))
|
|
353
|
+
return existing;
|
|
354
|
+
if (this.#tools.size >= MAX_TURN_TOOL_CALLS) {
|
|
355
|
+
throw RequestError.internalError({ maxToolCalls: MAX_TURN_TOOL_CALLS }, 'Model produced too many tool calls in one turn');
|
|
356
|
+
}
|
|
357
|
+
const safeName = truncateUtf8(name, MAX_TOOL_TITLE_BYTES * 2);
|
|
358
|
+
const state = {
|
|
359
|
+
wireId: this.#toolCallIds.claim(toolCallId),
|
|
360
|
+
name: safeName,
|
|
361
|
+
args,
|
|
362
|
+
status: 'pending',
|
|
363
|
+
};
|
|
364
|
+
this.#tools.set(toolCallId, state);
|
|
365
|
+
const update = createAcpToolCallUpdate(this.#cwd, state.wireId, safeName, args);
|
|
366
|
+
this.#enqueue({
|
|
367
|
+
sessionUpdate: 'tool_call',
|
|
368
|
+
status: 'pending',
|
|
369
|
+
...update,
|
|
370
|
+
});
|
|
371
|
+
return state;
|
|
372
|
+
}
|
|
373
|
+
#startTool(toolCallId, name, args, newOccurrence = true) {
|
|
374
|
+
const state = this.#ensureTool(toolCallId, name, args, newOccurrence);
|
|
375
|
+
if (state.status !== 'pending')
|
|
376
|
+
return state;
|
|
377
|
+
state.name = truncateUtf8(name, MAX_TOOL_TITLE_BYTES * 2);
|
|
378
|
+
if (args !== undefined)
|
|
379
|
+
state.args = args;
|
|
380
|
+
state.status = 'in_progress';
|
|
381
|
+
const snapshotPath = mutationFilePath(this.#cwd, name, args);
|
|
382
|
+
if (snapshotPath !== undefined) {
|
|
383
|
+
state.snapshotPath = snapshotPath;
|
|
384
|
+
const beforeSnapshot = snapshotTextFile(this.#cwd, snapshotPath);
|
|
385
|
+
if (beforeSnapshot !== undefined)
|
|
386
|
+
state.beforeSnapshot = beforeSnapshot;
|
|
387
|
+
}
|
|
388
|
+
const update = createAcpToolCallUpdate(this.#cwd, state.wireId, state.name, state.args);
|
|
389
|
+
this.#enqueue({
|
|
390
|
+
sessionUpdate: 'tool_call_update',
|
|
391
|
+
status: 'in_progress',
|
|
392
|
+
...update,
|
|
393
|
+
});
|
|
394
|
+
return state;
|
|
395
|
+
}
|
|
396
|
+
#updateTool(toolCallId, name, args, result) {
|
|
397
|
+
const state = this.#startTool(toolCallId, name, args);
|
|
398
|
+
if (state.status === 'terminal')
|
|
399
|
+
return;
|
|
400
|
+
const output = toolOutput(result, false, toolHasSensitivePath(this.#cwd, state.name, state.args));
|
|
401
|
+
this.#enqueue({
|
|
402
|
+
sessionUpdate: 'tool_call_update',
|
|
403
|
+
toolCallId: state.wireId,
|
|
404
|
+
status: 'in_progress',
|
|
405
|
+
...(output.content.length === 0 ? {} : { content: output.content }),
|
|
406
|
+
...(output.rawOutput === undefined ? {} : { rawOutput: output.rawOutput }),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
#finishTool(toolCallId, name, result, isError) {
|
|
410
|
+
const state = this.#startTool(toolCallId, name, undefined, false);
|
|
411
|
+
if (state.status === 'terminal')
|
|
412
|
+
return;
|
|
413
|
+
state.status = 'terminal';
|
|
414
|
+
const output = toolOutput(result, isError, toolHasSensitivePath(this.#cwd, state.name, state.args));
|
|
415
|
+
const afterSnapshot = state.snapshotPath === undefined
|
|
416
|
+
? undefined
|
|
417
|
+
: snapshotTextFile(this.#cwd, state.snapshotPath);
|
|
418
|
+
const content = [...output.content];
|
|
419
|
+
const diff = fileDiff(state.beforeSnapshot, afterSnapshot);
|
|
420
|
+
if (diff !== undefined)
|
|
421
|
+
content.push(diff);
|
|
422
|
+
this.#enqueue({
|
|
423
|
+
sessionUpdate: 'tool_call_update',
|
|
424
|
+
toolCallId: state.wireId,
|
|
425
|
+
status: isError ? 'failed' : 'completed',
|
|
426
|
+
...(content.length === 0 ? {} : { content }),
|
|
427
|
+
...(output.rawOutput === undefined ? {} : { rawOutput: output.rawOutput }),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
#nextMessageId(role) {
|
|
431
|
+
this.#messageSequence += 1;
|
|
432
|
+
return wireMessageId(`${this.#turnPrefix}:${role}:${this.#messageSequence}`);
|
|
433
|
+
}
|
|
434
|
+
#enqueue(update) {
|
|
435
|
+
if (this.#queuedUpdates >= MAX_TURN_UPDATES) {
|
|
436
|
+
this.#failure ??= RequestError.internalError({ maxUpdates: MAX_TURN_UPDATES }, 'Model produced too many updates in one turn');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.#queuedUpdates += 1;
|
|
440
|
+
const factory = typeof update === 'function' ? update : () => update;
|
|
441
|
+
this.#tail = this.#tail
|
|
442
|
+
.then(async () => {
|
|
443
|
+
if (this.#failure !== undefined || !this.#isActive())
|
|
444
|
+
return;
|
|
445
|
+
const next = await factory();
|
|
446
|
+
if (next === undefined || !this.#isActive())
|
|
447
|
+
return;
|
|
448
|
+
await this.#client.notify(methods.client.session.update, {
|
|
449
|
+
sessionId: this.#sessionId,
|
|
450
|
+
update: next,
|
|
451
|
+
});
|
|
452
|
+
})
|
|
453
|
+
.catch((error) => {
|
|
454
|
+
this.#failure ??= error;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
export async function replayAcpSessionEntries(sessionId, cwd, entries, client, isActive = () => true, toolCallIds = new AcpToolCallIdRegistry()) {
|
|
459
|
+
const tools = new Map();
|
|
460
|
+
const usedMessageIds = new Set();
|
|
461
|
+
const budget = { entries: 0, updates: 0, bytes: 0 };
|
|
462
|
+
for (const entry of entries) {
|
|
463
|
+
budget.entries += 1;
|
|
464
|
+
if (budget.entries > MAX_REPLAY_ENTRIES) {
|
|
465
|
+
throw RequestError.internalError({ maxEntries: MAX_REPLAY_ENTRIES }, 'Persisted session history exceeds the replay entry limit');
|
|
466
|
+
}
|
|
467
|
+
const messages = sessionEntryToContextMessages(entry);
|
|
468
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
469
|
+
const message = messages[index];
|
|
470
|
+
const messageId = uniqueWireMessageId(`${entry.id}:${index}`, usedMessageIds);
|
|
471
|
+
const updates = [];
|
|
472
|
+
if (message.role === 'user') {
|
|
473
|
+
const parts = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content;
|
|
474
|
+
for (const part of parts.slice(0, MAX_MESSAGE_CONTENT_PARTS)) {
|
|
475
|
+
if (part.type !== 'text')
|
|
476
|
+
continue;
|
|
477
|
+
updates.push({
|
|
478
|
+
sessionUpdate: 'user_message_chunk',
|
|
479
|
+
content: { type: 'text', text: truncateUtf8(part.text, MAX_REPLAY_TEXT_BYTES) },
|
|
480
|
+
messageId,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else if (message.role === 'assistant') {
|
|
485
|
+
for (const part of message.content.slice(0, MAX_MESSAGE_CONTENT_PARTS)) {
|
|
486
|
+
if (part.type === 'text') {
|
|
487
|
+
updates.push({
|
|
488
|
+
sessionUpdate: 'agent_message_chunk',
|
|
489
|
+
content: { type: 'text', text: truncateUtf8(part.text, MAX_REPLAY_TEXT_BYTES) },
|
|
490
|
+
messageId,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
else if (part.type === 'thinking') {
|
|
494
|
+
if (!part.redacted) {
|
|
495
|
+
updates.push({
|
|
496
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
497
|
+
content: { type: 'text', text: truncateUtf8(part.thinking, MAX_REPLAY_TEXT_BYTES) },
|
|
498
|
+
messageId,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
const existing = tools.get(part.id);
|
|
504
|
+
if (existing !== undefined && !existing.terminal)
|
|
505
|
+
continue;
|
|
506
|
+
if (tools.size >= MAX_REPLAY_TOOL_CALLS) {
|
|
507
|
+
throw RequestError.internalError({ maxToolCalls: MAX_REPLAY_TOOL_CALLS }, 'Persisted session history contains too many tool calls');
|
|
508
|
+
}
|
|
509
|
+
const safeName = truncateUtf8(part.name, MAX_TOOL_TITLE_BYTES * 2);
|
|
510
|
+
const state = {
|
|
511
|
+
wireId: toolCallIds.claim(part.id),
|
|
512
|
+
name: safeName,
|
|
513
|
+
args: part.arguments,
|
|
514
|
+
terminal: false,
|
|
515
|
+
};
|
|
516
|
+
tools.set(part.id, state);
|
|
517
|
+
const metadata = toolMetadata(cwd, safeName, part.arguments);
|
|
518
|
+
updates.push({
|
|
519
|
+
sessionUpdate: 'tool_call',
|
|
520
|
+
toolCallId: state.wireId,
|
|
521
|
+
title: metadata.title,
|
|
522
|
+
kind: metadata.kind,
|
|
523
|
+
status: 'pending',
|
|
524
|
+
...(metadata.locations.length === 0 ? {} : { locations: metadata.locations }),
|
|
525
|
+
rawInput: boundToolInput(cwd, part.name, part.arguments),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
else if (message.role === 'toolResult') {
|
|
531
|
+
let state = tools.get(message.toolCallId);
|
|
532
|
+
if (state === undefined) {
|
|
533
|
+
if (tools.size >= MAX_REPLAY_TOOL_CALLS) {
|
|
534
|
+
throw RequestError.internalError({ maxToolCalls: MAX_REPLAY_TOOL_CALLS }, 'Persisted session history contains too many tool calls');
|
|
535
|
+
}
|
|
536
|
+
state = {
|
|
537
|
+
wireId: toolCallIds.claim(message.toolCallId),
|
|
538
|
+
name: truncateUtf8(message.toolName, MAX_TOOL_TITLE_BYTES * 2),
|
|
539
|
+
args: undefined,
|
|
540
|
+
terminal: false,
|
|
541
|
+
};
|
|
542
|
+
tools.set(message.toolCallId, state);
|
|
543
|
+
const metadata = toolMetadata(cwd, message.toolName, undefined);
|
|
544
|
+
updates.push({
|
|
545
|
+
sessionUpdate: 'tool_call',
|
|
546
|
+
toolCallId: state.wireId,
|
|
547
|
+
title: metadata.title,
|
|
548
|
+
kind: metadata.kind,
|
|
549
|
+
status: 'pending',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
if (!state.terminal) {
|
|
553
|
+
state.terminal = true;
|
|
554
|
+
const output = toolOutput(message, message.isError, toolHasSensitivePath(cwd, state.name, state.args));
|
|
555
|
+
updates.push({
|
|
556
|
+
sessionUpdate: 'tool_call_update',
|
|
557
|
+
toolCallId: state.wireId,
|
|
558
|
+
status: message.isError ? 'failed' : 'completed',
|
|
559
|
+
...(output.content.length === 0 ? {} : { content: output.content }),
|
|
560
|
+
...(output.rawOutput === undefined ? {} : { rawOutput: output.rawOutput }),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const update of updates) {
|
|
565
|
+
await notifyReplayUpdate(sessionId, update, client, isActive, budget);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
for (const state of tools.values()) {
|
|
570
|
+
if (state.terminal)
|
|
571
|
+
continue;
|
|
572
|
+
await notifyReplayUpdate(sessionId, {
|
|
573
|
+
sessionUpdate: 'tool_call_update',
|
|
574
|
+
toolCallId: state.wireId,
|
|
575
|
+
status: 'failed',
|
|
576
|
+
content: textToolContent('Tool did not complete'),
|
|
577
|
+
}, client, isActive, budget);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
export function toAcpPromptError(error) {
|
|
581
|
+
if (error instanceof RequestError) {
|
|
582
|
+
return new RequestError(error.code, sanitizeAcpErrorMessage(error));
|
|
583
|
+
}
|
|
584
|
+
return RequestError.internalError(undefined, sanitizeAcpErrorMessage(error));
|
|
585
|
+
}
|
|
586
|
+
export function sanitizeAcpErrorMessage(error) {
|
|
587
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
588
|
+
const bounded = truncateUtf8(message, MAX_ERROR_SCAN_BYTES);
|
|
589
|
+
return truncateUtf8(redactSensitiveText(bounded), MAX_ERROR_BYTES).replace(/\s+/gu, ' ').trim()
|
|
590
|
+
|| 'Operation failed';
|
|
591
|
+
}
|
|
592
|
+
async function notifyIfActive(sessionId, update, client, isActive) {
|
|
593
|
+
if (!isActive()) {
|
|
594
|
+
throw RequestError.requestCancelled({ sessionId }, 'Session closed while sending updates');
|
|
595
|
+
}
|
|
596
|
+
await client.notify(methods.client.session.update, { sessionId, update });
|
|
597
|
+
}
|
|
598
|
+
async function notifyReplayUpdate(sessionId, update, client, isActive, budget) {
|
|
599
|
+
budget.updates += 1;
|
|
600
|
+
budget.bytes += Buffer.byteLength(JSON.stringify(update));
|
|
601
|
+
if (budget.updates > MAX_REPLAY_UPDATES || budget.bytes > MAX_REPLAY_BYTES) {
|
|
602
|
+
throw RequestError.internalError({ maxUpdates: MAX_REPLAY_UPDATES, maxBytes: MAX_REPLAY_BYTES }, 'Persisted session history exceeds the replay output limit');
|
|
603
|
+
}
|
|
604
|
+
await notifyIfActive(sessionId, update, client, isActive);
|
|
605
|
+
}
|
|
606
|
+
function formatResourceLink(link) {
|
|
607
|
+
return [
|
|
608
|
+
`Resource link: ${link.name}`,
|
|
609
|
+
`URI: ${link.uri}`,
|
|
610
|
+
...(link.description === undefined || link.description === null
|
|
611
|
+
? []
|
|
612
|
+
: [`Description: ${link.description}`]),
|
|
613
|
+
].join('\n');
|
|
614
|
+
}
|
|
615
|
+
function boundedResourceField(value, label) {
|
|
616
|
+
assertNoNul(value, label);
|
|
617
|
+
assertBoundedText(value, MAX_RESOURCE_FIELD_BYTES, label);
|
|
618
|
+
return value;
|
|
619
|
+
}
|
|
620
|
+
function assertNoNul(value, label) {
|
|
621
|
+
if (value.includes('\0')) {
|
|
622
|
+
throw RequestError.invalidParams(undefined, `${label} must not contain NUL bytes`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function assertBoundedText(value, maxBytes, label) {
|
|
626
|
+
if (Buffer.byteLength(value) > maxBytes) {
|
|
627
|
+
throw RequestError.invalidParams({ maxBytes }, `${label} exceeds the maximum encoded size`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function isRedactedThinking(message, contentIndex) {
|
|
631
|
+
if (message.role !== 'assistant')
|
|
632
|
+
return false;
|
|
633
|
+
const content = message.content[contentIndex];
|
|
634
|
+
return content?.type === 'thinking' && content.redacted === true;
|
|
635
|
+
}
|
|
636
|
+
function toolMetadata(cwd, name, args) {
|
|
637
|
+
const kind = toolKind(name);
|
|
638
|
+
const locations = toolLocations(cwd, name, args);
|
|
639
|
+
const record = isRecord(args) ? args : {};
|
|
640
|
+
const subject = kind === 'execute' ? firstString(record, ['command', 'cmd'])
|
|
641
|
+
: kind === 'search' ? firstString(record, ['pattern', 'query'])
|
|
642
|
+
: kind === 'fetch' ? firstString(record, ['url', 'uri'])
|
|
643
|
+
: locations[0]?.path;
|
|
644
|
+
const verb = kind === 'read' ? 'Read'
|
|
645
|
+
: kind === 'edit' ? 'Edit'
|
|
646
|
+
: kind === 'delete' ? 'Delete'
|
|
647
|
+
: kind === 'move' ? 'Move'
|
|
648
|
+
: kind === 'search' ? 'Search'
|
|
649
|
+
: kind === 'execute' ? 'Run'
|
|
650
|
+
: kind === 'fetch' ? 'Fetch'
|
|
651
|
+
: kind === 'think' ? 'Reason'
|
|
652
|
+
: titleCase(name);
|
|
653
|
+
const titleSubject = subject === undefined
|
|
654
|
+
? undefined
|
|
655
|
+
: sanitizeTitle(truncateUtf8(subject, MAX_TOOL_TITLE_BYTES * 4));
|
|
656
|
+
const title = (titleSubject ? `${verb} ${titleSubject}` : titleCase(name) || verb).trim();
|
|
657
|
+
return { title: truncateUtf8(title, MAX_TOOL_TITLE_BYTES), kind, locations };
|
|
658
|
+
}
|
|
659
|
+
function toolKind(name) {
|
|
660
|
+
const normalized = name.toLowerCase();
|
|
661
|
+
if (normalized === 'read' || normalized === 'ls' || normalized.includes('read_file'))
|
|
662
|
+
return 'read';
|
|
663
|
+
if (normalized.includes('delete') || normalized === 'rm')
|
|
664
|
+
return 'delete';
|
|
665
|
+
if (normalized.includes('move') || normalized.includes('rename'))
|
|
666
|
+
return 'move';
|
|
667
|
+
if (normalized === 'edit' || normalized === 'write' || normalized.includes('patch'))
|
|
668
|
+
return 'edit';
|
|
669
|
+
if (normalized.includes('grep') || normalized.includes('find') || normalized.includes('search'))
|
|
670
|
+
return 'search';
|
|
671
|
+
if (normalized.includes('bash') || normalized.includes('process') || normalized.includes('terminal') || normalized.includes('exec'))
|
|
672
|
+
return 'execute';
|
|
673
|
+
if (normalized.includes('fetch') || normalized.includes('browser') || normalized.includes('web'))
|
|
674
|
+
return 'fetch';
|
|
675
|
+
if (normalized.includes('think') || normalized.includes('reason'))
|
|
676
|
+
return 'think';
|
|
677
|
+
if (normalized.includes('mode'))
|
|
678
|
+
return 'switch_mode';
|
|
679
|
+
return 'other';
|
|
680
|
+
}
|
|
681
|
+
function toolLocations(cwd, name, args) {
|
|
682
|
+
if (!isRecord(args))
|
|
683
|
+
return [];
|
|
684
|
+
const values = [];
|
|
685
|
+
for (const key of ['path', 'file_path', 'source', 'destination', 'target']) {
|
|
686
|
+
const value = args[key];
|
|
687
|
+
if (typeof value === 'string')
|
|
688
|
+
values.push(value);
|
|
689
|
+
}
|
|
690
|
+
if (Array.isArray(args.paths)) {
|
|
691
|
+
for (const value of args.paths)
|
|
692
|
+
if (typeof value === 'string')
|
|
693
|
+
values.push(value);
|
|
694
|
+
}
|
|
695
|
+
if (name.toLowerCase().includes('patch')) {
|
|
696
|
+
const patch = firstString(args, ['input', 'patch', 'patchText']);
|
|
697
|
+
if (patch) {
|
|
698
|
+
for (const match of patch.slice(0, MAX_RAW_VALUE_BYTES * 2)
|
|
699
|
+
.matchAll(/^\*\*\* (?:(?:Add|Delete|Update) File:|Move to:) (.+)$/gmu)) {
|
|
700
|
+
if (match[1])
|
|
701
|
+
values.push(match[1]);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const lineValue = typeof args.line === 'number' ? args.line
|
|
706
|
+
: typeof args.offset === 'number' ? args.offset : undefined;
|
|
707
|
+
const line = lineValue !== undefined && Number.isSafeInteger(lineValue) && lineValue > 0
|
|
708
|
+
? lineValue
|
|
709
|
+
: undefined;
|
|
710
|
+
const locations = [];
|
|
711
|
+
const seen = new Set();
|
|
712
|
+
for (const value of values) {
|
|
713
|
+
if (locations.length >= MAX_LOCATIONS
|
|
714
|
+
|| Buffer.byteLength(value) > MAX_LOCATION_BYTES
|
|
715
|
+
|| value.includes('\0')
|
|
716
|
+
|| value.includes('://')
|
|
717
|
+
|| [...'*?{}[]'].some((character) => value.includes(character)))
|
|
718
|
+
continue;
|
|
719
|
+
const path = resolve(cwd, value);
|
|
720
|
+
if (Buffer.byteLength(path) > MAX_LOCATION_BYTES || seen.has(path))
|
|
721
|
+
continue;
|
|
722
|
+
seen.add(path);
|
|
723
|
+
locations.push({ path, ...(line === undefined ? {} : { line }) });
|
|
724
|
+
}
|
|
725
|
+
return locations;
|
|
726
|
+
}
|
|
727
|
+
function mutationFilePath(cwd, name, args) {
|
|
728
|
+
if (name !== 'edit' && name !== 'write')
|
|
729
|
+
return undefined;
|
|
730
|
+
if (!isRecord(args)
|
|
731
|
+
|| typeof args.path !== 'string'
|
|
732
|
+
|| args.path.includes('\0')
|
|
733
|
+
|| Buffer.byteLength(args.path) > MAX_LOCATION_BYTES)
|
|
734
|
+
return undefined;
|
|
735
|
+
const path = resolve(cwd, args.path);
|
|
736
|
+
return isWithin(cwd, path) && !isSensitivePath(path) ? path : undefined;
|
|
737
|
+
}
|
|
738
|
+
function snapshotTextFile(cwd, path) {
|
|
739
|
+
try {
|
|
740
|
+
const root = realpathSync(cwd);
|
|
741
|
+
let target;
|
|
742
|
+
try {
|
|
743
|
+
target = realpathSync(path);
|
|
744
|
+
}
|
|
745
|
+
catch (error) {
|
|
746
|
+
if (!isMissingFile(error))
|
|
747
|
+
return undefined;
|
|
748
|
+
const parent = realpathSync(dirname(path));
|
|
749
|
+
if (!isWithin(root, parent))
|
|
750
|
+
return undefined;
|
|
751
|
+
return { path, text: null };
|
|
752
|
+
}
|
|
753
|
+
if (!isWithin(root, target) || isSensitivePath(target))
|
|
754
|
+
return undefined;
|
|
755
|
+
// AgentSession listeners are synchronous, so this bounded read must finish before tool execution resumes.
|
|
756
|
+
const descriptor = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
757
|
+
try {
|
|
758
|
+
const beforeRead = fstatSync(descriptor);
|
|
759
|
+
const openedTarget = realpathSync(target);
|
|
760
|
+
const resolvedMetadata = statSync(openedTarget);
|
|
761
|
+
if (!isWithin(root, openedTarget)
|
|
762
|
+
|| isSensitivePath(openedTarget)
|
|
763
|
+
|| !sameFile(resolvedMetadata, beforeRead)
|
|
764
|
+
|| !beforeRead.isFile()
|
|
765
|
+
|| beforeRead.size > MAX_DIFF_FILE_BYTES) {
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
768
|
+
const bytes = readFileSync(descriptor);
|
|
769
|
+
if (bytes.byteLength > MAX_DIFF_FILE_BYTES)
|
|
770
|
+
return undefined;
|
|
771
|
+
const afterRead = fstatSync(descriptor);
|
|
772
|
+
if (!sameFile(beforeRead, afterRead) || !sameFileVersion(beforeRead, afterRead))
|
|
773
|
+
return undefined;
|
|
774
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
775
|
+
if (containsSensitiveText(text))
|
|
776
|
+
return undefined;
|
|
777
|
+
return { path, text };
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
closeSync(descriptor);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function fileDiff(before, after) {
|
|
788
|
+
if (before === undefined || after === undefined)
|
|
789
|
+
return undefined;
|
|
790
|
+
if (after.text === null
|
|
791
|
+
|| before.path !== after.path
|
|
792
|
+
|| before.text === after.text)
|
|
793
|
+
return undefined;
|
|
794
|
+
return {
|
|
795
|
+
type: 'diff',
|
|
796
|
+
path: after.path,
|
|
797
|
+
oldText: before.text,
|
|
798
|
+
newText: after.text,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function sameFile(left, right) {
|
|
802
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
803
|
+
}
|
|
804
|
+
function sameFileVersion(left, right) {
|
|
805
|
+
return left.size === right.size
|
|
806
|
+
&& left.mtimeMs === right.mtimeMs
|
|
807
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
808
|
+
}
|
|
809
|
+
function toolOutput(value, isError, sensitive = false) {
|
|
810
|
+
if (sensitive) {
|
|
811
|
+
return {
|
|
812
|
+
content: textToolContent('Sensitive tool output omitted'),
|
|
813
|
+
rawOutput: '[redacted sensitive tool output]',
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const rawOutput = value === undefined ? undefined : boundProtocolValue(value);
|
|
817
|
+
const content = [];
|
|
818
|
+
let remaining = MAX_TOOL_CONTENT_BYTES;
|
|
819
|
+
const blocks = isRecord(value) && Array.isArray(value.content)
|
|
820
|
+
? value.content.slice(0, MAX_TOOL_CONTENT_ITEMS)
|
|
821
|
+
: [];
|
|
822
|
+
for (const block of blocks) {
|
|
823
|
+
if (remaining <= 0)
|
|
824
|
+
break;
|
|
825
|
+
if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') {
|
|
826
|
+
const text = truncateUtf8(redactSensitiveText(block.text), Math.min(remaining, MAX_UPDATE_TEXT_BYTES));
|
|
827
|
+
remaining -= Buffer.byteLength(text);
|
|
828
|
+
content.push({ type: 'content', content: { type: 'text', text } });
|
|
829
|
+
}
|
|
830
|
+
else if (isRecord(block) && block.type === 'image') {
|
|
831
|
+
const mimeType = typeof block.mimeType === 'string' ? sanitizeTitle(block.mimeType) : 'image';
|
|
832
|
+
const text = truncateUtf8(`[${mimeType} output omitted]`, remaining);
|
|
833
|
+
remaining -= Buffer.byteLength(text);
|
|
834
|
+
content.push({ type: 'content', content: { type: 'text', text } });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (content.length === 0 && isError) {
|
|
838
|
+
const error = isRecord(value) && typeof value.error === 'string' ? value.error : value;
|
|
839
|
+
content.push(...textToolContent(sanitizeAcpErrorMessage(error)));
|
|
840
|
+
}
|
|
841
|
+
return {
|
|
842
|
+
content,
|
|
843
|
+
...(rawOutput === undefined ? {} : { rawOutput }),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
function textToolContent(text) {
|
|
847
|
+
return [{
|
|
848
|
+
type: 'content',
|
|
849
|
+
content: { type: 'text', text: truncateUtf8(redactSensitiveText(text), MAX_UPDATE_TEXT_BYTES) },
|
|
850
|
+
}];
|
|
851
|
+
}
|
|
852
|
+
function boundProtocolValue(value) {
|
|
853
|
+
const state = { remaining: MAX_RAW_VALUE_BYTES, seen: new WeakSet() };
|
|
854
|
+
try {
|
|
855
|
+
const bounded = boundValue(value, state, 0, '');
|
|
856
|
+
const encoded = JSON.stringify(bounded) ?? 'null';
|
|
857
|
+
if (Buffer.byteLength(encoded) <= MAX_RAW_VALUE_BYTES)
|
|
858
|
+
return bounded;
|
|
859
|
+
return { _felanTruncated: true, preview: truncateUtf8(encoded, MAX_RAW_VALUE_BYTES / 2) };
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return '[unserializable value]';
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function boundToolInput(cwd, name, value) {
|
|
866
|
+
if (!toolHasSensitivePath(cwd, name, value) || !isRecord(value)) {
|
|
867
|
+
return boundProtocolValue(value);
|
|
868
|
+
}
|
|
869
|
+
const redacted = {};
|
|
870
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
871
|
+
redacted[key] = [
|
|
872
|
+
'destination',
|
|
873
|
+
'file_path',
|
|
874
|
+
'line',
|
|
875
|
+
'offset',
|
|
876
|
+
'path',
|
|
877
|
+
'paths',
|
|
878
|
+
'source',
|
|
879
|
+
'target',
|
|
880
|
+
].includes(key) ? entry : '[redacted sensitive file content]';
|
|
881
|
+
}
|
|
882
|
+
return boundProtocolValue(redacted);
|
|
883
|
+
}
|
|
884
|
+
function boundValue(value, state, depth, key) {
|
|
885
|
+
if (isSensitiveKey(key))
|
|
886
|
+
return '[redacted]';
|
|
887
|
+
if (typeof value === 'string') {
|
|
888
|
+
const result = truncateUtf8(redactSensitiveText(value), Math.min(MAX_RAW_STRING_BYTES, state.remaining));
|
|
889
|
+
state.remaining -= Buffer.byteLength(result);
|
|
890
|
+
return result;
|
|
891
|
+
}
|
|
892
|
+
if (typeof value === 'bigint')
|
|
893
|
+
return value.toString();
|
|
894
|
+
if (value === null || typeof value === 'boolean')
|
|
895
|
+
return value;
|
|
896
|
+
if (typeof value === 'number')
|
|
897
|
+
return Number.isFinite(value) ? value : String(value);
|
|
898
|
+
if (value === undefined)
|
|
899
|
+
return null;
|
|
900
|
+
if (typeof value !== 'object')
|
|
901
|
+
return truncateUtf8(String(value), Math.min(256, state.remaining));
|
|
902
|
+
if (depth >= MAX_RAW_DEPTH)
|
|
903
|
+
return '[maximum depth reached]';
|
|
904
|
+
if (state.seen.has(value))
|
|
905
|
+
return '[circular]';
|
|
906
|
+
state.seen.add(value);
|
|
907
|
+
if (Array.isArray(value)) {
|
|
908
|
+
const result = value.slice(0, MAX_RAW_ENTRIES).map((entry) => (state.remaining <= 0 ? '[truncated]' : boundValue(entry, state, depth + 1, '')));
|
|
909
|
+
if (value.length > result.length)
|
|
910
|
+
result.push(`[${value.length - result.length} items omitted]`);
|
|
911
|
+
return result;
|
|
912
|
+
}
|
|
913
|
+
const result = {};
|
|
914
|
+
try {
|
|
915
|
+
const entries = Object.entries(value).slice(0, MAX_RAW_ENTRIES);
|
|
916
|
+
for (const [entryKey, entry] of entries) {
|
|
917
|
+
if (state.remaining <= 0) {
|
|
918
|
+
result._felanTruncated = true;
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
result[truncateUtf8(entryKey, 512)] = boundValue(entry, state, depth + 1, entryKey);
|
|
922
|
+
}
|
|
923
|
+
if (Object.keys(value).length > entries.length)
|
|
924
|
+
result._felanOmittedFields = true;
|
|
925
|
+
return result;
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
return '[unavailable value]';
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function wireToolCallId(id) {
|
|
932
|
+
if (id && Buffer.byteLength(id) <= MAX_TOOL_ID_BYTES && !/[\u0000-\u001f\u007f]/u.test(id))
|
|
933
|
+
return id;
|
|
934
|
+
return `tool-${createHash('sha256').update(id).digest('hex').slice(0, 32)}`;
|
|
935
|
+
}
|
|
936
|
+
function wireMessageId(id) {
|
|
937
|
+
if (id && Buffer.byteLength(id) <= MAX_MESSAGE_ID_BYTES && !/[\u0000-\u001f\u007f]/u.test(id)) {
|
|
938
|
+
return id;
|
|
939
|
+
}
|
|
940
|
+
return `message-${createHash('sha256').update(id).digest('hex').slice(0, 32)}`;
|
|
941
|
+
}
|
|
942
|
+
function uniqueWireMessageId(id, used) {
|
|
943
|
+
const preferred = wireMessageId(id);
|
|
944
|
+
if (!used.has(preferred)) {
|
|
945
|
+
used.add(preferred);
|
|
946
|
+
return preferred;
|
|
947
|
+
}
|
|
948
|
+
let sequence = 1;
|
|
949
|
+
while (true) {
|
|
950
|
+
const candidate = `message-${createHash('sha256')
|
|
951
|
+
.update(`${id}\0${sequence}`)
|
|
952
|
+
.digest('hex')
|
|
953
|
+
.slice(0, 32)}`;
|
|
954
|
+
if (!used.has(candidate)) {
|
|
955
|
+
used.add(candidate);
|
|
956
|
+
return candidate;
|
|
957
|
+
}
|
|
958
|
+
sequence += 1;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function redactSensitiveText(value) {
|
|
962
|
+
return value
|
|
963
|
+
.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gu, '[REDACTED_PRIVATE_KEY]')
|
|
964
|
+
.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*$/gu, '[REDACTED_PRIVATE_KEY]')
|
|
965
|
+
.replace(/\b(?:sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{12,})\b/gu, '[REDACTED_TOKEN]')
|
|
966
|
+
.replace(/\bAKIA[A-Z0-9]{16}\b/gu, '[REDACTED_ACCESS_KEY]')
|
|
967
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, '$1[REDACTED_TOKEN]')
|
|
968
|
+
.replace(/(Basic\s+)[A-Za-z0-9+/=]+/giu, '$1[REDACTED_CREDENTIAL]')
|
|
969
|
+
.replace(/((?:Set-)?Cookie\s*:)[^\r\n]*/giu, '$1 [REDACTED_COOKIE]')
|
|
970
|
+
.replace(/(_authToken\s*=\s*)[^\s]+/giu, '$1[REDACTED_TOKEN]')
|
|
971
|
+
.replace(/(\b[A-Z0-9_]{0,128}(?:API_KEY|SECRET_ACCESS_KEY|ACCESS_TOKEN|REFRESH_TOKEN|SESSION_TOKEN|PASSWORD|CLIENT_SECRET)\s*=\s*)[^\s]+/giu, '$1[REDACTED_SECRET]')
|
|
972
|
+
.replace(/(["']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret|authorization)["']?\s*[:=]\s*["']?)[^"'\s,}]+/giu, '$1[REDACTED_SECRET]')
|
|
973
|
+
.replace(/((?:--)?(?:api[-_]?key|access[-_]?token|refresh[-_]?token|password|secret|authorization)\s+)[^\s]+/giu, '$1[REDACTED_SECRET]')
|
|
974
|
+
.replace(/([?&](?:code|state|token|access_token|refresh_token|client_secret)=)[^&#\s]*/giu, '$1[REDACTED_SECRET]')
|
|
975
|
+
.replace(/https?:\/\/[^\s)]+/giu, redactUrl)
|
|
976
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, '');
|
|
977
|
+
}
|
|
978
|
+
function containsSensitiveText(value) {
|
|
979
|
+
return redactSensitiveText(value) !== value;
|
|
980
|
+
}
|
|
981
|
+
function isSensitiveKey(key) {
|
|
982
|
+
const normalized = key.replace(/[^a-z0-9]/giu, '').toLowerCase();
|
|
983
|
+
return SENSITIVE_KEYS.has(normalized)
|
|
984
|
+
|| normalized.endsWith('apikey')
|
|
985
|
+
|| normalized.endsWith('cookie')
|
|
986
|
+
|| normalized.endsWith('password')
|
|
987
|
+
|| normalized.endsWith('token')
|
|
988
|
+
|| normalized.includes('secret')
|
|
989
|
+
|| normalized.includes('privatekey');
|
|
990
|
+
}
|
|
991
|
+
function isSensitivePath(path) {
|
|
992
|
+
return /(?:^|[/\\])(?:\.aws|\.docker|\.git-credentials|\.netrc|\.npmrc|\.pypirc|\.ssh|\.env|auth|credentials?|id_(?:dsa|ecdsa|ed25519|rsa)|secrets?|[^/\\]+\.(?:key|pem))(?:[./\\]|$)/iu.test(path);
|
|
993
|
+
}
|
|
994
|
+
function toolHasSensitivePath(cwd, name, args) {
|
|
995
|
+
if (!isRecord(args))
|
|
996
|
+
return false;
|
|
997
|
+
for (const key of ['path', 'file_path', 'source', 'destination', 'target']) {
|
|
998
|
+
const value = args[key];
|
|
999
|
+
if (typeof value === 'string'
|
|
1000
|
+
&& Buffer.byteLength(value) <= MAX_LOCATION_BYTES
|
|
1001
|
+
&& !value.includes('\0')
|
|
1002
|
+
&& isSensitivePath(resolve(cwd, value))) {
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (Array.isArray(args.paths) && args.paths.some((value) => (typeof value === 'string'
|
|
1007
|
+
&& Buffer.byteLength(value) <= MAX_LOCATION_BYTES
|
|
1008
|
+
&& !value.includes('\0')
|
|
1009
|
+
&& isSensitivePath(resolve(cwd, value)))))
|
|
1010
|
+
return true;
|
|
1011
|
+
if (name.toLowerCase().includes('patch')) {
|
|
1012
|
+
const patch = firstString(args, ['input', 'patch', 'patchText']);
|
|
1013
|
+
if (patch) {
|
|
1014
|
+
const boundedPatch = patch.slice(0, MAX_RAW_VALUE_BYTES * 2);
|
|
1015
|
+
for (const match of boundedPatch.matchAll(/^\*\*\* (?:(?:Add|Delete|Update) File:|Move to:) (.+)$/gmu)) {
|
|
1016
|
+
const path = match[1];
|
|
1017
|
+
if (path && isSensitivePath(resolve(cwd, path)))
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
function sanitizeTitle(value) {
|
|
1025
|
+
return redactSensitiveText(value).replace(/\s+/gu, ' ').trim();
|
|
1026
|
+
}
|
|
1027
|
+
function titleCase(value) {
|
|
1028
|
+
const words = value.replace(/[_-]+/gu, ' ').trim();
|
|
1029
|
+
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : 'Tool';
|
|
1030
|
+
}
|
|
1031
|
+
function firstString(record, keys) {
|
|
1032
|
+
for (const key of keys) {
|
|
1033
|
+
const value = record[key];
|
|
1034
|
+
if (typeof value === 'string' && value.trim())
|
|
1035
|
+
return value;
|
|
1036
|
+
}
|
|
1037
|
+
return undefined;
|
|
1038
|
+
}
|
|
1039
|
+
function redactUrl(value) {
|
|
1040
|
+
try {
|
|
1041
|
+
const url = new URL(value);
|
|
1042
|
+
if (url.username)
|
|
1043
|
+
url.username = '[redacted]';
|
|
1044
|
+
if (url.password)
|
|
1045
|
+
url.password = '[redacted]';
|
|
1046
|
+
url.search = '';
|
|
1047
|
+
url.hash = '';
|
|
1048
|
+
return url.toString();
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
return '[redacted URL]';
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
function truncateUtf8(value, maxBytes) {
|
|
1055
|
+
const limit = Math.max(0, Math.floor(maxBytes));
|
|
1056
|
+
const encoded = Buffer.from(value);
|
|
1057
|
+
if (encoded.byteLength <= limit)
|
|
1058
|
+
return value;
|
|
1059
|
+
if (limit === 0)
|
|
1060
|
+
return '';
|
|
1061
|
+
const marker = Buffer.byteLength(TRUNCATION_MARKER) < limit ? TRUNCATION_MARKER : '';
|
|
1062
|
+
const contentLimit = limit - Buffer.byteLength(marker);
|
|
1063
|
+
const prefix = encoded.subarray(0, contentLimit).toString('utf8').replace(/\uFFFD$/u, '');
|
|
1064
|
+
return `${prefix}${marker}`;
|
|
1065
|
+
}
|
|
1066
|
+
function isWithin(root, target) {
|
|
1067
|
+
const path = relative(resolve(root), resolve(target));
|
|
1068
|
+
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
1069
|
+
}
|
|
1070
|
+
function isMissingFile(error) {
|
|
1071
|
+
return isRecord(error) && error.code === 'ENOENT';
|
|
1072
|
+
}
|
|
1073
|
+
function isRecord(value) {
|
|
1074
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1075
|
+
}
|
|
1076
|
+
//# sourceMappingURL=session-updates.js.map
|