@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.
@@ -34,11 +34,11 @@ export function reachOf(toolName, wrapped, allow) {
34
34
  export function modeFor(steps, wrapped, allow) {
35
35
  if (steps.length === 0)
36
36
  return "none";
37
- return steps.every((s) => reachOf(s.toolName, wrapped, allow) === "direct") ? "direct" : "steer";
37
+ return steps.every((s) => reachOf(s.toolName ?? "", wrapped, allow) === "direct") ? "direct" : "steer";
38
38
  }
39
39
  /** Per-step reach, in step order — what `plan.armed` logs and `bir doctor` shows. */
40
40
  export function coverageOf(steps, wrapped, allow) {
41
- return steps.map((s) => reachOf(s.toolName, wrapped, allow));
41
+ return steps.map((s) => reachOf(s.toolName ?? "", wrapped, allow));
42
42
  }
43
43
  /**
44
44
  * Parse `BIR_REPLAY_ALLOW_SERVERS`. Unset (or empty) means *every wrapped
@@ -10,11 +10,20 @@
10
10
  * most sessions never arm. This is ~40 lines of `fetch` and it keeps the package
11
11
  * at zero runtime dependencies.
12
12
  *
13
- * NO KEY IS A SUPPORTED CONFIGURATION. Without `ANTHROPIC_API_KEY` the extraction
14
- * is skipped entirely and every parameter takes the value it had in the recorded
15
- * run — a plain replay of the recorded parameters, at zero cost. For a scenario
16
- * whose parameters rarely change that is a complete, free replay; for one that
17
- * keys off the prompt, it is why you want the key.
13
+ * NO KEY IS THE ORDINARY CONFIGURATION (segmented.md R-PARAM-5). Without
14
+ * `ANTHROPIC_API_KEY` the reading is done by the **service**, over one authorized
15
+ * POST to `/scenarios/:id/derive`, under the key it already uses to calculate
16
+ * scenarios and to ask its live question. That is the path nearly every session
17
+ * takes: the people this runs for are inside Claude Code on a subscription and
18
+ * have no API key to give it.
19
+ *
20
+ * A key here is an override, not a requirement. It keeps the reading on this
21
+ * machine — the prompt never reaches the service — which is what an in-house
22
+ * deployment wants, and it is one round trip faster.
23
+ *
24
+ * NEITHER is still supported and still means the same thing it always did:
25
+ * settings take their recorded samples and a scenario with a target declines,
26
+ * because a target is never guessed.
18
27
  *
19
28
  * The prompt and the parsing tolerances mirror RRepeat's
20
29
  * `deriveParametersFromScenarioPayload` exactly, so the two produce the same
@@ -23,12 +32,38 @@
23
32
  */
24
33
  import type { ParamsSchema } from "./types.js";
25
34
  export declare const DEFAULT_DERIVE_MODEL = "claude-haiku-4-5-20251001";
26
- export interface DeriveOptions {
35
+ /** The call the agent was about to make when a plan armed mid-task. */
36
+ export interface LiveCall {
37
+ toolName: string;
38
+ /** Redacted and capped, exactly as the probe sent it. */
39
+ toolInput: string;
40
+ }
41
+ /** What a turn can tell derivation beyond its prompt (segmented.md R-PARAM-2). */
42
+ export interface DeriveContext {
43
+ liveCall?: LiveCall;
44
+ recentResults?: string[];
45
+ }
46
+ /**
47
+ * The service, which derives when this runner has no key of its own
48
+ * (R-PARAM-5). All three fields or none: a session that is not signed in has
49
+ * nowhere to ask.
50
+ */
51
+ export interface DeriveService {
52
+ /** `BIR_AUTH_URL`, the same base the recorder reports to. */
53
+ baseUrl: string;
54
+ /** A live access token. Read late — the recorder refreshes it mid-session. */
55
+ token: string;
56
+ /** Whose parameters to read. The service takes the schema from its own row. */
57
+ scenarioId: string;
58
+ }
59
+ export interface DeriveOptions extends DeriveContext {
27
60
  prompt: string;
28
61
  intent: string;
29
62
  paramsObject: ParamsSchema | null;
30
- /** Absent → sample values, at zero cost. */
63
+ /** An override that keeps the reading on this machine. Absent → the service. */
31
64
  apiKey?: string;
65
+ /** Used when there is no `apiKey`. Absent too → recorded samples. */
66
+ service?: DeriveService;
32
67
  model?: string;
33
68
  signal?: AbortSignal;
34
69
  /** Injected by tests. Defaults to global `fetch`. */
@@ -39,15 +74,27 @@ export interface DeriveResult {
39
74
  costUsd: number;
40
75
  /** False when the values came from the recorded samples rather than the model. */
41
76
  derived: boolean;
77
+ /** Who read the turn. For the audit line, and for nothing else. */
78
+ via?: "key" | "service" | "samples";
79
+ /** Why nothing was derived, when the service said. */
80
+ reason?: string;
42
81
  model?: string;
43
82
  inputTokens?: number;
44
83
  outputTokens?: number;
84
+ /**
85
+ * Targets that came back `null` (segmented.md R-PARAM-3). Non-empty means the
86
+ * plan must not run: the turn does not say what this task is to act on.
87
+ */
88
+ missing: string[];
89
+ /** Settings filled from their recorded sample (R-PARAM-2). */
90
+ sampled: string[];
45
91
  }
46
92
  /**
47
93
  * First balanced `{…}` in a text blob. Models wrap JSON in prose and fences more
48
94
  * often than they emit it bare, and a failed parse here costs a whole replay.
49
95
  */
50
96
  export declare function extractJsonObject(text: string): string | undefined;
97
+ export declare function buildExtractionPrompt(prompt: string, intent: string, schema: ParamsSchema, ctx?: DeriveContext): string;
51
98
  /**
52
99
  * Derive the scenario's parameters from `prompt`. Never throws: every failure
53
100
  * path resolves with the recorded sample values, because a replay with stale
@@ -10,11 +10,20 @@
10
10
  * most sessions never arm. This is ~40 lines of `fetch` and it keeps the package
11
11
  * at zero runtime dependencies.
12
12
  *
13
- * NO KEY IS A SUPPORTED CONFIGURATION. Without `ANTHROPIC_API_KEY` the extraction
14
- * is skipped entirely and every parameter takes the value it had in the recorded
15
- * run — a plain replay of the recorded parameters, at zero cost. For a scenario
16
- * whose parameters rarely change that is a complete, free replay; for one that
17
- * keys off the prompt, it is why you want the key.
13
+ * NO KEY IS THE ORDINARY CONFIGURATION (segmented.md R-PARAM-5). Without
14
+ * `ANTHROPIC_API_KEY` the reading is done by the **service**, over one authorized
15
+ * POST to `/scenarios/:id/derive`, under the key it already uses to calculate
16
+ * scenarios and to ask its live question. That is the path nearly every session
17
+ * takes: the people this runs for are inside Claude Code on a subscription and
18
+ * have no API key to give it.
19
+ *
20
+ * A key here is an override, not a requirement. It keeps the reading on this
21
+ * machine — the prompt never reaches the service — which is what an in-house
22
+ * deployment wants, and it is one round trip faster.
23
+ *
24
+ * NEITHER is still supported and still means the same thing it always did:
25
+ * settings take their recorded samples and a scenario with a target declines,
26
+ * because a target is never guessed.
18
27
  *
19
28
  * The prompt and the parsing tolerances mirror RRepeat's
20
29
  * `deriveParametersFromScenarioPayload` exactly, so the two produce the same
@@ -32,6 +41,10 @@ function sampleValues(schema) {
32
41
  out[key] = schema[key].sampleValue;
33
42
  return out;
34
43
  }
44
+ /** A parameter with no kind reads as a target (segmented.md R-PARAM-3). */
45
+ function isTarget(schema, key) {
46
+ return schema[key]?.kind !== "setting";
47
+ }
35
48
  /**
36
49
  * First balanced `{…}` in a text blob. Models wrap JSON in prose and fences more
37
50
  * often than they emit it bare, and a failed parse here costs a whole replay.
@@ -66,24 +79,40 @@ export function extractJsonObject(text) {
66
79
  }
67
80
  return undefined;
68
81
  }
69
- function buildExtractionPrompt(prompt, intent, schema) {
82
+ export function buildExtractionPrompt(prompt, intent, schema, ctx = {}) {
83
+ // Each parameter wears its kind, because the last rule below treats the two
84
+ // differently and the model cannot tell them apart from the name alone
85
+ // (segmented.md R-PARAM-2).
70
86
  const descriptions = Object.keys(schema)
71
87
  .map((key) => {
72
88
  const { description, sampleValue } = schema[key];
73
- return `- ${key}: ${description} (sample: ${JSON.stringify(sampleValue)})`;
89
+ const kind = isTarget(schema, key) ? "target" : "setting";
90
+ return `- ${key} (${kind}): ${description} (sample: ${JSON.stringify(sampleValue)})`;
74
91
  })
75
92
  .join("\n");
93
+ // The call the agent was about to make is the most specific thing the turn
94
+ // says about a target — more specific than the prompt, which may not mention
95
+ // it at all (R-PARAM-8).
96
+ const live = ctx.liveCall
97
+ ? `\nThe agent was about to call ${ctx.liveCall.toolName} with ${ctx.liveCall.toolInput}.\n`
98
+ : "";
99
+ const results = (ctx.recentResults ?? []).filter((r) => r.trim().length > 0);
100
+ const recent = results.length > 0
101
+ ? `\nRecent tool results from this task, oldest first:\n${results
102
+ .map((r) => `\`\`\`\n${r}\n\`\`\``)
103
+ .join("\n")}\n`
104
+ : "";
76
105
  return `You are extracting parameter values from a user prompt to execute a pre-defined scenario.
77
106
 
78
107
  Scenario intent: ${intent}
79
-
108
+ ${live}
80
109
  Parameters needed (with sample values from past runs):
81
110
  ${descriptions}
82
111
 
83
112
  User prompt:
84
113
  ${prompt}
85
-
86
- Extract the parameter values from the user prompt. If a value is not explicitly mentioned, infer a reasonable default from the sample values above.
114
+ ${recent}
115
+ Extract the parameter values. A setting you cannot find takes its sample value. A target you cannot find in the prompt, the reasoning, the call or the results is null — never invent it and never copy the sample.
87
116
 
88
117
  Respond ONLY with a valid JSON object mapping each parameter name to its value. No markdown fences, no prose.
89
118
  Example: { "directory": "src/utils", "pattern": "*.ts" }`;
@@ -98,10 +127,25 @@ export async function deriveParameters(opts) {
98
127
  const schema = opts.paramsObject ?? {};
99
128
  const keys = Object.keys(schema);
100
129
  if (keys.length === 0)
101
- return { params: {}, costUsd: 0, derived: false };
130
+ return { params: {}, costUsd: 0, derived: false, missing: [], sampled: [] };
102
131
  const apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY;
132
+ // No key of our own is the ordinary case: the service reads the turn instead,
133
+ // under the key it already spends on this account (R-PARAM-5).
134
+ if (!apiKey && opts.service)
135
+ return deriveViaService(opts, opts.service, schema, keys);
136
+ // No key and nowhere to ask — an unauthenticated session, or a deployment that
137
+ // turned service derivation off. `derived: false` is how the caller tells this
138
+ // apart from a real extraction: it declines any scenario that has a target and
139
+ // arms an all-settings one on its recorded values.
103
140
  if (!apiKey)
104
- return { params: sampleValues(schema), costUsd: 0, derived: false };
141
+ return {
142
+ params: sampleValues(schema),
143
+ costUsd: 0,
144
+ derived: false,
145
+ via: "samples",
146
+ missing: [],
147
+ sampled: keys,
148
+ };
105
149
  const model = opts.model ?? process.env.BIR_DERIVE_MODEL ?? DEFAULT_DERIVE_MODEL;
106
150
  const doFetch = opts.fetchImpl ?? fetch;
107
151
  const res = await doFetch(ANTHROPIC_URL, {
@@ -115,7 +159,13 @@ export async function deriveParameters(opts) {
115
159
  model,
116
160
  max_tokens: 2024,
117
161
  messages: [
118
- { role: "user", content: buildExtractionPrompt(opts.prompt, opts.intent, schema) },
162
+ {
163
+ role: "user",
164
+ content: buildExtractionPrompt(opts.prompt, opts.intent, schema, {
165
+ liveCall: opts.liveCall,
166
+ recentResults: opts.recentResults,
167
+ }),
168
+ },
119
169
  ],
120
170
  }),
121
171
  signal: opts.signal,
@@ -155,12 +205,119 @@ export async function deriveParameters(opts) {
155
205
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
156
206
  throw new Error("derive returned a non-object");
157
207
  }
158
- // A missing or null key falls back to its recorded sample, so a partial
159
- // extraction still yields a runnable parameter set.
208
+ // A setting nobody stated takes its recorded sample — that is what a setting
209
+ // is. A target nobody stated stays null and is named in `missing`, and the
210
+ // caller declines the plan (segmented.md R-PARAM-2, R-PARAM-3). Copying the
211
+ // sample here is exactly the guess the design forbids: it is how a replay
212
+ // ends up querying last quarter's region because this prompt named no region.
213
+ const missing = [];
214
+ const sampled = [];
160
215
  for (const key of keys) {
161
- if (parsed[key] === null || parsed[key] === undefined)
216
+ if (parsed[key] !== null && parsed[key] !== undefined)
217
+ continue;
218
+ if (isTarget(schema, key)) {
219
+ parsed[key] = null;
220
+ missing.push(key);
221
+ }
222
+ else {
162
223
  parsed[key] = schema[key].sampleValue;
224
+ sampled.push(key);
225
+ }
226
+ }
227
+ return {
228
+ params: parsed,
229
+ costUsd,
230
+ derived: true,
231
+ via: "key",
232
+ model,
233
+ inputTokens,
234
+ outputTokens,
235
+ missing,
236
+ sampled,
237
+ };
238
+ }
239
+ /**
240
+ * Ask the service to read the turn (segmented.md R-PARAM-5).
241
+ *
242
+ * One authorized POST. The body carries the turn — the prompt, and whatever else
243
+ * this turn has said about its target — and never the schema: the service reads
244
+ * that from the scenario row it owns, so a stale copy here cannot change what is
245
+ * asked for.
246
+ *
247
+ * Never throws, and never worse than no key. A service that is off, out of
248
+ * budget, too slow or simply down answers `derived: false`, and so does every
249
+ * failure on this side, which is precisely the keyless behaviour this replaced:
250
+ * settings stand on their samples, and a target does not run.
251
+ */
252
+ async function deriveViaService(opts, service, schema, keys) {
253
+ const samples = (reason) => ({
254
+ params: sampleValues(schema),
255
+ costUsd: 0,
256
+ derived: false,
257
+ via: "service",
258
+ reason,
259
+ missing: [],
260
+ sampled: keys,
261
+ });
262
+ const doFetch = opts.fetchImpl ?? fetch;
263
+ const url = `${service.baseUrl.replace(/\/+$/, "")}/scenarios/${service.scenarioId}/derive`;
264
+ let body;
265
+ try {
266
+ const res = await doFetch(url, {
267
+ method: "POST",
268
+ headers: {
269
+ "content-type": "application/json",
270
+ authorization: `Bearer ${service.token}`,
271
+ },
272
+ body: JSON.stringify({
273
+ prompt: opts.prompt,
274
+ liveCall: opts.liveCall,
275
+ recentResults: opts.recentResults,
276
+ }),
277
+ signal: opts.signal,
278
+ });
279
+ if (!res.ok)
280
+ return samples(`service HTTP ${res.status}`);
281
+ body = (await res.json());
282
+ }
283
+ catch (err) {
284
+ return samples(err instanceof Error ? err.message : String(err));
285
+ }
286
+ if (!body || typeof body !== "object")
287
+ return samples("service sent no answer");
288
+ if (body.derived !== true)
289
+ return samples(`service: ${body.reason ?? "declined"}`);
290
+ // Normalised again on this side, over *our* copy of the schema. The service
291
+ // answers in the same shape, but the plan runs on these values and the gate
292
+ // reads this `missing`: an older or newer service must not be able to leave a
293
+ // target unaccounted for.
294
+ const params = { ...(body.params ?? {}) };
295
+ const missing = [];
296
+ const sampled = [];
297
+ for (const key of keys) {
298
+ const value = params[key];
299
+ if (value !== null && value !== undefined)
300
+ continue;
301
+ if (isTarget(schema, key)) {
302
+ params[key] = null;
303
+ missing.push(key);
304
+ }
305
+ else {
306
+ params[key] = schema[key].sampleValue;
307
+ sampled.push(key);
308
+ }
163
309
  }
164
- return { params: parsed, costUsd, derived: true, model, inputTokens, outputTokens };
310
+ return {
311
+ params,
312
+ // What the service spent on this turn, reported back on the execution so a
313
+ // replay's cost stays the whole truth about it (R-MONEY-2). The account is
314
+ // not billed for it; the service books it on its own ledger as well.
315
+ costUsd: typeof body.costUsd === "number" ? body.costUsd : 0,
316
+ derived: true,
317
+ via: "service",
318
+ model: body.model,
319
+ missing,
320
+ sampled,
321
+ };
165
322
  }
166
323
  //# sourceMappingURL=derive.js.map
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Flattening calls into frames (segmented.md 10.6).
3
+ *
4
+ * A chain may hold a **call**: one row that says "here the agent does this
5
+ * sub-task", answered by a recorded scenario the service inlines into the
6
+ * payload. Running it is not a second plan. It is one plan over one flat list
7
+ * of steps, in which some steps belong to another scenario and run with *its*
8
+ * parameters, *its* intent and *its* accumulated values.
9
+ *
10
+ * That is what a **frame** is: the scope one stretch of steps runs in. Frame 0
11
+ * is the caller's own. Entering a called frame evaluates the call's
12
+ * `paramMapLogic` to build the segment's parameters; leaving it evaluates
13
+ * `resultMapLogic` and merges the result back into the caller's `respParams`
14
+ * (R-CALL-29).
15
+ *
16
+ * Everything downstream reads the flat list rather than the chain: the gates
17
+ * judge the steps that will really run (R-CALL-30), recorded outputs come from
18
+ * the right recording and range (R-CALL-31), and a failure is reported on the
19
+ * scenario that owns the step (R-CALL-13).
20
+ *
21
+ * The flattener itself decides nothing about *which* segment answers a call —
22
+ * the service did that long before this payload was built. It only lays out
23
+ * what it was served, and stops in front of anything it cannot run.
24
+ */
25
+ import type { SerializedScenario, SerializedScenarioStep, CallUnusable } from "./types.js";
26
+ import type { ParamsSchema } from "./types.js";
27
+ /** The scope one stretch of steps runs in. Frame 0 is the caller's own. */
28
+ export interface Frame {
29
+ id: number;
30
+ /** 0 for the caller, 1 for a segment it calls, 2 for one that segment calls. */
31
+ depth: number;
32
+ scenarioId: string;
33
+ runId: string;
34
+ /** The recorded range the frame's outputs come from. Null means the whole run. */
35
+ stepFrom: number | null;
36
+ stepTo: number | null;
37
+ /** The intent the frame's step logic runs with (R-INTENT-12). */
38
+ intent: string;
39
+ parent?: Frame;
40
+ /** The caller's chain `stepIndex` of the call row that opened this frame. */
41
+ callerStepIndex?: number;
42
+ callIntent?: string;
43
+ paramMapLogic?: string;
44
+ resultMapLogic?: string;
45
+ segmentId?: string;
46
+ /** The inlined segment's `chainRevision` as served (R-CALL-13). */
47
+ chainRevision?: number;
48
+ /** The segment's own parameters, for the settings a mapping omits (R-CALL-21). */
49
+ paramsObject?: ParamsSchema | null;
50
+ /** Filled by the plan when the frame is entered. */
51
+ parameters?: Record<string, unknown>;
52
+ respParams: Record<string, unknown>;
53
+ }
54
+ /** One step that will really run, with the frame it runs in. */
55
+ export interface FlatEntry {
56
+ scenarioId: string;
57
+ runId: string;
58
+ stepFrom: number | null;
59
+ stepTo: number | null;
60
+ /** The owning scenario's own `scenario_steps.step_index`, not a flat position. */
61
+ stepIndex: number;
62
+ /** Always a tool row: a call row becomes a frame, or a stop. */
63
+ step: SerializedScenarioStep;
64
+ frame: Frame;
65
+ depth: number;
66
+ /** First / last entry of its frame — where the two mapping bodies run. */
67
+ first: boolean;
68
+ last: boolean;
69
+ }
70
+ /** Why the flat list stops short, and which call row said so. */
71
+ export interface FlatStop {
72
+ kind: "unusable";
73
+ reason: CallUnusable;
74
+ /** The scenario whose chain holds the call row. */
75
+ scenarioId: string;
76
+ /** That call row's own `stepIndex`. */
77
+ stepIndex: number;
78
+ segmentId?: string | null;
79
+ callIntent?: string | null;
80
+ }
81
+ export interface FlatChain {
82
+ entries: FlatEntry[];
83
+ /** Flat position the plan stops in front of. Undefined means the whole chain. */
84
+ stopAt?: number;
85
+ stopReason?: FlatStop;
86
+ }
87
+ /**
88
+ * Mirrors the service's `SEGMENT_MAX_DEPTH`. A constant, not a setting: one
89
+ * call row holds one answer, which is only correct because a call inside a
90
+ * level-two segment is never answered (R-CALL-12).
91
+ */
92
+ export declare const MAX_CALL_DEPTH = 2;
93
+ /**
94
+ * Lay a served chain out as one flat list of steps, with a frame per call.
95
+ *
96
+ * Stops at the first call it cannot run. The stop is *clamped* to the start of
97
+ * the outermost called frame that contains it: a plan must never run half a
98
+ * sub-task and leave its `resultMapLogic` unevaluated, because the caller's
99
+ * later steps read what that body returns (R-CALL-30).
100
+ */
101
+ export declare function flattenChain(scenario: SerializedScenario, opts?: {
102
+ maxDepth?: number;
103
+ /** The range a segment armed on its own covers (its own recording). */
104
+ range?: {
105
+ stepFrom: number;
106
+ stepTo: number;
107
+ } | null;
108
+ }): FlatChain;
109
+ /**
110
+ * Move a stop back to the start of the outermost called frame it falls in
111
+ * (R-CALL-30, 10.6 step 3).
112
+ *
113
+ * Used for the known-bad-step gate, which judges entries: a parked step in the
114
+ * middle of a sub-task stops the plan in front of the *sub-task*, not in front
115
+ * of the step, because stopping inside it would skip the body that hands its
116
+ * results back.
117
+ */
118
+ export declare function clampToFrameStart(entries: readonly FlatEntry[], index: number): number;
119
+ /**
120
+ * One frame per chain, for a payload with no calls in it and for the callers
121
+ * that build a plan by hand (tests, `bir replay`). Byte for byte the old
122
+ * behaviour: one frame, one scenario, one range.
123
+ */
124
+ export declare function flatEntriesOf(scenario: SerializedScenario): FlatEntry[];
125
+ //# sourceMappingURL=flatten.d.ts.map
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Flattening calls into frames (segmented.md 10.6).
3
+ *
4
+ * A chain may hold a **call**: one row that says "here the agent does this
5
+ * sub-task", answered by a recorded scenario the service inlines into the
6
+ * payload. Running it is not a second plan. It is one plan over one flat list
7
+ * of steps, in which some steps belong to another scenario and run with *its*
8
+ * parameters, *its* intent and *its* accumulated values.
9
+ *
10
+ * That is what a **frame** is: the scope one stretch of steps runs in. Frame 0
11
+ * is the caller's own. Entering a called frame evaluates the call's
12
+ * `paramMapLogic` to build the segment's parameters; leaving it evaluates
13
+ * `resultMapLogic` and merges the result back into the caller's `respParams`
14
+ * (R-CALL-29).
15
+ *
16
+ * Everything downstream reads the flat list rather than the chain: the gates
17
+ * judge the steps that will really run (R-CALL-30), recorded outputs come from
18
+ * the right recording and range (R-CALL-31), and a failure is reported on the
19
+ * scenario that owns the step (R-CALL-13).
20
+ *
21
+ * The flattener itself decides nothing about *which* segment answers a call —
22
+ * the service did that long before this payload was built. It only lays out
23
+ * what it was served, and stops in front of anything it cannot run.
24
+ */
25
+ /**
26
+ * Mirrors the service's `SEGMENT_MAX_DEPTH`. A constant, not a setting: one
27
+ * call row holds one answer, which is only correct because a call inside a
28
+ * level-two segment is never answered (R-CALL-12).
29
+ */
30
+ export const MAX_CALL_DEPTH = 2;
31
+ /**
32
+ * Lay a served chain out as one flat list of steps, with a frame per call.
33
+ *
34
+ * Stops at the first call it cannot run. The stop is *clamped* to the start of
35
+ * the outermost called frame that contains it: a plan must never run half a
36
+ * sub-task and leave its `resultMapLogic` unevaluated, because the caller's
37
+ * later steps read what that body returns (R-CALL-30).
38
+ */
39
+ export function flattenChain(scenario, opts = {}) {
40
+ const maxDepth = opts.maxDepth ?? MAX_CALL_DEPTH;
41
+ const entries = [];
42
+ let frameId = 0;
43
+ let stopAt;
44
+ let stopReason;
45
+ const root = {
46
+ id: frameId++,
47
+ depth: 0,
48
+ scenarioId: scenario.id,
49
+ runId: scenario.runId,
50
+ stepFrom: opts.range?.stepFrom ?? null,
51
+ stepTo: opts.range?.stepTo ?? null,
52
+ intent: scenario.intent ?? "",
53
+ respParams: {},
54
+ };
55
+ /** The entry a frame's steps start at, for the clamp. */
56
+ const frameStart = new Map();
57
+ const walk = (steps, frame, stack) => {
58
+ for (const step of steps) {
59
+ if (stopAt !== undefined)
60
+ return;
61
+ if (step.kind !== "segment") {
62
+ entries.push({
63
+ scenarioId: frame.scenarioId,
64
+ runId: frame.runId,
65
+ stepFrom: frame.stepFrom,
66
+ stepTo: frame.stepTo,
67
+ stepIndex: step.stepIndex,
68
+ step,
69
+ frame,
70
+ depth: frame.depth,
71
+ first: false,
72
+ last: false,
73
+ });
74
+ continue;
75
+ }
76
+ // A call the service already marked unrunnable, a third level, or a
77
+ // segment already on this stack (which would be a cycle): all the same
78
+ // thing to a plan — it stops here and the agent takes over (R-CALL-29).
79
+ const cycles = !!step.segment && stack.includes(step.segment.id);
80
+ const tooDeep = frame.depth + 1 > maxDepth;
81
+ const reason = step.unusable
82
+ ? step.unusable
83
+ : !step.segment || cycles || tooDeep
84
+ ? "depth"
85
+ : undefined;
86
+ if (reason) {
87
+ // The clamp: a stop inside a called frame moves back to the first entry
88
+ // of the outermost called frame containing it, so the caller never runs
89
+ // part of a sub-task (R-CALL-30).
90
+ let outermost = frame;
91
+ while (outermost && outermost.depth > 1)
92
+ outermost = outermost.parent;
93
+ stopAt =
94
+ frame.depth > 0 && outermost
95
+ ? (frameStart.get(outermost.id) ?? entries.length)
96
+ : entries.length;
97
+ stopReason = {
98
+ kind: "unusable",
99
+ reason,
100
+ scenarioId: frame.scenarioId,
101
+ stepIndex: step.stepIndex,
102
+ segmentId: step.segmentId ?? step.segment?.id ?? null,
103
+ callIntent: step.callIntent ?? null,
104
+ };
105
+ return;
106
+ }
107
+ const segment = step.segment;
108
+ const child = {
109
+ id: frameId++,
110
+ depth: frame.depth + 1,
111
+ scenarioId: segment.id,
112
+ runId: segment.runId,
113
+ stepFrom: segment.stepFrom,
114
+ stepTo: segment.stepTo,
115
+ intent: segment.intent ?? "",
116
+ parent: frame,
117
+ callerStepIndex: step.stepIndex,
118
+ callIntent: step.callIntent ?? undefined,
119
+ paramMapLogic: step.paramMapLogic ?? undefined,
120
+ resultMapLogic: step.resultMapLogic ?? undefined,
121
+ segmentId: segment.id,
122
+ chainRevision: segment.chainRevision,
123
+ paramsObject: segment.paramsObject ?? null,
124
+ respParams: {},
125
+ };
126
+ frameStart.set(child.id, entries.length);
127
+ walk(segment.steps ?? [], child, [...stack, segment.id]);
128
+ }
129
+ };
130
+ frameStart.set(root.id, 0);
131
+ walk(scenario.steps ?? [], root, [scenario.id]);
132
+ // `first` / `last` per frame, marked once the whole list is known: they are
133
+ // where the two mapping bodies run. A frame's entries are not necessarily
134
+ // contiguous — a call sitting in the middle of a chain interleaves its
135
+ // callee's entries with its own — so these are the frame's first and last
136
+ // entries overall, never the edges of a run of them.
137
+ const firstOf = new Map();
138
+ const lastOf = new Map();
139
+ entries.forEach((entry, index) => {
140
+ if (!firstOf.has(entry.frame.id))
141
+ firstOf.set(entry.frame.id, index);
142
+ lastOf.set(entry.frame.id, index);
143
+ });
144
+ entries.forEach((entry, index) => {
145
+ entry.first = firstOf.get(entry.frame.id) === index;
146
+ entry.last = lastOf.get(entry.frame.id) === index;
147
+ });
148
+ return { entries, stopAt, stopReason };
149
+ }
150
+ /**
151
+ * Move a stop back to the start of the outermost called frame it falls in
152
+ * (R-CALL-30, 10.6 step 3).
153
+ *
154
+ * Used for the known-bad-step gate, which judges entries: a parked step in the
155
+ * middle of a sub-task stops the plan in front of the *sub-task*, not in front
156
+ * of the step, because stopping inside it would skip the body that hands its
157
+ * results back.
158
+ */
159
+ export function clampToFrameStart(entries, index) {
160
+ const at = entries[index];
161
+ if (!at || at.depth === 0)
162
+ return index;
163
+ let outermost = at.frame;
164
+ while (outermost && outermost.depth > 1)
165
+ outermost = outermost.parent;
166
+ if (!outermost)
167
+ return index;
168
+ for (let i = 0; i < entries.length; i += 1) {
169
+ if (entries[i].frame.id === outermost.id)
170
+ return i;
171
+ }
172
+ return index;
173
+ }
174
+ /**
175
+ * One frame per chain, for a payload with no calls in it and for the callers
176
+ * that build a plan by hand (tests, `bir replay`). Byte for byte the old
177
+ * behaviour: one frame, one scenario, one range.
178
+ */
179
+ export function flatEntriesOf(scenario) {
180
+ return flattenChain(scenario).entries;
181
+ }
182
+ //# sourceMappingURL=flatten.js.map