@basein/runner 0.2.0 → 0.2.2
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/README.md +4 -2
- package/dist/auth/client.d.ts +12 -0
- package/dist/auth/client.js +41 -0
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +70 -4
- package/dist/bin/bir.js +56 -3
- package/dist/control/server.d.ts +84 -1
- package/dist/control/server.js +555 -51
- package/dist/control/transcript.d.ts +40 -0
- package/dist/control/transcript.js +105 -0
- package/dist/record/recorder.d.ts +178 -4
- package/dist/record/recorder.js +6 -0
- package/dist/record/remote-recorder.d.ts +20 -2
- package/dist/record/remote-recorder.js +66 -6
- package/dist/replay/bundle.d.ts +10 -1
- package/dist/replay/bundle.js +41 -3
- package/dist/replay/controller.d.ts +179 -6
- package/dist/replay/controller.js +584 -55
- package/dist/replay/coverage.js +2 -2
- package/dist/replay/derive.d.ts +54 -7
- package/dist/replay/derive.js +174 -17
- package/dist/replay/flatten.d.ts +125 -0
- package/dist/replay/flatten.js +182 -0
- package/dist/replay/handover.d.ts +60 -0
- package/dist/replay/handover.js +82 -0
- package/dist/replay/logic.d.ts +11 -0
- package/dist/replay/logic.js +17 -0
- package/dist/replay/plan.d.ts +105 -8
- package/dist/replay/plan.js +309 -47
- package/dist/replay/source-run.d.ts +24 -10
- package/dist/replay/source-run.js +65 -30
- package/dist/replay/types.d.ts +108 -5
- package/dist/replay/types.js +33 -3
- package/docs/calculatedReplay.md +16 -8
- package/docs/calculatedReplayGuide.md +4 -4
- package/package.json +1 -1
package/dist/control/server.js
CHANGED
|
@@ -24,21 +24,29 @@
|
|
|
24
24
|
* accepts unauthenticated step reports is a local exfiltration channel.
|
|
25
25
|
*/
|
|
26
26
|
import { createServer } from "node:http";
|
|
27
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
27
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
28
28
|
import { FINGERPRINT_WINDOW_MS, fingerprint, newCallId, parseQualifiedName, qualifyToolName, } from "./correlation.js";
|
|
29
29
|
import { StepIndexAllocator } from "./ordering.js";
|
|
30
|
-
import { contextForToolUse, markTranscriptUsage, settledLastAssistantText, usageSince, } from "./transcript.js";
|
|
30
|
+
import { contextForToolUse, intentForToolUse, markTranscriptUsage, recentToolResults, settledLastAssistantText, usageSince, } from "./transcript.js";
|
|
31
31
|
import { isHousekeeping } from "../record/housekeeping.js";
|
|
32
32
|
import { redact } from "../record/redact.js";
|
|
33
33
|
import { serializeCapped } from "../record/truncate.js";
|
|
34
34
|
import { StepQueue } from "../record/queue.js";
|
|
35
|
-
import { NullRecorder, isMatchAware, isScenarioReporter, } from "../record/recorder.js";
|
|
35
|
+
import { NullRecorder, isIntentMatcher, isMatchAware, isRunCreationAware, isScenarioReporter, } from "../record/recorder.js";
|
|
36
36
|
import { ReplayController, POLL_HOLD_MS, } from "../replay/controller.js";
|
|
37
37
|
import { calculateCostUsd } from "../replay/pricing.js";
|
|
38
38
|
import { logDetail, logLine, errText } from "../util/log.js";
|
|
39
39
|
import { packageVersion } from "../util/version.js";
|
|
40
40
|
/** How long a `/tool/post` waits for the proxy's own report before recording its own view. */
|
|
41
41
|
const PROXY_REPORT_GRACE_MS = 1_500;
|
|
42
|
+
/** One MCP call, as seen from up to two sides. */
|
|
43
|
+
/**
|
|
44
|
+
* Field separator for the probe's dedupe hash (segmented.md R-HIT-5). A NUL,
|
|
45
|
+
* so that a text ending where a tool name begins can never hash the same as the
|
|
46
|
+
* other way round. Built rather than written as an escape, because a literal
|
|
47
|
+
* NUL in source is a trap for every tool that reads the file.
|
|
48
|
+
*/
|
|
49
|
+
const PROBE_SEP = String.fromCharCode(0);
|
|
42
50
|
export class ControlServer {
|
|
43
51
|
token;
|
|
44
52
|
sessionId = "birsess_" + randomUUID();
|
|
@@ -57,6 +65,18 @@ export class ControlServer {
|
|
|
57
65
|
pendingSeals = [];
|
|
58
66
|
/** Calculated replay. Inert unless `opts.replay.enabled`. */
|
|
59
67
|
replay;
|
|
68
|
+
/**
|
|
69
|
+
* How many of this prompt's tool results derivation may read
|
|
70
|
+
* (`BIR_DERIVE_RECENT_RESULTS`, segmented.md R-PARAM-2). A target is often
|
|
71
|
+
* named by an earlier step's output and never by the prompt.
|
|
72
|
+
*/
|
|
73
|
+
deriveRecentResults;
|
|
74
|
+
/**
|
|
75
|
+
* Which rungs of R-INTENT-1 the runner may send (`BIR_INTENT_SOURCES`). It
|
|
76
|
+
* governs only what is *sent*: with every source off, the probe still goes out
|
|
77
|
+
* with an empty text and the server builds the tool line (R-HIT-4).
|
|
78
|
+
*/
|
|
79
|
+
intentSources;
|
|
60
80
|
constructor(opts) {
|
|
61
81
|
this.opts = opts;
|
|
62
82
|
this.recorder = opts.recorder;
|
|
@@ -64,6 +84,8 @@ export class ControlServer {
|
|
|
64
84
|
for (const name of opts.wrappedServers ?? [])
|
|
65
85
|
this.wrapped.add(name);
|
|
66
86
|
this.replay = new ReplayController(opts.replay ?? { enabled: false, minSimilarity: 1 });
|
|
87
|
+
this.deriveRecentResults = opts.deriveRecentResults ?? 5;
|
|
88
|
+
this.intentSources = opts.intentSources ?? new Set(["text", "thinking", "tool"]);
|
|
67
89
|
this.queue = new StepQueue({
|
|
68
90
|
onDrop: (dropped) => {
|
|
69
91
|
// §10: drop oldest, flag the run lossy. The flag is louder than a
|
|
@@ -138,8 +160,25 @@ export class ControlServer {
|
|
|
138
160
|
stepsPlanned: s.run.replay.stepsPlanned,
|
|
139
161
|
stepsPinned: s.run.replay.stepsPinned,
|
|
140
162
|
armed: Boolean(s.run.replay.plan) && !s.run.replay.retired,
|
|
163
|
+
armedBy: s.run.replay.armedBy,
|
|
164
|
+
handover: s.run.replay.handover
|
|
165
|
+
? { kind: s.run.replay.handover.kind, stepIndex: s.run.replay.handover.stepIndex }
|
|
166
|
+
: null,
|
|
167
|
+
plans: s.run.replays.length,
|
|
168
|
+
kind: s.run.replay.kind,
|
|
169
|
+
}
|
|
170
|
+
: null,
|
|
171
|
+
// Present whether or not a plan is armed (segmented.md R-LIFE-8): the
|
|
172
|
+
// whole point of the observe-only period is that it is visible, and
|
|
173
|
+
// "nothing armed" and "nothing even probed" are different problems.
|
|
174
|
+
intent: s.run
|
|
175
|
+
? {
|
|
176
|
+
probes: s.run.intent.requests,
|
|
177
|
+
armed: s.run.intent.armed,
|
|
178
|
+
lastStepHit: s.run.intent.lastStepHit ?? null,
|
|
141
179
|
}
|
|
142
180
|
: null,
|
|
181
|
+
fragment: s.run?.fragment ? { state: s.run.fragment.state } : null,
|
|
143
182
|
}));
|
|
144
183
|
return {
|
|
145
184
|
ok: true,
|
|
@@ -147,6 +186,11 @@ export class ControlServer {
|
|
|
147
186
|
tier: "bound",
|
|
148
187
|
// The authoritative answer to "is anything actually being saved?".
|
|
149
188
|
recording: this.opts.recording ?? !(this.recorder instanceof NullRecorder),
|
|
189
|
+
// Whether a handed-out segment may actually run mid-task, or whether the
|
|
190
|
+
// runner is only watching (R-OUT-10, R-LIFE-8). Observe-only must never
|
|
191
|
+
// be invisible: an operator has to be able to see which of the two this
|
|
192
|
+
// machine is doing without reading a log file.
|
|
193
|
+
segmentArm: this.replay.segmentArm,
|
|
150
194
|
authUrl: process.env.BIR_AUTH_URL ?? null,
|
|
151
195
|
sessionId: this.sessionId,
|
|
152
196
|
pid: process.pid,
|
|
@@ -168,6 +212,15 @@ export class ControlServer {
|
|
|
168
212
|
minSimilarity: this.opts.replay?.minSimilarity ?? null,
|
|
169
213
|
allowServers: this.opts.replay?.allowServers ? [...this.opts.replay.allowServers] : null,
|
|
170
214
|
deriveKey: Boolean(this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY),
|
|
215
|
+
// Who reads the turn for its parameters (segmented.md R-PARAM-5). The
|
|
216
|
+
// ordinary answer is `service`; a key here is an override; `samples`
|
|
217
|
+
// means no scenario with a target can run, which is the one state an
|
|
218
|
+
// operator must be able to see without reading a log.
|
|
219
|
+
deriveVia: (this.opts.replay?.apiKey ?? process.env.ANTHROPIC_API_KEY)
|
|
220
|
+
? "key"
|
|
221
|
+
: this.opts.replay?.authUrl && this.opts.replay?.authToken
|
|
222
|
+
? "service"
|
|
223
|
+
: "samples",
|
|
171
224
|
pollingProxies: this.replay.work.pollingServers(),
|
|
172
225
|
},
|
|
173
226
|
lossy: this.lossy,
|
|
@@ -326,6 +379,9 @@ export class ControlServer {
|
|
|
326
379
|
promptSeen: input.length > 0,
|
|
327
380
|
builtIns: new Map(),
|
|
328
381
|
correlations: new Map(),
|
|
382
|
+
replays: [],
|
|
383
|
+
turnId: runId,
|
|
384
|
+
intent: { armed: 0, requests: 0 },
|
|
329
385
|
// Taken here, beside `startedAtMs`, so the cost window and the duration
|
|
330
386
|
// window are the same window (docs/calculatedReplay.md §11.4).
|
|
331
387
|
usageMark: markTranscriptUsage(session.transcriptPath),
|
|
@@ -334,7 +390,7 @@ export class ControlServer {
|
|
|
334
390
|
logLine("run.start", { run: runId, sess: session.sessionId, tier: "bound" });
|
|
335
391
|
// Started here so the round trip overlaps whatever the caller does next;
|
|
336
392
|
// `onPrompt` awaits the same memoized promise under its own budget.
|
|
337
|
-
void this.watchForMatch(run);
|
|
393
|
+
void this.watchForMatch(session, run);
|
|
338
394
|
return run;
|
|
339
395
|
}
|
|
340
396
|
runMetadata(session) {
|
|
@@ -362,7 +418,7 @@ export class ControlServer {
|
|
|
362
418
|
*
|
|
363
419
|
* Resolves with the steering directive to inject, or undefined.
|
|
364
420
|
*/
|
|
365
|
-
watchForMatch(run, prompt = "") {
|
|
421
|
+
watchForMatch(session, run, prompt = "") {
|
|
366
422
|
if (run.matchWatch)
|
|
367
423
|
return run.matchWatch;
|
|
368
424
|
if (!isMatchAware(this.recorder))
|
|
@@ -370,7 +426,7 @@ export class ControlServer {
|
|
|
370
426
|
const pending = this.recorder.getMatch(run.runId);
|
|
371
427
|
run.matchWatch = this.replay
|
|
372
428
|
.awaitMatch(pending)
|
|
373
|
-
.then((match) => {
|
|
429
|
+
.then(async (match) => {
|
|
374
430
|
if (!match)
|
|
375
431
|
return undefined;
|
|
376
432
|
run.recording = false;
|
|
@@ -381,8 +437,15 @@ export class ControlServer {
|
|
|
381
437
|
similarity: match.similarity,
|
|
382
438
|
why: "similar prompt — the service kept its own run; not recording this one",
|
|
383
439
|
});
|
|
384
|
-
|
|
385
|
-
|
|
440
|
+
// Awaited: a scenario with a target derives before the directive goes
|
|
441
|
+
// out, so a plan that cannot find its target is never delivered
|
|
442
|
+
// (segmented.md R-PARAM-4). Inside `UserPromptSubmit`'s 15 s.
|
|
443
|
+
const state = await this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
|
|
444
|
+
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
445
|
+
});
|
|
446
|
+
run.replay = state;
|
|
447
|
+
run.replays.push(state);
|
|
448
|
+
return this.replay.directiveFor(state);
|
|
386
449
|
})
|
|
387
450
|
.catch((err) => {
|
|
388
451
|
// Replay is an optimisation; a broken one must never cost a turn.
|
|
@@ -423,7 +486,12 @@ export class ControlServer {
|
|
|
423
486
|
// A matched turn records nothing, but it still owes the ledger a number —
|
|
424
487
|
// including when it declined to steer, which is a *baseline* sample and is
|
|
425
488
|
// what every saving is measured against (docs/calculatedReplay.md §11).
|
|
426
|
-
this.reportExecution(run, durationMs, cost);
|
|
489
|
+
this.reportExecution(session, run, durationMs, cost);
|
|
490
|
+
// A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
|
|
491
|
+
// and duration are the model's own work, not the steps the scenario did.
|
|
492
|
+
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
493
|
+
const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
|
|
494
|
+
const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
|
|
427
495
|
if (run.recording) {
|
|
428
496
|
const transcriptPath = session.transcriptPath;
|
|
429
497
|
// Resolve the answer *outside* the queue: the wait below is up to two
|
|
@@ -442,8 +510,8 @@ export class ControlServer {
|
|
|
442
510
|
// scenario that has no samples yet. A run recorded at $0 makes
|
|
443
511
|
// every later saving measured against nothing.
|
|
444
512
|
this.recorder.finishRun(run.runId, answer || undefined, {
|
|
445
|
-
durationMs,
|
|
446
|
-
costUsd:
|
|
513
|
+
durationMs: recordedMs,
|
|
514
|
+
costUsd: recordedCost.measured ? recordedCost.usd : undefined,
|
|
447
515
|
});
|
|
448
516
|
});
|
|
449
517
|
}));
|
|
@@ -486,24 +554,66 @@ export class ControlServer {
|
|
|
486
554
|
});
|
|
487
555
|
return { usd: 0, measured: false };
|
|
488
556
|
}
|
|
489
|
-
|
|
557
|
+
return this.costSince(session, run.usageMark);
|
|
558
|
+
}
|
|
559
|
+
/** Transcript cost since `mark`; unmeasured (and zero) without one. */
|
|
560
|
+
costSince(session, mark) {
|
|
561
|
+
if (mark === undefined)
|
|
562
|
+
return { usd: 0, measured: false };
|
|
563
|
+
const deltas = usageSince(session.transcriptPath, mark);
|
|
490
564
|
const usd = deltas.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
|
|
491
565
|
return { usd, measured: deltas.length > 0 };
|
|
492
566
|
}
|
|
493
|
-
reportExecution(run, durationMs, cost) {
|
|
494
|
-
|
|
495
|
-
if (!state || !isScenarioReporter(this.recorder))
|
|
496
|
-
return;
|
|
497
|
-
const report = this.replay.buildReport(state, {
|
|
498
|
-
sessionCostUsd: cost.usd,
|
|
499
|
-
measured: cost.measured,
|
|
500
|
-
durationMs,
|
|
501
|
-
prompt: run.input || undefined,
|
|
502
|
-
});
|
|
503
|
-
if (!report)
|
|
567
|
+
reportExecution(session, run, durationMs, cost) {
|
|
568
|
+
if (!isScenarioReporter(this.recorder))
|
|
504
569
|
return;
|
|
570
|
+
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
505
571
|
const recorder = this.recorder;
|
|
506
|
-
this.
|
|
572
|
+
// One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
|
|
573
|
+
// for its own window of the transcript — from when it armed to when the next
|
|
574
|
+
// one did — so the turn's tokens are split between them, never counted twice.
|
|
575
|
+
// A turn with a single prompt-armed plan is exactly the report it always was.
|
|
576
|
+
states.forEach((state, i) => {
|
|
577
|
+
const next = states[i + 1];
|
|
578
|
+
const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
|
|
579
|
+
let windowCost = cost;
|
|
580
|
+
let windowMs = durationMs;
|
|
581
|
+
// An intent-armed plan's window *ends* where its work ended (segmented.md
|
|
582
|
+
// R-MONEY-3): at its own retire mark when it completed, else at the next
|
|
583
|
+
// plan's arm, else at the end of the turn. Without the first, a segment
|
|
584
|
+
// that finished in three steps would be billed for everything the agent
|
|
585
|
+
// did afterwards, and its saving would read as a loss.
|
|
586
|
+
let end = nextMark;
|
|
587
|
+
if (state.armedBy === "intent") {
|
|
588
|
+
windowCost = this.costSince(session, state.usageMark);
|
|
589
|
+
windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
|
|
590
|
+
end = state.retiredMark ?? nextMark;
|
|
591
|
+
}
|
|
592
|
+
else if (next) {
|
|
593
|
+
windowMs = next.armedAt - run.startedAtMs;
|
|
594
|
+
}
|
|
595
|
+
if (end !== undefined) {
|
|
596
|
+
const later = this.costSince(session, end);
|
|
597
|
+
windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
|
|
598
|
+
}
|
|
599
|
+
const report = this.replay.buildReport(state, {
|
|
600
|
+
sessionCostUsd: windowCost.usd,
|
|
601
|
+
measured: windowCost.measured,
|
|
602
|
+
durationMs: Math.max(0, windowMs),
|
|
603
|
+
prompt: run.input || undefined,
|
|
604
|
+
siblings: states,
|
|
605
|
+
});
|
|
606
|
+
if (!report)
|
|
607
|
+
return;
|
|
608
|
+
if (state.armedBy === "intent") {
|
|
609
|
+
logDetail("replay.done", {
|
|
610
|
+
scenario: state.scenarioId ?? undefined,
|
|
611
|
+
windowMs: Math.max(0, windowMs),
|
|
612
|
+
billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
this.queue.push(() => recorder.reportExecution(report));
|
|
616
|
+
});
|
|
507
617
|
}
|
|
508
618
|
/** Seal the run *and* wait for everything queued to reach the service. */
|
|
509
619
|
async finalizeRun(session) {
|
|
@@ -569,7 +679,7 @@ export class ControlServer {
|
|
|
569
679
|
// a slow or unreachable service costs the user nothing but an ordinary turn
|
|
570
680
|
// (docs/calculatedReplay.md §10); `additionalContext` carries no `decision`,
|
|
571
681
|
// so the prompt still reaches the model either way.
|
|
572
|
-
const directive = await this.watchForMatch(run, input);
|
|
682
|
+
const directive = await this.watchForMatch(session, run, input);
|
|
573
683
|
if (!directive)
|
|
574
684
|
return {};
|
|
575
685
|
return {
|
|
@@ -605,36 +715,49 @@ export class ControlServer {
|
|
|
605
715
|
return {};
|
|
606
716
|
}
|
|
607
717
|
const agentId = payload.agent_id ?? session.agentId;
|
|
608
|
-
|
|
718
|
+
// The real intent, read from every line sharing this message's id
|
|
719
|
+
// (segmented.md R-INTENT-3). `context` stays the *written* text only, with
|
|
720
|
+
// its agent prefix: thinking is embedded but never stored as text
|
|
721
|
+
// (R-INTENT-2), so it rides separately in `intent`.
|
|
722
|
+
const intent = intentForToolUse(session.transcriptPath, toolUseId, this.intentSources);
|
|
723
|
+
const reasoning = intent?.source === "text" ? intent.text : "";
|
|
724
|
+
// The prefix rides on whether there is a subagent, not on whether it said
|
|
725
|
+
// anything: a subagent's steps must be tellable apart even when it narrated
|
|
726
|
+
// nothing. What it never carries is thinking (R-INTENT-2).
|
|
609
727
|
const context = agentId ? `[agent ${agentId}] ${reasoning}`.trim() : reasoning;
|
|
728
|
+
const thinkingIntent = intent?.source === "thinking" ? { text: intent.text, source: "thinking" } : undefined;
|
|
610
729
|
// ── replay steering (docs/calculatedReplay.md §7) ────────────────────────
|
|
611
730
|
// Runs before anything else, because two of its four answers end the call.
|
|
612
731
|
let pinned;
|
|
613
732
|
if (run.replay?.plan && !run.replay.retired) {
|
|
614
733
|
const action = await this.replay.preTool(run.replay, toolName, toolUseId);
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
734
|
+
// A hand-over inside `preTool` (an input logic that threw) must still
|
|
735
|
+
// schedule the fragment, even though this call ends here.
|
|
736
|
+
this.noteHandover(run);
|
|
737
|
+
if (action.kind === "pin")
|
|
738
|
+
pinned = action.input;
|
|
739
|
+
const answer = this.preToolAnswer(action);
|
|
740
|
+
if (answer)
|
|
741
|
+
return answer;
|
|
742
|
+
}
|
|
743
|
+
if (!pinned && (!run.replay?.plan || run.replay.retired)) {
|
|
744
|
+
// ── falling back to the model (fallbk.md) ──────────────────────────────
|
|
745
|
+
// 1. A hand-over noticed where no hook answer could carry it — a proxy
|
|
746
|
+
// report threaded after its PostToolUse had already answered — reaches
|
|
747
|
+
// the model in place of this call. Never silence (D4).
|
|
748
|
+
const note = this.replay.takeNote(run.replay);
|
|
749
|
+
if (note) {
|
|
750
|
+
this.noteHandover(run);
|
|
751
|
+
return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
|
|
752
|
+
}
|
|
753
|
+
// 2. The model is driving: look for a scenario by this iteration's intent.
|
|
754
|
+
const armed = await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
|
|
755
|
+
if (armed)
|
|
756
|
+
return armed;
|
|
757
|
+
// 3. No scenario took over: the model's own work after a hand-over is
|
|
758
|
+
// recorded as a fragment, starting with this call.
|
|
759
|
+
if (run.fragment?.state === "due") {
|
|
760
|
+
await this.openFragment(session, run, reasoning, toolName, modelArgs);
|
|
638
761
|
}
|
|
639
762
|
}
|
|
640
763
|
// The arguments that will actually run — which is what must be correlated and
|
|
@@ -654,6 +777,11 @@ export class ControlServer {
|
|
|
654
777
|
createdAt: Date.now(),
|
|
655
778
|
settled: false,
|
|
656
779
|
fingerprint: fingerprint(mcp.serverName, mcp.toolName, args),
|
|
780
|
+
// Captured here because this is the last moment the code knows: by the
|
|
781
|
+
// time `settle` records the step, `postTool` has consumed the pin
|
|
782
|
+
// (segmented.md R-INTENT-7).
|
|
783
|
+
pinnedBy: pinned ? (run.replay?.scenarioId ?? undefined) : undefined,
|
|
784
|
+
intent: thinkingIntent,
|
|
657
785
|
};
|
|
658
786
|
run.correlations.set(correlation.callId, correlation);
|
|
659
787
|
logDetail("tool.pre.correlate", {
|
|
@@ -699,16 +827,26 @@ export class ControlServer {
|
|
|
699
827
|
responseIndex: pair.response,
|
|
700
828
|
toolName,
|
|
701
829
|
});
|
|
830
|
+
// A step a plan executed is pinned and never embedded (R-INTENT-7), so the
|
|
831
|
+
// system's own replays never look like the work recurring. Two paths reach
|
|
832
|
+
// here: a built-in step the plan pinned, and the direct plan's own vehicle
|
|
833
|
+
// `mcp__bir__run_scenario`, which is recorded as a built-in while its plan
|
|
834
|
+
// is live and is just as much the system's own doing.
|
|
835
|
+
const pinnedBy = pinned || (this.replay.isDirectTool(toolName) && run.replay?.plan && !run.replay.retired)
|
|
836
|
+
? (run.replay?.scenarioId ?? undefined)
|
|
837
|
+
: undefined;
|
|
702
838
|
if (run.recording) {
|
|
703
839
|
this.queue.push(() => {
|
|
704
840
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
705
841
|
toolName,
|
|
706
842
|
toolInput: serializeCapped(redact(args)),
|
|
707
843
|
context: context || undefined,
|
|
844
|
+
intent: thinkingIntent,
|
|
845
|
+
metadata: pinnedBy ? { pinnedBy } : undefined,
|
|
708
846
|
});
|
|
709
847
|
});
|
|
710
848
|
}
|
|
711
|
-
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
|
|
849
|
+
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
|
|
712
850
|
if (pinned) {
|
|
713
851
|
return {
|
|
714
852
|
stepIndex: pair.selected,
|
|
@@ -721,6 +859,365 @@ export class ControlServer {
|
|
|
721
859
|
}
|
|
722
860
|
return { stepIndex: pair.selected };
|
|
723
861
|
}
|
|
862
|
+
/** A controller decision as a `PreToolUse` answer, or undefined to carry on. */
|
|
863
|
+
preToolAnswer(action) {
|
|
864
|
+
switch (action.kind) {
|
|
865
|
+
case "bash":
|
|
866
|
+
return {
|
|
867
|
+
hookSpecificOutput: {
|
|
868
|
+
hookEventName: "PreToolUse",
|
|
869
|
+
permissionDecision: "allow",
|
|
870
|
+
updatedInput: { command: action.command },
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
case "deny":
|
|
874
|
+
return {
|
|
875
|
+
hookSpecificOutput: {
|
|
876
|
+
hookEventName: "PreToolUse",
|
|
877
|
+
permissionDecision: "deny",
|
|
878
|
+
permissionDecisionReason: action.reason,
|
|
879
|
+
},
|
|
880
|
+
};
|
|
881
|
+
default:
|
|
882
|
+
return undefined;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Recordings this turn must not count a step hit against (segmented.md
|
|
887
|
+
* R-HIT-7).
|
|
888
|
+
*
|
|
889
|
+
* A repeat of a recording's own prompt is *prompt* recurrence, counted as
|
|
890
|
+
* `iterations`, never step recurrence. Without this, three prompt repeats
|
|
891
|
+
* would reach `SEGMENT_MIN_HITS` on every step of the whole recording and the
|
|
892
|
+
* detector would build a whole-run-sized segment beside the whole-run
|
|
893
|
+
* scenario.
|
|
894
|
+
*/
|
|
895
|
+
excludeRunsFor(run) {
|
|
896
|
+
const out = new Set();
|
|
897
|
+
if (run.recording)
|
|
898
|
+
out.add(run.runId);
|
|
899
|
+
const prompt = run.replays.find((s) => s.armedBy === "prompt")?.matchedRunId;
|
|
900
|
+
if (prompt)
|
|
901
|
+
out.add(prompt);
|
|
902
|
+
if (run.fragment?.hitRunId)
|
|
903
|
+
out.add(run.fragment.hitRunId);
|
|
904
|
+
return [...out];
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Per recording a plan of this turn came from, how far that plan got
|
|
908
|
+
* (segmented.md R-OUT-6).
|
|
909
|
+
*
|
|
910
|
+
* The server uses it to refuse a segment that starts *before* where this turn
|
|
911
|
+
* already is: re-running steps the turn has just done is worse than not
|
|
912
|
+
* replaying at all. The largest position per recording wins, and a plan
|
|
913
|
+
* declined for anything but a known-bad first step reports nothing — it ran
|
|
914
|
+
* none of the recording, so it blocks none of it.
|
|
915
|
+
*
|
|
916
|
+
* Rebuilt on every probe rather than cached: a plan that hands over between
|
|
917
|
+
* two calls changes the answer.
|
|
918
|
+
*/
|
|
919
|
+
ranThroughFor(run) {
|
|
920
|
+
const best = new Map();
|
|
921
|
+
for (const state of run.replays) {
|
|
922
|
+
if (!state.scenarioId)
|
|
923
|
+
continue;
|
|
924
|
+
const recording = state.matchedRunId;
|
|
925
|
+
if (!recording)
|
|
926
|
+
continue;
|
|
927
|
+
let position;
|
|
928
|
+
if (!state.plan) {
|
|
929
|
+
// Only a known-bad first step means the turn is committed to doing this
|
|
930
|
+
// recording's work itself from position 0 (R-HIT-14); every other
|
|
931
|
+
// decline leaves the recording untouched.
|
|
932
|
+
if (state.declined === "known_bad_first_step")
|
|
933
|
+
position = 0;
|
|
934
|
+
}
|
|
935
|
+
else if (state.handover) {
|
|
936
|
+
position = state.handover.stepIndex;
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
// Ran to its end: the last position it planned.
|
|
940
|
+
position = Math.max(0, state.plan.stepCount - 1);
|
|
941
|
+
}
|
|
942
|
+
if (position === undefined)
|
|
943
|
+
continue;
|
|
944
|
+
const seen = best.get(recording);
|
|
945
|
+
if (!seen || position > seen.position) {
|
|
946
|
+
best.set(recording, { position, scenarioId: state.scenarioId });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return [...best].map(([runId, v]) => ({
|
|
950
|
+
runId,
|
|
951
|
+
position: v.position,
|
|
952
|
+
scenarioId: v.scenarioId,
|
|
953
|
+
}));
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Intent matching in the ReAct loop (fallbk.md §Runner 4, segmented.md 10.3).
|
|
957
|
+
*
|
|
958
|
+
* Called only while the model is driving. Every unsteered, non-housekeeping
|
|
959
|
+
* tool call is sent: the service counts it as a hit on whatever recorded step
|
|
960
|
+
* it matches, and may hand back a scenario or a segment to run in its place.
|
|
961
|
+
*
|
|
962
|
+
* A call made with **no reasoning at all** still probes (R-HIT-4): the server
|
|
963
|
+
* builds an intent from the tool name and arguments, and a step the agent took
|
|
964
|
+
* without narrating it is exactly as much a recurrence as one it explained.
|
|
965
|
+
* Every failure is a miss.
|
|
966
|
+
*/
|
|
967
|
+
async tryIntentMatch(session, run, toolName, intent, modelArgs) {
|
|
968
|
+
const opts = this.replay.intentMatch;
|
|
969
|
+
if (!this.replay.enabled || !opts.enabled)
|
|
970
|
+
return undefined;
|
|
971
|
+
if (!isIntentMatcher(this.recorder))
|
|
972
|
+
return undefined;
|
|
973
|
+
if (this.replay.isDirectTool(toolName))
|
|
974
|
+
return undefined;
|
|
975
|
+
const text = (intent?.text ?? "").trim().slice(0, 4_000);
|
|
976
|
+
const source = intent?.source ?? "tool";
|
|
977
|
+
// Valid JSON with the serializer's own truncation marker, never a raw slice:
|
|
978
|
+
// the same string feeds the dedupe hash, the server's live question and the
|
|
979
|
+
// live call handed to derivation (R-HIT-4).
|
|
980
|
+
const toolInput = serializeCapped(redact(modelArgs), {
|
|
981
|
+
maxPayloadBytes: 2048,
|
|
982
|
+
maxStringBytes: 256,
|
|
983
|
+
});
|
|
984
|
+
// A retried identical call is not sent again; two different calls under one
|
|
985
|
+
// reasoning line both are (R-HIT-5).
|
|
986
|
+
const probeHash = createHash("sha1")
|
|
987
|
+
.update([text, toolName, toolInput].join(PROBE_SEP))
|
|
988
|
+
.digest("hex");
|
|
989
|
+
if (probeHash === run.intent.lastProbeHash)
|
|
990
|
+
return undefined;
|
|
991
|
+
run.intent.lastProbeHash = probeHash;
|
|
992
|
+
if (run.intent.requests >= opts.maxRequestsPerTurn)
|
|
993
|
+
return undefined;
|
|
994
|
+
run.intent.requests += 1;
|
|
995
|
+
// Every scenario armed this turn, so a continuation cannot re-arm the plan
|
|
996
|
+
// that just handed over.
|
|
997
|
+
const exclude = run.replays
|
|
998
|
+
.map((s) => s.scenarioId)
|
|
999
|
+
.filter((id) => typeof id === "string");
|
|
1000
|
+
const startedAt = Date.now();
|
|
1001
|
+
const answer = await this.withinBudget(this.recorder.matchIntent({
|
|
1002
|
+
text,
|
|
1003
|
+
source,
|
|
1004
|
+
// The same value twice: `currentToolName` is what today's server reads,
|
|
1005
|
+
// `toolName` is what the new one reads beside `toolInput`.
|
|
1006
|
+
currentToolName: toolName,
|
|
1007
|
+
toolName,
|
|
1008
|
+
toolInput,
|
|
1009
|
+
exclude,
|
|
1010
|
+
turnId: run.turnId,
|
|
1011
|
+
stepPosition: run.ordering.next,
|
|
1012
|
+
excludeRuns: this.excludeRunsFor(run),
|
|
1013
|
+
ranThrough: this.ranThroughFor(run),
|
|
1014
|
+
...(this.replay.segmentArm ? { acceptSegments: true } : {}),
|
|
1015
|
+
supportsCalls: true,
|
|
1016
|
+
}), opts.budgetMs);
|
|
1017
|
+
if (!answer) {
|
|
1018
|
+
logDetail("replay.intent_miss", { run: run.runId, tool: toolName });
|
|
1019
|
+
return undefined;
|
|
1020
|
+
}
|
|
1021
|
+
if (answer.stepHit) {
|
|
1022
|
+
run.intent.lastStepHit = { ...answer.stepHit, at: Date.now() };
|
|
1023
|
+
}
|
|
1024
|
+
// What *would* have armed, had segments been armed (segmented.md R-OUT-10).
|
|
1025
|
+
// Logged at line level, not detail: this is the whole observe-only period,
|
|
1026
|
+
// and the decision to turn arming on is made by reading these. The turn
|
|
1027
|
+
// then carries on with whatever else the answer holds (R-OUT-15).
|
|
1028
|
+
if (answer.segmentWouldArm) {
|
|
1029
|
+
const w = answer.segmentWouldArm;
|
|
1030
|
+
logLine("replay.segment_would_arm", {
|
|
1031
|
+
scenario: w.scenarioId,
|
|
1032
|
+
run: w.runId,
|
|
1033
|
+
key: w.key,
|
|
1034
|
+
similarity: w.similarity.toFixed(3),
|
|
1035
|
+
band: w.band,
|
|
1036
|
+
verified: w.verified
|
|
1037
|
+
? w.verified.verdict === "skipped"
|
|
1038
|
+
? `skipped:${w.verified.reason}`
|
|
1039
|
+
: `${w.verified.verdict}: ${w.verified.reason}`
|
|
1040
|
+
: undefined,
|
|
1041
|
+
tool: w.tool ?? toolName,
|
|
1042
|
+
firstTool: w.firstTool,
|
|
1043
|
+
stepFrom: w.stepFrom,
|
|
1044
|
+
stepTo: w.stepTo,
|
|
1045
|
+
hitCount: w.hitCount,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
const match = answer.matched;
|
|
1049
|
+
logDetail("replay.intent_probe", {
|
|
1050
|
+
run: run.runId,
|
|
1051
|
+
tool: toolName,
|
|
1052
|
+
source,
|
|
1053
|
+
stepHit: answer.stepHit ? `${answer.stepHit.runId}:${answer.stepHit.stepIndex}` : "none",
|
|
1054
|
+
bestStepSimilarity: answer.bestStepSimilarity,
|
|
1055
|
+
elapsedMs: Date.now() - startedAt,
|
|
1056
|
+
matched: match?.scenarioId ?? "none",
|
|
1057
|
+
kind: match?.kind,
|
|
1058
|
+
});
|
|
1059
|
+
if (!match)
|
|
1060
|
+
return undefined;
|
|
1061
|
+
if (match.scenarioId && exclude.includes(match.scenarioId))
|
|
1062
|
+
return undefined;
|
|
1063
|
+
// The arms cap is checked *after* the response, not before it (R-HIT-5):
|
|
1064
|
+
// hit counting must not stop just because the turn has already armed three
|
|
1065
|
+
// plans. The ticket the server issued goes unredeemed and is swept
|
|
1066
|
+
// (R-OUT-12).
|
|
1067
|
+
if (run.intent.armed >= opts.maxPerTurn) {
|
|
1068
|
+
logLine("replay.intent_arm_capped", {
|
|
1069
|
+
scenario: match.scenarioId ?? undefined,
|
|
1070
|
+
kind: match.kind ?? "scenario",
|
|
1071
|
+
similarity: match.similarity.toFixed(3),
|
|
1072
|
+
tool: toolName,
|
|
1073
|
+
});
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const state = await this.replay.arm(match, text, this.wrapped, "intent", {
|
|
1077
|
+
liveCall: { toolName, toolInput },
|
|
1078
|
+
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
1079
|
+
});
|
|
1080
|
+
if (!state.plan) {
|
|
1081
|
+
// A declined intent hit reports nothing: the turn's cost is not a
|
|
1082
|
+
// measurement of that scenario's task, so it is no baseline sample either.
|
|
1083
|
+
state.reported = true;
|
|
1084
|
+
return undefined;
|
|
1085
|
+
}
|
|
1086
|
+
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
1087
|
+
// The closure, not the mark: only the control server holds the transcript
|
|
1088
|
+
// path, and a completing retire happens in the controller (R-MONEY-3).
|
|
1089
|
+
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
1090
|
+
// A segment of a recording whose whole-run plan already armed this turn
|
|
1091
|
+
// takes its share off that plan's baseline (R-MONEY-4). One recording's
|
|
1092
|
+
// steps are never counted in two baselines of one turn.
|
|
1093
|
+
if (state.kind === "segment" && state.segment && state.scenarioId) {
|
|
1094
|
+
for (const earlier of run.replays) {
|
|
1095
|
+
if (earlier === state || !earlier.plan)
|
|
1096
|
+
continue;
|
|
1097
|
+
if (earlier.kind !== "scenario")
|
|
1098
|
+
continue;
|
|
1099
|
+
if (earlier.matchedRunId !== state.segment.runId)
|
|
1100
|
+
continue;
|
|
1101
|
+
earlier.sharedWith.push(state.scenarioId);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
run.replays.push(state);
|
|
1105
|
+
run.replay = state;
|
|
1106
|
+
run.intent.armed += 1;
|
|
1107
|
+
const directive = this.replay.directiveFor(state) ?? "";
|
|
1108
|
+
logLine("replay.intent_armed", {
|
|
1109
|
+
run: run.runId,
|
|
1110
|
+
scenario: state.scenarioId ?? undefined,
|
|
1111
|
+
similarity: match.similarity.toFixed(3),
|
|
1112
|
+
mode: state.mode,
|
|
1113
|
+
instead: toolName,
|
|
1114
|
+
kind: match.kind ?? "scenario",
|
|
1115
|
+
key: match.key,
|
|
1116
|
+
verified: match.verified?.verdict,
|
|
1117
|
+
why: "the model's reasoning matched a calculated scenario's intent",
|
|
1118
|
+
});
|
|
1119
|
+
return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Schedule a fragment once a matched turn's plan has handed over (fallbk.md
|
|
1123
|
+
* D7). Only a turn that is not already recording needs one: an unmatched turn
|
|
1124
|
+
* whose intent-armed plan handed over is recording the model's work anyway.
|
|
1125
|
+
*/
|
|
1126
|
+
noteHandover(run) {
|
|
1127
|
+
if (run.fragment || run.recording || run.finished)
|
|
1128
|
+
return;
|
|
1129
|
+
const state = run.replay;
|
|
1130
|
+
const h = state?.handover;
|
|
1131
|
+
if (!h || !state?.scenarioId)
|
|
1132
|
+
return;
|
|
1133
|
+
run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Open the fragment run and record from this call on (fallbk.md D7).
|
|
1137
|
+
*
|
|
1138
|
+
* The run id is swapped in place: steps allocated before belonged to a turn
|
|
1139
|
+
* the service never created a run for, and nothing of theirs may land in the
|
|
1140
|
+
* fragment — so pending correlations are latched and built-ins forgotten.
|
|
1141
|
+
*/
|
|
1142
|
+
async openFragment(session, run, reasoning, toolName, args) {
|
|
1143
|
+
const f = run.fragment;
|
|
1144
|
+
if (!f || f.state !== "due")
|
|
1145
|
+
return;
|
|
1146
|
+
const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
|
|
1147
|
+
const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
1148
|
+
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, { fallbackOf });
|
|
1149
|
+
for (const c of run.correlations.values()) {
|
|
1150
|
+
c.settled = true;
|
|
1151
|
+
if (c.fallback)
|
|
1152
|
+
clearTimeout(c.fallback);
|
|
1153
|
+
}
|
|
1154
|
+
run.builtIns.clear();
|
|
1155
|
+
run.runId = id;
|
|
1156
|
+
run.ordering = new StepIndexAllocator();
|
|
1157
|
+
run.recording = true;
|
|
1158
|
+
f.state = "open";
|
|
1159
|
+
f.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
1160
|
+
f.startedAtMs = Date.now();
|
|
1161
|
+
logLine("run.fragment", {
|
|
1162
|
+
run: id,
|
|
1163
|
+
scenario: f.scenarioId,
|
|
1164
|
+
step: f.stepIndex,
|
|
1165
|
+
why: "recording the agent's own work after the scenario handed over",
|
|
1166
|
+
});
|
|
1167
|
+
if (isRunCreationAware(this.recorder)) {
|
|
1168
|
+
const created = await this.withinBudget(this.recorder.runCreated(id), this.replay.budgets.matchMs);
|
|
1169
|
+
if (created && !created.created) {
|
|
1170
|
+
run.recording = false;
|
|
1171
|
+
f.state = "hit";
|
|
1172
|
+
// Which run it hit matters from here on: this turn must not count step
|
|
1173
|
+
// hits against the fragment it is a repeat of (segmented.md R-HIT-7).
|
|
1174
|
+
f.hitRunId = created.fragmentOf?.runId;
|
|
1175
|
+
logLine("run.fragment_hit", {
|
|
1176
|
+
run: id,
|
|
1177
|
+
scenario: f.scenarioId,
|
|
1178
|
+
step: f.stepIndex,
|
|
1179
|
+
of: created.fragmentOf?.runId,
|
|
1180
|
+
why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/** Resolve with `p`, or null once `ms` passes or it rejects. */
|
|
1186
|
+
async withinBudget(p, ms) {
|
|
1187
|
+
let timer;
|
|
1188
|
+
const budget = new Promise((resolve) => {
|
|
1189
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
1190
|
+
timer.unref?.();
|
|
1191
|
+
});
|
|
1192
|
+
try {
|
|
1193
|
+
return await Promise.race([p, budget]);
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
finally {
|
|
1199
|
+
if (timer)
|
|
1200
|
+
clearTimeout(timer);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* `PostToolUse`, and where a hand-over noticed while threading this call's
|
|
1205
|
+
* output reaches the model at once, as `additionalContext` (fallbk.md D4).
|
|
1206
|
+
*/
|
|
1207
|
+
onToolPost(payload) {
|
|
1208
|
+
const out = this.toolPost(payload);
|
|
1209
|
+
const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
|
|
1210
|
+
if (!run || run.finished)
|
|
1211
|
+
return out;
|
|
1212
|
+
this.noteHandover(run);
|
|
1213
|
+
const note = this.replay.takeNote(run.replay);
|
|
1214
|
+
if (!note)
|
|
1215
|
+
return out;
|
|
1216
|
+
return {
|
|
1217
|
+
...out,
|
|
1218
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: note },
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
724
1221
|
/**
|
|
725
1222
|
* `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
|
|
726
1223
|
* For a correlated MCP call it normally does nothing — the proxy owns that
|
|
@@ -728,7 +1225,7 @@ export class ControlServer {
|
|
|
728
1225
|
* or the call never reached it), the hook's own view is recorded after
|
|
729
1226
|
* {@link PROXY_REPORT_GRACE_MS} rather than the step being lost entirely.
|
|
730
1227
|
*/
|
|
731
|
-
|
|
1228
|
+
toolPost(payload) {
|
|
732
1229
|
const session = this.ensureSession(payload);
|
|
733
1230
|
const run = this.ensureRun(session);
|
|
734
1231
|
const toolUseId = payload.tool_use_id ?? "";
|
|
@@ -1004,6 +1501,7 @@ export class ControlServer {
|
|
|
1004
1501
|
correlation.threadFallback = undefined;
|
|
1005
1502
|
}
|
|
1006
1503
|
this.replay.postTool(run.replay, toolUseId, serializeCapped(redact(report.result)));
|
|
1504
|
+
this.noteHandover(run);
|
|
1007
1505
|
}
|
|
1008
1506
|
if (correlation.settled) {
|
|
1009
1507
|
logDetail("proxy.step.deduped", { run: run.runId, tool: report.qualifiedName });
|
|
@@ -1088,6 +1586,10 @@ export class ControlServer {
|
|
|
1088
1586
|
isError: data.isError,
|
|
1089
1587
|
errorMessage: data.errorMessage,
|
|
1090
1588
|
context: correlation.context,
|
|
1589
|
+
// Carried from where the correlation was created: by now the pin is gone
|
|
1590
|
+
// from the map (segmented.md R-INTENT-7).
|
|
1591
|
+
pinnedBy: correlation.pinnedBy,
|
|
1592
|
+
intent: correlation.intent,
|
|
1091
1593
|
});
|
|
1092
1594
|
logDetail("proxy.step.merged", {
|
|
1093
1595
|
run: run.runId,
|
|
@@ -1111,6 +1613,8 @@ export class ControlServer {
|
|
|
1111
1613
|
toolName: d.toolName,
|
|
1112
1614
|
toolInput,
|
|
1113
1615
|
context: d.context,
|
|
1616
|
+
intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
|
|
1617
|
+
metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } : undefined,
|
|
1114
1618
|
});
|
|
1115
1619
|
this.recorder.recordToolResponse(run.runId, pair.response, {
|
|
1116
1620
|
toolName: d.toolName,
|