@basein/runner 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/auth/client.d.ts +12 -0
- package/dist/auth/client.js +41 -0
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +70 -4
- package/dist/bin/bir.js +56 -3
- package/dist/control/server.d.ts +84 -1
- package/dist/control/server.js +555 -51
- package/dist/control/transcript.d.ts +40 -0
- package/dist/control/transcript.js +105 -0
- package/dist/record/recorder.d.ts +178 -4
- package/dist/record/recorder.js +6 -0
- package/dist/record/remote-recorder.d.ts +20 -2
- package/dist/record/remote-recorder.js +66 -6
- package/dist/replay/bundle.d.ts +10 -1
- package/dist/replay/bundle.js +41 -3
- package/dist/replay/controller.d.ts +179 -6
- package/dist/replay/controller.js +584 -55
- package/dist/replay/coverage.js +2 -2
- package/dist/replay/derive.d.ts +54 -7
- package/dist/replay/derive.js +174 -17
- package/dist/replay/flatten.d.ts +125 -0
- package/dist/replay/flatten.js +182 -0
- package/dist/replay/handover.d.ts +60 -0
- package/dist/replay/handover.js +82 -0
- package/dist/replay/logic.d.ts +11 -0
- package/dist/replay/logic.js +17 -0
- package/dist/replay/plan.d.ts +105 -8
- package/dist/replay/plan.js +309 -47
- package/dist/replay/source-run.d.ts +24 -10
- package/dist/replay/source-run.js +65 -30
- package/dist/replay/types.d.ts +108 -5
- package/dist/replay/types.js +33 -3
- package/docs/calculatedReplay.md +16 -8
- package/docs/calculatedReplayGuide.md +4 -4
- package/package.json +1 -1
package/dist/replay/coverage.js
CHANGED
|
@@ -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
|
package/dist/replay/derive.d.ts
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
package/dist/replay/derive.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
{
|
|
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
|
|
159
|
-
//
|
|
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]
|
|
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 {
|
|
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
|