@aexol/spectral 0.2.7 → 0.2.8
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/mcp/tool-registrar.js +46 -7
- package/dist/server/pi-bridge.js +132 -5
- package/package.json +1 -1
|
@@ -1,28 +1,67 @@
|
|
|
1
1
|
// tool-registrar.ts - MCP content transformation
|
|
2
2
|
// NOTE: Tools are NOT registered with Pi - only the unified `mcp` proxy tool is registered.
|
|
3
3
|
// This keeps the LLM context small (1 tool instead of 100s).
|
|
4
|
+
/**
|
|
5
|
+
* Maximum characters per text block from MCP tool results.
|
|
6
|
+
*
|
|
7
|
+
* MCP tools (especially Playwright's browser_snapshot, browser_evaluate,
|
|
8
|
+
* browser_network_requests) can return massive content — accessibility
|
|
9
|
+
* trees of 100-500KB, large JSON arrays, etc. When passed untruncated into
|
|
10
|
+
* pi's ToolResultMessage.content, they blow up the LLM context window,
|
|
11
|
+
* causing API errors (400/413) and dead sessions ("skipping empty
|
|
12
|
+
* intermediate message" loops).
|
|
13
|
+
*
|
|
14
|
+
* 20,000 characters ≈ 5,000-8,000 tokens depending on the model, which
|
|
15
|
+
* keeps individual tool results manageable even across multiple MCP calls
|
|
16
|
+
* in the same turn.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_MCP_TEXT_LENGTH = 20_000;
|
|
19
|
+
function truncateTextBlock(block) {
|
|
20
|
+
if (block.type !== "text")
|
|
21
|
+
return block;
|
|
22
|
+
if (block.text.length <= MAX_MCP_TEXT_LENGTH)
|
|
23
|
+
return block;
|
|
24
|
+
const omitted = block.text.length - MAX_MCP_TEXT_LENGTH;
|
|
25
|
+
return {
|
|
26
|
+
type: "text",
|
|
27
|
+
text: block.text.slice(0, MAX_MCP_TEXT_LENGTH) +
|
|
28
|
+
`\n\n[truncated — ${omitted.toLocaleString()} bytes omitted. ` +
|
|
29
|
+
`Use more specific tool calls or narrower queries to get complete results.]`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
4
32
|
/**
|
|
5
33
|
* Transform MCP content types to Pi content blocks.
|
|
34
|
+
*
|
|
35
|
+
* Text blocks exceeding MAX_MCP_TEXT_LENGTH are truncated to prevent
|
|
36
|
+
* context-window overflow in downstream LLM calls.
|
|
6
37
|
*/
|
|
7
38
|
export function transformMcpContent(content) {
|
|
8
39
|
return content.map(c => {
|
|
9
40
|
if (c.type === "text") {
|
|
10
|
-
return { type: "text", text: c.text ?? "" };
|
|
41
|
+
return truncateTextBlock({ type: "text", text: c.text ?? "" });
|
|
11
42
|
}
|
|
12
43
|
if (c.type === "image") {
|
|
44
|
+
// Most LLM APIs reject ImageContent blocks inside tool-result
|
|
45
|
+
// messages (even multimodal models often only accept images in
|
|
46
|
+
// user-turn content, not in tool results). Passing through
|
|
47
|
+
// base64 images here causes 400 errors and dead sessions.
|
|
48
|
+
// Convert to a text placeholder that preserves metadata.
|
|
49
|
+
const sizeBytes = c.data?.length ?? 0;
|
|
13
50
|
return {
|
|
14
|
-
type: "
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
type: "text",
|
|
52
|
+
text: `[Image: ${c.mimeType ?? "image/png"}, ${sizeBytes.toLocaleString()} bytes base64]` +
|
|
53
|
+
`\n(Image data is available in the raw MCP result — use direct tool\n` +
|
|
54
|
+
`calls like browser_take_screenshot or browser_run_code to\n` +
|
|
55
|
+
`save/process images to disk for further analysis.)`,
|
|
17
56
|
};
|
|
18
57
|
}
|
|
19
58
|
if (c.type === "resource") {
|
|
20
59
|
const resourceUri = c.resource?.uri ?? "(no URI)";
|
|
21
60
|
const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)");
|
|
22
|
-
return {
|
|
61
|
+
return truncateTextBlock({
|
|
23
62
|
type: "text",
|
|
24
63
|
text: `[Resource: ${resourceUri}]\n${resourceContent}`,
|
|
25
|
-
};
|
|
64
|
+
});
|
|
26
65
|
}
|
|
27
66
|
if (c.type === "resource_link") {
|
|
28
67
|
const linkName = c.name ?? c.uri ?? "unknown";
|
|
@@ -38,6 +77,6 @@ export function transformMcpContent(content) {
|
|
|
38
77
|
text: `[Audio content: ${c.mimeType ?? "audio/*"}]`,
|
|
39
78
|
};
|
|
40
79
|
}
|
|
41
|
-
return { type: "text", text: JSON.stringify(c) };
|
|
80
|
+
return truncateTextBlock({ type: "text", text: JSON.stringify(c) });
|
|
42
81
|
});
|
|
43
82
|
}
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -158,6 +158,84 @@ function lookupPricing(modelId) {
|
|
|
158
158
|
}
|
|
159
159
|
return null;
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Parse the newline-delimited JSON of wire events persisted alongside an
|
|
163
|
+
* assistant message and extract all tool_call / tool_result events.
|
|
164
|
+
*
|
|
165
|
+
* These are used during session rehydration to reconstruct the full tool
|
|
166
|
+
* interaction history (call → result) so the LLM context is complete —
|
|
167
|
+
* without this, reconnected sessions would see only the assistant text
|
|
168
|
+
* and never the tool results, causing the model to get confused about
|
|
169
|
+
* the conversation state.
|
|
170
|
+
*/
|
|
171
|
+
function parseWireToolEvents(eventsJsonl) {
|
|
172
|
+
const toolCalls = [];
|
|
173
|
+
const toolResults = [];
|
|
174
|
+
if (!eventsJsonl)
|
|
175
|
+
return { toolCalls, toolResults };
|
|
176
|
+
for (const line of eventsJsonl.split("\n")) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed)
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
const ev = JSON.parse(trimmed);
|
|
182
|
+
if (ev.type === "tool_call") {
|
|
183
|
+
toolCalls.push({
|
|
184
|
+
id: ev.id,
|
|
185
|
+
name: ev.name,
|
|
186
|
+
arguments: ev.args ?? {},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else if (ev.type === "tool_result") {
|
|
190
|
+
toolResults.push({
|
|
191
|
+
id: ev.id,
|
|
192
|
+
result: ev.result,
|
|
193
|
+
isError: ev.isError ?? false,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Skip malformed JSON lines — they won't be meaningful for
|
|
199
|
+
// rehydration and we'd rather keep the session alive than fail.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { toolCalls, toolResults };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Safety clean-up for content blocks rehydrated from persisted wire events.
|
|
206
|
+
*
|
|
207
|
+
* Sessions created before the fixes in tool-registrar.ts may carry raw
|
|
208
|
+
* ImageContent (base64 blobs) and untruncated large text blocks in their
|
|
209
|
+
* events_jsonl. When replayed into a fresh session manager without cleanup
|
|
210
|
+
* they can trigger 400 API errors (unsupported image content in tool results)
|
|
211
|
+
* or context-window overflow — both surface as "skipping empty intermediate
|
|
212
|
+
* message" / hung sessions after reconnect.
|
|
213
|
+
*/
|
|
214
|
+
const MAX_REHYDRATED_TEXT = 20_000;
|
|
215
|
+
function sanitizeRehydratedBlock(block) {
|
|
216
|
+
// Convert images to text placeholders — most LLM APIs reject
|
|
217
|
+
// ImageContent inside tool-result messages (even multimodal
|
|
218
|
+
// models often only accept images in user-turn content).
|
|
219
|
+
if (block.type === "image") {
|
|
220
|
+
const sizeBytes = typeof block.data === "string" ? block.data.length : 0;
|
|
221
|
+
return {
|
|
222
|
+
type: "text",
|
|
223
|
+
text: `[Image: ${block.mimeType ?? "image/png"}, ${sizeBytes.toLocaleString()} bytes base64]` +
|
|
224
|
+
`\n(Image data is available in the raw MCP result.)`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
// Truncate oversized text blocks to prevent context overflow.
|
|
228
|
+
if (block.type !== "text")
|
|
229
|
+
return block;
|
|
230
|
+
if (block.text.length <= MAX_REHYDRATED_TEXT)
|
|
231
|
+
return block;
|
|
232
|
+
const omitted = block.text.length - MAX_REHYDRATED_TEXT;
|
|
233
|
+
return {
|
|
234
|
+
...block,
|
|
235
|
+
text: block.text.slice(0, MAX_REHYDRATED_TEXT) +
|
|
236
|
+
`\n\n[truncated — ${omitted.toLocaleString()} bytes omitted]`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
161
239
|
export class PiBridge {
|
|
162
240
|
session;
|
|
163
241
|
unsubscribe;
|
|
@@ -227,8 +305,11 @@ export class PiBridge {
|
|
|
227
305
|
const sessionManager = SessionManager.inMemory(this.opts.cwd);
|
|
228
306
|
// Rehydrate session history so the LLM sees the full conversation
|
|
229
307
|
// transcript from the beginning (not just the current prompt).
|
|
230
|
-
//
|
|
231
|
-
//
|
|
308
|
+
// Tool calls and their results are reconstructed from the persisted
|
|
309
|
+
// wire-event JSONL so the LLM context is complete — without them
|
|
310
|
+
// reconnected sessions would see assistant messages that reference
|
|
311
|
+
// tool invocations whose results never appear, causing hung/confused
|
|
312
|
+
// responses.
|
|
232
313
|
if (this.opts.history && this.opts.history.length > 0) {
|
|
233
314
|
for (const msg of this.opts.history) {
|
|
234
315
|
if (msg.role === "user") {
|
|
@@ -249,10 +330,31 @@ export class PiBridge {
|
|
|
249
330
|
});
|
|
250
331
|
}
|
|
251
332
|
else if (msg.role === "assistant") {
|
|
252
|
-
|
|
333
|
+
// Parse wire events to reconstruct tool calls & results from the
|
|
334
|
+
// original turn. Without this, reconnected sessions lose all tool
|
|
335
|
+
// interaction context.
|
|
336
|
+
const { toolCalls, toolResults } = parseWireToolEvents(msg.events);
|
|
337
|
+
// Build a toolCallId → toolName lookup for tool result messages.
|
|
338
|
+
const toolNameById = new Map();
|
|
339
|
+
for (const tc of toolCalls) {
|
|
340
|
+
toolNameById.set(tc.id, tc.name);
|
|
341
|
+
}
|
|
342
|
+
// Build assistant message content: text + reconstructed ToolCall blocks.
|
|
343
|
+
const content = [];
|
|
344
|
+
if (msg.content) {
|
|
345
|
+
content.push({ type: "text", text: msg.content });
|
|
346
|
+
}
|
|
347
|
+
for (const tc of toolCalls) {
|
|
348
|
+
content.push({
|
|
349
|
+
type: "toolCall",
|
|
350
|
+
id: tc.id,
|
|
351
|
+
name: tc.name,
|
|
352
|
+
arguments: tc.arguments,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
253
355
|
sessionManager.appendMessage({
|
|
254
356
|
role: "assistant",
|
|
255
|
-
content
|
|
357
|
+
content,
|
|
256
358
|
api: "anthropic-messages",
|
|
257
359
|
provider: "spectral-proxy-anthropic",
|
|
258
360
|
model: "unknown",
|
|
@@ -264,9 +366,34 @@ export class PiBridge {
|
|
|
264
366
|
totalTokens: 0,
|
|
265
367
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
266
368
|
},
|
|
267
|
-
stopReason:
|
|
369
|
+
stopReason: toolCalls.length > 0
|
|
370
|
+
? "toolUse"
|
|
371
|
+
: "stop",
|
|
268
372
|
timestamp: msg.createdAt,
|
|
269
373
|
});
|
|
374
|
+
// Append tool result messages so the LLM sees the full tool
|
|
375
|
+
// interaction (call → result → next assistant response).
|
|
376
|
+
//
|
|
377
|
+
// Safety: truncate text blocks here too — sessions created before
|
|
378
|
+
// the truncation fix (in tool-registrar.ts) may have untruncated
|
|
379
|
+
// large MCP results in their wire events, which would overflow the
|
|
380
|
+
// context window when replayed into the fresh session manager.
|
|
381
|
+
for (const tr of toolResults) {
|
|
382
|
+
const toolName = toolNameById.get(tr.id) ?? "unknown";
|
|
383
|
+
const rawResult = tr.result;
|
|
384
|
+
const rawContent = Array.isArray(rawResult?.content)
|
|
385
|
+
? rawResult.content
|
|
386
|
+
: [];
|
|
387
|
+
sessionManager.appendMessage({
|
|
388
|
+
role: "toolResult",
|
|
389
|
+
toolCallId: tr.id,
|
|
390
|
+
toolName,
|
|
391
|
+
content: rawContent.map(sanitizeRehydratedBlock),
|
|
392
|
+
details: rawResult?.details,
|
|
393
|
+
isError: tr.isError,
|
|
394
|
+
timestamp: msg.createdAt,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
270
397
|
}
|
|
271
398
|
// system messages are informational only; skip for LLM context
|
|
272
399
|
}
|