@basein/runner 0.3.16 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/bin/bir-scenario.d.ts +5 -1
  2. package/dist/bin/bir-scenario.js +155 -5
  3. package/dist/bin/bir.js +2 -2
  4. package/dist/bin/scenario-edit.d.ts +7 -0
  5. package/dist/bin/scenario-edit.js +9 -2
  6. package/dist/bin/scenario-liveness.d.ts +12 -1
  7. package/dist/bin/scenario-liveness.js +8 -4
  8. package/dist/bin/setup.js +3 -3
  9. package/dist/control/client.d.ts +5 -0
  10. package/dist/control/client.js +7 -0
  11. package/dist/control/ensure-hook.d.ts +14 -3
  12. package/dist/control/ensure-hook.js +18 -5
  13. package/dist/control/server.d.ts +165 -2
  14. package/dist/control/server.js +754 -24
  15. package/dist/control/transcript.d.ts +37 -0
  16. package/dist/control/transcript.js +112 -0
  17. package/dist/proxy/intercept.js +13 -0
  18. package/dist/proxy/session.d.ts +23 -0
  19. package/dist/proxy/session.js +71 -3
  20. package/dist/record/recorder.d.ts +154 -0
  21. package/dist/record/recorder.js +9 -0
  22. package/dist/record/remote-recorder.d.ts +61 -1
  23. package/dist/record/remote-recorder.js +303 -20
  24. package/dist/record/switchable-recorder.js +6 -0
  25. package/dist/replay/bundle.d.ts +8 -0
  26. package/dist/replay/bundle.js +20 -6
  27. package/dist/replay/controller.d.ts +33 -1
  28. package/dist/replay/controller.js +137 -12
  29. package/dist/replay/derive.d.ts +15 -0
  30. package/dist/replay/derive.js +5 -0
  31. package/dist/replay/plan.d.ts +90 -1
  32. package/dist/replay/plan.js +232 -10
  33. package/dist/replay/source-run.d.ts +2 -2
  34. package/dist/replay/source-run.js +3 -3
  35. package/dist/replay/targets.d.ts +40 -1
  36. package/dist/replay/targets.js +105 -3
  37. package/dist/replay/types.d.ts +6 -5
  38. package/package.json +1 -1
@@ -25,9 +25,93 @@ import { flatEntriesOf, frameEdges } from "./flatten.js";
25
25
  import { assembleBundle, bundleInput, MAX_REPLAY_REASON, testWords } from "./bundle.js";
26
26
  import { redact, redactText } from "../record/redact.js";
27
27
  import { sinkOfPlan, unsafeParameter } from "./safety.js";
28
+ import { testedOnlyTargets } from "./targets.js";
28
29
  import { errText } from "../util/log.js";
29
30
  import { awaitWithin } from "../util/service-health.js";
30
31
  import { isMissingToolError, toolResultError } from "./tool-error.js";
32
+ /**
33
+ * A value a step of the recording worked out — an address the directory
34
+ * returned, the id a search found; the service labels it `from: "step"` —
35
+ * is never replayed from the recording (issue 1212). The plan holds this mark
36
+ * in its place. A step whose input still holds it, because its earlier steps
37
+ * did not return the value and its code fell back on the parameter, stops the
38
+ * plan in front of it, for the agent; a summary that holds it is left out.
39
+ * Never the recorded e-mail address, never the recorded lead.
40
+ */
41
+ export const FROM_STEP_MARK = "\u2063bir-from-step:";
42
+ const markOf = (key) => `${FROM_STEP_MARK}${key}\u2063`;
43
+ /**
44
+ * A value the request did not give, which only the steps of one half of an
45
+ * "if \u2026 otherwise" read (the service's review of calcscenarioautocorrect.md,
46
+ * item 11; targets.ts, `testedOnlyTargets`). The plan arms without it \u2014 the
47
+ * other half needs nothing it lacks \u2014 and holds this mark in its place, never
48
+ * `null` and never the recorded value. A step whose input reads or holds it
49
+ * stops in front of its call, and the agent does that half; a test that reads
50
+ * it decides nothing; a summary that reads it is left out.
51
+ */
52
+ export const MISSING_VALUE_MARK = "\u2063bir-missing:";
53
+ const missingMarkOf = (key) => `${MISSING_VALUE_MARK}${key}\u2063`;
54
+ /**
55
+ * The plan's values a step worked out in the recording: settings labelled
56
+ * `step` that code found in an earlier step's output (`verified`), so the
57
+ * plan's own steps have them. One the calculation only claimed has no such
58
+ * source, and keeps its recorded value as before.
59
+ */
60
+ function fromStepKeys(schema) {
61
+ if (!schema || typeof schema !== "object")
62
+ return [];
63
+ return Object.entries(schema)
64
+ .filter(([, p]) => p?.kind === "setting" && p.from === "step" && p.verified === true)
65
+ .map(([key]) => key);
66
+ }
67
+ /**
68
+ * A step's code read a value a step works out, which here only the recording
69
+ * holds (issue 1212). Not a fault of the step: its earlier steps did not
70
+ * return the value this time. The plan stops in front of it, the step counts
71
+ * as not run, and the agent does the rest.
72
+ */
73
+ export class RecordedValueError extends Error {
74
+ key;
75
+ constructor(key, message) {
76
+ super(message ?? `the value "${key}" was not in what the earlier steps returned, and a replay never takes it from the recording`);
77
+ this.name = "RecordedValueError";
78
+ this.key = key;
79
+ }
80
+ }
81
+ /**
82
+ * A step's code read a value the request did not give ({@link
83
+ * MISSING_VALUE_MARK}). The same stop as {@link RecordedValueError} \u2014 not a
84
+ * fault of the step: it counts as not run, nothing is parked or repaired, and
85
+ * the agent does the rest \u2014 so it is one, and every place that tells that
86
+ * stop apart tells this one apart too. Its words name the value.
87
+ */
88
+ export class MissingValueError extends RecordedValueError {
89
+ constructor(key) {
90
+ super(key, `a value the request did not give: ${key}`);
91
+ this.name = "MissingValueError";
92
+ }
93
+ }
94
+ /** The first mark `value` holds anywhere, and which kind it is. */
95
+ function markIn(value) {
96
+ let text;
97
+ try {
98
+ text = typeof value === "string" ? value : (JSON.stringify(value) ?? "");
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ const step = text.indexOf(FROM_STEP_MARK);
104
+ const missing = text.indexOf(MISSING_VALUE_MARK);
105
+ if (step === -1 && missing === -1)
106
+ return undefined;
107
+ const isMissing = missing !== -1 && (step === -1 || missing < step);
108
+ const at = isMissing ? missing + MISSING_VALUE_MARK.length : step + FROM_STEP_MARK.length;
109
+ return { key: text.slice(at).split("\u2063")[0], missing: isMissing };
110
+ }
111
+ /** The value a mark stands for, when `value` holds one anywhere \u2014 either kind. */
112
+ export function markedKey(value) {
113
+ return markIn(value)?.key;
114
+ }
31
115
  /** A rejection from an {@link ExecuteStep} that says what happened to the call. */
32
116
  export class StepRunError extends Error {
33
117
  rejection;
@@ -229,7 +313,8 @@ function reportingStage(onStep, entry, stage, input, startedAt, fn, output) {
229
313
  entry,
230
314
  input,
231
315
  output,
232
- outcome: "failed",
316
+ // A value only the recording holds is no fault of the step's (issue 1212).
317
+ outcome: err instanceof RecordedValueError ? "skipped" : "failed",
233
318
  stage,
234
319
  error: err instanceof Error ? err.message : String(err),
235
320
  ms: Date.now() - startedAt,
@@ -246,6 +331,16 @@ export class ScenarioReplayPlan {
246
331
  /** The flat list this plan walks. One entry per step that will really run. */
247
332
  entries;
248
333
  paramsPromise;
334
+ /** The values a step worked out in the recording ({@link FROM_STEP_MARK}). */
335
+ fromStep;
336
+ /**
337
+ * The targets only the steps of one half read (targets.ts,
338
+ * `testedOnlyTargets`): each held as {@link MISSING_VALUE_MARK} when the
339
+ * request does not give it.
340
+ */
341
+ oneHalf;
342
+ /** Of {@link oneHalf}, the ones the request did not give: held as the mark now. */
343
+ missingMarked = [];
249
344
  params = {};
250
345
  /** The derivation's own answer, before `paramsLogic`. What safety.ts judges. */
251
346
  derivedParams = {};
@@ -279,6 +374,7 @@ export class ScenarioReplayPlan {
279
374
  this.entries = opts.entries ?? flatEntriesOf(opts.scenario);
280
375
  this.mode = opts.mode;
281
376
  this.paramsPromise = opts.params;
377
+ this.fromStep = fromStepKeys(opts.scenario.paramsObject);
282
378
  this.fallbackTargets = opts.fallbackTargets ?? "null";
283
379
  this.respParams = { ...(opts.respParamsInit ?? {}) };
284
380
  // The caller's own frame accumulates into the plan's `respParams`, so
@@ -291,6 +387,12 @@ export class ScenarioReplayPlan {
291
387
  typeof stop === "number" && stop >= 0 && stop < this.entries.length
292
388
  ? stop
293
389
  : this.entries.length;
390
+ // Judged over the steps this plan will run, as the gate that armed it was.
391
+ const schema = opts.scenario.paramsObject;
392
+ this.oneHalf =
393
+ schema && typeof schema === "object"
394
+ ? testedOnlyTargets(Object.keys(schema).filter((k) => schema[k]?.kind !== "setting"), schema, this.entries.slice(0, this.stopAt), opts.scenario.paramsLogic)
395
+ : [];
294
396
  this.edges = frameEdges(this.entries);
295
397
  this.entries.forEach((entry, index) => {
296
398
  this.positions.set(entry, index);
@@ -477,13 +579,17 @@ export class ScenarioReplayPlan {
477
579
  if (this.paramsSettled)
478
580
  return Promise.resolve();
479
581
  if (!this.readyPromise) {
480
- this.readyPromise = this.paramsPromise
582
+ const source = this.paramsPromise;
583
+ this.readyPromise = source
481
584
  .then((derived) => derived, () => undefined)
482
585
  .then((derived) => {
483
586
  // Settled already, without the derivation: a late answer must not
484
587
  // change the parameters under steps that ran on the others.
485
588
  if (this.paramsSettled)
486
589
  return;
590
+ // Read again since (`resetParams`): this answer is no one's now.
591
+ if (this.paramsPromise !== source)
592
+ return;
487
593
  this.settleParams(derived ?? this.fallbackValues());
488
594
  });
489
595
  }
@@ -508,6 +614,20 @@ export class ScenarioReplayPlan {
508
614
  return;
509
615
  this.settleParams(this.fallbackValues());
510
616
  }
617
+ /**
618
+ * The values read again, before any step ran (issue 1212): the agent's own
619
+ * day showed in the session only after the first reading. False once a step
620
+ * has run, when the values the steps ran on are the ones that stand.
621
+ */
622
+ resetParams(params) {
623
+ if (this.stepIndex > 0)
624
+ return false;
625
+ params.catch(() => undefined);
626
+ this.paramsPromise = params;
627
+ this.readyPromise = undefined;
628
+ this.paramsSettled = false;
629
+ return true;
630
+ }
511
631
  /** Whether the parameters are final. */
512
632
  get parametersSettled() {
513
633
  return this.paramsSettled;
@@ -530,9 +650,28 @@ export class ScenarioReplayPlan {
530
650
  settleParams(derived) {
531
651
  const own = { ...derived };
532
652
  let params = { ...derived };
653
+ // A value a step worked out: its mark, never the recorded value (issue 1212).
654
+ for (const key of this.fromStep) {
655
+ delete own[key];
656
+ params[key] = markOf(key);
657
+ }
658
+ // A value only one half reads, which this request did not give (review
659
+ // item 11): its mark, never `null` — a step with a null in its input would
660
+ // still run — and never the recorded value.
661
+ const missing = this.oneHalf.filter((key) => derived[key] === null || derived[key] === undefined);
662
+ for (const key of missing)
663
+ params[key] = missingMarkOf(key);
533
664
  if (this.scenario.paramsLogic) {
534
665
  params = evalParamsLogic(this.scenario.paramsLogic, params, this.intent);
666
+ // And after `paramsLogic` too, whatever it made of the key: dropped, a
667
+ // step would read `undefined`; replaced, a default someone typed —
668
+ // either way a value this request did not give (targets.ts). On a copy:
669
+ // what the body returned may be frozen.
670
+ if (missing.length > 0) {
671
+ params = { ...params, ...Object.fromEntries(missing.map((key) => [key, missingMarkOf(key)])) };
672
+ }
535
673
  }
674
+ this.missingMarked = missing;
536
675
  this.derivedParams = own;
537
676
  this.params = params;
538
677
  this.paramsSettled = true;
@@ -571,7 +710,45 @@ export class ScenarioReplayPlan {
571
710
  }
572
711
  /** The parameters in force. Empty until {@link ready} resolves. */
573
712
  get parameters() {
574
- return this.params;
713
+ // For the agent's eyes (the hand-over note): never a mark.
714
+ if (this.fromStep.length === 0 && this.missingMarked.length === 0)
715
+ return this.params;
716
+ return Object.fromEntries(Object.entries(this.params).filter(([, v]) => markedKey(v) === undefined));
717
+ }
718
+ /**
719
+ * The values a body may not use: a value a step works out ({@link
720
+ * FROM_STEP_MARK}) and a value the request did not give ({@link
721
+ * MISSING_VALUE_MARK}). A read of one is the stop {@link valueError} names.
722
+ */
723
+ watchedKeys() {
724
+ return this.missingMarked.length === 0 ? this.fromStep : [...this.fromStep, ...this.missingMarked];
725
+ }
726
+ /** The stop a read of `key` means: a value the request did not give, or one only the recording had. */
727
+ valueError(key) {
728
+ return this.missingMarked.includes(key) ? new MissingValueError(key) : new RecordedValueError(key);
729
+ }
730
+ /**
731
+ * `parameters` for one body, with every read of a value a step works out
732
+ * noted (issue 1212): a read is the recorded value used, however the code
733
+ * changes it afterwards — upper-cased, encoded, sliced or counted. A value
734
+ * the request did not give is watched the same way (review item 11).
735
+ */
736
+ watched(params) {
737
+ const read = new Set();
738
+ const watchedKeys = this.watchedKeys();
739
+ if (watchedKeys.length === 0)
740
+ return { params, read };
741
+ const keys = new Set(watchedKeys);
742
+ return {
743
+ read,
744
+ params: new Proxy(params, {
745
+ get(target, key, receiver) {
746
+ if (typeof key === "string" && keys.has(key))
747
+ read.add(key);
748
+ return Reflect.get(target, key, receiver);
749
+ },
750
+ }),
751
+ };
575
752
  }
576
753
  /** Everything the steps have accumulated so far. */
577
754
  get accumulated() {
@@ -626,7 +803,25 @@ export class ScenarioReplayPlan {
626
803
  // the first moment the caller's own accumulation is final (R-CALL-29) —
627
804
  // unless the step's test needed the frame first (AC5).
628
805
  this.enterFramesAt(this.stepIndex, this.walk);
629
- return evalToolInputLogic(entry.step.toolInputLogic ?? "return {};", this.paramsFor(entry), entry.frame.intent, this.respFor(entry));
806
+ return this.inputOf(entry, this.paramsFor(entry), entry.frame.intent, this.respFor(entry));
807
+ }
808
+ /**
809
+ * A step's input, refused when it holds a value only the recording had: its
810
+ * code fell back on a parameter a step works out, and the earlier steps did
811
+ * not return it (issue 1212). The caller stops the plan in front of it. So
812
+ * is one that reads, or holds, a value the request did not give (review
813
+ * item 11): {@link MissingValueError}.
814
+ */
815
+ inputOf(entry, params, intent, resp, opts) {
816
+ const w = this.watched(params);
817
+ const input = evalToolInputLogic(entry.step.toolInputLogic ?? "return {};", w.params, intent, resp, opts);
818
+ const read = [...w.read][0];
819
+ if (read !== undefined)
820
+ throw this.valueError(read);
821
+ const held = markIn(input);
822
+ if (held)
823
+ throw held.missing ? new MissingValueError(held.key) : new RecordedValueError(held.key);
824
+ return input;
630
825
  }
631
826
  // ── tests (the service's autocorrect.md AC5) ──────────────────────────────
632
827
  //
@@ -680,7 +875,14 @@ export class ScenarioReplayPlan {
680
875
  /** One test, answered — or the reason it could not be. Never throws. */
681
876
  answer(test, params, intent, resp) {
682
877
  try {
683
- return { yes: evalWhenLogic(test.logic, params, intent, resp) };
878
+ const w = this.watched(params);
879
+ const yes = evalWhenLogic(test.logic, w.params, intent, resp);
880
+ // A test on a value only the recording holds decides nothing (issue
881
+ // 1212), and nor does one on a value the request did not give (item 11).
882
+ const key = [...w.read][0];
883
+ if (key !== undefined)
884
+ return { error: this.valueError(key).message };
885
+ return { yes };
684
886
  }
685
887
  catch (err) {
686
888
  return { error: errText(err) };
@@ -910,9 +1112,25 @@ export class ScenarioReplayPlan {
910
1112
  }
911
1113
  /** Build the final response model from everything the steps accumulated. */
912
1114
  responseModel() {
913
- if (!this.scenario.responseParamsLogic)
914
- return { ...this.respParams };
915
- return evalResponseParamsLogic(this.scenario.responseParamsLogic, this.respParams, this.params, this.intent);
1115
+ const w = this.watched(this.params);
1116
+ const model = this.scenario.responseParamsLogic
1117
+ ? evalResponseParamsLogic(this.scenario.responseParamsLogic, this.respParams, w.params, this.intent)
1118
+ : { ...this.respParams };
1119
+ // The answer's code read a value only the recording had (issue 1212): its
1120
+ // summary would name the recorded address and the recorded lead. Left out.
1121
+ // So is one that read a value the request did not give (review item 11):
1122
+ // it would name a mark, or what the code made of one.
1123
+ const read = [...w.read];
1124
+ const held = markIn(model);
1125
+ if (read.length > 0 || held) {
1126
+ const missing = read.length > 0 ? read.every((k) => this.missingMarked.includes(k)) : held.missing;
1127
+ return {
1128
+ summaryLeftOut: missing
1129
+ ? "The plan's summary read a value this request did not give, so it is left out: answer from the steps' own results."
1130
+ : "The plan's summary read values recorded from another run, so it is left out: answer from the steps' own results.",
1131
+ };
1132
+ }
1133
+ return model;
916
1134
  }
917
1135
  /**
918
1136
  * The arguments each planned step will run with, where they are known before
@@ -963,7 +1181,7 @@ export class ScenarioReplayPlan {
963
1181
  });
964
1182
  let input;
965
1183
  try {
966
- input = evalToolInputLogic(entry.step.toolInputLogic ?? "return {};", params, entry.frame.intent, resp, { timeoutMs: Math.min(bodyMs, left) });
1184
+ input = this.inputOf(entry, params, entry.frame.intent, resp, { timeoutMs: Math.min(bodyMs, left) });
967
1185
  }
968
1186
  catch (err) {
969
1187
  // A body that throws (often on an earlier result it does not have
@@ -1220,7 +1438,7 @@ export class ScenarioReplayPlan {
1220
1438
  computed = reportingStage(onStep, entry, "tool_input_logic", {}, startedAt, () => {
1221
1439
  this.enterFramesAt(flatIndex, walk);
1222
1440
  const respParams = resp.get(entry.frame.id) ?? {};
1223
- return evalToolInputLogic(step.toolInputLogic ?? "return {};", this.paramsFor(entry), entry.frame.intent, respParams);
1441
+ return this.inputOf(entry, this.paramsFor(entry), entry.frame.intent, respParams);
1224
1442
  });
1225
1443
  }
1226
1444
  catch (err) {
@@ -1232,6 +1450,7 @@ export class ScenarioReplayPlan {
1232
1450
  flatIndex,
1233
1451
  stage: "tool_input_logic",
1234
1452
  error: errText(err),
1453
+ ...(err instanceof RecordedValueError ? { reason: "not_run" } : {}),
1235
1454
  };
1236
1455
  break;
1237
1456
  }
@@ -1330,6 +1549,7 @@ export class ScenarioReplayPlan {
1330
1549
  // left out a sub-task reaching past the parked step did not stop short.
1331
1550
  handover: (opts.handover === true && flatIndex <= this.stopAt) || stopped !== undefined,
1332
1551
  subTask: this.subTask,
1552
+ reportTool: opts.reportTool,
1333
1553
  }),
1334
1554
  executed,
1335
1555
  recorded: recordedCount,
@@ -1418,6 +1638,7 @@ export class ScenarioReplayPlan {
1418
1638
  flatIndex,
1419
1639
  stage: "tool_input_logic",
1420
1640
  error: errText(err),
1641
+ ...(err instanceof RecordedValueError ? { reason: "not_run" } : {}),
1421
1642
  };
1422
1643
  break;
1423
1644
  }
@@ -1516,6 +1737,7 @@ export class ScenarioReplayPlan {
1516
1737
  text: assembleBundle(entries, maxChars, {
1517
1738
  handover: stopped !== undefined,
1518
1739
  subTask: this.subTask,
1740
+ reportTool: opts.reportTool,
1519
1741
  }),
1520
1742
  executed,
1521
1743
  recorded: recordedCount,
@@ -68,8 +68,8 @@ export declare class SourceRunCache {
68
68
  /**
69
69
  * A step its test left out (the service's autocorrect.md AC5). Every step
70
70
  * of a plan was in the recording, and made its call there — unless a
71
- * correction added it from the agent's redo (`correctionKind: "added"`),
72
- * which made none. So one that was in it passes over its recorded output,
71
+ * correction added it from the agent's redo (`correctionKind: "added"`)
72
+ * or wrote it from the agent's answer (`"written"`, AC12), which made none. So one that was in it passes over its recorded output,
73
73
  * and the next step of its tool takes its own; an added one passes over
74
74
  * nothing. Nothing is fetched: the cursor is a count of this frame's calls
75
75
  * of the tool, whichever of them are read later (docs/calculatedReplay.md
@@ -90,8 +90,8 @@ export class SourceRunCache {
90
90
  /**
91
91
  * A step its test left out (the service's autocorrect.md AC5). Every step
92
92
  * of a plan was in the recording, and made its call there — unless a
93
- * correction added it from the agent's redo (`correctionKind: "added"`),
94
- * which made none. So one that was in it passes over its recorded output,
93
+ * correction added it from the agent's redo (`correctionKind: "added"`)
94
+ * or wrote it from the agent's answer (`"written"`, AC12), which made none. So one that was in it passes over its recorded output,
95
95
  * and the next step of its tool takes its own; an added one passes over
96
96
  * nothing. Nothing is fetched: the cursor is a count of this frame's calls
97
97
  * of the tool, whichever of them are read later (docs/calculatedReplay.md
@@ -99,7 +99,7 @@ export class SourceRunCache {
99
99
  */
100
100
  leftOut(entry) {
101
101
  const toolName = entry.step.toolName;
102
- if (!toolName || entry.step.correctionKind === "added")
102
+ if (!toolName || entry.step.correctionKind === "added" || entry.step.correctionKind === "written")
103
103
  return;
104
104
  const key = cursorKey(entry, toolName);
105
105
  this.cursor.set(key, (this.cursor.get(key) ?? 0) + 1);
@@ -39,6 +39,18 @@
39
39
  * call's `paramMapLogic` builds, not on these, so that body is scanned as a
40
40
  * consumer and the segment's own steps are not (R-CALL-29).
41
41
  *
42
+ * A THIRD WAY NOT TO BLOCK: A VALUE ONLY ONE HALF NEEDS (the service's review
43
+ * of calcscenarioautocorrect.md, item 11). A plan that holds both halves of an
44
+ * "if … otherwise" reads values only one half needs — the task's name, its
45
+ * board, the meeting's day — and a prompt for the other half names none of
46
+ * them. Declined as a whole (`missing_target`), the half that needed nothing
47
+ * the prompt lacked never ran. Such a value does not block when every use of
48
+ * it is in the input of a step that runs only under a test of its own: the
49
+ * step is reached only when this request is its half. The plan holds a mark
50
+ * in its place then, never `null` and never the recorded value (plan.ts,
51
+ * `MISSING_VALUE_MARK`), and a step whose input reads it stops in front of its
52
+ * call, for the agent. See {@link testedOnlyTargets}.
53
+ *
42
54
  * WHEN IN DOUBT, IT BLOCKS. This reads source text, not a parse tree, so it can
43
55
  * only prove "safe" for the shapes it recognises: `parameters.key`,
44
56
  * `parameters?.key` and `parameters["key"]`. Any other use of `parameters` —
@@ -51,7 +63,7 @@
51
63
  * the steps then read under another name.
52
64
  */
53
65
  import type { FlatEntry } from "./flatten.js";
54
- import type { ParamsSchema } from "./types.js";
66
+ import { type ParamsSchema } from "./types.js";
55
67
  /**
56
68
  * The subset of `missing` this plan must decline on.
57
69
  *
@@ -63,4 +75,31 @@ import type { ParamsSchema } from "./types.js";
63
75
  * parameters before step 0, and what it returns is what every step reads.
64
76
  */
65
77
  export declare function blockingTargets(missing: readonly string[], schema: ParamsSchema | null | undefined, planned: readonly FlatEntry[], paramsLogic?: string | null): string[];
78
+ /**
79
+ * Of `missing`, the targets that only the steps of one half read (see A THIRD
80
+ * WAY NOT TO BLOCK above): the plan holds them as a mark, and they do not stop
81
+ * it. A key is one only when
82
+ *
83
+ * - some step of frame 0 that runs under a test of its own (`when`, either
84
+ * `otherwise`) reads it in its input logic; and
85
+ * - nothing else names it: no step without a test, no output logic, no test,
86
+ * no sub-task's parameter mapping or test — the mark is never something a
87
+ * test decides on, or that an output passes on to another value; and
88
+ * - `paramsLogic`, when there is one, does not name the key at all, not even
89
+ * as a bare word (`{ ...parameters, task_name: 'x' }` is a typed default:
90
+ * a guessed target), and uses `parameters` only as a spread that hands
91
+ * every key on; and
92
+ * - no body carries the recorded sample as a literal.
93
+ *
94
+ * Anything this cannot follow — `parameters` destructured, passed or aliased —
95
+ * and it answers none: every missing target blocks, as before. Nor in a plan
96
+ * that calls a sub-task: the sub-task's mappings — the one that builds its
97
+ * parameters, the one that hands its results back — run on the caller's
98
+ * values out of every watch, and a mark is a truthy text a mapping could
99
+ * branch on, change past recognising, or pass into what a later test decides
100
+ * on. The plan also puts the mark back after `paramsLogic` has run, so a body
101
+ * that dropped the key or wrote one of its own in its place still stops in
102
+ * front of the step.
103
+ */
104
+ export declare function testedOnlyTargets(missing: readonly string[], schema: ParamsSchema | null | undefined, planned: readonly FlatEntry[], paramsLogic?: string | null): string[];
66
105
  //# sourceMappingURL=targets.d.ts.map
@@ -39,6 +39,18 @@
39
39
  * call's `paramMapLogic` builds, not on these, so that body is scanned as a
40
40
  * consumer and the segment's own steps are not (R-CALL-29).
41
41
  *
42
+ * A THIRD WAY NOT TO BLOCK: A VALUE ONLY ONE HALF NEEDS (the service's review
43
+ * of calcscenarioautocorrect.md, item 11). A plan that holds both halves of an
44
+ * "if … otherwise" reads values only one half needs — the task's name, its
45
+ * board, the meeting's day — and a prompt for the other half names none of
46
+ * them. Declined as a whole (`missing_target`), the half that needed nothing
47
+ * the prompt lacked never ran. Such a value does not block when every use of
48
+ * it is in the input of a step that runs only under a test of its own: the
49
+ * step is reached only when this request is its half. The plan holds a mark
50
+ * in its place then, never `null` and never the recorded value (plan.ts,
51
+ * `MISSING_VALUE_MARK`), and a step whose input reads it stops in front of its
52
+ * call, for the agent. See {@link testedOnlyTargets}.
53
+ *
42
54
  * WHEN IN DOUBT, IT BLOCKS. This reads source text, not a parse tree, so it can
43
55
  * only prove "safe" for the shapes it recognises: `parameters.key`,
44
56
  * `parameters?.key` and `parameters["key"]`. Any other use of `parameters` —
@@ -50,6 +62,7 @@
50
62
  * more strictly still, because it runs before any step and can rename a target
51
63
  * the steps then read under another name.
52
64
  */
65
+ import { isStepTest } from "./types.js";
53
66
  /**
54
67
  * The subset of `missing` this plan must decline on.
55
68
  *
@@ -82,9 +95,98 @@ export function blockingTargets(missing, schema, planned, paramsLogic) {
82
95
  const logic = paramsLogic?.trim() ? paramsLogic : undefined;
83
96
  if (logic && readsWhole(withoutSpread(logic)))
84
97
  return [...missing];
85
- return missing.filter((key) => (logic !== undefined &&
86
- (mentions(logic, key) || carriesLiteral(logic, schema[key]?.sampleValue))) ||
87
- bodies.some((body) => readsOutright(body, key) || carriesLiteral(body, schema[key]?.sampleValue)));
98
+ // Read only by the steps of one half: not blocking, and held as a mark.
99
+ const oneHalf = new Set(testedOnlyTargets(missing, schema, planned, paramsLogic));
100
+ return missing.filter((key) => !oneHalf.has(key) &&
101
+ ((logic !== undefined &&
102
+ (mentions(logic, key) || carriesLiteral(logic, schema[key]?.sampleValue))) ||
103
+ bodies.some((body) => readsOutright(body, key) || carriesLiteral(body, schema[key]?.sampleValue))));
104
+ }
105
+ /**
106
+ * Of `missing`, the targets that only the steps of one half read (see A THIRD
107
+ * WAY NOT TO BLOCK above): the plan holds them as a mark, and they do not stop
108
+ * it. A key is one only when
109
+ *
110
+ * - some step of frame 0 that runs under a test of its own (`when`, either
111
+ * `otherwise`) reads it in its input logic; and
112
+ * - nothing else names it: no step without a test, no output logic, no test,
113
+ * no sub-task's parameter mapping or test — the mark is never something a
114
+ * test decides on, or that an output passes on to another value; and
115
+ * - `paramsLogic`, when there is one, does not name the key at all, not even
116
+ * as a bare word (`{ ...parameters, task_name: 'x' }` is a typed default:
117
+ * a guessed target), and uses `parameters` only as a spread that hands
118
+ * every key on; and
119
+ * - no body carries the recorded sample as a literal.
120
+ *
121
+ * Anything this cannot follow — `parameters` destructured, passed or aliased —
122
+ * and it answers none: every missing target blocks, as before. Nor in a plan
123
+ * that calls a sub-task: the sub-task's mappings — the one that builds its
124
+ * parameters, the one that hands its results back — run on the caller's
125
+ * values out of every watch, and a mark is a truthy text a mapping could
126
+ * branch on, change past recognising, or pass into what a later test decides
127
+ * on. The plan also puts the mark back after `paramsLogic` has run, so a body
128
+ * that dropped the key or wrote one of its own in its place still stops in
129
+ * front of the step.
130
+ */
131
+ export function testedOnlyTargets(missing, schema, planned, paramsLogic) {
132
+ if (missing.length === 0 || !schema || planned.length === 0)
133
+ return [];
134
+ if (planned.some((entry) => entry.depth > 0))
135
+ return [];
136
+ const { tested, others } = consumerBodies(planned);
137
+ const all = [...tested, ...others];
138
+ if (all.length === 0 || all.some(readsWhole))
139
+ return [];
140
+ const logic = paramsLogic?.trim() ? paramsLogic : undefined;
141
+ if (logic && readsWhole(withoutSpread(logic)))
142
+ return [];
143
+ return missing.filter((key) => {
144
+ const sample = schema[key]?.sampleValue;
145
+ if (logic !== undefined && (namesKey(logic, key) || carriesLiteral(logic, sample)))
146
+ return false;
147
+ if (all.some((body) => carriesLiteral(body, sample)))
148
+ return false;
149
+ if (others.some((body) => mentions(body, key)))
150
+ return false;
151
+ return tested.some((body) => mentions(body, key));
152
+ });
153
+ }
154
+ /**
155
+ * The bodies that run with the caller's own `parameters`, as
156
+ * {@link callerBodies} lists them, told apart: the input logic of each step of
157
+ * frame 0 that runs under a test of its own, and every other body.
158
+ */
159
+ function consumerBodies(planned) {
160
+ const tested = [];
161
+ const others = [];
162
+ const seenFrames = new Set();
163
+ for (const entry of planned) {
164
+ if (entry.depth === 0) {
165
+ const input = entry.step.toolInputLogic;
166
+ if (input)
167
+ (isStepTest(entry.step.when) ? tested : others).push(input);
168
+ if (entry.step.toolOutputLogic)
169
+ others.push(entry.step.toolOutputLogic);
170
+ if (entry.step.when?.logic)
171
+ others.push(entry.step.when.logic);
172
+ continue;
173
+ }
174
+ const frame = entry.frame;
175
+ if (frame.depth !== 1 || seenFrames.has(frame.id))
176
+ continue;
177
+ seenFrames.add(frame.id);
178
+ if (frame.paramMapLogic)
179
+ others.push(frame.paramMapLogic);
180
+ if (frame.when?.logic)
181
+ others.push(frame.when.logic);
182
+ }
183
+ return { tested, others };
184
+ }
185
+ /** Whether `body` names `key` at all — `parameters.key`, or the bare word, as a key of an object it returns. */
186
+ function namesKey(body, key) {
187
+ if (mentions(body, key))
188
+ return true;
189
+ return new RegExp(`(?<![\\w$])${escapeRegExp(key)}(?![\\w$])`).test(body);
88
190
  }
89
191
  /**
90
192
  * `parameters` used in any shape other than `parameters.key`,
@@ -83,13 +83,14 @@ export interface SerializedScenarioStep {
83
83
  when?: StepTest | null;
84
84
  /**
85
85
  * The correction that wrote this step, and what it did (the service's
86
- * autocorrect.md AC6). `"added"` is a step taken from the agent's redo: it
87
- * made no call in the recording the plan was calculated from, so it pairs
88
- * with no recorded output (source-run.ts). Absent or null on every other
89
- * step, and on every step of an older service.
86
+ * autocorrect.md AC6). `"added"` is a step taken from the agent's redo,
87
+ * and `"written"` one written from the agent's answer (AC12): neither made
88
+ * a call in the recording the plan was calculated from, so it pairs with no
89
+ * recorded output (source-run.ts). Absent or null on every other step, and
90
+ * on every step of an older service.
90
91
  */
91
92
  correctionId?: string | null;
92
- correctionKind?: "added" | "tested" | "changed" | null;
93
+ correctionKind?: "added" | "tested" | "changed" | "written" | null;
93
94
  /**
94
95
  * `"segment"` is a call: a sub-task this chain runs by calling a recorded
95
96
  * scenario instead of carrying a copy of its steps (R-CALL-1). Absent means
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basein/runner",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
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",