@basein/runner 0.3.24 → 0.3.26

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.
Files changed (67) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.js +5 -0
  7. package/dist/bin/scenario-edit.d.ts +5 -0
  8. package/dist/bin/scenario-edit.js +17 -1
  9. package/dist/config/adapters/claude-code.d.ts +51 -7
  10. package/dist/config/adapters/claude-code.js +105 -18
  11. package/dist/config/generate.d.ts +61 -13
  12. package/dist/config/generate.js +108 -20
  13. package/dist/config/resolve.d.ts +5 -0
  14. package/dist/config/resolve.js +7 -0
  15. package/dist/control/correlation.js +0 -0
  16. package/dist/control/daemon.d.ts +28 -0
  17. package/dist/control/daemon.js +62 -0
  18. package/dist/control/paths.d.ts +19 -1
  19. package/dist/control/paths.js +20 -2
  20. package/dist/control/pin-guard.js +8 -2
  21. package/dist/control/recorder-main.js +17 -5
  22. package/dist/control/server.d.ts +63 -9
  23. package/dist/control/server.js +350 -62
  24. package/dist/control/transcript.d.ts +64 -3
  25. package/dist/control/transcript.js +318 -37
  26. package/dist/jsonrpc/framing.d.ts +18 -3
  27. package/dist/jsonrpc/framing.js +57 -4
  28. package/dist/proxy/intercept.d.ts +4 -0
  29. package/dist/proxy/intercept.js +4 -0
  30. package/dist/proxy/relay.d.ts +13 -0
  31. package/dist/proxy/relay.js +12 -1
  32. package/dist/proxy/session.d.ts +8 -1
  33. package/dist/proxy/session.js +67 -10
  34. package/dist/record/recorder.d.ts +6 -1
  35. package/dist/record/redact.d.ts +18 -4
  36. package/dist/record/redact.js +538 -79
  37. package/dist/record/remote-recorder.d.ts +26 -5
  38. package/dist/record/remote-recorder.js +174 -18
  39. package/dist/record/truncate.js +3 -1
  40. package/dist/replay/bash-channel.d.ts +7 -1
  41. package/dist/replay/bash-channel.js +9 -3
  42. package/dist/replay/builtins.d.ts +14 -0
  43. package/dist/replay/builtins.js +460 -95
  44. package/dist/replay/bundle.d.ts +80 -0
  45. package/dist/replay/bundle.js +455 -24
  46. package/dist/replay/controller.d.ts +64 -1
  47. package/dist/replay/controller.js +238 -38
  48. package/dist/replay/derive.js +46 -4
  49. package/dist/replay/handover.d.ts +13 -0
  50. package/dist/replay/handover.js +18 -0
  51. package/dist/replay/plan.d.ts +15 -0
  52. package/dist/replay/plan.js +48 -30
  53. package/dist/replay/pricing.d.ts +61 -11
  54. package/dist/replay/pricing.js +135 -42
  55. package/dist/replay/safety.js +43 -5
  56. package/dist/upstream/http-client.d.ts +6 -3
  57. package/dist/upstream/http-client.js +54 -13
  58. package/dist/upstream/stdio-client.d.ts +7 -0
  59. package/dist/upstream/stdio-client.js +1 -1
  60. package/dist/util/atomic-write.d.ts +9 -1
  61. package/dist/util/atomic-write.js +9 -6
  62. package/dist/util/service-health.d.ts +7 -0
  63. package/dist/util/service-health.js +10 -3
  64. package/docs/calculatedReplay.md +32 -0
  65. package/package.json +1 -1
  66. package/production.md +2 -2
  67. package/scripts/install.ps1 +14 -2
@@ -13,6 +13,20 @@
13
13
  * fresh execution (§8.1). It is surfaced in the header *and* per entry, because
14
14
  * a bundle that quietly passed off a stale result as a live one would be the
15
15
  * worst outcome available here.
16
+ *
17
+ * The agent pays to take the bundle in, so it carries nothing the model already
18
+ * has (docs/calculatedReplay.md, "How a bundle reads"): a result is its text,
19
+ * not MCP's JSON envelope, and consecutive calls to one tool share a section
20
+ * that says once what they share.
21
+ *
22
+ * And it carries detail only where detail is needed. A run that went entirely
23
+ * well is laid out briefly ({@link AssembleOptions.brief}): the agent's job is
24
+ * to see that the steps were right and say what was done, which takes every
25
+ * record and field a long result has — the IDs, dates, senders and names it
26
+ * checks the steps against — but not every word of its long text. Anything
27
+ * else — a step that broke, a run that stopped short, a recorded stand-in, a
28
+ * sub-task the agent carries on with — gets every result whole, because that
29
+ * is what the agent works from.
16
30
  */
17
31
  /** `permissionDecisionReason` is fed to the model as text; keep denials bounded. */
18
32
  export declare const MAX_REPLAY_REASON = 60000;
@@ -63,11 +77,77 @@ export interface AssembleOptions {
63
77
  * leave the agent its tools.
64
78
  */
65
79
  reportTool?: string;
80
+ /**
81
+ * Lay the run out briefly if it went entirely well, with `summary` — the
82
+ * plan's response model — in a few words above the footer. Asked for by a
83
+ * caller whose agent answers from the bundle and nothing else (`bir-
84
+ * scenario`'s run of a whole plan); `summary` may be absent or `{}`.
85
+ *
86
+ * Entirely well means every entry ran live and none reported an error, and
87
+ * the agent is to answer now: with `handover` or `subTask` set, a recorded
88
+ * entry, or a result marked as an error, the bundle is laid out in full as
89
+ * if this were not set. The summary is then the caller's to deliver.
90
+ */
91
+ brief?: {
92
+ summary?: Record<string, unknown>;
93
+ };
66
94
  }
67
95
  /**
68
96
  * Compose the bundle. `maxChars` is a hard ceiling on the returned string.
69
97
  */
70
98
  export declare function assembleBundle(entries: readonly BundleEntry[], maxChars: number, opts?: AssembleOptions): string;
99
+ /**
100
+ * Whether a run went entirely well and the agent answers now — the one case a
101
+ * bundle may be brief ({@link AssembleOptions.brief}). A run that went wrong
102
+ * anywhere, stood on a recording, or is one the agent carries on from is what
103
+ * the agent works from next: it gets every result.
104
+ */
105
+ export declare function wentWell(entries: readonly BundleEntry[], opts: Pick<AssembleOptions, "handover" | "subTask">): boolean;
106
+ /**
107
+ * JSON text with every string of prose longer than 40 characters cut to its
108
+ * first 40 and "…". Keys, numbers, short strings and strings with no space in
109
+ * them ({@link BRIEF_ID_CHARS}: a link, a path, an id) stay as they are. A
110
+ * character is never split: an escape counts as one, `\n` and a Unicode
111
+ * escape (a backslash, a `u` and four hex digits) alike, and so does a pair of
112
+ * surrogates, written out (an emoji) or as two such escapes.
113
+ * Works on JSON a parser would refuse — a tool's `NaN`, a result cut short —
114
+ * because it walks the text's string literals from the start, so it always
115
+ * knows which quote opens one: the text between two strings, a list of numbers
116
+ * say, is never taken for one. An unterminated string is left as it is.
117
+ */
118
+ export declare function cutLongText(json: string): string;
119
+ /**
120
+ * `key = value` for each field, each value in a few words ({@link briefValue}),
121
+ * ended at a field's end with "…" rather than run past `max` characters.
122
+ */
123
+ export declare function briefFields(fields: Record<string, unknown>, max: number): string;
124
+ /**
125
+ * A value of the plan's summary in a few words. Numbers, booleans and short
126
+ * strings whole; long text cut, as a result's is ({@link cutLongText}: a link
127
+ * or an id is whole); a list or object whose JSON is short — a few IDs, names
128
+ * or addresses — whole; a longer list counted; a longer object by its first
129
+ * fields, one level down, below which it is counted too. A summary's long
130
+ * lists are mostly the records the steps above already show.
131
+ */
132
+ export declare function briefValue(value: unknown, depth?: number): string;
133
+ /**
134
+ * A tool result as the model should read it.
135
+ *
136
+ * Executors hand the bundle the JSON of MCP's `CallToolResult`. Laid out as it
137
+ * came, every result pays for the envelope —
138
+ * `{"content":[{"type":"text","text":"…"}],"isError":false}`, some fifty
139
+ * characters — and for a second layer of escaping: every quote in the text
140
+ * written `\"`, every backslash `\\`. Neither tells the model anything, and on
141
+ * a long plan they are a large share of what it reads (and of what the agent
142
+ * pays to put in its context). A result whose content is text and nothing else
143
+ * is shown as that text, marked when `isError` is true. Anything else — an
144
+ * image, a resource, structured content, an answer that is not a
145
+ * `CallToolResult` at all — is shown exactly as it came.
146
+ */
147
+ export declare function resultText(response: string): {
148
+ text: string;
149
+ error: boolean;
150
+ };
71
151
  /** Truncate to `max` chars, appending a marker naming how much was dropped. */
72
152
  export declare function truncate(s: string, max: number): string;
73
153
  //# sourceMappingURL=bundle.d.ts.map
@@ -13,6 +13,20 @@
13
13
  * fresh execution (§8.1). It is surfaced in the header *and* per entry, because
14
14
  * a bundle that quietly passed off a stale result as a live one would be the
15
15
  * worst outcome available here.
16
+ *
17
+ * The agent pays to take the bundle in, so it carries nothing the model already
18
+ * has (docs/calculatedReplay.md, "How a bundle reads"): a result is its text,
19
+ * not MCP's JSON envelope, and consecutive calls to one tool share a section
20
+ * that says once what they share.
21
+ *
22
+ * And it carries detail only where detail is needed. A run that went entirely
23
+ * well is laid out briefly ({@link AssembleOptions.brief}): the agent's job is
24
+ * to see that the steps were right and say what was done, which takes every
25
+ * record and field a long result has — the IDs, dates, senders and names it
26
+ * checks the steps against — but not every word of its long text. Anything
27
+ * else — a step that broke, a run that stopped short, a recorded stand-in, a
28
+ * sub-task the agent carries on with — gets every result whole, because that
29
+ * is what the agent works from.
16
30
  */
17
31
  /** `permissionDecisionReason` is fed to the model as text; keep denials bounded. */
18
32
  export const MAX_REPLAY_REASON = 60_000;
@@ -30,6 +44,14 @@ export const BASH_CHANNEL_MAX = 25_000;
30
44
  const MAX_INPUT_CHARS = 2_000;
31
45
  /** Fixed structural overhead reserved per step (labels, separators). */
32
46
  const PER_STEP_OVERHEAD = 80;
47
+ /**
48
+ * The least each result is shown of. When the inputs leave the results less
49
+ * than this each, the results keep it and the inputs give up the difference:
50
+ * the results are what the agent answers from, and fourteen inputs at their cap
51
+ * once left them nothing at all, every "Result:" empty under a header that
52
+ * calls it the call's actual output.
53
+ */
54
+ const MIN_RESULT_CHARS = 200;
33
55
  /** The test's words, one line and short, for a bundle or a note. */
34
56
  export function testWords(text) {
35
57
  const line = (text ?? "").trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
@@ -111,6 +133,37 @@ const SUBTASK_HEADER = "[BaseInstRunner calculated replay] The tool calls below
111
133
  "steps are done: do not repeat these calls. Continue your task from where they end, " +
112
134
  "calling tools as needed.";
113
135
  const SUBTASK_FOOTER = "\n\nThe calls above are done. Continue your task, calling tools as needed.";
136
+ /**
137
+ * Every step ran live and succeeded ({@link AssembleOptions.brief}). The same
138
+ * assurances as {@link LIVE_HEADER} — it happened, now, for real — plus what
139
+ * the agent needs to read the steps below: why some text ends in "…".
140
+ */
141
+ const BRIEF_HEADER = "[BaseInstRunner calculated replay] The tool calls below have JUST been executed " +
142
+ "for real, moments ago, against the live system, on the user's behalf. Every one " +
143
+ "of them succeeded, and every side effect is already in place. Do NOT repeat, redo " +
144
+ "or re-verify this work, and do NOT call any tools. In a long result, every record " +
145
+ "and field is shown, and text longer than 40 characters is cut short with \"…\"; " +
146
+ "a link, a path or an id (no spaces, up to 300 characters) is shown whole.";
147
+ /** The reply is output, the dearest tokens an agent spends: asked for briefly. */
148
+ const BRIEF_FOOTER = "\n\nThe work above is done. Answer the user's request from it in a few sentences: " +
149
+ "what was done, and the key results. Do not call any tools.";
150
+ /** A result up to this long is shown whole even in a brief bundle: it is its own best evidence. */
151
+ const BRIEF_RESULT_CHARS = 300;
152
+ /** At most this much of a brief bundle is the plan's summary. */
153
+ const BRIEF_SUMMARY_CHARS = 1_000;
154
+ /** A longer string — in a long result, or in the summary — is cut to this many characters. */
155
+ const BRIEF_STRING_CHARS = 40;
156
+ /**
157
+ * But a string with no space in it — a link, a path, an id, a hash, an e-mail
158
+ * address — is what the agent passes on to the user, and cut it opens nothing
159
+ * and names nothing: it is kept whole up to this many characters. Past them it
160
+ * is a blob (an encoded image, a minified line), and is cut like text.
161
+ */
162
+ const BRIEF_ID_CHARS = 300;
163
+ /** A list or object in the summary whose JSON is up to this long is shown whole; a longer one is counted. */
164
+ const BRIEF_INLINE_CHARS = 160;
165
+ /** A longer object in the summary shows its first fields, this many. */
166
+ const BRIEF_OBJECT_FIELDS = 6;
114
167
  /**
115
168
  * Compose the bundle. `maxChars` is a hard ceiling on the returned string.
116
169
  */
@@ -119,6 +172,7 @@ export function assembleBundle(entries, maxChars, opts = {}) {
119
172
  // that answered split it.
120
173
  const n = entries.filter((e) => !e.notNeeded).length;
121
174
  const anyRecorded = entries.some((e) => e.recorded);
175
+ const brief = opts.brief !== undefined && wentWell(entries, opts);
122
176
  // A hand-over outranks a sub-task: it says "continue" too, and it also has to
123
177
  // say that the plan stopped short. A sub-task outranks the live and mixed
124
178
  // headers, whose wording forbids further tool calls.
@@ -126,36 +180,413 @@ export function assembleBundle(entries, maxChars, opts = {}) {
126
180
  ? HANDOVER_HEADER
127
181
  : opts.subTask
128
182
  ? SUBTASK_HEADER
129
- : withReportException(anyRecorded ? MIXED_HEADER : LIVE_HEADER, opts.reportTool);
183
+ : withReportException(brief ? BRIEF_HEADER : anyRecorded ? MIXED_HEADER : LIVE_HEADER, opts.reportTool);
130
184
  const footer = opts.handover
131
185
  ? HANDOVER_FOOTER
132
186
  : opts.subTask
133
187
  ? SUBTASK_FOOTER
134
- : withReportException(anyRecorded ? MIXED_FOOTER : LIVE_FOOTER, opts.reportTool);
135
- const fixedCost = header.length +
136
- footer.length +
137
- entries.reduce((sum, e) => sum +
138
- PER_STEP_OVERHEAD +
139
- e.toolName.length +
140
- e.input.length +
141
- // A left-out step's line, which has no response to take from.
142
- (e.notNeeded ? PER_STEP_OVERHEAD + (testWords(e.notNeeded.text)?.length ?? 0) : 0), 0);
143
- const responseBudget = Math.max(0, maxChars - fixedCost);
144
- const perResponse = n > 0 ? Math.floor(responseBudget / n) : responseBudget;
145
- const sections = entries.map((e, i) => {
146
- if (e.notNeeded) {
147
- // Never "skipped" or "failed": the step was left out on purpose, because
148
- // this request does not need it, and nothing is missing for its absence.
149
- const why = testWords(e.notNeeded.text);
150
- return (`\n\n--- Step ${i + 1}: ${e.toolName} (not needed) ---\n` +
151
- `Not run: this request does not need it${why ? ` — it is only for when ${why}` : ""}.`);
188
+ : withReportException(brief ? BRIEF_FOOTER : anyRecorded ? MIXED_FOOTER : LIVE_FOOTER, opts.reportTool);
189
+ const summary = brief ? briefSummary(opts.brief?.summary) : "";
190
+ const labels = entries.reduce((sum, e) => sum +
191
+ PER_STEP_OVERHEAD +
192
+ e.toolName.length +
193
+ // A left-out step's line, which has no response to take from.
194
+ (e.notNeeded ? PER_STEP_OVERHEAD + (testWords(e.notNeeded.text)?.length ?? 0) : 0), 0);
195
+ const inputs = entries.reduce((sum, e) => sum + e.input.length, 0);
196
+ // What the frame and the labels leave is shared by the inputs and the
197
+ // results. The inputs go in whole and the results split what they leave —
198
+ // unless that is less than a floor each (or nothing at all, when the inputs
199
+ // alone pass the limit): then each result keeps the floor, and the inputs,
200
+ // cut to a share of the rest, say how much of themselves is left out.
201
+ const free = Math.max(0, maxChars - (header.length + footer.length + summary.length + labels));
202
+ let perResponse = n > 0 ? Math.floor(Math.max(0, free - inputs) / n) : Math.max(0, free - inputs);
203
+ let inputCap = Number.POSITIVE_INFINITY;
204
+ const floor = Math.min(MIN_RESULT_CHARS, Math.floor(free / (2 * Math.max(1, n))));
205
+ if (n > 0 && perResponse < floor) {
206
+ perResponse = floor;
207
+ inputCap = Math.floor((free - n * floor) / entries.length);
208
+ }
209
+ const shownEntries = Number.isFinite(inputCap)
210
+ ? entries.map((e) => ({ ...e, input: truncate(e.input, inputCap) }))
211
+ : entries;
212
+ const show = (e) => {
213
+ const result = resultText(e.response);
214
+ // A built-in's result is shown as its own text, whole within its share.
215
+ const own = brief ? builtinText(e.toolName, e.response) : undefined;
216
+ const text = own ?? result.text;
217
+ return {
218
+ text: brief && own === undefined ? briefResult(text, perResponse) : truncate(text, perResponse),
219
+ marks: resultMarks(e.recorded, result.error),
220
+ };
221
+ };
222
+ const sections = [];
223
+ for (const run of consecutiveRuns(shownEntries)) {
224
+ if (run.length === 1) {
225
+ const { entry: e, index: i } = run[0];
226
+ if (e.notNeeded) {
227
+ // Never "skipped" or "failed": the step was left out on purpose, because
228
+ // this request does not need it, and nothing is missing for its absence.
229
+ const why = testWords(e.notNeeded.text);
230
+ sections.push(`\n\n--- Step ${i + 1}: ${e.toolName} (not needed) ---\n` +
231
+ `Not run: this request does not need it${why ? ` — it is only for when ${why}` : ""}.`);
232
+ continue;
233
+ }
234
+ const shown = show(e);
235
+ sections.push(`\n\n--- Step ${i + 1}: ${e.toolName} ---\n` +
236
+ `Call: ${e.input}\n` +
237
+ `Result${shown.marks}: ${shown.text}`);
238
+ continue;
239
+ }
240
+ sections.push(runSection(run, show));
241
+ }
242
+ // The labels are an estimate, and a bundle that still passes the limit loses
243
+ // its middle, never its footer: the last thing the agent is told. Where even
244
+ // that does not fit the limit, the end goes, as it always did.
245
+ const whole = header + sections.join("") + summary + footer;
246
+ if (whole.length <= maxChars)
247
+ return whole;
248
+ const keepFooter = truncate(header + sections.join("") + summary, Math.max(0, maxChars - footer.length)) + footer;
249
+ return keepFooter.length <= maxChars ? keepFooter : truncate(whole, maxChars);
250
+ }
251
+ /**
252
+ * Whether a run went entirely well and the agent answers now — the one case a
253
+ * bundle may be brief ({@link AssembleOptions.brief}). A run that went wrong
254
+ * anywhere, stood on a recording, or is one the agent carries on from is what
255
+ * the agent works from next: it gets every result.
256
+ */
257
+ export function wentWell(entries, opts) {
258
+ return (!opts.handover &&
259
+ !opts.subTask &&
260
+ entries.every((e) => e.notNeeded || (!e.recorded && !resultText(e.response).error)));
261
+ }
262
+ /**
263
+ * A result in a brief bundle. Whole when it is short. A long one that is JSON
264
+ * — a search's records, nearly always — keeps every record and field, and
265
+ * only its long text is cut ({@link cutLongText}): the agent checks the steps
266
+ * against the IDs, dates, senders and names, and those are short. A long
267
+ * result in words is shown whole: it may be the very answer.
268
+ *
269
+ * Measured on B2's length tests: showing only the values the plan took from a
270
+ * result instead left the agent unable to check the plan's choices — "I
271
+ * couldn't confirm these were the newest", "or who sent them" — in one reply
272
+ * in six, and once it reported a correct run as wrong.
273
+ */
274
+ function briefResult(text, budget) {
275
+ if (text.length > BRIEF_RESULT_CHARS && /^\s*[[{]/.test(text))
276
+ return truncate(cutLongText(text), budget);
277
+ return truncate(text, budget);
278
+ }
279
+ /** The runner's built-ins whose results {@link builtinText} lays out as text. */
280
+ const BUILTIN_RESULT_TOOLS = new Set(["Bash", "Read", "Glob", "Grep"]);
281
+ /**
282
+ * A built-in tool's result as the text it is, or undefined when it is not one
283
+ * the brief layout can show so: what a command printed, a file's lines, a
284
+ * search's lines or file names.
285
+ *
286
+ * The runner's own tools answer in the hook's shape — `{stdout, stderr, …}`,
287
+ * `{type, file: {content, …}}`, `{mode, content | filenames, …}`, `{filenames,
288
+ * …}` — and not in MCP's `CallToolResult`, so {@link resultText} leaves them as
289
+ * JSON, and the brief layout took all of it for one long string: a command's
290
+ * output of 470 characters ending "TOTAL DIFFERENCE 100.00" was cut to its
291
+ * first 40, a Glob's file names every one, under a header that says to call no
292
+ * tools. The same words from an MCP tool were shown whole.
293
+ */
294
+ function builtinText(toolName, response) {
295
+ if (!BUILTIN_RESULT_TOOLS.has(toolName))
296
+ return undefined;
297
+ let parsed;
298
+ try {
299
+ parsed = JSON.parse(response);
300
+ }
301
+ catch {
302
+ return undefined;
303
+ }
304
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
305
+ return undefined;
306
+ const r = parsed;
307
+ const lines = (v) => Array.isArray(v) && v.every((x) => typeof x === "string") ? v.join("\n") : undefined;
308
+ let body;
309
+ if (toolName === "Bash") {
310
+ if (typeof r.stdout !== "string" || r.interrupted === true || r.isImage === true)
311
+ return undefined;
312
+ const stderr = typeof r.stderr === "string" && r.stderr !== "" ? `stderr: ${r.stderr}` : "";
313
+ body = stderr && r.stdout !== "" && !r.stdout.endsWith("\n") ? `${r.stdout}\n${stderr}` : r.stdout + stderr;
314
+ }
315
+ else if (toolName === "Read") {
316
+ const file = r.file;
317
+ body = r.type === "text" && typeof file?.content === "string" ? file.content : undefined;
318
+ }
319
+ else {
320
+ body = typeof r.content === "string" && toolName === "Grep" ? r.content : lines(r.filenames);
321
+ }
322
+ if (body === undefined)
323
+ return undefined;
324
+ if (body === "")
325
+ return toolName === "Bash" ? "(no output)" : toolName === "Read" ? "(empty file)" : "(nothing found)";
326
+ // A list the tool cut where it made it: its text alone would read as the whole answer.
327
+ return r.truncated === true ? `${body}\n(cut short by the tool: there may be more than is shown)` : body;
328
+ }
329
+ /**
330
+ * JSON text with every string of prose longer than 40 characters cut to its
331
+ * first 40 and "…". Keys, numbers, short strings and strings with no space in
332
+ * them ({@link BRIEF_ID_CHARS}: a link, a path, an id) stay as they are. A
333
+ * character is never split: an escape counts as one, `\n` and a Unicode
334
+ * escape (a backslash, a `u` and four hex digits) alike, and so does a pair of
335
+ * surrogates, written out (an emoji) or as two such escapes.
336
+ * Works on JSON a parser would refuse — a tool's `NaN`, a result cut short —
337
+ * because it walks the text's string literals from the start, so it always
338
+ * knows which quote opens one: the text between two strings, a list of numbers
339
+ * say, is never taken for one. An unterminated string is left as it is.
340
+ */
341
+ export function cutLongText(json) {
342
+ let out = "";
343
+ let at = 0;
344
+ while (at < json.length) {
345
+ const open = json.indexOf('"', at);
346
+ if (open < 0)
347
+ return out + json.slice(at);
348
+ out += json.slice(at, open + 1);
349
+ // Walk the string a character at a time to its closing quote, noting where
350
+ // the 40th ends and whether the text has a space in it.
351
+ let end = open + 1;
352
+ let units = 0;
353
+ let cutAt = -1;
354
+ let spaced = false;
355
+ while (end < json.length && json[end] !== '"') {
356
+ const code = json.charCodeAt(end);
357
+ if (code === 92 || (code >= 0xd800 && code <= 0xdbff)) {
358
+ // An escape, or the first half of a surrogate pair: it may take more
359
+ // than one character of the text.
360
+ const unit = characterAt(json, end);
361
+ end += unit.length;
362
+ spaced ||= unit.space;
363
+ }
364
+ else {
365
+ // Nearly every character, and a result may be megabytes of them: one
366
+ // of the text, a space only when it says so.
367
+ spaced ||= code === 32 || ((code < 32 || code > 126) && /\s/.test(json[end]));
368
+ end += 1;
369
+ }
370
+ units += 1;
371
+ if (units === BRIEF_STRING_CHARS)
372
+ cutAt = end;
373
+ }
374
+ if (end >= json.length)
375
+ return out + json.slice(open + 1);
376
+ const cut = units > (spaced ? BRIEF_STRING_CHARS : BRIEF_ID_CHARS);
377
+ out += cut ? `${json.slice(open + 1, cutAt)}…"` : json.slice(open + 1, end + 1);
378
+ at = end + 1;
379
+ }
380
+ return out;
381
+ }
382
+ /**
383
+ * The character of a JSON string's text that starts at `i`: how many
384
+ * characters of the text it takes, and whether it is a space (a `\t`, or a
385
+ * Unicode escape of a space, is one as much as the character itself).
386
+ */
387
+ function characterAt(text, i) {
388
+ const c = text[i];
389
+ if (c === "\\") {
390
+ const unit = unicodeEscape(text, i);
391
+ if (unit === undefined)
392
+ return { length: 2, space: /^[nrtf]$/.test(text[i + 1] ?? "") };
393
+ // Two escapes that are the halves of one character are one character.
394
+ const low = unit >= 0xd800 && unit <= 0xdbff ? unicodeEscape(text, i + 6) : undefined;
395
+ if (low !== undefined && low >= 0xdc00 && low <= 0xdfff)
396
+ return { length: 12, space: false };
397
+ return { length: 6, space: /\s/.test(String.fromCharCode(unit)) };
398
+ }
399
+ const code = c.charCodeAt(0);
400
+ const next = text.charCodeAt(i + 1);
401
+ if (code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff)
402
+ return { length: 2, space: false };
403
+ return { length: 1, space: /\s/.test(c) };
404
+ }
405
+ /** The code unit of a `\uXXXX` escape at `i`, or undefined when there is none there. */
406
+ function unicodeEscape(text, i) {
407
+ return text[i] === "\\" && text[i + 1] === "u" && /^[0-9a-fA-F]{4}$/.test(text.slice(i + 2, i + 6))
408
+ ? parseInt(text.slice(i + 2, i + 6), 16)
409
+ : undefined;
410
+ }
411
+ /**
412
+ * The plan's summary (its response model) in a few words, or "" when it has
413
+ * nothing to add: none at all, or only the note that it was left out — which
414
+ * sends the model to the steps, and they are right above it.
415
+ */
416
+ function briefSummary(model) {
417
+ if (!model)
418
+ return "";
419
+ const keys = Object.keys(model);
420
+ if (keys.length === 0 || keys.every((k) => k === "summaryLeftOut"))
421
+ return "";
422
+ return `\n\nThe plan's summary: ${briefFields(model, BRIEF_SUMMARY_CHARS)}`;
423
+ }
424
+ /**
425
+ * `key = value` for each field, each value in a few words ({@link briefValue}),
426
+ * ended at a field's end with "…" rather than run past `max` characters.
427
+ */
428
+ export function briefFields(fields, max) {
429
+ let out = "";
430
+ for (const [key, value] of Object.entries(fields)) {
431
+ const part = `${key} = ${briefValue(value)}`;
432
+ const next = out ? `${out}; ${part}` : part;
433
+ if (next.length > max)
434
+ return out ? `${out}; …` : truncate(part, max);
435
+ out = next;
436
+ }
437
+ return out;
438
+ }
439
+ /**
440
+ * A value of the plan's summary in a few words. Numbers, booleans and short
441
+ * strings whole; long text cut, as a result's is ({@link cutLongText}: a link
442
+ * or an id is whole); a list or object whose JSON is short — a few IDs, names
443
+ * or addresses — whole; a longer list counted; a longer object by its first
444
+ * fields, one level down, below which it is counted too. A summary's long
445
+ * lists are mostly the records the steps above already show.
446
+ */
447
+ export function briefValue(value, depth = 0) {
448
+ if (typeof value === "string")
449
+ return cutLongText(JSON.stringify(value));
450
+ if (value === null || typeof value !== "object")
451
+ return String(value);
452
+ let whole;
453
+ try {
454
+ whole = JSON.stringify(value) ?? "";
455
+ }
456
+ catch {
457
+ return "[unserializable]";
458
+ }
459
+ if (whole.length <= BRIEF_INLINE_CHARS)
460
+ return whole;
461
+ if (Array.isArray(value))
462
+ return `${value.length} ${value.length === 1 ? "item" : "items"}`;
463
+ const fields = Object.entries(value);
464
+ if (depth >= 1)
465
+ return `{${fields.length} ${fields.length === 1 ? "field" : "fields"}}`;
466
+ const shown = fields.slice(0, BRIEF_OBJECT_FIELDS).map(([k, v]) => `${k}: ${briefValue(v, depth + 1)}`);
467
+ return `{${shown.join(", ")}${fields.length > BRIEF_OBJECT_FIELDS ? ", …" : ""}}`;
468
+ }
469
+ /**
470
+ * A tool result as the model should read it.
471
+ *
472
+ * Executors hand the bundle the JSON of MCP's `CallToolResult`. Laid out as it
473
+ * came, every result pays for the envelope —
474
+ * `{"content":[{"type":"text","text":"…"}],"isError":false}`, some fifty
475
+ * characters — and for a second layer of escaping: every quote in the text
476
+ * written `\"`, every backslash `\\`. Neither tells the model anything, and on
477
+ * a long plan they are a large share of what it reads (and of what the agent
478
+ * pays to put in its context). A result whose content is text and nothing else
479
+ * is shown as that text, marked when `isError` is true. Anything else — an
480
+ * image, a resource, structured content, an answer that is not a
481
+ * `CallToolResult` at all — is shown exactly as it came.
482
+ */
483
+ export function resultText(response) {
484
+ const asIs = { text: response, error: false };
485
+ let parsed;
486
+ try {
487
+ parsed = JSON.parse(response);
488
+ }
489
+ catch {
490
+ return asIs;
491
+ }
492
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
493
+ return asIs;
494
+ const result = parsed;
495
+ if (!Object.keys(result).every((k) => k === "content" || k === "isError"))
496
+ return asIs;
497
+ if (result.isError !== undefined && typeof result.isError !== "boolean")
498
+ return asIs;
499
+ if (!Array.isArray(result.content) || result.content.length === 0)
500
+ return asIs;
501
+ const texts = [];
502
+ for (const item of result.content) {
503
+ if (!item || typeof item !== "object" || Array.isArray(item))
504
+ return asIs;
505
+ const part = item;
506
+ if (part.type !== "text" || typeof part.text !== "string")
507
+ return asIs;
508
+ if (!Object.keys(part).every((k) => k === "type" || k === "text"))
509
+ return asIs;
510
+ texts.push(part.text);
511
+ }
512
+ return { text: texts.join("\n"), error: result.isError === true };
513
+ }
514
+ /** `(recorded)`, `(error)` or `(recorded, error)` after a result's label; "" for a plain live result. */
515
+ function resultMarks(recorded, error) {
516
+ const marks = [recorded ? "recorded" : "", error ? "error" : ""].filter(Boolean);
517
+ return marks.length > 0 ? ` (${marks.join(", ")})` : "";
518
+ }
519
+ /**
520
+ * The entries cut into runs of consecutive calls to one tool. A long plan is
521
+ * mostly such runs — eleven look-ups, then thirty-two forwards — and laying
522
+ * each step out on its own repeats the tool's name and the section frame every
523
+ * time. A step left out stands alone, so its "not needed" line reads as before.
524
+ */
525
+ function consecutiveRuns(entries) {
526
+ const runs = [];
527
+ entries.forEach((entry, index) => {
528
+ const last = runs.at(-1);
529
+ const prev = last?.at(-1)?.entry;
530
+ if (last && prev && !prev.notNeeded && !entry.notNeeded && prev.toolName === entry.toolName) {
531
+ last.push({ entry, index });
532
+ }
533
+ else {
534
+ runs.push([{ entry, index }]);
152
535
  }
153
- const response = truncate(e.response, perResponse);
154
- return (`\n\n--- Step ${i + 1}: ${e.toolName} ---\n` +
155
- `Call: ${e.input}\n` +
156
- `Result${e.recorded ? " (recorded)" : ""}: ${response}`);
157
536
  });
158
- return truncate(header + sections.join("") + footer, maxChars);
537
+ return runs;
538
+ }
539
+ /**
540
+ * Two or more consecutive calls to one tool, as one section: one line per call,
541
+ * numbered as its step, with its result after the arrow. What every call shares
542
+ * is said once above the calls — input fields with the same value in each, and
543
+ * the answer, when every call got the same one. Each result is shown as `show`
544
+ * shows a step on its own, budget included.
545
+ */
546
+ function runSection(run, show) {
547
+ const first = run[0];
548
+ const head = `\n\n--- Steps ${first.index + 1}-${run[run.length - 1].index + 1}: ${first.entry.toolName} (${run.length} calls) ---`;
549
+ const inputs = sharedInput(run.map(({ entry }) => entry.input));
550
+ const shown = run.map(({ entry, index }, i) => ({ step: index + 1, input: inputs.own[i], ...show(entry) }));
551
+ const lines = inputs.shared === null ? "" : `\nIn every call: ${inputs.shared}`;
552
+ const same = shown.every((s) => s.text === shown[0].text && s.marks === shown[0].marks);
553
+ if (same) {
554
+ return (`${head}${lines}\nEvery call's result${shown[0].marks}: ${shown[0].text}` +
555
+ shown.map((s) => `\n${s.step}: ${s.input}`).join(""));
556
+ }
557
+ return head + lines + shown.map((s) => `\n${s.step}${s.marks}: ${s.input} -> ${s.text}`).join("");
558
+ }
559
+ /**
560
+ * The input fields a run of calls all share, said once, and what is left of
561
+ * each call's input. A forward sends thirty-two emails to one recipient; the
562
+ * recipient need not be read thirty-two times. Only plain JSON objects are
563
+ * split, and only on fields present in every call with the same value; an
564
+ * input that was capped, or is not an object, leaves the run as it came.
565
+ */
566
+ function sharedInput(inputs) {
567
+ const asCame = { shared: null, own: [...inputs] };
568
+ const objects = [];
569
+ for (const input of inputs) {
570
+ let parsed;
571
+ try {
572
+ parsed = JSON.parse(input);
573
+ }
574
+ catch {
575
+ return asCame;
576
+ }
577
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
578
+ return asCame;
579
+ objects.push(parsed);
580
+ }
581
+ const value = (o, key) => JSON.stringify(o[key]);
582
+ const common = Object.keys(objects[0]).filter((key) => objects.every((o) => Object.hasOwn(o, key) && value(o, key) === value(objects[0], key)));
583
+ if (common.length === 0)
584
+ return asCame;
585
+ const pick = (o, keep) => JSON.stringify(Object.fromEntries(Object.entries(o).filter(([key]) => keep(key))));
586
+ return {
587
+ shared: pick(objects[0], (key) => common.includes(key)),
588
+ own: objects.map((o) => pick(o, (key) => !common.includes(key))),
589
+ };
159
590
  }
160
591
  /** Truncate to `max` chars, appending a marker naming how much was dropped. */
161
592
  export function truncate(s, max) {