@basein/runner 0.3.16 → 0.3.17

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.
@@ -31,7 +31,7 @@ import { BIR_CALL_ID, fingerprint, newCallId, parseQualifiedName, qualifyToolNam
31
31
  import { StepIndexAllocator } from "./ordering.js";
32
32
  import { pidAlive } from "./discovery.js";
33
33
  import { pinCompatible } from "./pin-guard.js";
34
- import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, thinkingForToolUse, usageSince, } from "./transcript.js";
34
+ import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, settledTextAfterToolUse, thinkingForToolUse, usageSince, } from "./transcript.js";
35
35
  import { isHousekeepingCall } from "../record/housekeeping.js";
36
36
  import { asSent, userText } from "../record/prompt-text.js";
37
37
  import { redact, redactText } from "../record/redact.js";
@@ -2294,7 +2294,7 @@ export class ControlServer {
2294
2294
  // A matched turn records nothing, but it still owes the ledger a number —
2295
2295
  // including when it declined to steer, which is a *baseline* sample and is
2296
2296
  // what every saving is measured against (docs/calculatedReplay.md §11).
2297
- this.reportExecution(session, run, durationMs, cost, cutShort);
2297
+ this.reportExecution(session, run, durationMs, cost, cutShort, reason);
2298
2298
  // The plan's calls kept for a redo went into the repeat when it opened
2299
2299
  // (AC2); nothing reads them after the turn, and they can run to megabytes
2300
2300
  // on a sealed run the session keeps as its previous one.
@@ -2335,8 +2335,10 @@ export class ControlServer {
2335
2335
  // Resolve the answer *outside* the queue: the wait below is up to two
2336
2336
  // seconds, and the queue is serial — blocking it here would delay the
2337
2337
  // first step of the user's next turn by that much. Kept on the run, so a
2338
- // drain of this run waits for this seal and for nobody else's.
2339
- run.sealing = answerText
2338
+ // drain of this run waits for this seal and for nobody else's — beside a
2339
+ // plan's report that waits for the answer too (AC11), never in its place.
2340
+ const before = run.sealing;
2341
+ const finishing = answerText
2340
2342
  .then((answer) => {
2341
2343
  run.finishQueued = true;
2342
2344
  this.queue.push(() => {
@@ -2359,6 +2361,7 @@ export class ControlServer {
2359
2361
  });
2360
2362
  });
2361
2363
  });
2364
+ run.sealing = before ? Promise.all([before, finishing]).then(() => undefined) : finishing;
2362
2365
  }
2363
2366
  logLine("run.finish", {
2364
2367
  run: run.runId,
@@ -2451,7 +2454,9 @@ export class ControlServer {
2451
2454
  }
2452
2455
  reportExecution(session, run, durationMs, cost,
2453
2456
  /** The run was closed before its task ended for the caller (another host only). */
2454
- cutShort = false) {
2457
+ cutShort = false,
2458
+ /** Why the turn was sealed: only an ordinary `stop` ends with an answer (AC11). */
2459
+ sealedBy = "stop") {
2455
2460
  if (!isScenarioReporter(this.recorder))
2456
2461
  return;
2457
2462
  const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
@@ -2515,6 +2520,30 @@ export class ControlServer {
2515
2520
  billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
2516
2521
  });
2517
2522
  }
2523
+ // The plan ran to its end, and no redo of the model's own was recorded:
2524
+ // the model only answered. Its answer goes with the report, so that the
2525
+ // service can tell whether it took the plan's result (the service's
2526
+ // autocorrect.md AC11). Read off the queue, as a recording's answer is —
2527
+ // the wait is up to two seconds, and the queue is serial — and kept on
2528
+ // the run, so a drain of this run waits for it. Only after an ordinary
2529
+ // stop, and only the text written after the turn's last call: a turn cut
2530
+ // short has no answer, and an older text is none. Claude Code only: the
2531
+ // service corrects no other host's plan.
2532
+ if (state === prompt &&
2533
+ state.ranToEnd === true &&
2534
+ !redoneBy &&
2535
+ report.outcome === "steered_full" &&
2536
+ sealedBy === "stop" &&
2537
+ run.lastToolUseId !== undefined &&
2538
+ !otherHost(session)) {
2539
+ const sent = settledTextAfterToolUse(session.transcriptPath, run.lastToolUseId)
2540
+ .catch(() => "")
2541
+ .then((answer) => {
2542
+ this.queue.push(() => recorder.reportExecution(answer ? { ...report, answer } : report));
2543
+ });
2544
+ run.sealing = run.sealing ? Promise.all([run.sealing, sent]).then(() => undefined) : sent;
2545
+ return;
2546
+ }
2518
2547
  this.queue.push(() => recorder.reportExecution(report));
2519
2548
  });
2520
2549
  // The prompt match that landed too late (see `noteLateMatch`). Not when a
@@ -4282,6 +4311,9 @@ export class ControlServer {
4282
4311
  context.push(stashed);
4283
4312
  const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
4284
4313
  if (run && !run.finished) {
4314
+ // The answer, if one comes, is written after this call (AC11).
4315
+ if (typeof payload.tool_use_id === "string" && payload.tool_use_id)
4316
+ run.lastToolUseId = payload.tool_use_id;
4285
4317
  this.noteHandover(run);
4286
4318
  const note = this.replay.takeNote(run.replay);
4287
4319
  if (note)
@@ -34,6 +34,24 @@
34
34
  * birth time, mtime, and the bytes at its head and at the offset) and read
35
35
  * again from the start, which is exactly what the old code did every time.
36
36
  */
37
+ /**
38
+ * The model's text after the message that made the tool call `toolUseId`: its
39
+ * answer, once that call's result came back (the service's autocorrect.md
40
+ * AC11). Empty while none has been written, and when the call is not in the
41
+ * transcript — never an older text, which `lastAssistantText` can be: the
42
+ * sentence before a call, or the turn before's answer.
43
+ */
44
+ export declare function textAfterToolUse(transcriptPath: string | undefined, toolUseId: string | undefined): string;
45
+ /**
46
+ * {@link textAfterToolUse}, waited for: up to `timeoutMs` for a text to appear,
47
+ * then until it stops changing for `confirmMs`. Empty when none came in time —
48
+ * a plan's report then goes without an answer, rather than with a wrong one.
49
+ */
50
+ export declare function settledTextAfterToolUse(transcriptPath: string | undefined, toolUseId: string | undefined, opts?: {
51
+ timeoutMs?: number;
52
+ confirmMs?: number;
53
+ pollMs?: number;
54
+ }): Promise<string>;
37
55
  /** Forget every cached index (tests; a caller that knows files were replaced). */
38
56
  export declare function forgetTranscripts(): void;
39
57
  /**
@@ -397,6 +397,61 @@ function foldLine(idx, line) {
397
397
  }
398
398
  }
399
399
  }
400
+ /**
401
+ * The model's text after the message that made the tool call `toolUseId`: its
402
+ * answer, once that call's result came back (the service's autocorrect.md
403
+ * AC11). Empty while none has been written, and when the call is not in the
404
+ * transcript — never an older text, which `lastAssistantText` can be: the
405
+ * sentence before a call, or the turn before's answer.
406
+ */
407
+ export function textAfterToolUse(transcriptPath, toolUseId) {
408
+ if (!transcriptPath || !toolUseId)
409
+ return "";
410
+ const idx = indexFor(transcriptPath);
411
+ const at = idx?.toolUses.get(toolUseId)?.message;
412
+ if (!idx || at === undefined)
413
+ return "";
414
+ let after = false;
415
+ let text = "";
416
+ for (const [key, entry] of idx.messages) {
417
+ if (key === at) {
418
+ after = true;
419
+ continue;
420
+ }
421
+ if (after && entry.texts.length > 0)
422
+ text = entry.texts.join("");
423
+ }
424
+ return text.trim();
425
+ }
426
+ /**
427
+ * {@link textAfterToolUse}, waited for: up to `timeoutMs` for a text to appear,
428
+ * then until it stops changing for `confirmMs`. Empty when none came in time —
429
+ * a plan's report then goes without an answer, rather than with a wrong one.
430
+ */
431
+ export async function settledTextAfterToolUse(transcriptPath, toolUseId, opts = {}) {
432
+ if (!transcriptPath || !toolUseId)
433
+ return "";
434
+ const pollMs = opts.pollMs ?? 150;
435
+ const confirmMs = opts.confirmMs ?? 400;
436
+ const deadline = Date.now() + (opts.timeoutMs ?? 2_000);
437
+ const sleep = () => new Promise((resolve) => {
438
+ const t = setTimeout(resolve, pollMs);
439
+ t.unref?.();
440
+ });
441
+ let latest = textAfterToolUse(transcriptPath, toolUseId);
442
+ let since = Date.now();
443
+ while (Date.now() < deadline) {
444
+ if (latest && Date.now() - since >= confirmMs)
445
+ return latest;
446
+ await sleep();
447
+ const now = textAfterToolUse(transcriptPath, toolUseId);
448
+ if (now !== latest) {
449
+ latest = now;
450
+ since = Date.now();
451
+ }
452
+ }
453
+ return latest;
454
+ }
400
455
  /** Forget every cached index (tests; a caller that knows files were replaced). */
401
456
  export function forgetTranscripts() {
402
457
  indexes.clear();
@@ -452,6 +452,14 @@ export interface ExecutionReport {
452
452
  runId: string;
453
453
  ownCalls: number;
454
454
  };
455
+ /**
456
+ * The model's final answer, when the prompt's plan ran to its end and no
457
+ * redo of the model's own was recorded (the service's autocorrect.md AC11):
458
+ * the service checks whether the model took the plan's result, and holds and
459
+ * corrects the plan when it did not. Read from the transcript after the turn
460
+ * ends; an older service drops the field.
461
+ */
462
+ answer?: string;
455
463
  }
456
464
  /** Optional capability: reporting needs a service, and a NullRecorder has none. */
457
465
  export interface ScenarioReporter {
@@ -70,6 +70,8 @@ export declare const TEXT_CAPS: {
70
70
  readonly toolError: number;
71
71
  /** An execution report's error lines, rendered in a web page. */
72
72
  readonly reportError: number;
73
+ /** The model's answer on a plan's report (AC11): the service keeps 4,000 characters of it. */
74
+ readonly reportAnswer: number;
73
75
  };
74
76
  /**
75
77
  * The most an execution report's request may weigh. The service reads this
@@ -75,6 +75,8 @@ export const TEXT_CAPS = {
75
75
  toolError: 8 * 1024,
76
76
  /** An execution report's error lines, rendered in a web page. */
77
77
  reportError: 2 * 1024,
78
+ /** The model's answer on a plan's report (AC11): the service keeps 4,000 characters of it. */
79
+ reportAnswer: 8 * 1024,
78
80
  };
79
81
  /**
80
82
  * The most an execution report's request may weigh. The service reads this
@@ -742,11 +744,12 @@ export class RemoteRecorder {
742
744
  const costUsd = report.deriveCostUsd + report.sessionCostUsd + report.fallbackCostUsd;
743
745
  const prompt = pend(report.prompt, TEXT_CAPS.prompt);
744
746
  const error = pend(report.error, TEXT_CAPS.reportError);
747
+ const answer = pend(report.answer, TEXT_CAPS.reportAnswer);
745
748
  const steps = report.steps?.map(pendStep);
746
749
  this.enqueue({
747
750
  lane: EXECUTIONS,
748
751
  kind: "report",
749
- bytes: sizeOf(prompt, error, ...(steps ?? []).flatMap((s) => [s.toolInput, s.toolOutput])) + 1024,
752
+ bytes: sizeOf(prompt, error, answer, ...(steps ?? []).flatMap((s) => [s.toolInput, s.toolOutput])) + 1024,
750
753
  send: async () => {
751
754
  // A redo whose run the service refused to create is no run of its:
752
755
  // never named (the service's autocorrect.md AC1), even when the
@@ -789,6 +792,9 @@ export class RemoteRecorder {
789
792
  // The plan ran to its end and the agent redid the task (the
790
793
  // service's autocorrect.md AC1). Additive, like `declined`.
791
794
  redoneBy,
795
+ // ...or only answered: its answer, capped and redacted like any
796
+ // text (AC11). Additive, like `redoneBy`.
797
+ answer: out(answer),
792
798
  }, steps), { retryTransient: Boolean(report.ticket) });
793
799
  const r = (body ?? {});
794
800
  const failed = report.steps?.filter((s) => s.status === "failed").length ?? 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "description": "A recording MCP proxy: sits between any MCP client and its MCP servers, executes each call on the client's behalf, and records the run as a reusable BaseIn scenario.",
5
5
  "type": "module",
6
6
  "license": "MIT",