@basein/runner 0.2.3 → 0.2.5
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/control/client.d.ts +13 -1
- package/dist/control/client.js +7 -4
- package/dist/control/correlation.d.ts +2 -0
- package/dist/control/server.d.ts +8 -0
- package/dist/control/server.js +48 -2
- package/dist/proxy/session.d.ts +2 -0
- package/dist/proxy/session.js +8 -1
- package/dist/replay/controller.d.ts +6 -0
- package/dist/replay/controller.js +5 -5
- package/dist/replay/executor.d.ts +82 -11
- package/dist/replay/executor.js +232 -39
- package/dist/replay/tool-error.d.ts +10 -9
- package/dist/replay/tool-error.js +13 -21
- package/docs/mcpmark.md +752 -752
- package/package.json +1 -1
package/dist/replay/executor.js
CHANGED
|
@@ -18,6 +18,36 @@
|
|
|
18
18
|
* A proxy that never polls (an old build, or one started without `BIR_REPLAY`)
|
|
19
19
|
* is not an error: `call` rejects with `no_proxy`, and the caller falls back to
|
|
20
20
|
* the step's recorded output.
|
|
21
|
+
*
|
|
22
|
+
* WHICH PROXY. Several proxies can poll for the same server at once — one per
|
|
23
|
+
* Claude Code session in the project, and a session's proxy outlives a
|
|
24
|
+
* `bir-hooks` restart by simply re-polling. They are NOT interchangeable: each
|
|
25
|
+
* holds its own upstream, and another session's upstream may be in any state (a
|
|
26
|
+
* database connection a benchmark reset killed, a browser on another page).
|
|
27
|
+
* Handing a step to "whoever is parked first" therefore ran it on the oldest
|
|
28
|
+
* proxy in the project, which is precisely the one most likely to be stale. So
|
|
29
|
+
* every proxy polls under its own id, and a step goes to, in order:
|
|
30
|
+
*
|
|
31
|
+
* 1. a proxy bound to the step's session — learned from a correlated step the
|
|
32
|
+
* proxy reported, or from an earlier step of this session it ran;
|
|
33
|
+
* 2. otherwise the newest unbound proxy (latest process start) — a session's
|
|
34
|
+
* proxies are spawned when it starts, so the newest is the best guess for
|
|
35
|
+
* a session that has not called the server yet;
|
|
36
|
+
* 3. otherwise NOBODY. A proxy bound to another session is never used for
|
|
37
|
+
* this one: the step resolves as a tool error, so the plan stops and hands
|
|
38
|
+
* over to the agent, who calls the tool on its own upstream. It does not
|
|
39
|
+
* reject — a rejection lets a recorded output stand in, or skips the step
|
|
40
|
+
* and runs the rest of the plan past it.
|
|
41
|
+
*
|
|
42
|
+
* A session's bindings are released when it ends (a `/clear` starts a new
|
|
43
|
+
* session on the same proxies). Legacy proxies, which send no id, share one
|
|
44
|
+
* record per server and are never bound: they stay a pool anyone may use.
|
|
45
|
+
*
|
|
46
|
+
* `bir replay` has no session, and takes the newest proxy, bound or not.
|
|
47
|
+
*
|
|
48
|
+
* The choice is made against proxies that are *present*, parked or not, and the
|
|
49
|
+
* work is then held for that proxy if it is mid-round-trip — never handed to a
|
|
50
|
+
* different one that happens to be parked.
|
|
21
51
|
*/
|
|
22
52
|
import { randomUUID } from "node:crypto";
|
|
23
53
|
import { logDetail } from "../util/log.js";
|
|
@@ -32,44 +62,70 @@ export const NO_PROXY = "no_proxy";
|
|
|
32
62
|
* plan lose every step after the first.
|
|
33
63
|
*/
|
|
34
64
|
const PRESENT_MS = 60_000;
|
|
65
|
+
/**
|
|
66
|
+
* The id a proxy that sends none polls under. One per server, so older proxies
|
|
67
|
+
* keep the old behaviour between themselves: any of them may take the work.
|
|
68
|
+
*/
|
|
69
|
+
function legacyId(serverName) {
|
|
70
|
+
return `legacy:${serverName}`;
|
|
71
|
+
}
|
|
35
72
|
export class ProxyWorkQueue {
|
|
36
|
-
/** Work
|
|
73
|
+
/** Work held for a proxy that was mid-round-trip when it was dispatched, per proxy id. */
|
|
37
74
|
pending = new Map();
|
|
38
75
|
/** Pollers currently parked, per server. */
|
|
39
76
|
waiters = new Map();
|
|
40
77
|
/** Work handed out and awaiting a result. */
|
|
41
78
|
inFlight = new Map();
|
|
42
|
-
/**
|
|
43
|
-
|
|
79
|
+
/** Every proxy that has polled, by id. A proxy between polls is still present. */
|
|
80
|
+
proxies = new Map();
|
|
44
81
|
closed = false;
|
|
45
82
|
/** Servers a proxy is currently serving — what `bir doctor` reports. */
|
|
46
83
|
pollingServers() {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
.
|
|
50
|
-
|
|
84
|
+
const names = new Set();
|
|
85
|
+
for (const info of this.proxies.values()) {
|
|
86
|
+
if (this.isLive(info))
|
|
87
|
+
names.add(info.serverName);
|
|
88
|
+
}
|
|
89
|
+
return [...names];
|
|
51
90
|
}
|
|
52
91
|
/** True when a proxy for `serverName` is available to take work. */
|
|
53
92
|
hasPoller(serverName) {
|
|
54
93
|
if ((this.waiters.get(serverName)?.length ?? 0) > 0)
|
|
55
94
|
return true;
|
|
56
|
-
return this.
|
|
95
|
+
return this.present(serverName).length > 0;
|
|
57
96
|
}
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Record that `proxyId` serves `sessionId`: it reported a call whose id the
|
|
99
|
+
* session's own hook minted. Proof, so it replaces any earlier binding — a
|
|
100
|
+
* `/clear` starts a new session on the same proxies.
|
|
101
|
+
*/
|
|
102
|
+
bindProxy(proxyId, sessionId) {
|
|
103
|
+
const info = this.proxies.get(proxyId);
|
|
104
|
+
if (!info)
|
|
105
|
+
return;
|
|
106
|
+
if (info.sessionId === sessionId && info.boundBy === "correlated")
|
|
107
|
+
return;
|
|
108
|
+
info.sessionId = sessionId;
|
|
109
|
+
info.boundBy = "correlated";
|
|
110
|
+
logDetail("replay.proxy_bound", {
|
|
111
|
+
server: info.serverName,
|
|
112
|
+
proxy: shortId(proxyId),
|
|
113
|
+
pid: info.pid,
|
|
114
|
+
sess: sessionId,
|
|
115
|
+
by: "correlated",
|
|
116
|
+
});
|
|
62
117
|
}
|
|
63
118
|
/**
|
|
64
119
|
* `POST /proxy/poll`. Resolves with work, or with `undefined` at the poll
|
|
65
|
-
* deadline so the proxy re-polls. Work
|
|
66
|
-
* polls is handed over immediately.
|
|
120
|
+
* deadline so the proxy re-polls. Work held for this proxy while it was
|
|
121
|
+
* between polls is handed over immediately.
|
|
67
122
|
*/
|
|
68
|
-
waitForWork(serverName, pollDeadlineMs, signal) {
|
|
123
|
+
waitForWork(serverName, pollDeadlineMs, signal, who = {}) {
|
|
69
124
|
if (this.closed)
|
|
70
125
|
return Promise.resolve(undefined);
|
|
71
|
-
|
|
72
|
-
|
|
126
|
+
const proxyId = who.proxyId || legacyId(serverName);
|
|
127
|
+
this.touch(proxyId, serverName, who);
|
|
128
|
+
const queued = this.pending.get(proxyId);
|
|
73
129
|
if (queued && queued.length > 0) {
|
|
74
130
|
return Promise.resolve(queued.shift());
|
|
75
131
|
}
|
|
@@ -79,6 +135,7 @@ export class ProxyWorkQueue {
|
|
|
79
135
|
const list = this.waiters.get(serverName) ?? [];
|
|
80
136
|
const waiter = {
|
|
81
137
|
serverName,
|
|
138
|
+
proxyId,
|
|
82
139
|
resolve,
|
|
83
140
|
signal,
|
|
84
141
|
timer: setTimeout(() => {
|
|
@@ -90,6 +147,7 @@ export class ProxyWorkQueue {
|
|
|
90
147
|
waiter.onAbort = () => {
|
|
91
148
|
logDetail("replay.poller_gone", { server: serverName, why: "its request closed" });
|
|
92
149
|
this.removeWaiter(waiter);
|
|
150
|
+
this.forgetIfGone(proxyId);
|
|
93
151
|
resolve(undefined);
|
|
94
152
|
};
|
|
95
153
|
signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
@@ -120,18 +178,36 @@ export class ProxyWorkQueue {
|
|
|
120
178
|
* "could not be run here", which is exactly the condition under which the
|
|
121
179
|
* caller may substitute a recorded output.
|
|
122
180
|
*/
|
|
123
|
-
call(serverName, toolName, args, timeoutMs) {
|
|
181
|
+
call(serverName, toolName, args, timeoutMs, route = {}) {
|
|
124
182
|
if (this.closed)
|
|
125
183
|
return Promise.reject(new Error(NO_PROXY));
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
if (!waiter && !this.isPresent(serverName)) {
|
|
184
|
+
const picked = this.pickProxy(serverName, route.sessionId);
|
|
185
|
+
if (!picked) {
|
|
129
186
|
// No proxy has ever polled for this server, or one has been gone for a
|
|
130
187
|
// minute. Do NOT queue: the caller is inside a turn the user is waiting on,
|
|
131
188
|
// and work that sits until some proxy happens to appear would stall it past
|
|
132
189
|
// every budget. Fail fast, and let the recorded-output fallback decide.
|
|
133
190
|
return Promise.reject(new Error(NO_PROXY));
|
|
134
191
|
}
|
|
192
|
+
if (picked === "other_sessions") {
|
|
193
|
+
const others = this.present(serverName).length;
|
|
194
|
+
logDetail("replay.no_session_proxy", {
|
|
195
|
+
server: serverName,
|
|
196
|
+
tool: toolName,
|
|
197
|
+
sess: route.sessionId,
|
|
198
|
+
others,
|
|
199
|
+
why: "only other sessions' proxies are here — the step is not run on their upstream",
|
|
200
|
+
});
|
|
201
|
+
return Promise.resolve(noSessionProxy(serverName, others));
|
|
202
|
+
}
|
|
203
|
+
const { info, why } = picked;
|
|
204
|
+
if (route.sessionId && !info.sessionId && !isLegacy(info.proxyId)) {
|
|
205
|
+
// Keep the rest of this session's plan on the same upstream.
|
|
206
|
+
info.sessionId = route.sessionId;
|
|
207
|
+
info.boundBy = "dispatched";
|
|
208
|
+
}
|
|
209
|
+
const work = { workId: "birwork_" + randomUUID(), toolName, arguments: args, timeoutMs };
|
|
210
|
+
const waiter = this.takeWaiter(serverName, info.proxyId);
|
|
135
211
|
return new Promise((resolve, reject) => {
|
|
136
212
|
const entry = {
|
|
137
213
|
serverName,
|
|
@@ -139,7 +215,7 @@ export class ProxyWorkQueue {
|
|
|
139
215
|
reject,
|
|
140
216
|
timer: setTimeout(() => {
|
|
141
217
|
this.inFlight.delete(work.workId);
|
|
142
|
-
this.dropPending(
|
|
218
|
+
this.dropPending(info.proxyId, work.workId);
|
|
143
219
|
reject(new Error(`timeout after ${timeoutMs}ms`));
|
|
144
220
|
}, timeoutMs),
|
|
145
221
|
};
|
|
@@ -149,23 +225,27 @@ export class ProxyWorkQueue {
|
|
|
149
225
|
server: serverName,
|
|
150
226
|
tool: toolName,
|
|
151
227
|
work: work.workId,
|
|
228
|
+
proxy: shortId(info.proxyId),
|
|
229
|
+
pid: info.pid,
|
|
230
|
+
pick: why,
|
|
152
231
|
queued: waiter ? undefined : true,
|
|
153
232
|
});
|
|
154
233
|
if (waiter) {
|
|
155
234
|
waiter.resolve(work);
|
|
156
235
|
return;
|
|
157
236
|
}
|
|
158
|
-
// The proxy is mid-round-trip — POSTing the previous step's result,
|
|
159
|
-
// to poll again. Hold the work for
|
|
160
|
-
//
|
|
161
|
-
|
|
237
|
+
// The chosen proxy is mid-round-trip — POSTing the previous step's result,
|
|
238
|
+
// about to poll again. Hold the work for IT; another proxy that happens to
|
|
239
|
+
// be parked right now is not a substitute. The per-call timeout above is
|
|
240
|
+
// what bounds the wait if it never comes back.
|
|
241
|
+
const list = this.pending.get(info.proxyId) ?? [];
|
|
162
242
|
list.push(work);
|
|
163
|
-
this.pending.set(
|
|
243
|
+
this.pending.set(info.proxyId, list);
|
|
164
244
|
});
|
|
165
245
|
}
|
|
166
246
|
/** Remove queued work that timed out, so a later poll never gets stale work. */
|
|
167
|
-
dropPending(
|
|
168
|
-
const list = this.pending.get(
|
|
247
|
+
dropPending(proxyId, workId) {
|
|
248
|
+
const list = this.pending.get(proxyId);
|
|
169
249
|
if (!list)
|
|
170
250
|
return;
|
|
171
251
|
const i = list.findIndex((w) => w.workId === workId);
|
|
@@ -184,33 +264,121 @@ export class ProxyWorkQueue {
|
|
|
184
264
|
this.waiters.clear();
|
|
185
265
|
this.pending.clear();
|
|
186
266
|
// A closed queue has no proxies, whatever they were doing a moment ago.
|
|
187
|
-
this.
|
|
267
|
+
this.proxies.clear();
|
|
188
268
|
for (const [, entry] of this.inFlight) {
|
|
189
269
|
clearTimeout(entry.timer);
|
|
190
270
|
entry.reject(new Error("control server closed"));
|
|
191
271
|
}
|
|
192
272
|
this.inFlight.clear();
|
|
193
273
|
}
|
|
274
|
+
/** Note a poll: create the proxy's record on first sight, refresh it after. */
|
|
275
|
+
touch(proxyId, serverName, who) {
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
const info = this.proxies.get(proxyId);
|
|
278
|
+
if (info) {
|
|
279
|
+
info.lastSeen = now;
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const started = Number(who.startedAt);
|
|
283
|
+
this.proxies.set(proxyId, {
|
|
284
|
+
proxyId,
|
|
285
|
+
serverName,
|
|
286
|
+
startedAt: Number.isFinite(started) && started > 0 ? started : now,
|
|
287
|
+
pid: who.pid,
|
|
288
|
+
lastSeen: now,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Parked now, or polled recently enough to be mid-round-trip. */
|
|
292
|
+
isLive(info) {
|
|
293
|
+
return Date.now() - info.lastSeen <= PRESENT_MS;
|
|
294
|
+
}
|
|
295
|
+
/** Live proxies for a server, newest first. */
|
|
296
|
+
present(serverName) {
|
|
297
|
+
return [...this.proxies.values()]
|
|
298
|
+
.filter((p) => p.serverName === serverName && this.isLive(p))
|
|
299
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The proxy a step for `sessionId` should run on — see the file comment.
|
|
303
|
+
* Undefined when no proxy is live at all; `other_sessions` when every live
|
|
304
|
+
* one belongs to a different session.
|
|
305
|
+
*/
|
|
306
|
+
pickProxy(serverName, sessionId) {
|
|
307
|
+
const live = this.present(serverName);
|
|
308
|
+
if (live.length === 0)
|
|
309
|
+
return undefined;
|
|
310
|
+
if (!sessionId)
|
|
311
|
+
return { info: live[0], why: "newest" };
|
|
312
|
+
const mine = live.filter((p) => p.sessionId === sessionId);
|
|
313
|
+
const proven = mine.find((p) => p.boundBy === "correlated");
|
|
314
|
+
if (proven)
|
|
315
|
+
return { info: proven, why: "session" };
|
|
316
|
+
if (mine[0])
|
|
317
|
+
return { info: mine[0], why: "session" };
|
|
318
|
+
const unbound = live.find((p) => !p.sessionId);
|
|
319
|
+
if (unbound)
|
|
320
|
+
return { info: unbound, why: "newest" };
|
|
321
|
+
return "other_sessions";
|
|
322
|
+
}
|
|
194
323
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
324
|
+
* Free every proxy bound to `sessionId`: the session ended. After a `/clear`
|
|
325
|
+
* the same proxies serve the next session, which must be able to pick them.
|
|
197
326
|
*/
|
|
198
|
-
|
|
327
|
+
releaseSession(sessionId) {
|
|
328
|
+
for (const info of this.proxies.values()) {
|
|
329
|
+
if (info.sessionId !== sessionId)
|
|
330
|
+
continue;
|
|
331
|
+
info.sessionId = undefined;
|
|
332
|
+
info.boundBy = undefined;
|
|
333
|
+
logDetail("replay.proxy_released", {
|
|
334
|
+
server: info.serverName,
|
|
335
|
+
proxy: shortId(info.proxyId),
|
|
336
|
+
pid: info.pid,
|
|
337
|
+
sess: sessionId,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* A live parked poller of `proxyId`. Waiters whose request has already gone
|
|
343
|
+
* are discarded rather than handed work they can never run.
|
|
344
|
+
*/
|
|
345
|
+
takeWaiter(serverName, proxyId) {
|
|
199
346
|
const list = this.waiters.get(serverName);
|
|
200
347
|
if (!list)
|
|
201
348
|
return undefined;
|
|
202
|
-
for (
|
|
203
|
-
const waiter = list
|
|
204
|
-
if (!waiter)
|
|
205
|
-
return undefined;
|
|
206
|
-
this.detach(waiter);
|
|
349
|
+
for (let i = 0; i < list.length;) {
|
|
350
|
+
const waiter = list[i];
|
|
207
351
|
if (waiter.signal?.aborted) {
|
|
208
352
|
// Its proxy is gone. Release the promise and keep looking.
|
|
353
|
+
list.splice(i, 1);
|
|
354
|
+
this.detach(waiter);
|
|
209
355
|
waiter.resolve(undefined);
|
|
210
356
|
continue;
|
|
211
357
|
}
|
|
212
|
-
|
|
358
|
+
if (waiter.proxyId === proxyId) {
|
|
359
|
+
list.splice(i, 1);
|
|
360
|
+
this.detach(waiter);
|
|
361
|
+
return waiter;
|
|
362
|
+
}
|
|
363
|
+
i++;
|
|
213
364
|
}
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* A proxy whose poll request closed has, almost always, exited. Forget it
|
|
369
|
+
* unless another of its polls is still parked, so it is never picked for a
|
|
370
|
+
* minute after it died. A proxy that merely lost one request re-registers on
|
|
371
|
+
* its next poll.
|
|
372
|
+
*/
|
|
373
|
+
forgetIfGone(proxyId) {
|
|
374
|
+
for (const list of this.waiters.values()) {
|
|
375
|
+
if (list.some((w) => w.proxyId === proxyId && !w.signal?.aborted))
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if ((this.pending.get(proxyId)?.length ?? 0) > 0)
|
|
379
|
+
return;
|
|
380
|
+
this.proxies.delete(proxyId);
|
|
381
|
+
this.pending.delete(proxyId);
|
|
214
382
|
}
|
|
215
383
|
removeWaiter(waiter) {
|
|
216
384
|
this.detach(waiter);
|
|
@@ -230,4 +398,29 @@ export class ProxyWorkQueue {
|
|
|
230
398
|
}
|
|
231
399
|
}
|
|
232
400
|
}
|
|
401
|
+
function isLegacy(proxyId) {
|
|
402
|
+
return proxyId.startsWith("legacy:");
|
|
403
|
+
}
|
|
404
|
+
/** Enough of an id to tell proxies apart in a log line. */
|
|
405
|
+
function shortId(proxyId) {
|
|
406
|
+
return isLegacy(proxyId) ? proxyId : proxyId.slice(-8);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The step's result when only other sessions' proxies are here: an MCP tool
|
|
410
|
+
* error, so the plan stops on it and hands over (tool-error.ts), and the agent
|
|
411
|
+
* reads why.
|
|
412
|
+
*/
|
|
413
|
+
function noSessionProxy(serverName, others) {
|
|
414
|
+
return {
|
|
415
|
+
isError: true,
|
|
416
|
+
content: [
|
|
417
|
+
{
|
|
418
|
+
type: "text",
|
|
419
|
+
text: `Error: bir did not run this step. No ${serverName} proxy of this session is ` +
|
|
420
|
+
`connected; the ${others} that are belong to other sessions, and a step never ` +
|
|
421
|
+
`runs on another session's upstream. Call the tool yourself.`,
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
};
|
|
425
|
+
}
|
|
233
426
|
//# sourceMappingURL=executor.js.map
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A step's tool call resolving is not the same as the step's work happening.
|
|
5
5
|
* `executeStep` rejects only when a tool could not be run here; a tool that ran
|
|
6
|
-
* and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `ok=true` each, the plan reported `steered_full`, and the ledger booked its
|
|
10
|
-
* largest saving of the day on a replay that created nothing (docs/mcpmark.md §13).
|
|
6
|
+
* and reported a failure resolves normally, with that failure as its response —
|
|
7
|
+
* which is right for threading (the output logic may want to see it) and wrong
|
|
8
|
+
* for the verdict (docs/mcpmark.md §13).
|
|
11
9
|
*
|
|
12
|
-
* The signal is
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* The signal is `isError: true` on the MCP result, per the spec, and nothing
|
|
11
|
+
* else — the same rule the recorder uses (`proxy/intercept.ts`), so a step is a
|
|
12
|
+
* success or an error in the recording and in its replay alike. A server that
|
|
13
|
+
* reports failures as ordinary text with `isError: false`, `postgres-mcp` among
|
|
14
|
+
* them, is taken at its word on both sides: its `Error: relation does not exist`
|
|
15
|
+
* is a successful call here, as it was when it was recorded. A built-in's
|
|
16
|
+
* hook-shaped response is never judged.
|
|
16
17
|
*/
|
|
17
18
|
/**
|
|
18
19
|
* The error a serialized `CallToolResult` reports, or undefined when it does
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A step's tool call resolving is not the same as the step's work happening.
|
|
5
5
|
* `executeStep` rejects only when a tool could not be run here; a tool that ran
|
|
6
|
-
* and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `ok=true` each, the plan reported `steered_full`, and the ledger booked its
|
|
10
|
-
* largest saving of the day on a replay that created nothing (docs/mcpmark.md §13).
|
|
6
|
+
* and reported a failure resolves normally, with that failure as its response —
|
|
7
|
+
* which is right for threading (the output logic may want to see it) and wrong
|
|
8
|
+
* for the verdict (docs/mcpmark.md §13).
|
|
11
9
|
*
|
|
12
|
-
* The signal is
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* The signal is `isError: true` on the MCP result, per the spec, and nothing
|
|
11
|
+
* else — the same rule the recorder uses (`proxy/intercept.ts`), so a step is a
|
|
12
|
+
* success or an error in the recording and in its replay alike. A server that
|
|
13
|
+
* reports failures as ordinary text with `isError: false`, `postgres-mcp` among
|
|
14
|
+
* them, is taken at its word on both sides: its `Error: relation does not exist`
|
|
15
|
+
* is a successful call here, as it was when it was recorded. A built-in's
|
|
16
|
+
* hook-shaped response is never judged.
|
|
16
17
|
*/
|
|
17
18
|
const MAX_ERROR_CHARS = 500;
|
|
18
19
|
/**
|
|
@@ -27,17 +28,12 @@ export function toolResultError(serialized) {
|
|
|
27
28
|
catch {
|
|
28
29
|
return undefined;
|
|
29
30
|
}
|
|
30
|
-
if (typeof parsed === "string")
|
|
31
|
-
return looksLikeError(parsed) ? clip(parsed) : undefined;
|
|
32
31
|
if (!parsed || typeof parsed !== "object")
|
|
33
32
|
return undefined;
|
|
34
33
|
const result = parsed;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (text !== undefined && looksLikeError(text))
|
|
39
|
-
return clip(text);
|
|
40
|
-
return undefined;
|
|
34
|
+
if (result.isError !== true)
|
|
35
|
+
return undefined;
|
|
36
|
+
return clip(firstText(result.content) ?? "the tool reported an error");
|
|
41
37
|
}
|
|
42
38
|
function firstText(content) {
|
|
43
39
|
if (!Array.isArray(content))
|
|
@@ -49,10 +45,6 @@ function firstText(content) {
|
|
|
49
45
|
}
|
|
50
46
|
return undefined;
|
|
51
47
|
}
|
|
52
|
-
/** "Error: …" / "ERROR: …" / "error occurred" — not "Errors were fixed". */
|
|
53
|
-
function looksLikeError(text) {
|
|
54
|
-
return /^\s*error\b/i.test(text);
|
|
55
|
-
}
|
|
56
48
|
function clip(text) {
|
|
57
49
|
const trimmed = text.trim();
|
|
58
50
|
return trimmed.length > MAX_ERROR_CHARS ? `${trimmed.slice(0, MAX_ERROR_CHARS)}…` : trimmed;
|