@hunterzhu/pulse-adapters 0.1.10 → 0.2.0
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/documents/tools.d.ts +67 -0
- package/dist/documents/tools.js +338 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/stdio.d.ts +78 -0
- package/dist/mcp/stdio.js +395 -0
- package/dist/providers/anthropic.js +4 -2
- package/dist/providers/normalize.d.ts +1 -1
- package/dist/providers/normalize.js +7 -5
- package/dist/providers/openai-compat.js +15 -4
- package/dist/providers/runtime-executor.js +3 -1
- package/dist/tools/filesystem.d.ts +4 -0
- package/dist/tools/filesystem.js +20 -2
- package/dist/tools/shell.d.ts +4 -0
- package/dist/tools/shell.js +255 -44
- package/package.json +6 -3
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
4
|
+
import { matchesJsonSchema } from '@hunterzhu/pulse-tool-sdk';
|
|
5
|
+
const DEFAULT_PROTOCOL_VERSION = '2025-11-25';
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
7
|
+
const DEFAULT_MAX_LINE_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
function asRecord(value, label) {
|
|
9
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
10
|
+
throw new Error(`MCP_PROTOCOL_ERROR:${label} must be an object`);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function asError(error) { return error instanceof Error ? error : new Error(String(error)); }
|
|
14
|
+
function defaultMcpEnvironment(source) {
|
|
15
|
+
const safeKeys = new Set(['PATH', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT']);
|
|
16
|
+
return Object.fromEntries(Object.entries(source).filter(([key, value]) => safeKeys.has(key.toUpperCase()) && value !== undefined && !/(API_KEY|TOKEN|SECRET|PASSWORD|PRIVATE_KEY|AUTHORIZATION|BEARER|CREDENTIAL|COOKIE)/i.test(key) && key !== 'NODE_OPTIONS'));
|
|
17
|
+
}
|
|
18
|
+
function toJson(value, seen = new Set()) {
|
|
19
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
20
|
+
return value;
|
|
21
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
22
|
+
return value;
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
if (seen.has(value))
|
|
25
|
+
throw new Error('MCP_TOOL_RESULT_NOT_JSON');
|
|
26
|
+
seen.add(value);
|
|
27
|
+
try {
|
|
28
|
+
return value.map((item) => toJson(item, seen));
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
seen.delete(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === 'object') {
|
|
35
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
36
|
+
throw new Error('MCP_TOOL_RESULT_NOT_JSON');
|
|
37
|
+
if (seen.has(value))
|
|
38
|
+
throw new Error('MCP_TOOL_RESULT_NOT_JSON');
|
|
39
|
+
seen.add(value);
|
|
40
|
+
try {
|
|
41
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, toJson(item, seen)]));
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
seen.delete(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error('MCP_TOOL_RESULT_NOT_JSON');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Minimal MCP stdio client for the initialize lifecycle and tools capability.
|
|
51
|
+
* The subprocess uses newline-delimited JSON-RPC on stdout; stderr is never parsed as protocol.
|
|
52
|
+
*/
|
|
53
|
+
export class McpStdioClient {
|
|
54
|
+
options;
|
|
55
|
+
child;
|
|
56
|
+
pending = new Map();
|
|
57
|
+
requestIds = new WeakMap();
|
|
58
|
+
remoteTools = new Map();
|
|
59
|
+
nextId = 1;
|
|
60
|
+
buffer = '';
|
|
61
|
+
closed = false;
|
|
62
|
+
started = false;
|
|
63
|
+
decoder = new StringDecoder('utf8');
|
|
64
|
+
constructor(options) {
|
|
65
|
+
this.options = options;
|
|
66
|
+
if (!options.command.trim())
|
|
67
|
+
throw new Error('MCP_COMMAND_REQUIRED');
|
|
68
|
+
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0))
|
|
69
|
+
throw new Error('MCP_TIMEOUT_MUST_BE_POSITIVE');
|
|
70
|
+
if (options.maxLineBytes !== undefined && (!Number.isInteger(options.maxLineBytes) || options.maxLineBytes <= 0))
|
|
71
|
+
throw new Error('MCP_MAX_LINE_BYTES_MUST_BE_POSITIVE');
|
|
72
|
+
if (options.maxToolPages !== undefined && (!Number.isInteger(options.maxToolPages) || options.maxToolPages < 1 || options.maxToolPages > 256))
|
|
73
|
+
throw new Error('MCP_MAX_TOOL_PAGES_MUST_BE_BETWEEN_1_AND_256');
|
|
74
|
+
}
|
|
75
|
+
/** Starts the child, negotiates the protocol, then discovers the remote tools. */
|
|
76
|
+
async connect() {
|
|
77
|
+
if (this.started)
|
|
78
|
+
throw new Error('MCP_CLIENT_ALREADY_STARTED');
|
|
79
|
+
this.started = true;
|
|
80
|
+
this.child = spawn(this.options.command, this.options.args ?? [], {
|
|
81
|
+
cwd: this.options.cwd,
|
|
82
|
+
// MCP servers are installed and enabled explicitly, but should not
|
|
83
|
+
// silently inherit provider credentials from the Pulse process.
|
|
84
|
+
env: { ...defaultMcpEnvironment(process.env), ...this.options.env },
|
|
85
|
+
shell: false,
|
|
86
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
87
|
+
});
|
|
88
|
+
this.child.stdout.on('data', (chunk) => this.consume(chunk));
|
|
89
|
+
// Drain stderr so a verbose server cannot block on a full pipe; keep diagnostics out of protocol handling.
|
|
90
|
+
this.child.stderr.on('data', () => undefined);
|
|
91
|
+
this.child.stdout.on('end', () => {
|
|
92
|
+
const tail = this.decoder.end();
|
|
93
|
+
if (tail)
|
|
94
|
+
this.buffer += tail;
|
|
95
|
+
if (this.buffer.trim())
|
|
96
|
+
this.fail(new Error('MCP_PROTOCOL_ERROR:unterminated stdout frame'));
|
|
97
|
+
});
|
|
98
|
+
this.child.on('error', (error) => this.fail(error));
|
|
99
|
+
this.child.on('exit', (code, signal) => this.fail(new Error(`MCP_SERVER_EXITED:${code ?? signal ?? 'unknown'}`)));
|
|
100
|
+
try {
|
|
101
|
+
const initializeResult = asRecord(await this.request('initialize', {
|
|
102
|
+
protocolVersion: this.options.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
103
|
+
capabilities: {},
|
|
104
|
+
clientInfo: this.options.clientInfo ?? { name: 'pulse', version: '0.1.0' },
|
|
105
|
+
}), 'initialize result');
|
|
106
|
+
if (typeof initializeResult.protocolVersion !== 'string')
|
|
107
|
+
throw new Error('MCP_PROTOCOL_ERROR:initialize response has no protocolVersion');
|
|
108
|
+
this.notify('notifications/initialized');
|
|
109
|
+
await this.refreshTools();
|
|
110
|
+
return this.toToolDefinitions();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
await this.close();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Refreshes and returns the server's complete paginated tools/list result. */
|
|
118
|
+
async refreshTools() {
|
|
119
|
+
this.ensureConnected();
|
|
120
|
+
const found = new Map();
|
|
121
|
+
const cursors = new Set();
|
|
122
|
+
let cursor;
|
|
123
|
+
let pageCount = 0;
|
|
124
|
+
const maxPages = this.options.maxToolPages ?? 32;
|
|
125
|
+
do {
|
|
126
|
+
if (++pageCount > maxPages)
|
|
127
|
+
throw new Error(`MCP_PROTOCOL_ERROR:tools/list exceeded ${maxPages} pages`);
|
|
128
|
+
const result = asRecord(await this.request('tools/list', cursor === undefined ? {} : { cursor }), 'tools/list result');
|
|
129
|
+
if (!Array.isArray(result.tools))
|
|
130
|
+
throw new Error('MCP_PROTOCOL_ERROR:tools/list result has no tools array');
|
|
131
|
+
for (const rawTool of result.tools) {
|
|
132
|
+
const tool = asRecord(rawTool, 'tool');
|
|
133
|
+
if (typeof tool.name !== 'string' || !tool.name)
|
|
134
|
+
throw new Error('MCP_PROTOCOL_ERROR:tool has no name');
|
|
135
|
+
if (found.has(tool.name))
|
|
136
|
+
throw new Error(`MCP_PROTOCOL_ERROR:duplicate tool ${tool.name}`);
|
|
137
|
+
const inputSchema = tool.inputSchema === undefined ? { type: 'object' } : asRecord(tool.inputSchema, `tool ${tool.name} inputSchema`);
|
|
138
|
+
found.set(tool.name, { ...tool, name: tool.name, ...(typeof tool.description === 'string' ? { description: tool.description } : {}), inputSchema });
|
|
139
|
+
}
|
|
140
|
+
const nextCursor = typeof result.nextCursor === 'string' && result.nextCursor.length > 0 ? result.nextCursor : undefined;
|
|
141
|
+
if (nextCursor !== undefined && (nextCursor === cursor || cursors.has(nextCursor)))
|
|
142
|
+
throw new Error('MCP_PROTOCOL_ERROR:tools/list cursor repeated');
|
|
143
|
+
if (cursor !== undefined)
|
|
144
|
+
cursors.add(cursor);
|
|
145
|
+
cursor = nextCursor;
|
|
146
|
+
} while (cursor !== undefined);
|
|
147
|
+
this.remoteTools.clear();
|
|
148
|
+
for (const [name, tool] of found)
|
|
149
|
+
this.remoteTools.set(name, tool);
|
|
150
|
+
return [...this.remoteTools.values()];
|
|
151
|
+
}
|
|
152
|
+
/** Calls one discovered remote tool and returns the JSON-compatible MCP result envelope. */
|
|
153
|
+
async callTool(name, arguments_, options = {}) {
|
|
154
|
+
this.ensureConnected();
|
|
155
|
+
if (!this.remoteTools.has(name))
|
|
156
|
+
throw new Error(`MCP_UNKNOWN_TOOL:${name}`);
|
|
157
|
+
if (options.signal?.aborted)
|
|
158
|
+
throw options.signal.reason instanceof Error ? options.signal.reason : new Error('MCP_TOOL_CALL_ABORTED');
|
|
159
|
+
const request = this.request('tools/call', { name, arguments: arguments_ }, options.timeoutMs);
|
|
160
|
+
let abortHandler;
|
|
161
|
+
const aborted = options.signal && new Promise((_, reject) => {
|
|
162
|
+
abortHandler = () => {
|
|
163
|
+
this.cancelRequest(request, 'MCP_TOOL_CALL_ABORTED');
|
|
164
|
+
reject(options.signal?.reason instanceof Error ? options.signal.reason : new Error('MCP_TOOL_CALL_ABORTED'));
|
|
165
|
+
};
|
|
166
|
+
options.signal?.addEventListener('abort', abortHandler, { once: true });
|
|
167
|
+
if (options.signal?.aborted)
|
|
168
|
+
abortHandler();
|
|
169
|
+
});
|
|
170
|
+
try {
|
|
171
|
+
const result = await (aborted ? Promise.race([request, aborted]) : request);
|
|
172
|
+
const record = asRecord(result, 'tools/call result');
|
|
173
|
+
if (record.isError === true)
|
|
174
|
+
throw new Error(`MCP_TOOL_ERROR:${JSON.stringify(record.content ?? record.structuredContent ?? record)}`);
|
|
175
|
+
return toJson(record);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
if (abortHandler)
|
|
179
|
+
options.signal?.removeEventListener('abort', abortHandler);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Closes stdin, waits briefly for exit, then terminates a server that stays alive. */
|
|
183
|
+
async close() {
|
|
184
|
+
if (this.closed)
|
|
185
|
+
return;
|
|
186
|
+
this.closed = true;
|
|
187
|
+
this.rejectPending(new Error('MCP_CLIENT_CLOSED'));
|
|
188
|
+
const child = this.child;
|
|
189
|
+
if (!child || child.exitCode !== null || child.signalCode !== null)
|
|
190
|
+
return;
|
|
191
|
+
child.stdin.end();
|
|
192
|
+
const timeoutMs = this.options.shutdownTimeoutMs ?? 500;
|
|
193
|
+
await Promise.race([
|
|
194
|
+
new Promise((resolve) => child.once('exit', () => resolve())),
|
|
195
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
196
|
+
]);
|
|
197
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
198
|
+
child.kill('SIGTERM');
|
|
199
|
+
await Promise.race([
|
|
200
|
+
new Promise((resolve) => child.once('exit', () => resolve())),
|
|
201
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
202
|
+
]);
|
|
203
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
204
|
+
child.kill('SIGKILL');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Exposes the discovered tools as Pulse ToolDefinitions, preserving each remote input schema. */
|
|
208
|
+
toToolDefinitions() {
|
|
209
|
+
const namespace = this.options.namespace ?? 'mcp';
|
|
210
|
+
return [...this.remoteTools.values()].map((tool) => {
|
|
211
|
+
const pulseName = `${namespace}.${tool.name}`;
|
|
212
|
+
return {
|
|
213
|
+
manifest: {
|
|
214
|
+
name: pulseName,
|
|
215
|
+
version: 'mcp-remote',
|
|
216
|
+
description: tool.description ?? `MCP tool ${tool.name}`,
|
|
217
|
+
inputSchema: tool.inputSchema,
|
|
218
|
+
outputSchema: {},
|
|
219
|
+
concurrencyClass: 'tool',
|
|
220
|
+
locks: [],
|
|
221
|
+
supportsAbortSignal: true,
|
|
222
|
+
sideEffectPolicy: 'external',
|
|
223
|
+
retrySafety: 'unsafe',
|
|
224
|
+
defaultTimeoutMs: this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
225
|
+
tags: ['mcp'],
|
|
226
|
+
},
|
|
227
|
+
validateInput: (input) => {
|
|
228
|
+
if (!matchesJsonSchema(input, tool.inputSchema))
|
|
229
|
+
throw new Error(`MCP_INVALID_TOOL_INPUT:${tool.name}`);
|
|
230
|
+
return input;
|
|
231
|
+
},
|
|
232
|
+
execute: async (input, context) => this.callTool(tool.name, input, { signal: context.signal }),
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
consume(chunk) {
|
|
237
|
+
if (this.closed)
|
|
238
|
+
return;
|
|
239
|
+
this.buffer += this.decoder.write(chunk);
|
|
240
|
+
const maxBytes = this.options.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
|
|
241
|
+
let newline;
|
|
242
|
+
while ((newline = this.buffer.indexOf('\n')) >= 0) {
|
|
243
|
+
const line = this.buffer.slice(0, newline).replace(/\r$/, '');
|
|
244
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
245
|
+
if (Buffer.byteLength(line, 'utf8') > maxBytes) {
|
|
246
|
+
this.fail(new Error('MCP_FRAME_TOO_LARGE'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!line.trim())
|
|
250
|
+
continue;
|
|
251
|
+
this.receive(line);
|
|
252
|
+
}
|
|
253
|
+
if (Buffer.byteLength(this.buffer, 'utf8') > maxBytes)
|
|
254
|
+
this.fail(new Error('MCP_FRAME_TOO_LARGE'));
|
|
255
|
+
}
|
|
256
|
+
receive(line) {
|
|
257
|
+
let parsed;
|
|
258
|
+
try {
|
|
259
|
+
parsed = JSON.parse(line);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
this.fail(new Error('MCP_PROTOCOL_ERROR:invalid JSON frame'));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let message;
|
|
266
|
+
try {
|
|
267
|
+
message = asRecord(parsed, 'message');
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
this.fail(asError(error));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (message.jsonrpc !== '2.0') {
|
|
274
|
+
this.fail(new Error('MCP_PROTOCOL_ERROR:jsonrpc must be 2.0'));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (message.method === 'ping' && message.id !== undefined) {
|
|
278
|
+
this.write({ jsonrpc: '2.0', id: message.id, result: {} });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (message.id === undefined)
|
|
282
|
+
return; // Server notifications are advisory for this tools-only adapter.
|
|
283
|
+
if (typeof message.id !== 'number') {
|
|
284
|
+
this.fail(new Error('MCP_PROTOCOL_ERROR:response id must be numeric'));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const pending = this.pending.get(message.id);
|
|
288
|
+
if (!pending)
|
|
289
|
+
return;
|
|
290
|
+
clearTimeout(pending.timer);
|
|
291
|
+
this.pending.delete(message.id);
|
|
292
|
+
if (message.error !== undefined) {
|
|
293
|
+
const error = asRecord(message.error, 'JSON-RPC error');
|
|
294
|
+
pending.reject(new Error(`MCP_JSONRPC_ERROR:${String(error.code)}:${String(error.message)}`));
|
|
295
|
+
}
|
|
296
|
+
else if (!('result' in message))
|
|
297
|
+
pending.reject(new Error('MCP_PROTOCOL_ERROR:response has no result'));
|
|
298
|
+
else
|
|
299
|
+
pending.resolve(message.result);
|
|
300
|
+
}
|
|
301
|
+
request(method, params, timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS) {
|
|
302
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
303
|
+
return Promise.reject(new Error('MCP_TIMEOUT_MUST_BE_POSITIVE'));
|
|
304
|
+
if (this.closed || !this.child || !this.child.stdin.writable)
|
|
305
|
+
return Promise.reject(new Error('MCP_CLIENT_NOT_CONNECTED'));
|
|
306
|
+
const id = this.nextId++;
|
|
307
|
+
const message = { jsonrpc: '2.0', id, method, params };
|
|
308
|
+
const promise = new Promise((resolve, reject) => {
|
|
309
|
+
const timer = setTimeout(() => {
|
|
310
|
+
this.pending.delete(id);
|
|
311
|
+
this.notify('notifications/cancelled', { requestId: id, reason: `Timed out after ${timeoutMs}ms` });
|
|
312
|
+
reject(new Error(`MCP_REQUEST_TIMEOUT:${method}:${timeoutMs}`));
|
|
313
|
+
}, timeoutMs);
|
|
314
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
315
|
+
this.write(message, (error) => {
|
|
316
|
+
if (!error)
|
|
317
|
+
return;
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
this.pending.delete(id);
|
|
320
|
+
reject(error);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
this.requestIds.set(promise, id);
|
|
324
|
+
return promise;
|
|
325
|
+
}
|
|
326
|
+
cancelRequest(request, code) {
|
|
327
|
+
const id = this.requestIds.get(request);
|
|
328
|
+
const item = id === undefined ? undefined : this.pending.get(id);
|
|
329
|
+
if (id === undefined || !item)
|
|
330
|
+
return;
|
|
331
|
+
clearTimeout(item.timer);
|
|
332
|
+
this.pending.delete(id);
|
|
333
|
+
this.notify('notifications/cancelled', { requestId: id, reason: code });
|
|
334
|
+
item.reject(new Error(code));
|
|
335
|
+
void request.catch(() => undefined);
|
|
336
|
+
}
|
|
337
|
+
notify(method, params) {
|
|
338
|
+
if (!this.child || !this.child.stdin.writable || this.closed)
|
|
339
|
+
return;
|
|
340
|
+
this.write({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) });
|
|
341
|
+
}
|
|
342
|
+
write(message, callback) {
|
|
343
|
+
const child = this.child;
|
|
344
|
+
if (!child || !child.stdin.writable) {
|
|
345
|
+
callback?.(new Error('MCP_CLIENT_NOT_CONNECTED'));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let encoded;
|
|
349
|
+
try {
|
|
350
|
+
encoded = `${JSON.stringify(message)}\n`;
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
callback?.(asError(error));
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
child.stdin.write(encoded, 'utf8', (error) => callback?.(error ?? undefined));
|
|
357
|
+
}
|
|
358
|
+
ensureConnected() {
|
|
359
|
+
if (!this.started || this.closed || !this.child || this.child.exitCode !== null || this.child.signalCode !== null)
|
|
360
|
+
throw new Error('MCP_CLIENT_NOT_CONNECTED');
|
|
361
|
+
}
|
|
362
|
+
rejectPending(error) {
|
|
363
|
+
for (const [id, pending] of this.pending) {
|
|
364
|
+
clearTimeout(pending.timer);
|
|
365
|
+
this.pending.delete(id);
|
|
366
|
+
pending.reject(error);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
fail(error) {
|
|
370
|
+
if (this.closed)
|
|
371
|
+
return;
|
|
372
|
+
this.rejectPending(error);
|
|
373
|
+
this.closed = true;
|
|
374
|
+
const child = this.child;
|
|
375
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
376
|
+
child.kill('SIGTERM');
|
|
377
|
+
const forceTimer = setTimeout(() => {
|
|
378
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
379
|
+
child.kill('SIGKILL');
|
|
380
|
+
}, this.options.shutdownTimeoutMs ?? 500);
|
|
381
|
+
forceTimer.unref();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Starts one MCP server and returns a client plus Pulse-compatible tool definitions. */
|
|
386
|
+
export async function createMcpStdioAdapter(options) {
|
|
387
|
+
const client = new McpStdioClient(options);
|
|
388
|
+
const tools = await client.connect();
|
|
389
|
+
return { client, tools };
|
|
390
|
+
}
|
|
391
|
+
/** Creates an opaque namespace suitable for using the server name in Pulse tool names. */
|
|
392
|
+
export function mcpToolNamespace(serverName) {
|
|
393
|
+
const normalized = serverName.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
394
|
+
return `mcp.${normalized || randomUUID().slice(0, 8)}`;
|
|
395
|
+
}
|
|
@@ -54,7 +54,10 @@ export class AnthropicAdapter {
|
|
|
54
54
|
throw await providerHttpErrorFromResponse(response);
|
|
55
55
|
if (!streaming || !response.headers.get('content-type')?.includes('text/event-stream'))
|
|
56
56
|
return normalizeAnthropicResponse(await parseProviderJson(response));
|
|
57
|
-
const events = await consumeProviderSse(response)
|
|
57
|
+
const events = await consumeProviderSse(response, (event) => {
|
|
58
|
+
if (event.event === 'content_block_delta' && event.data?.delta?.type === 'text_delta' && typeof event.data.delta.text === 'string')
|
|
59
|
+
params.onObservation?.(event.data.delta.text);
|
|
60
|
+
});
|
|
58
61
|
const blocks = [];
|
|
59
62
|
let stopReason;
|
|
60
63
|
let usage = {};
|
|
@@ -72,7 +75,6 @@ export class AnthropicAdapter {
|
|
|
72
75
|
if (data.delta.type === 'text_delta' && typeof data.delta.text === 'string') {
|
|
73
76
|
block.type = 'text';
|
|
74
77
|
block.text = `${typeof block.text === 'string' ? block.text : ''}${data.delta.text}`;
|
|
75
|
-
params.onObservation?.(data.delta.text);
|
|
76
78
|
}
|
|
77
79
|
if (data.delta.type === 'input_json_delta' && typeof data.delta.partial_json === 'string')
|
|
78
80
|
block.inputJson = `${typeof block.inputJson === 'string' ? block.inputJson : ''}${data.delta.partial_json}`;
|
|
@@ -22,6 +22,6 @@ export declare function providerResponseError(detail: string): Error & {
|
|
|
22
22
|
};
|
|
23
23
|
export declare function parseProviderJson(response: Response): Promise<unknown>;
|
|
24
24
|
/** Read provider SSE frames without treating incomplete tool arguments as executable input. */
|
|
25
|
-
export declare function consumeProviderSse(response: Response): Promise<ProviderSseEvent[]>;
|
|
25
|
+
export declare function consumeProviderSse(response: Response, onEvent?: (event: ProviderSseEvent) => void): Promise<ProviderSseEvent[]>;
|
|
26
26
|
export declare function normalizeOpenAIResponse(response: any, toolNameAliases?: ReadonlyMap<string, string>): LLMResult;
|
|
27
27
|
export declare function normalizeAnthropicResponse(response: any): LLMResult;
|
|
@@ -54,7 +54,7 @@ export async function parseProviderJson(response) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
/** Read provider SSE frames without treating incomplete tool arguments as executable input. */
|
|
57
|
-
export async function consumeProviderSse(response) {
|
|
57
|
+
export async function consumeProviderSse(response, onEvent) {
|
|
58
58
|
if (!response.body)
|
|
59
59
|
throw providerResponseError('PROVIDER_STREAM_BODY_MISSING');
|
|
60
60
|
const reader = response.body.getReader();
|
|
@@ -76,7 +76,9 @@ export async function consumeProviderSse(response) {
|
|
|
76
76
|
catch {
|
|
77
77
|
throw providerResponseError('PROVIDER_STREAM_INVALID_JSON');
|
|
78
78
|
} })();
|
|
79
|
-
|
|
79
|
+
const event = { ...(eventName === undefined ? {} : { event: eventName }), data };
|
|
80
|
+
events.push(event);
|
|
81
|
+
onEvent?.(event);
|
|
80
82
|
eventName = undefined;
|
|
81
83
|
};
|
|
82
84
|
const consumeLines = (text) => {
|
|
@@ -114,8 +116,8 @@ export function normalizeOpenAIResponse(response, toolNameAliases) {
|
|
|
114
116
|
throw providerResponseError('OpenAI response must contain at least one choice');
|
|
115
117
|
const choice = providerRecord(root.choices[0], 'OpenAI choice');
|
|
116
118
|
const message = providerRecord(choice.message, 'OpenAI message');
|
|
117
|
-
const toolCalls = message.tool_calls === undefined ? [] : normalizeOpenAIToolCalls(message.tool_calls, toolNameAliases);
|
|
118
|
-
const refusal = message.refusal
|
|
119
|
+
const toolCalls = choice.finish_reason === 'length' || message.tool_calls === undefined ? [] : normalizeOpenAIToolCalls(message.tool_calls, toolNameAliases);
|
|
120
|
+
const refusal = message.refusal == null ? undefined : requiredProviderString(message.refusal, 'OpenAI refusal');
|
|
119
121
|
const text = providerText(message.content, 'OpenAI message content');
|
|
120
122
|
const finishReason = normalizeOpenAIFinishReason(choice.finish_reason, refusal, toolCalls.length > 0);
|
|
121
123
|
const rawUsage = root.usage === undefined ? undefined : providerRecord(root.usage, 'OpenAI usage');
|
|
@@ -130,7 +132,7 @@ export function normalizeAnthropicResponse(response) {
|
|
|
130
132
|
const blocks = root.content;
|
|
131
133
|
const text = blocks.filter((block) => providerRecord(block, 'Anthropic content block').type === 'text').map((block) => requiredProviderString(providerRecord(block, 'Anthropic text block').text, 'Anthropic text block text')).join('');
|
|
132
134
|
const toolBlocks = blocks.filter((block) => providerRecord(block, 'Anthropic content block').type === 'tool_use');
|
|
133
|
-
const toolCalls = toolBlocks.map((block, index) => {
|
|
135
|
+
const toolCalls = (root.stop_reason === 'max_tokens' ? [] : toolBlocks).map((block, index) => {
|
|
134
136
|
const value = providerRecord(block, 'Anthropic tool block');
|
|
135
137
|
return { toolCallId: `pulse-tool-${index + 1}`, name: requiredProviderString(value.name, 'Anthropic tool name'), input: parseJson(value.input) };
|
|
136
138
|
});
|
|
@@ -22,14 +22,27 @@ export class OpenAICompatibleAdapter {
|
|
|
22
22
|
const streaming = params.onObservation !== undefined;
|
|
23
23
|
const { definitions: tools, aliases: toolNameAliases } = toolDefinitions(params.request);
|
|
24
24
|
const includeReasoning = Boolean(this.config.reasoningEffort) && !this.reasoningUnsupported;
|
|
25
|
-
const
|
|
25
|
+
const messages = toMessages(params.request);
|
|
26
|
+
const deepSeekJsonMode = params.outputSchema !== undefined && this.config.provider === 'deepseek';
|
|
27
|
+
if (deepSeekJsonMode)
|
|
28
|
+
messages.unshift({ role: 'system', content: `Return only a JSON object matching this schema. The application will validate the result:\n${JSON.stringify(params.outputSchema)}` });
|
|
29
|
+
const responseFormat = params.outputSchema === undefined ? undefined : deepSeekJsonMode
|
|
30
|
+
? { type: 'json_object' }
|
|
31
|
+
: { type: 'json_schema', json_schema: { name: 'pulse_output', strict: true, schema: params.outputSchema } };
|
|
32
|
+
const body = { ...(params.model ?? this.config.defaultModel ? { model: params.model ?? this.config.defaultModel } : {}), ...((params.maxOutputTokens ?? this.config.maxOutputTokens) === undefined ? {} : { max_tokens: params.maxOutputTokens ?? this.config.maxOutputTokens }), ...(includeReasoning ? { reasoning_effort: this.config.reasoningEffort } : {}), messages, ...(tools.length ? { tools, ...(this.config.toolChoice === undefined ? {} : { tool_choice: this.config.toolChoice }) } : {}), ...(responseFormat === undefined ? {} : { response_format: responseFormat }), ...(streaming ? { stream: true, stream_options: { include_usage: true } } : {}) };
|
|
26
33
|
try {
|
|
27
34
|
const response = await fetch(`${this.baseURL.replace(/\/$/, '')}/chat/completions`, { method: 'POST', signal: params.signal, headers: { 'content-type': 'application/json', ...(this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : {}), ...(this.config.extraHeaders ?? {}) }, body: JSON.stringify(body) });
|
|
28
35
|
if (!response.ok)
|
|
29
36
|
throw await providerHttpErrorFromResponse(response);
|
|
30
37
|
if (!streaming || !response.headers.get('content-type')?.includes('text/event-stream'))
|
|
31
38
|
return normalizeOpenAIResponse(await parseProviderJson(response), toolNameAliases);
|
|
32
|
-
const events = await consumeProviderSse(response)
|
|
39
|
+
const events = await consumeProviderSse(response, (event) => {
|
|
40
|
+
const delta = event.data?.choices?.[0]?.delta;
|
|
41
|
+
if (typeof delta?.content === 'string')
|
|
42
|
+
params.onObservation?.(delta.content);
|
|
43
|
+
if (typeof delta?.refusal === 'string')
|
|
44
|
+
params.onObservation?.(delta.refusal);
|
|
45
|
+
});
|
|
33
46
|
const content = [];
|
|
34
47
|
const refusals = [];
|
|
35
48
|
const toolCalls = new Map();
|
|
@@ -42,11 +55,9 @@ export class OpenAICompatibleAdapter {
|
|
|
42
55
|
const delta = choice?.delta;
|
|
43
56
|
if (typeof delta?.content === 'string') {
|
|
44
57
|
content.push(delta.content);
|
|
45
|
-
params.onObservation?.(delta.content);
|
|
46
58
|
}
|
|
47
59
|
if (typeof delta?.refusal === 'string') {
|
|
48
60
|
refusals.push(delta.refusal);
|
|
49
|
-
params.onObservation?.(delta.refusal);
|
|
50
61
|
}
|
|
51
62
|
if (typeof choice?.finish_reason === 'string')
|
|
52
63
|
finishReason = choice.finish_reason;
|
|
@@ -165,6 +165,8 @@ export function createModelEffectExecutor(config) {
|
|
|
165
165
|
const output = assignRuntimeToolCallIds(validateAdapterResult(await provider.executeAttempt({ request: projection, signal, model: attempt.candidate.id, ...(input.outputSchema === undefined ? {} : { outputSchema: input.outputSchema }), ...(typeof routeRequirements.maxOutputTokens === 'number' ? { maxOutputTokens: routeRequirements.maxOutputTokens } : {}), onObservation })), effect.id);
|
|
166
166
|
const measuredUsage = output.usage === undefined ? { latencyMs: Math.max(0, Date.now() - startedAt) } : { ...output.usage, latencyMs: output.usage.latencyMs ?? Math.max(0, Date.now() - startedAt), ...(output.usage.uncachedInputTokens === undefined && output.usage.inputTokens !== undefined && output.usage.cachedInputTokens !== undefined ? { uncachedInputTokens: Math.max(0, output.usage.inputTokens - output.usage.cachedInputTokens) } : {}) };
|
|
167
167
|
usage.set(attempt.attemptId, measuredUsage);
|
|
168
|
+
if (output.finishReason === 'length' && input.outputSchema !== undefined)
|
|
169
|
+
throw Object.assign(new OutputValidationError('adapter', 'OUTPUT_TRUNCATED', 'Model output reached its token limit; increase maxOutputTokens before retrying.'), { retryable: false });
|
|
168
170
|
if (output.finishReason === 'refusal') {
|
|
169
171
|
recordFeedback('refused', 0);
|
|
170
172
|
throw new OutputValidationError('adapter', 'MODEL_REFUSAL', output.refusal ?? 'Provider refused the request.');
|
|
@@ -201,7 +203,7 @@ export function createModelEffectExecutor(config) {
|
|
|
201
203
|
const waited = providerAttempt === undefined ? undefined : slotWaitMs.get(providerAttempt.attemptId);
|
|
202
204
|
if (waited !== undefined)
|
|
203
205
|
slotWaitMs.set(effect.attemptId, waited);
|
|
204
|
-
return { ...value, attempts: value.attempts.map(() => ({ effectId: effect.id
|
|
206
|
+
return { ...value, attempts: value.attempts.map((attempt) => ({ ...attempt, effectId: effect.id })) };
|
|
205
207
|
}).catch((cause) => {
|
|
206
208
|
if (failedForSchema && lastSchemaViolation !== undefined)
|
|
207
209
|
return { result: { text: '', toolCalls: [], finishReason: 'error' }, candidate, attempts: [{ effectId: effect.id, attemptId: effect.attemptId, attemptNo, candidate }], schemaRejected: lastSchemaViolation };
|
|
@@ -15,6 +15,10 @@ export declare class FilesystemTool {
|
|
|
15
15
|
private writable;
|
|
16
16
|
read(path: string, signal?: AbortSignal): Promise<string>;
|
|
17
17
|
readLimited(path: string, maxBytes: number, signal?: AbortSignal): Promise<FilesystemReadResult>;
|
|
18
|
+
readRange(path: string, maxBytes: number, offset?: number, signal?: AbortSignal): Promise<FilesystemReadResult & {
|
|
19
|
+
offset: number;
|
|
20
|
+
nextOffset: number | null;
|
|
21
|
+
}>;
|
|
18
22
|
list(path?: string, signal?: AbortSignal): Promise<string[]>;
|
|
19
23
|
write(path: string, content: string, signal?: AbortSignal): Promise<void>;
|
|
20
24
|
move(source: string, destination: string, expectedHash?: string, signal?: AbortSignal): Promise<{
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -56,13 +56,31 @@ export class FilesystemTool {
|
|
|
56
56
|
async read(path, signal) { if (signal?.aborted)
|
|
57
57
|
throw filesystemError('ABORTED'); return readFile(await this.existing(path), 'utf8'); }
|
|
58
58
|
async readLimited(path, maxBytes, signal) {
|
|
59
|
+
const { content, truncated } = await this.readRange(path, maxBytes, 0, signal);
|
|
60
|
+
return { content, truncated };
|
|
61
|
+
}
|
|
62
|
+
async readRange(path, maxBytes, offset = 0, signal) {
|
|
59
63
|
if (signal?.aborted)
|
|
60
64
|
throw filesystemError('ABORTED');
|
|
65
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1_000_000 || !Number.isSafeInteger(offset) || offset < 0)
|
|
66
|
+
throw filesystemError('INVALID_READ_RANGE');
|
|
61
67
|
const handle = await open(await this.existing(path), 'r');
|
|
62
68
|
try {
|
|
63
69
|
const buffer = Buffer.alloc(maxBytes + 1);
|
|
64
|
-
const { bytesRead } = await handle.read(buffer, 0, maxBytes + 1,
|
|
65
|
-
|
|
70
|
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes + 1, offset);
|
|
71
|
+
if (bytesRead && (buffer[0] & 0xc0) === 0x80)
|
|
72
|
+
throw filesystemError('INVALID_UTF8_OFFSET');
|
|
73
|
+
let end = Math.min(bytesRead, maxBytes);
|
|
74
|
+
if (bytesRead > maxBytes) {
|
|
75
|
+
// The byte after the window is a continuation: drop the partial codepoint.
|
|
76
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80)
|
|
77
|
+
end--;
|
|
78
|
+
}
|
|
79
|
+
if (end === 0 && bytesRead > 0)
|
|
80
|
+
throw filesystemError('READ_WINDOW_TOO_SMALL');
|
|
81
|
+
const content = buffer.subarray(0, end).toString('utf8');
|
|
82
|
+
const truncated = bytesRead > end;
|
|
83
|
+
return { content, truncated, offset, nextOffset: truncated ? offset + end : null };
|
|
66
84
|
}
|
|
67
85
|
finally {
|
|
68
86
|
await handle.close();
|
package/dist/tools/shell.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export interface ShellResult {
|
|
|
6
6
|
timedOut: boolean;
|
|
7
7
|
aborted: boolean;
|
|
8
8
|
}
|
|
9
|
+
export declare function quoteWindowsArgument(value: string): string;
|
|
10
|
+
/** Build the command text expected by srt without interpreting model argv as shell syntax. */
|
|
11
|
+
export declare function encodeSandboxCommand(command: string, args: string[], platform?: NodeJS.Platform): string;
|
|
12
|
+
export declare function decodeUtf8WithinByteLimit(value: Buffer, maxBytes: number): string;
|
|
9
13
|
export declare function runShell(command: string, args?: string[], options?: {
|
|
10
14
|
cwd?: string;
|
|
11
15
|
signal?: AbortSignal;
|