@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.
package/dist/control/server.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|