@basein/runner 0.2.0 → 0.2.1
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 +2 -0
- package/dist/auth/client.d.ts +12 -0
- package/dist/auth/client.js +41 -0
- package/dist/bin/bir-hooks.d.ts +13 -0
- package/dist/bin/bir-hooks.js +58 -0
- package/dist/bin/bir.js +42 -2
- package/dist/control/server.d.ts +84 -1
- package/dist/control/server.js +546 -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 +164 -5
- package/dist/replay/controller.js +556 -54
- package/dist/replay/coverage.js +2 -2
- package/dist/replay/derive.d.ts +20 -1
- package/dist/replay/derive.js +69 -12
- 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/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,
|
|
@@ -326,6 +370,9 @@ export class ControlServer {
|
|
|
326
370
|
promptSeen: input.length > 0,
|
|
327
371
|
builtIns: new Map(),
|
|
328
372
|
correlations: new Map(),
|
|
373
|
+
replays: [],
|
|
374
|
+
turnId: runId,
|
|
375
|
+
intent: { armed: 0, requests: 0 },
|
|
329
376
|
// Taken here, beside `startedAtMs`, so the cost window and the duration
|
|
330
377
|
// window are the same window (docs/calculatedReplay.md §11.4).
|
|
331
378
|
usageMark: markTranscriptUsage(session.transcriptPath),
|
|
@@ -334,7 +381,7 @@ export class ControlServer {
|
|
|
334
381
|
logLine("run.start", { run: runId, sess: session.sessionId, tier: "bound" });
|
|
335
382
|
// Started here so the round trip overlaps whatever the caller does next;
|
|
336
383
|
// `onPrompt` awaits the same memoized promise under its own budget.
|
|
337
|
-
void this.watchForMatch(run);
|
|
384
|
+
void this.watchForMatch(session, run);
|
|
338
385
|
return run;
|
|
339
386
|
}
|
|
340
387
|
runMetadata(session) {
|
|
@@ -362,7 +409,7 @@ export class ControlServer {
|
|
|
362
409
|
*
|
|
363
410
|
* Resolves with the steering directive to inject, or undefined.
|
|
364
411
|
*/
|
|
365
|
-
watchForMatch(run, prompt = "") {
|
|
412
|
+
watchForMatch(session, run, prompt = "") {
|
|
366
413
|
if (run.matchWatch)
|
|
367
414
|
return run.matchWatch;
|
|
368
415
|
if (!isMatchAware(this.recorder))
|
|
@@ -370,7 +417,7 @@ export class ControlServer {
|
|
|
370
417
|
const pending = this.recorder.getMatch(run.runId);
|
|
371
418
|
run.matchWatch = this.replay
|
|
372
419
|
.awaitMatch(pending)
|
|
373
|
-
.then((match) => {
|
|
420
|
+
.then(async (match) => {
|
|
374
421
|
if (!match)
|
|
375
422
|
return undefined;
|
|
376
423
|
run.recording = false;
|
|
@@ -381,8 +428,15 @@ export class ControlServer {
|
|
|
381
428
|
similarity: match.similarity,
|
|
382
429
|
why: "similar prompt — the service kept its own run; not recording this one",
|
|
383
430
|
});
|
|
384
|
-
|
|
385
|
-
|
|
431
|
+
// Awaited: a scenario with a target derives before the directive goes
|
|
432
|
+
// out, so a plan that cannot find its target is never delivered
|
|
433
|
+
// (segmented.md R-PARAM-4). Inside `UserPromptSubmit`'s 15 s.
|
|
434
|
+
const state = await this.replay.arm(match, prompt || run.input, this.wrapped, "prompt", {
|
|
435
|
+
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
436
|
+
});
|
|
437
|
+
run.replay = state;
|
|
438
|
+
run.replays.push(state);
|
|
439
|
+
return this.replay.directiveFor(state);
|
|
386
440
|
})
|
|
387
441
|
.catch((err) => {
|
|
388
442
|
// Replay is an optimisation; a broken one must never cost a turn.
|
|
@@ -423,7 +477,12 @@ export class ControlServer {
|
|
|
423
477
|
// A matched turn records nothing, but it still owes the ledger a number —
|
|
424
478
|
// including when it declined to steer, which is a *baseline* sample and is
|
|
425
479
|
// what every saving is measured against (docs/calculatedReplay.md §11).
|
|
426
|
-
this.reportExecution(run, durationMs, cost);
|
|
480
|
+
this.reportExecution(session, run, durationMs, cost);
|
|
481
|
+
// A fragment is recorded from the hand-over on (fallbk.md D7), so its cost
|
|
482
|
+
// and duration are the model's own work, not the steps the scenario did.
|
|
483
|
+
const fragment = run.fragment?.state === "open" ? run.fragment : undefined;
|
|
484
|
+
const recordedCost = fragment ? this.costSince(session, fragment.usageMark) : cost;
|
|
485
|
+
const recordedMs = fragment?.startedAtMs ? Date.now() - fragment.startedAtMs : durationMs;
|
|
427
486
|
if (run.recording) {
|
|
428
487
|
const transcriptPath = session.transcriptPath;
|
|
429
488
|
// Resolve the answer *outside* the queue: the wait below is up to two
|
|
@@ -442,8 +501,8 @@ export class ControlServer {
|
|
|
442
501
|
// scenario that has no samples yet. A run recorded at $0 makes
|
|
443
502
|
// every later saving measured against nothing.
|
|
444
503
|
this.recorder.finishRun(run.runId, answer || undefined, {
|
|
445
|
-
durationMs,
|
|
446
|
-
costUsd:
|
|
504
|
+
durationMs: recordedMs,
|
|
505
|
+
costUsd: recordedCost.measured ? recordedCost.usd : undefined,
|
|
447
506
|
});
|
|
448
507
|
});
|
|
449
508
|
}));
|
|
@@ -486,24 +545,66 @@ export class ControlServer {
|
|
|
486
545
|
});
|
|
487
546
|
return { usd: 0, measured: false };
|
|
488
547
|
}
|
|
489
|
-
|
|
548
|
+
return this.costSince(session, run.usageMark);
|
|
549
|
+
}
|
|
550
|
+
/** Transcript cost since `mark`; unmeasured (and zero) without one. */
|
|
551
|
+
costSince(session, mark) {
|
|
552
|
+
if (mark === undefined)
|
|
553
|
+
return { usd: 0, measured: false };
|
|
554
|
+
const deltas = usageSince(session.transcriptPath, mark);
|
|
490
555
|
const usd = deltas.reduce((sum, u) => sum + calculateCostUsd(u.model, u), 0);
|
|
491
556
|
return { usd, measured: deltas.length > 0 };
|
|
492
557
|
}
|
|
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)
|
|
558
|
+
reportExecution(session, run, durationMs, cost) {
|
|
559
|
+
if (!isScenarioReporter(this.recorder))
|
|
504
560
|
return;
|
|
561
|
+
const states = run.replays.length > 0 ? run.replays : run.replay ? [run.replay] : [];
|
|
505
562
|
const recorder = this.recorder;
|
|
506
|
-
this.
|
|
563
|
+
// One report per plan armed this turn (fallbk.md §Runner 4). Each is billed
|
|
564
|
+
// for its own window of the transcript — from when it armed to when the next
|
|
565
|
+
// one did — so the turn's tokens are split between them, never counted twice.
|
|
566
|
+
// A turn with a single prompt-armed plan is exactly the report it always was.
|
|
567
|
+
states.forEach((state, i) => {
|
|
568
|
+
const next = states[i + 1];
|
|
569
|
+
const nextMark = next?.armedBy === "intent" ? next.usageMark : undefined;
|
|
570
|
+
let windowCost = cost;
|
|
571
|
+
let windowMs = durationMs;
|
|
572
|
+
// An intent-armed plan's window *ends* where its work ended (segmented.md
|
|
573
|
+
// R-MONEY-3): at its own retire mark when it completed, else at the next
|
|
574
|
+
// plan's arm, else at the end of the turn. Without the first, a segment
|
|
575
|
+
// that finished in three steps would be billed for everything the agent
|
|
576
|
+
// did afterwards, and its saving would read as a loss.
|
|
577
|
+
let end = nextMark;
|
|
578
|
+
if (state.armedBy === "intent") {
|
|
579
|
+
windowCost = this.costSince(session, state.usageMark);
|
|
580
|
+
windowMs = (state.retiredAt ?? next?.armedAt ?? Date.now()) - state.armedAt;
|
|
581
|
+
end = state.retiredMark ?? nextMark;
|
|
582
|
+
}
|
|
583
|
+
else if (next) {
|
|
584
|
+
windowMs = next.armedAt - run.startedAtMs;
|
|
585
|
+
}
|
|
586
|
+
if (end !== undefined) {
|
|
587
|
+
const later = this.costSince(session, end);
|
|
588
|
+
windowCost = { usd: Math.max(0, windowCost.usd - later.usd), measured: windowCost.measured };
|
|
589
|
+
}
|
|
590
|
+
const report = this.replay.buildReport(state, {
|
|
591
|
+
sessionCostUsd: windowCost.usd,
|
|
592
|
+
measured: windowCost.measured,
|
|
593
|
+
durationMs: Math.max(0, windowMs),
|
|
594
|
+
prompt: run.input || undefined,
|
|
595
|
+
siblings: states,
|
|
596
|
+
});
|
|
597
|
+
if (!report)
|
|
598
|
+
return;
|
|
599
|
+
if (state.armedBy === "intent") {
|
|
600
|
+
logDetail("replay.done", {
|
|
601
|
+
scenario: state.scenarioId ?? undefined,
|
|
602
|
+
windowMs: Math.max(0, windowMs),
|
|
603
|
+
billedTo: state.retiredMark !== undefined ? "its own retire" : "the next arm or the turn",
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
this.queue.push(() => recorder.reportExecution(report));
|
|
607
|
+
});
|
|
507
608
|
}
|
|
508
609
|
/** Seal the run *and* wait for everything queued to reach the service. */
|
|
509
610
|
async finalizeRun(session) {
|
|
@@ -569,7 +670,7 @@ export class ControlServer {
|
|
|
569
670
|
// a slow or unreachable service costs the user nothing but an ordinary turn
|
|
570
671
|
// (docs/calculatedReplay.md §10); `additionalContext` carries no `decision`,
|
|
571
672
|
// so the prompt still reaches the model either way.
|
|
572
|
-
const directive = await this.watchForMatch(run, input);
|
|
673
|
+
const directive = await this.watchForMatch(session, run, input);
|
|
573
674
|
if (!directive)
|
|
574
675
|
return {};
|
|
575
676
|
return {
|
|
@@ -605,36 +706,49 @@ export class ControlServer {
|
|
|
605
706
|
return {};
|
|
606
707
|
}
|
|
607
708
|
const agentId = payload.agent_id ?? session.agentId;
|
|
608
|
-
|
|
709
|
+
// The real intent, read from every line sharing this message's id
|
|
710
|
+
// (segmented.md R-INTENT-3). `context` stays the *written* text only, with
|
|
711
|
+
// its agent prefix: thinking is embedded but never stored as text
|
|
712
|
+
// (R-INTENT-2), so it rides separately in `intent`.
|
|
713
|
+
const intent = intentForToolUse(session.transcriptPath, toolUseId, this.intentSources);
|
|
714
|
+
const reasoning = intent?.source === "text" ? intent.text : "";
|
|
715
|
+
// The prefix rides on whether there is a subagent, not on whether it said
|
|
716
|
+
// anything: a subagent's steps must be tellable apart even when it narrated
|
|
717
|
+
// nothing. What it never carries is thinking (R-INTENT-2).
|
|
609
718
|
const context = agentId ? `[agent ${agentId}] ${reasoning}`.trim() : reasoning;
|
|
719
|
+
const thinkingIntent = intent?.source === "thinking" ? { text: intent.text, source: "thinking" } : undefined;
|
|
610
720
|
// ── replay steering (docs/calculatedReplay.md §7) ────────────────────────
|
|
611
721
|
// Runs before anything else, because two of its four answers end the call.
|
|
612
722
|
let pinned;
|
|
613
723
|
if (run.replay?.plan && !run.replay.retired) {
|
|
614
724
|
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
|
-
|
|
725
|
+
// A hand-over inside `preTool` (an input logic that threw) must still
|
|
726
|
+
// schedule the fragment, even though this call ends here.
|
|
727
|
+
this.noteHandover(run);
|
|
728
|
+
if (action.kind === "pin")
|
|
729
|
+
pinned = action.input;
|
|
730
|
+
const answer = this.preToolAnswer(action);
|
|
731
|
+
if (answer)
|
|
732
|
+
return answer;
|
|
733
|
+
}
|
|
734
|
+
if (!pinned && (!run.replay?.plan || run.replay.retired)) {
|
|
735
|
+
// ── falling back to the model (fallbk.md) ──────────────────────────────
|
|
736
|
+
// 1. A hand-over noticed where no hook answer could carry it — a proxy
|
|
737
|
+
// report threaded after its PostToolUse had already answered — reaches
|
|
738
|
+
// the model in place of this call. Never silence (D4).
|
|
739
|
+
const note = this.replay.takeNote(run.replay);
|
|
740
|
+
if (note) {
|
|
741
|
+
this.noteHandover(run);
|
|
742
|
+
return this.preToolAnswer(this.replay.deliverInstead(note, toolName));
|
|
743
|
+
}
|
|
744
|
+
// 2. The model is driving: look for a scenario by this iteration's intent.
|
|
745
|
+
const armed = await this.tryIntentMatch(session, run, toolName, intent, modelArgs);
|
|
746
|
+
if (armed)
|
|
747
|
+
return armed;
|
|
748
|
+
// 3. No scenario took over: the model's own work after a hand-over is
|
|
749
|
+
// recorded as a fragment, starting with this call.
|
|
750
|
+
if (run.fragment?.state === "due") {
|
|
751
|
+
await this.openFragment(session, run, reasoning, toolName, modelArgs);
|
|
638
752
|
}
|
|
639
753
|
}
|
|
640
754
|
// The arguments that will actually run — which is what must be correlated and
|
|
@@ -654,6 +768,11 @@ export class ControlServer {
|
|
|
654
768
|
createdAt: Date.now(),
|
|
655
769
|
settled: false,
|
|
656
770
|
fingerprint: fingerprint(mcp.serverName, mcp.toolName, args),
|
|
771
|
+
// Captured here because this is the last moment the code knows: by the
|
|
772
|
+
// time `settle` records the step, `postTool` has consumed the pin
|
|
773
|
+
// (segmented.md R-INTENT-7).
|
|
774
|
+
pinnedBy: pinned ? (run.replay?.scenarioId ?? undefined) : undefined,
|
|
775
|
+
intent: thinkingIntent,
|
|
657
776
|
};
|
|
658
777
|
run.correlations.set(correlation.callId, correlation);
|
|
659
778
|
logDetail("tool.pre.correlate", {
|
|
@@ -699,16 +818,26 @@ export class ControlServer {
|
|
|
699
818
|
responseIndex: pair.response,
|
|
700
819
|
toolName,
|
|
701
820
|
});
|
|
821
|
+
// A step a plan executed is pinned and never embedded (R-INTENT-7), so the
|
|
822
|
+
// system's own replays never look like the work recurring. Two paths reach
|
|
823
|
+
// here: a built-in step the plan pinned, and the direct plan's own vehicle
|
|
824
|
+
// `mcp__bir__run_scenario`, which is recorded as a built-in while its plan
|
|
825
|
+
// is live and is just as much the system's own doing.
|
|
826
|
+
const pinnedBy = pinned || (this.replay.isDirectTool(toolName) && run.replay?.plan && !run.replay.retired)
|
|
827
|
+
? (run.replay?.scenarioId ?? undefined)
|
|
828
|
+
: undefined;
|
|
702
829
|
if (run.recording) {
|
|
703
830
|
this.queue.push(() => {
|
|
704
831
|
this.recorder.recordToolSelected(run.runId, pair.selected, {
|
|
705
832
|
toolName,
|
|
706
833
|
toolInput: serializeCapped(redact(args)),
|
|
707
834
|
context: context || undefined,
|
|
835
|
+
intent: thinkingIntent,
|
|
836
|
+
metadata: pinnedBy ? { pinnedBy } : undefined,
|
|
708
837
|
});
|
|
709
838
|
});
|
|
710
839
|
}
|
|
711
|
-
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected });
|
|
840
|
+
logDetail("tool.pre", { run: run.runId, tool: toolName, step: pair.selected, pinnedBy });
|
|
712
841
|
if (pinned) {
|
|
713
842
|
return {
|
|
714
843
|
stepIndex: pair.selected,
|
|
@@ -721,6 +850,365 @@ export class ControlServer {
|
|
|
721
850
|
}
|
|
722
851
|
return { stepIndex: pair.selected };
|
|
723
852
|
}
|
|
853
|
+
/** A controller decision as a `PreToolUse` answer, or undefined to carry on. */
|
|
854
|
+
preToolAnswer(action) {
|
|
855
|
+
switch (action.kind) {
|
|
856
|
+
case "bash":
|
|
857
|
+
return {
|
|
858
|
+
hookSpecificOutput: {
|
|
859
|
+
hookEventName: "PreToolUse",
|
|
860
|
+
permissionDecision: "allow",
|
|
861
|
+
updatedInput: { command: action.command },
|
|
862
|
+
},
|
|
863
|
+
};
|
|
864
|
+
case "deny":
|
|
865
|
+
return {
|
|
866
|
+
hookSpecificOutput: {
|
|
867
|
+
hookEventName: "PreToolUse",
|
|
868
|
+
permissionDecision: "deny",
|
|
869
|
+
permissionDecisionReason: action.reason,
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
default:
|
|
873
|
+
return undefined;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Recordings this turn must not count a step hit against (segmented.md
|
|
878
|
+
* R-HIT-7).
|
|
879
|
+
*
|
|
880
|
+
* A repeat of a recording's own prompt is *prompt* recurrence, counted as
|
|
881
|
+
* `iterations`, never step recurrence. Without this, three prompt repeats
|
|
882
|
+
* would reach `SEGMENT_MIN_HITS` on every step of the whole recording and the
|
|
883
|
+
* detector would build a whole-run-sized segment beside the whole-run
|
|
884
|
+
* scenario.
|
|
885
|
+
*/
|
|
886
|
+
excludeRunsFor(run) {
|
|
887
|
+
const out = new Set();
|
|
888
|
+
if (run.recording)
|
|
889
|
+
out.add(run.runId);
|
|
890
|
+
const prompt = run.replays.find((s) => s.armedBy === "prompt")?.matchedRunId;
|
|
891
|
+
if (prompt)
|
|
892
|
+
out.add(prompt);
|
|
893
|
+
if (run.fragment?.hitRunId)
|
|
894
|
+
out.add(run.fragment.hitRunId);
|
|
895
|
+
return [...out];
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Per recording a plan of this turn came from, how far that plan got
|
|
899
|
+
* (segmented.md R-OUT-6).
|
|
900
|
+
*
|
|
901
|
+
* The server uses it to refuse a segment that starts *before* where this turn
|
|
902
|
+
* already is: re-running steps the turn has just done is worse than not
|
|
903
|
+
* replaying at all. The largest position per recording wins, and a plan
|
|
904
|
+
* declined for anything but a known-bad first step reports nothing — it ran
|
|
905
|
+
* none of the recording, so it blocks none of it.
|
|
906
|
+
*
|
|
907
|
+
* Rebuilt on every probe rather than cached: a plan that hands over between
|
|
908
|
+
* two calls changes the answer.
|
|
909
|
+
*/
|
|
910
|
+
ranThroughFor(run) {
|
|
911
|
+
const best = new Map();
|
|
912
|
+
for (const state of run.replays) {
|
|
913
|
+
if (!state.scenarioId)
|
|
914
|
+
continue;
|
|
915
|
+
const recording = state.matchedRunId;
|
|
916
|
+
if (!recording)
|
|
917
|
+
continue;
|
|
918
|
+
let position;
|
|
919
|
+
if (!state.plan) {
|
|
920
|
+
// Only a known-bad first step means the turn is committed to doing this
|
|
921
|
+
// recording's work itself from position 0 (R-HIT-14); every other
|
|
922
|
+
// decline leaves the recording untouched.
|
|
923
|
+
if (state.declined === "known_bad_first_step")
|
|
924
|
+
position = 0;
|
|
925
|
+
}
|
|
926
|
+
else if (state.handover) {
|
|
927
|
+
position = state.handover.stepIndex;
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
// Ran to its end: the last position it planned.
|
|
931
|
+
position = Math.max(0, state.plan.stepCount - 1);
|
|
932
|
+
}
|
|
933
|
+
if (position === undefined)
|
|
934
|
+
continue;
|
|
935
|
+
const seen = best.get(recording);
|
|
936
|
+
if (!seen || position > seen.position) {
|
|
937
|
+
best.set(recording, { position, scenarioId: state.scenarioId });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return [...best].map(([runId, v]) => ({
|
|
941
|
+
runId,
|
|
942
|
+
position: v.position,
|
|
943
|
+
scenarioId: v.scenarioId,
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Intent matching in the ReAct loop (fallbk.md §Runner 4, segmented.md 10.3).
|
|
948
|
+
*
|
|
949
|
+
* Called only while the model is driving. Every unsteered, non-housekeeping
|
|
950
|
+
* tool call is sent: the service counts it as a hit on whatever recorded step
|
|
951
|
+
* it matches, and may hand back a scenario or a segment to run in its place.
|
|
952
|
+
*
|
|
953
|
+
* A call made with **no reasoning at all** still probes (R-HIT-4): the server
|
|
954
|
+
* builds an intent from the tool name and arguments, and a step the agent took
|
|
955
|
+
* without narrating it is exactly as much a recurrence as one it explained.
|
|
956
|
+
* Every failure is a miss.
|
|
957
|
+
*/
|
|
958
|
+
async tryIntentMatch(session, run, toolName, intent, modelArgs) {
|
|
959
|
+
const opts = this.replay.intentMatch;
|
|
960
|
+
if (!this.replay.enabled || !opts.enabled)
|
|
961
|
+
return undefined;
|
|
962
|
+
if (!isIntentMatcher(this.recorder))
|
|
963
|
+
return undefined;
|
|
964
|
+
if (this.replay.isDirectTool(toolName))
|
|
965
|
+
return undefined;
|
|
966
|
+
const text = (intent?.text ?? "").trim().slice(0, 4_000);
|
|
967
|
+
const source = intent?.source ?? "tool";
|
|
968
|
+
// Valid JSON with the serializer's own truncation marker, never a raw slice:
|
|
969
|
+
// the same string feeds the dedupe hash, the server's live question and the
|
|
970
|
+
// live call handed to derivation (R-HIT-4).
|
|
971
|
+
const toolInput = serializeCapped(redact(modelArgs), {
|
|
972
|
+
maxPayloadBytes: 2048,
|
|
973
|
+
maxStringBytes: 256,
|
|
974
|
+
});
|
|
975
|
+
// A retried identical call is not sent again; two different calls under one
|
|
976
|
+
// reasoning line both are (R-HIT-5).
|
|
977
|
+
const probeHash = createHash("sha1")
|
|
978
|
+
.update([text, toolName, toolInput].join(PROBE_SEP))
|
|
979
|
+
.digest("hex");
|
|
980
|
+
if (probeHash === run.intent.lastProbeHash)
|
|
981
|
+
return undefined;
|
|
982
|
+
run.intent.lastProbeHash = probeHash;
|
|
983
|
+
if (run.intent.requests >= opts.maxRequestsPerTurn)
|
|
984
|
+
return undefined;
|
|
985
|
+
run.intent.requests += 1;
|
|
986
|
+
// Every scenario armed this turn, so a continuation cannot re-arm the plan
|
|
987
|
+
// that just handed over.
|
|
988
|
+
const exclude = run.replays
|
|
989
|
+
.map((s) => s.scenarioId)
|
|
990
|
+
.filter((id) => typeof id === "string");
|
|
991
|
+
const startedAt = Date.now();
|
|
992
|
+
const answer = await this.withinBudget(this.recorder.matchIntent({
|
|
993
|
+
text,
|
|
994
|
+
source,
|
|
995
|
+
// The same value twice: `currentToolName` is what today's server reads,
|
|
996
|
+
// `toolName` is what the new one reads beside `toolInput`.
|
|
997
|
+
currentToolName: toolName,
|
|
998
|
+
toolName,
|
|
999
|
+
toolInput,
|
|
1000
|
+
exclude,
|
|
1001
|
+
turnId: run.turnId,
|
|
1002
|
+
stepPosition: run.ordering.next,
|
|
1003
|
+
excludeRuns: this.excludeRunsFor(run),
|
|
1004
|
+
ranThrough: this.ranThroughFor(run),
|
|
1005
|
+
...(this.replay.segmentArm ? { acceptSegments: true } : {}),
|
|
1006
|
+
supportsCalls: true,
|
|
1007
|
+
}), opts.budgetMs);
|
|
1008
|
+
if (!answer) {
|
|
1009
|
+
logDetail("replay.intent_miss", { run: run.runId, tool: toolName });
|
|
1010
|
+
return undefined;
|
|
1011
|
+
}
|
|
1012
|
+
if (answer.stepHit) {
|
|
1013
|
+
run.intent.lastStepHit = { ...answer.stepHit, at: Date.now() };
|
|
1014
|
+
}
|
|
1015
|
+
// What *would* have armed, had segments been armed (segmented.md R-OUT-10).
|
|
1016
|
+
// Logged at line level, not detail: this is the whole observe-only period,
|
|
1017
|
+
// and the decision to turn arming on is made by reading these. The turn
|
|
1018
|
+
// then carries on with whatever else the answer holds (R-OUT-15).
|
|
1019
|
+
if (answer.segmentWouldArm) {
|
|
1020
|
+
const w = answer.segmentWouldArm;
|
|
1021
|
+
logLine("replay.segment_would_arm", {
|
|
1022
|
+
scenario: w.scenarioId,
|
|
1023
|
+
run: w.runId,
|
|
1024
|
+
key: w.key,
|
|
1025
|
+
similarity: w.similarity.toFixed(3),
|
|
1026
|
+
band: w.band,
|
|
1027
|
+
verified: w.verified
|
|
1028
|
+
? w.verified.verdict === "skipped"
|
|
1029
|
+
? `skipped:${w.verified.reason}`
|
|
1030
|
+
: `${w.verified.verdict}: ${w.verified.reason}`
|
|
1031
|
+
: undefined,
|
|
1032
|
+
tool: w.tool ?? toolName,
|
|
1033
|
+
firstTool: w.firstTool,
|
|
1034
|
+
stepFrom: w.stepFrom,
|
|
1035
|
+
stepTo: w.stepTo,
|
|
1036
|
+
hitCount: w.hitCount,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
const match = answer.matched;
|
|
1040
|
+
logDetail("replay.intent_probe", {
|
|
1041
|
+
run: run.runId,
|
|
1042
|
+
tool: toolName,
|
|
1043
|
+
source,
|
|
1044
|
+
stepHit: answer.stepHit ? `${answer.stepHit.runId}:${answer.stepHit.stepIndex}` : "none",
|
|
1045
|
+
bestStepSimilarity: answer.bestStepSimilarity,
|
|
1046
|
+
elapsedMs: Date.now() - startedAt,
|
|
1047
|
+
matched: match?.scenarioId ?? "none",
|
|
1048
|
+
kind: match?.kind,
|
|
1049
|
+
});
|
|
1050
|
+
if (!match)
|
|
1051
|
+
return undefined;
|
|
1052
|
+
if (match.scenarioId && exclude.includes(match.scenarioId))
|
|
1053
|
+
return undefined;
|
|
1054
|
+
// The arms cap is checked *after* the response, not before it (R-HIT-5):
|
|
1055
|
+
// hit counting must not stop just because the turn has already armed three
|
|
1056
|
+
// plans. The ticket the server issued goes unredeemed and is swept
|
|
1057
|
+
// (R-OUT-12).
|
|
1058
|
+
if (run.intent.armed >= opts.maxPerTurn) {
|
|
1059
|
+
logLine("replay.intent_arm_capped", {
|
|
1060
|
+
scenario: match.scenarioId ?? undefined,
|
|
1061
|
+
kind: match.kind ?? "scenario",
|
|
1062
|
+
similarity: match.similarity.toFixed(3),
|
|
1063
|
+
tool: toolName,
|
|
1064
|
+
});
|
|
1065
|
+
return undefined;
|
|
1066
|
+
}
|
|
1067
|
+
const state = await this.replay.arm(match, text, this.wrapped, "intent", {
|
|
1068
|
+
liveCall: { toolName, toolInput },
|
|
1069
|
+
recentResults: recentToolResults(session.transcriptPath, this.deriveRecentResults),
|
|
1070
|
+
});
|
|
1071
|
+
if (!state.plan) {
|
|
1072
|
+
// A declined intent hit reports nothing: the turn's cost is not a
|
|
1073
|
+
// measurement of that scenario's task, so it is no baseline sample either.
|
|
1074
|
+
state.reported = true;
|
|
1075
|
+
return undefined;
|
|
1076
|
+
}
|
|
1077
|
+
state.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
1078
|
+
// The closure, not the mark: only the control server holds the transcript
|
|
1079
|
+
// path, and a completing retire happens in the controller (R-MONEY-3).
|
|
1080
|
+
state.markUsage = () => markTranscriptUsage(session.transcriptPath);
|
|
1081
|
+
// A segment of a recording whose whole-run plan already armed this turn
|
|
1082
|
+
// takes its share off that plan's baseline (R-MONEY-4). One recording's
|
|
1083
|
+
// steps are never counted in two baselines of one turn.
|
|
1084
|
+
if (state.kind === "segment" && state.segment && state.scenarioId) {
|
|
1085
|
+
for (const earlier of run.replays) {
|
|
1086
|
+
if (earlier === state || !earlier.plan)
|
|
1087
|
+
continue;
|
|
1088
|
+
if (earlier.kind !== "scenario")
|
|
1089
|
+
continue;
|
|
1090
|
+
if (earlier.matchedRunId !== state.segment.runId)
|
|
1091
|
+
continue;
|
|
1092
|
+
earlier.sharedWith.push(state.scenarioId);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
run.replays.push(state);
|
|
1096
|
+
run.replay = state;
|
|
1097
|
+
run.intent.armed += 1;
|
|
1098
|
+
const directive = this.replay.directiveFor(state) ?? "";
|
|
1099
|
+
logLine("replay.intent_armed", {
|
|
1100
|
+
run: run.runId,
|
|
1101
|
+
scenario: state.scenarioId ?? undefined,
|
|
1102
|
+
similarity: match.similarity.toFixed(3),
|
|
1103
|
+
mode: state.mode,
|
|
1104
|
+
instead: toolName,
|
|
1105
|
+
kind: match.kind ?? "scenario",
|
|
1106
|
+
key: match.key,
|
|
1107
|
+
verified: match.verified?.verdict,
|
|
1108
|
+
why: "the model's reasoning matched a calculated scenario's intent",
|
|
1109
|
+
});
|
|
1110
|
+
return this.preToolAnswer(this.replay.deliverInstead(directive, toolName));
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Schedule a fragment once a matched turn's plan has handed over (fallbk.md
|
|
1114
|
+
* D7). Only a turn that is not already recording needs one: an unmatched turn
|
|
1115
|
+
* whose intent-armed plan handed over is recording the model's work anyway.
|
|
1116
|
+
*/
|
|
1117
|
+
noteHandover(run) {
|
|
1118
|
+
if (run.fragment || run.recording || run.finished)
|
|
1119
|
+
return;
|
|
1120
|
+
const state = run.replay;
|
|
1121
|
+
const h = state?.handover;
|
|
1122
|
+
if (!h || !state?.scenarioId)
|
|
1123
|
+
return;
|
|
1124
|
+
run.fragment = { scenarioId: state.scenarioId, stepIndex: h.stepIndex, state: "due" };
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Open the fragment run and record from this call on (fallbk.md D7).
|
|
1128
|
+
*
|
|
1129
|
+
* The run id is swapped in place: steps allocated before belonged to a turn
|
|
1130
|
+
* the service never created a run for, and nothing of theirs may land in the
|
|
1131
|
+
* fragment — so pending correlations are latched and built-ins forgotten.
|
|
1132
|
+
*/
|
|
1133
|
+
async openFragment(session, run, reasoning, toolName, args) {
|
|
1134
|
+
const f = run.fragment;
|
|
1135
|
+
if (!f || f.state !== "due")
|
|
1136
|
+
return;
|
|
1137
|
+
const input = reasoning.trim() || `${toolName} ${serializeCapped(redact(args))}`.slice(0, 2_000);
|
|
1138
|
+
const fallbackOf = { scenarioId: f.scenarioId, stepIndex: f.stepIndex };
|
|
1139
|
+
const id = this.recorder.startRun(input, { ...this.runMetadata(session), fallbackOf }, { fallbackOf });
|
|
1140
|
+
for (const c of run.correlations.values()) {
|
|
1141
|
+
c.settled = true;
|
|
1142
|
+
if (c.fallback)
|
|
1143
|
+
clearTimeout(c.fallback);
|
|
1144
|
+
}
|
|
1145
|
+
run.builtIns.clear();
|
|
1146
|
+
run.runId = id;
|
|
1147
|
+
run.ordering = new StepIndexAllocator();
|
|
1148
|
+
run.recording = true;
|
|
1149
|
+
f.state = "open";
|
|
1150
|
+
f.usageMark = markTranscriptUsage(session.transcriptPath);
|
|
1151
|
+
f.startedAtMs = Date.now();
|
|
1152
|
+
logLine("run.fragment", {
|
|
1153
|
+
run: id,
|
|
1154
|
+
scenario: f.scenarioId,
|
|
1155
|
+
step: f.stepIndex,
|
|
1156
|
+
why: "recording the agent's own work after the scenario handed over",
|
|
1157
|
+
});
|
|
1158
|
+
if (isRunCreationAware(this.recorder)) {
|
|
1159
|
+
const created = await this.withinBudget(this.recorder.runCreated(id), this.replay.budgets.matchMs);
|
|
1160
|
+
if (created && !created.created) {
|
|
1161
|
+
run.recording = false;
|
|
1162
|
+
f.state = "hit";
|
|
1163
|
+
// Which run it hit matters from here on: this turn must not count step
|
|
1164
|
+
// hits against the fragment it is a repeat of (segmented.md R-HIT-7).
|
|
1165
|
+
f.hitRunId = created.fragmentOf?.runId;
|
|
1166
|
+
logLine("run.fragment_hit", {
|
|
1167
|
+
run: id,
|
|
1168
|
+
scenario: f.scenarioId,
|
|
1169
|
+
step: f.stepIndex,
|
|
1170
|
+
of: created.fragmentOf?.runId,
|
|
1171
|
+
why: "a hand-over at this step was recorded before — counted as a hit, not recorded",
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
/** Resolve with `p`, or null once `ms` passes or it rejects. */
|
|
1177
|
+
async withinBudget(p, ms) {
|
|
1178
|
+
let timer;
|
|
1179
|
+
const budget = new Promise((resolve) => {
|
|
1180
|
+
timer = setTimeout(() => resolve(null), ms);
|
|
1181
|
+
timer.unref?.();
|
|
1182
|
+
});
|
|
1183
|
+
try {
|
|
1184
|
+
return await Promise.race([p, budget]);
|
|
1185
|
+
}
|
|
1186
|
+
catch {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
finally {
|
|
1190
|
+
if (timer)
|
|
1191
|
+
clearTimeout(timer);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* `PostToolUse`, and where a hand-over noticed while threading this call's
|
|
1196
|
+
* output reaches the model at once, as `additionalContext` (fallbk.md D4).
|
|
1197
|
+
*/
|
|
1198
|
+
onToolPost(payload) {
|
|
1199
|
+
const out = this.toolPost(payload);
|
|
1200
|
+
const run = this.sessions.get(payload.session_id ?? "unknown-session")?.run;
|
|
1201
|
+
if (!run || run.finished)
|
|
1202
|
+
return out;
|
|
1203
|
+
this.noteHandover(run);
|
|
1204
|
+
const note = this.replay.takeNote(run.replay);
|
|
1205
|
+
if (!note)
|
|
1206
|
+
return out;
|
|
1207
|
+
return {
|
|
1208
|
+
...out,
|
|
1209
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: note },
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
724
1212
|
/**
|
|
725
1213
|
* `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
|
|
726
1214
|
* For a correlated MCP call it normally does nothing — the proxy owns that
|
|
@@ -728,7 +1216,7 @@ export class ControlServer {
|
|
|
728
1216
|
* or the call never reached it), the hook's own view is recorded after
|
|
729
1217
|
* {@link PROXY_REPORT_GRACE_MS} rather than the step being lost entirely.
|
|
730
1218
|
*/
|
|
731
|
-
|
|
1219
|
+
toolPost(payload) {
|
|
732
1220
|
const session = this.ensureSession(payload);
|
|
733
1221
|
const run = this.ensureRun(session);
|
|
734
1222
|
const toolUseId = payload.tool_use_id ?? "";
|
|
@@ -1004,6 +1492,7 @@ export class ControlServer {
|
|
|
1004
1492
|
correlation.threadFallback = undefined;
|
|
1005
1493
|
}
|
|
1006
1494
|
this.replay.postTool(run.replay, toolUseId, serializeCapped(redact(report.result)));
|
|
1495
|
+
this.noteHandover(run);
|
|
1007
1496
|
}
|
|
1008
1497
|
if (correlation.settled) {
|
|
1009
1498
|
logDetail("proxy.step.deduped", { run: run.runId, tool: report.qualifiedName });
|
|
@@ -1088,6 +1577,10 @@ export class ControlServer {
|
|
|
1088
1577
|
isError: data.isError,
|
|
1089
1578
|
errorMessage: data.errorMessage,
|
|
1090
1579
|
context: correlation.context,
|
|
1580
|
+
// Carried from where the correlation was created: by now the pin is gone
|
|
1581
|
+
// from the map (segmented.md R-INTENT-7).
|
|
1582
|
+
pinnedBy: correlation.pinnedBy,
|
|
1583
|
+
intent: correlation.intent,
|
|
1091
1584
|
});
|
|
1092
1585
|
logDetail("proxy.step.merged", {
|
|
1093
1586
|
run: run.runId,
|
|
@@ -1111,6 +1604,8 @@ export class ControlServer {
|
|
|
1111
1604
|
toolName: d.toolName,
|
|
1112
1605
|
toolInput,
|
|
1113
1606
|
context: d.context,
|
|
1607
|
+
intent: d.intent?.source === "thinking" ? { text: d.intent.text, source: "thinking" } : undefined,
|
|
1608
|
+
metadata: d.pinnedBy ? { pinnedBy: d.pinnedBy } : undefined,
|
|
1114
1609
|
});
|
|
1115
1610
|
this.recorder.recordToolResponse(run.runId, pair.response, {
|
|
1116
1611
|
toolName: d.toolName,
|