@basein/runner 0.3.19 → 0.3.20
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/dist/bin/investigate.js +5 -0
- package/dist/control/server.d.ts +64 -0
- package/dist/control/server.js +637 -30
- package/dist/record/recorder.d.ts +37 -2
- package/dist/record/recorder.js +3 -0
- package/dist/record/remote-recorder.d.ts +9 -1
- package/dist/record/remote-recorder.js +30 -0
- package/dist/record/switchable-recorder.js +2 -0
- package/dist/replay/controller.js +3 -1
- package/dist/replay/derive.d.ts +10 -0
- package/dist/replay/derive.js +3 -0
- package/dist/replay/reads-only.d.ts +29 -0
- package/dist/replay/reads-only.js +261 -0
- package/docs/calculatedReplayGuide.md +4 -1
- package/package.json +1 -1
- package/production.md +14 -1
|
@@ -197,8 +197,13 @@ export declare function isRecorderHealthReporter(r: Recorder): r is Recorder & R
|
|
|
197
197
|
* callers must stop recording it. What the caller does *instead* of recording is
|
|
198
198
|
* v2's subject (docs/calculatedReplay.md); stopping is mandatory either way.
|
|
199
199
|
*/
|
|
200
|
-
/**
|
|
201
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Which of a segment's two keys scored against the live step (R-OUT-3).
|
|
202
|
+
* `decision`: no score at all — one of the calculated scenarios that can follow
|
|
203
|
+
* a plan's decision, handed out for the agent's next call (the service's
|
|
204
|
+
* offer1.md O3).
|
|
205
|
+
*/
|
|
206
|
+
export type MatchKey = "intent" | "firstStep" | "decision";
|
|
202
207
|
/** The cheap model's answer about one live step and one segment (R-VERIFY-1). */
|
|
203
208
|
export interface MatchVerdict {
|
|
204
209
|
model: string;
|
|
@@ -406,6 +411,36 @@ export interface IntentMatcher {
|
|
|
406
411
|
matchIntent(req: IntentMatchRequest, opts?: IntentMatchOptions): Promise<IntentMatchAnswer | null>;
|
|
407
412
|
}
|
|
408
413
|
export declare function isIntentMatcher(r: Recorder): r is Recorder & IntentMatcher;
|
|
414
|
+
/** What a turn sends when a plan of it has stopped at its decision (the service's offer1.md O3). */
|
|
415
|
+
export interface NextScenariosRequest {
|
|
416
|
+
/** The turn's identity, as a probe sends it (R-HIT-6). */
|
|
417
|
+
turnId?: string;
|
|
418
|
+
/** Always true from this runner (R-CALL-28). */
|
|
419
|
+
supportsCalls?: boolean;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* One calculated scenario that can follow a plan's decision: a hand-out in the
|
|
423
|
+
* shape of a match, with its own ticket, `key: "decision"`, and the tool of its
|
|
424
|
+
* first step. The service leaves withheld ones out.
|
|
425
|
+
*/
|
|
426
|
+
export interface NextHandOut extends RunMatch {
|
|
427
|
+
/** The tool its first served step calls; null when the service could not say. */
|
|
428
|
+
firstTool?: string | null;
|
|
429
|
+
}
|
|
430
|
+
/** The service's answer: the scenarios, and where the plan's decision is. */
|
|
431
|
+
export interface NextScenariosAnswer {
|
|
432
|
+
next: NextHandOut[];
|
|
433
|
+
decisionStep: number | null;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Optional capability: the calculated scenarios that can follow a decision
|
|
437
|
+
* (offer1.md O3) need a service. `null` is "none" — an older service (404), a
|
|
438
|
+
* cool-down, or any failure: the agent then does the work itself, as before.
|
|
439
|
+
*/
|
|
440
|
+
export interface NextScenarioSource {
|
|
441
|
+
nextScenarios(planId: string, req: NextScenariosRequest, opts?: IntentMatchOptions): Promise<NextScenariosAnswer | null>;
|
|
442
|
+
}
|
|
443
|
+
export declare function isNextScenarioSource(r: Recorder): r is Recorder & NextScenarioSource;
|
|
409
444
|
/**
|
|
410
445
|
* What a matched turn actually cost, reported once per match
|
|
411
446
|
* (docs/calculatedReplay.md §11).
|
package/dist/record/recorder.js
CHANGED
|
@@ -20,6 +20,9 @@ export function isServiceProber(r) {
|
|
|
20
20
|
export function isIntentMatcher(r) {
|
|
21
21
|
return typeof r.matchIntent === "function";
|
|
22
22
|
}
|
|
23
|
+
export function isNextScenarioSource(r) {
|
|
24
|
+
return typeof r.nextScenarios === "function";
|
|
25
|
+
}
|
|
23
26
|
export function isToolListReporter(r) {
|
|
24
27
|
return typeof r.reportTools === "function";
|
|
25
28
|
}
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
*/
|
|
51
51
|
import { type AuthSession } from "../auth/client.js";
|
|
52
52
|
import { type ServiceHealth } from "../util/service-health.js";
|
|
53
|
-
import type { AnswerCall, AttentionPlan, DisputeAnswer, ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata, ToolListEntry } from "./recorder.js";
|
|
53
|
+
import type { AnswerCall, AttentionPlan, DisputeAnswer, ExecutionReport, FlushOptions, IntentMatchAnswer, IntentMatchOptions, IntentMatchRequest, NextScenariosAnswer, NextScenariosRequest, Recorder, RecorderHealth, RecorderHealthReporter, RunCreation, RunMatch, RunMetrics, StartRunOptions, StepMetadata, ToolListEntry } from "./recorder.js";
|
|
54
54
|
/**
|
|
55
55
|
* Caps for the free text this recorder sends, in UTF-8 bytes. Tool payloads
|
|
56
56
|
* arrive capped at 64 KiB by their producers; text never passed through one,
|
|
@@ -206,6 +206,14 @@ export declare class RemoteRecorder implements Recorder, RecorderHealthReporter
|
|
|
206
206
|
* waiting, so an abandoned probe does not hold a socket for minutes.
|
|
207
207
|
*/
|
|
208
208
|
matchIntent(req: IntentMatchRequest, opts?: IntentMatchOptions): Promise<IntentMatchAnswer | null>;
|
|
209
|
+
/**
|
|
210
|
+
* The calculated scenarios that can follow a plan's decision (the service's
|
|
211
|
+
* offer1.md O3), each handed out with a ticket of its own. Asked once per
|
|
212
|
+
* decision hand-over, while the agent decides. An older service (404), a
|
|
213
|
+
* cool-down or any failure answers null: the agent then does the work
|
|
214
|
+
* itself, exactly as before.
|
|
215
|
+
*/
|
|
216
|
+
nextScenarios(planId: string, req: NextScenariosRequest, opts?: IntentMatchOptions): Promise<NextScenariosAnswer | null>;
|
|
209
217
|
/**
|
|
210
218
|
* The similar-prompt match for a run's create call (null = fresh run). Like
|
|
211
219
|
* {@link runCreated}, answers null at once while a failure is on record —
|
|
@@ -713,6 +713,36 @@ export class RemoteRecorder {
|
|
|
713
713
|
return null;
|
|
714
714
|
}
|
|
715
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* The calculated scenarios that can follow a plan's decision (the service's
|
|
718
|
+
* offer1.md O3), each handed out with a ticket of its own. Asked once per
|
|
719
|
+
* decision hand-over, while the agent decides. An older service (404), a
|
|
720
|
+
* cool-down or any failure answers null: the agent then does the work
|
|
721
|
+
* itself, exactly as before.
|
|
722
|
+
*/
|
|
723
|
+
async nextScenarios(planId, req, opts = {}) {
|
|
724
|
+
try {
|
|
725
|
+
if (!this.mayHotWait())
|
|
726
|
+
return null;
|
|
727
|
+
const answer = await this.request("POST", `/scenarios/${encodeURIComponent(planId)}/next`, JSON.stringify(req), { signal: opts.signal, timeoutMs: opts.timeoutMs ?? PROBE_TIMEOUT_MS });
|
|
728
|
+
const b = answer && typeof answer === "object" ? answer : {};
|
|
729
|
+
const next = Array.isArray(b.next)
|
|
730
|
+
? b.next.filter((n) => !!n && typeof n === "object" && typeof n.scenarioId === "string")
|
|
731
|
+
: [];
|
|
732
|
+
return { next, decisionStep: typeof b.decisionStep === "number" ? b.decisionStep : null };
|
|
733
|
+
}
|
|
734
|
+
catch (err) {
|
|
735
|
+
if (!opts.signal?.aborted) {
|
|
736
|
+
logEvery("recorder.next_scenarios_failed", 30_000, "replay.next_failed", {
|
|
737
|
+
error: errText(err),
|
|
738
|
+
why: err instanceof ServiceError && err.status === 404
|
|
739
|
+
? "the service has no next scenarios — an older service, or not this account's plan"
|
|
740
|
+
: "no next scenarios this time — the agent does the work after the decision",
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
716
746
|
/**
|
|
717
747
|
* The similar-prompt match for a run's create call (null = fresh run). Like
|
|
718
748
|
* {@link runCreated}, answers null at once while a failure is on record —
|
|
@@ -49,6 +49,8 @@ const NEUTRAL = {
|
|
|
49
49
|
settledMatch: () => Promise.resolve(null),
|
|
50
50
|
runCreated: () => Promise.resolve({ created: true }),
|
|
51
51
|
matchIntent: () => Promise.resolve(null),
|
|
52
|
+
// Signed out: no scenario follows a decision (offer1.md O3); the agent goes on.
|
|
53
|
+
nextScenarios: () => Promise.resolve(null),
|
|
52
54
|
reportExecution: () => undefined,
|
|
53
55
|
backfillContext: () => undefined,
|
|
54
56
|
probeIfFailing: () => undefined,
|
|
@@ -538,7 +538,7 @@ export class ReplayController {
|
|
|
538
538
|
// command nobody asked for.
|
|
539
539
|
let deriveCtx = ctx;
|
|
540
540
|
if (ctx.recentResults?.length && sinkOfPlan(planned, host?.sinks) === "command") {
|
|
541
|
-
deriveCtx = { liveCall: ctx.liveCall, agentDay: ctx.agentDay };
|
|
541
|
+
deriveCtx = { liveCall: ctx.liveCall, agentDay: ctx.agentDay, atDecision: ctx.atDecision };
|
|
542
542
|
logLine("replay.derive_context", {
|
|
543
543
|
scenario: scenario.id,
|
|
544
544
|
shell: hasShellStep(planned, host?.sinks) || undefined,
|
|
@@ -2352,6 +2352,8 @@ export class ReplayController {
|
|
|
2352
2352
|
fetchImpl: this.opts.fetchImpl,
|
|
2353
2353
|
liveCall: ctx.liveCall,
|
|
2354
2354
|
recentResults: ctx.recentResults,
|
|
2355
|
+
// A reading for a scenario that follows a plan's decision (offer1.md O4).
|
|
2356
|
+
...(ctx.atDecision ? { atDecision: ctx.atDecision } : {}),
|
|
2355
2357
|
// The request itself ends too. Without this a hung service or model held
|
|
2356
2358
|
// the socket for up to 300 s after every wait on it had given up. Long
|
|
2357
2359
|
// enough to cover both waits that can use its answer (the arm and the
|
package/dist/replay/derive.d.ts
CHANGED
|
@@ -53,6 +53,16 @@ export interface DeriveContext {
|
|
|
53
53
|
* into the session only after the prompt hook (issue 1212).
|
|
54
54
|
*/
|
|
55
55
|
agentDay?: () => AgentDay | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* A reading for a scenario that can follow a plan's decision, made while the
|
|
58
|
+
* agent decides (the service's offer1.md O4): the plan, and its decision
|
|
59
|
+
* step. With it the service reads this turn's results without a live call,
|
|
60
|
+
* and the prompt as the user's request. Only the service reads it.
|
|
61
|
+
*/
|
|
62
|
+
atDecision?: {
|
|
63
|
+
scenarioId: string;
|
|
64
|
+
stepIndex: number;
|
|
65
|
+
};
|
|
56
66
|
}
|
|
57
67
|
/**
|
|
58
68
|
* The service, which derives when this runner has no key of its own
|
package/dist/replay/derive.js
CHANGED
|
@@ -347,6 +347,9 @@ async function deriveViaService(opts, service, schema, keys) {
|
|
|
347
347
|
// 2023-11-30 …"), which the service turns into the day it reads from
|
|
348
348
|
// (issue 1212). Older services ignore it.
|
|
349
349
|
...(opts.todaySaid ? { todaySaid: opts.todaySaid } : {}),
|
|
350
|
+
// A reading made at a plan's decision (offer1.md O4): the service then
|
|
351
|
+
// takes this turn's results with no live call. Older services ignore it.
|
|
352
|
+
...(opts.atDecision ? { atDecision: opts.atDecision } : {}),
|
|
350
353
|
}),
|
|
351
354
|
signal: opts.signal ?? timeoutSignal(),
|
|
352
355
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a tool call may change something, judged by its name alone — the
|
|
3
|
+
* runner's copy of the service's own rule (BaseIn src/scenarios/correctionCheck.ts,
|
|
4
|
+
* `readsOnly`, `commandReadsOnly`, `callMayChange`), kept word for word so the
|
|
5
|
+
* two never disagree about the same call.
|
|
6
|
+
*
|
|
7
|
+
* Used where a call after a plan's decision is the agent starting a way of its
|
|
8
|
+
* own (the service's offer1.md O7), and where a plan started after a call
|
|
9
|
+
* would make it a second time (O8). When in doubt, a call is a change: that
|
|
10
|
+
* only ends a set of scenarios early or keeps a plan from starting, never lets
|
|
11
|
+
* a second write through.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* A tool that only reads: Claude Code's read-only tools, then the name — a
|
|
15
|
+
* reading word and no writing word (`calendar_search_events`, `getJiraIssue`;
|
|
16
|
+
* `run_sql` no: `run` writes). A name with both (`get_or_create_contact`)
|
|
17
|
+
* changes something.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readsOnly(toolName: string): boolean;
|
|
20
|
+
/** Whether a shell command only looks: every part of it is a looking command, and nothing is written to a file. */
|
|
21
|
+
export declare function commandReadsOnly(command: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether one call may change something. A Bash call changes nothing only
|
|
24
|
+
* when its command is there and only looks; any other tool is a change unless
|
|
25
|
+
* {@link readsOnly} says it only reads — a tool it cannot tell about counts as
|
|
26
|
+
* a change. `input` is the call's arguments, as an object or as JSON text.
|
|
27
|
+
*/
|
|
28
|
+
export declare function callMayChange(toolName: string, input: unknown): boolean;
|
|
29
|
+
//# sourceMappingURL=reads-only.d.ts.map
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a tool call may change something, judged by its name alone — the
|
|
3
|
+
* runner's copy of the service's own rule (BaseIn src/scenarios/correctionCheck.ts,
|
|
4
|
+
* `readsOnly`, `commandReadsOnly`, `callMayChange`), kept word for word so the
|
|
5
|
+
* two never disagree about the same call.
|
|
6
|
+
*
|
|
7
|
+
* Used where a call after a plan's decision is the agent starting a way of its
|
|
8
|
+
* own (the service's offer1.md O7), and where a plan started after a call
|
|
9
|
+
* would make it a second time (O8). When in doubt, a call is a change: that
|
|
10
|
+
* only ends a set of scenarios early or keeps a plan from starting, never lets
|
|
11
|
+
* a second write through.
|
|
12
|
+
*/
|
|
13
|
+
/** Built-in tools that only read (Claude Code's). */
|
|
14
|
+
const READ_TOOLS = new Set([
|
|
15
|
+
"Read",
|
|
16
|
+
"Grep",
|
|
17
|
+
"Glob",
|
|
18
|
+
"LS",
|
|
19
|
+
"WebFetch",
|
|
20
|
+
"WebSearch",
|
|
21
|
+
"NotebookRead",
|
|
22
|
+
"TodoWrite",
|
|
23
|
+
"TaskOutput",
|
|
24
|
+
"BashOutput",
|
|
25
|
+
]);
|
|
26
|
+
/** Built-in tools that change files or run commands (Claude Code's). */
|
|
27
|
+
const CHANGING_BUILTINS = new Set([
|
|
28
|
+
"Write",
|
|
29
|
+
"Edit",
|
|
30
|
+
"MultiEdit",
|
|
31
|
+
"NotebookEdit",
|
|
32
|
+
"Bash",
|
|
33
|
+
"KillShell",
|
|
34
|
+
]);
|
|
35
|
+
// Not `query`, `select` or `check`: a database query, a selected option, a
|
|
36
|
+
// check-in can change something.
|
|
37
|
+
const READ_WORDS = new Set([
|
|
38
|
+
"list",
|
|
39
|
+
"get",
|
|
40
|
+
"search",
|
|
41
|
+
"find",
|
|
42
|
+
"read",
|
|
43
|
+
"fetch",
|
|
44
|
+
"lookup",
|
|
45
|
+
"count",
|
|
46
|
+
"view",
|
|
47
|
+
"show",
|
|
48
|
+
"describe",
|
|
49
|
+
"retrieve",
|
|
50
|
+
"browse",
|
|
51
|
+
"peek",
|
|
52
|
+
"inspect",
|
|
53
|
+
"snapshot",
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* A word that may change something: a name holding one writes, whatever else
|
|
57
|
+
* it says (`get_or_create_contact`, `find_and_replace`).
|
|
58
|
+
*/
|
|
59
|
+
const WRITE_WORDS = new Set([
|
|
60
|
+
"create",
|
|
61
|
+
"add",
|
|
62
|
+
"set",
|
|
63
|
+
"update",
|
|
64
|
+
"upsert",
|
|
65
|
+
"insert",
|
|
66
|
+
"put",
|
|
67
|
+
"post",
|
|
68
|
+
"send",
|
|
69
|
+
"delete",
|
|
70
|
+
"remove",
|
|
71
|
+
"mark",
|
|
72
|
+
"replace",
|
|
73
|
+
"archive",
|
|
74
|
+
"move",
|
|
75
|
+
"assign",
|
|
76
|
+
"book",
|
|
77
|
+
"cancel",
|
|
78
|
+
"write",
|
|
79
|
+
"edit",
|
|
80
|
+
"modify",
|
|
81
|
+
"save",
|
|
82
|
+
"submit",
|
|
83
|
+
"approve",
|
|
84
|
+
"reject",
|
|
85
|
+
"close",
|
|
86
|
+
"open",
|
|
87
|
+
"merge",
|
|
88
|
+
"push",
|
|
89
|
+
"publish",
|
|
90
|
+
"upload",
|
|
91
|
+
"invite",
|
|
92
|
+
"schedule",
|
|
93
|
+
"reply",
|
|
94
|
+
"forward",
|
|
95
|
+
"transfer",
|
|
96
|
+
"pay",
|
|
97
|
+
"refund",
|
|
98
|
+
"notify",
|
|
99
|
+
"run",
|
|
100
|
+
"execute",
|
|
101
|
+
"exec",
|
|
102
|
+
"apply",
|
|
103
|
+
"reset",
|
|
104
|
+
"start",
|
|
105
|
+
"stop",
|
|
106
|
+
"kill",
|
|
107
|
+
"enable",
|
|
108
|
+
"disable",
|
|
109
|
+
"grant",
|
|
110
|
+
"revoke",
|
|
111
|
+
"rename",
|
|
112
|
+
"tag",
|
|
113
|
+
"label",
|
|
114
|
+
"comment",
|
|
115
|
+
]);
|
|
116
|
+
/** A tool name's words: `getJiraIssue` → get, jira, issue; `calendar_search_events` → calendar, search, events. */
|
|
117
|
+
function nameWords(name) {
|
|
118
|
+
return name
|
|
119
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
120
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
121
|
+
.toLowerCase()
|
|
122
|
+
.split(/[^a-z\d]+/)
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A tool that only reads: Claude Code's read-only tools, then the name — a
|
|
127
|
+
* reading word and no writing word (`calendar_search_events`, `getJiraIssue`;
|
|
128
|
+
* `run_sql` no: `run` writes). A name with both (`get_or_create_contact`)
|
|
129
|
+
* changes something.
|
|
130
|
+
*/
|
|
131
|
+
export function readsOnly(toolName) {
|
|
132
|
+
if (READ_TOOLS.has(toolName))
|
|
133
|
+
return true;
|
|
134
|
+
if (CHANGING_BUILTINS.has(toolName))
|
|
135
|
+
return false;
|
|
136
|
+
const words = nameWords(toolName.replace(/^mcp__.+?__/, ""));
|
|
137
|
+
if (words.some((w) => WRITE_WORDS.has(w)))
|
|
138
|
+
return false;
|
|
139
|
+
return words.some((w) => READ_WORDS.has(w));
|
|
140
|
+
}
|
|
141
|
+
/** Commands that only look (`ls`, `git status`, `grep …`). */
|
|
142
|
+
const READ_COMMANDS = new Set([
|
|
143
|
+
"ls",
|
|
144
|
+
"dir",
|
|
145
|
+
"cat",
|
|
146
|
+
"type",
|
|
147
|
+
"head",
|
|
148
|
+
"tail",
|
|
149
|
+
"less",
|
|
150
|
+
"more",
|
|
151
|
+
"grep",
|
|
152
|
+
"rg",
|
|
153
|
+
"egrep",
|
|
154
|
+
"fgrep",
|
|
155
|
+
"find",
|
|
156
|
+
"wc",
|
|
157
|
+
"pwd",
|
|
158
|
+
"echo",
|
|
159
|
+
"printf",
|
|
160
|
+
"date",
|
|
161
|
+
"whoami",
|
|
162
|
+
"which",
|
|
163
|
+
"where",
|
|
164
|
+
"env",
|
|
165
|
+
"printenv",
|
|
166
|
+
"stat",
|
|
167
|
+
"file",
|
|
168
|
+
"du",
|
|
169
|
+
"df",
|
|
170
|
+
"tree",
|
|
171
|
+
"jq",
|
|
172
|
+
"sort",
|
|
173
|
+
"uniq",
|
|
174
|
+
"cut",
|
|
175
|
+
"awk",
|
|
176
|
+
"diff",
|
|
177
|
+
"cmp",
|
|
178
|
+
"basename",
|
|
179
|
+
"dirname",
|
|
180
|
+
"realpath",
|
|
181
|
+
"readlink",
|
|
182
|
+
"true",
|
|
183
|
+
"false",
|
|
184
|
+
"test",
|
|
185
|
+
"sleep",
|
|
186
|
+
"ps",
|
|
187
|
+
"uname",
|
|
188
|
+
"hostname",
|
|
189
|
+
"id",
|
|
190
|
+
"nproc",
|
|
191
|
+
"free",
|
|
192
|
+
"uptime",
|
|
193
|
+
"node --version",
|
|
194
|
+
"npm --version",
|
|
195
|
+
]);
|
|
196
|
+
const READ_GIT = new Set([
|
|
197
|
+
"status",
|
|
198
|
+
"log",
|
|
199
|
+
"diff",
|
|
200
|
+
"show",
|
|
201
|
+
"branch",
|
|
202
|
+
"rev-parse",
|
|
203
|
+
"ls-files",
|
|
204
|
+
"blame",
|
|
205
|
+
"remote",
|
|
206
|
+
"describe",
|
|
207
|
+
"config --get",
|
|
208
|
+
"grep",
|
|
209
|
+
"shortlog",
|
|
210
|
+
"tag --list",
|
|
211
|
+
]);
|
|
212
|
+
/** Whether a shell command only looks: every part of it is a looking command, and nothing is written to a file. */
|
|
213
|
+
export function commandReadsOnly(command) {
|
|
214
|
+
const text = command.trim();
|
|
215
|
+
if (!text)
|
|
216
|
+
return true;
|
|
217
|
+
// A redirect into a file (not into /dev/null, not 2>&1), tee, or an in-place edit changes something.
|
|
218
|
+
if (/(^|[^0-9&])>{1,2}\s*(?!&|\/dev\/null)\S/.test(text) || /\btee\b/.test(text) || /\bsed\b[^|;&]*\s-i\b/.test(text)) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (/\bfind\b[^|;&]*\s-(delete|exec|execdir|ok)\b/.test(text))
|
|
222
|
+
return false;
|
|
223
|
+
const parts = text
|
|
224
|
+
.split(/\|\||&&|[|;\n]/)
|
|
225
|
+
.map((p) => p.trim())
|
|
226
|
+
.filter(Boolean);
|
|
227
|
+
return parts.every((part) => {
|
|
228
|
+
const words = part.replace(/^(\w+=\S+\s+)+/, "").split(/\s+/);
|
|
229
|
+
const cmd = (words[0] ?? "").replace(/^.*[\\/]/, "");
|
|
230
|
+
if (cmd === "git")
|
|
231
|
+
return READ_GIT.has(words[1] ?? "") || READ_GIT.has(`${words[1] ?? ""} ${words[2] ?? ""}`);
|
|
232
|
+
if (cmd === "sed" || cmd === "awk")
|
|
233
|
+
return !/\s-i\b/.test(part);
|
|
234
|
+
return READ_COMMANDS.has(cmd) || READ_COMMANDS.has(`${cmd} ${words[1] ?? ""}`);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Whether one call may change something. A Bash call changes nothing only
|
|
239
|
+
* when its command is there and only looks; any other tool is a change unless
|
|
240
|
+
* {@link readsOnly} says it only reads — a tool it cannot tell about counts as
|
|
241
|
+
* a change. `input` is the call's arguments, as an object or as JSON text.
|
|
242
|
+
*/
|
|
243
|
+
export function callMayChange(toolName, input) {
|
|
244
|
+
if (toolName === "Bash") {
|
|
245
|
+
let value = input;
|
|
246
|
+
if (typeof value === "string") {
|
|
247
|
+
try {
|
|
248
|
+
value = JSON.parse(value);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
value = undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const command = value && typeof value === "object" && typeof value.command === "string"
|
|
255
|
+
? value.command
|
|
256
|
+
: "";
|
|
257
|
+
return command.trim() === "" || !commandReadsOnly(command);
|
|
258
|
+
}
|
|
259
|
+
return !readsOnly(toolName);
|
|
260
|
+
}
|
|
261
|
+
//# sourceMappingURL=reads-only.js.map
|
|
@@ -495,7 +495,10 @@ Everything replay decides is one line. `grep` for these:
|
|
|
495
495
|
| `replay.step_not_taken` | A step's test said this request does not need it, and nothing was sent for it ([calculatedReplay.md](calculatedReplay.md) §9.2). `plan.armed … tests=` lists the tested steps by their place in the plan: `4:hand_over,11:skip` |
|
|
496
496
|
| `replay.diverge` / `replay.compose` | Off-script, and the recovery |
|
|
497
497
|
| `replay.handover` | The plan stopped and handed the rest of the task to the model; `kind=` and `step=` say where. `kind=decision` (0.3.19) is a plan that ran the steps before its decision and stopped there, as planned (`via=divergence` when a divergence's bundle ran up to it) |
|
|
498
|
-
| `run.fragment … decision=true` / `run.fragment_dropped` | After a plan's decision: the agent's own work from its next call is recorded as a fragment, from which the service makes
|
|
498
|
+
| `run.fragment … decision=true` / `run.fragment_dropped` | After a plan's decision: the agent's own work from its next call is recorded as a fragment, from which the service makes a calculated scenario of that way / a plan the intent check armed, or a next scenario, took that call instead, so nothing is recorded as the agent's own |
|
|
499
|
+
| `replay.next_ready` / `replay.next_hit` / `replay.next_miss` | 0.3.20: the calculated scenarios that can follow a plan's decision, armed while the agent decided / the agent's call was one's first step, value for value, so it runs in the call's place / none was used: `reason=other_values` (the same first tool with other values), `own_way` (a call that may change something and starts none of them), `not_ready` (the one that starts with that tool was not armed in time), `calls_limit` (5 calls started none), `turn_ended`. A call that only reads ends nothing |
|
|
500
|
+
| `run.fragment_closed` | 0.3.20: a scenario that follows the decision took over after the agent's own looks had opened the fragment; the fragment ends there, with what the agent did itself |
|
|
501
|
+
| `replay.decision … code=would_repeat` | 0.3.20: a plan an intent match armed after the agent's call would have made that call again (the same tool that may change something, or for a shell the same command), so it was not started (never twice) |
|
|
499
502
|
| `replay.vetoed` | A direct plan retired without running anything: `run_scenario` was not the first call, or never arrived |
|
|
500
503
|
| `replay.run_ambiguous` | Two sessions had `run_scenario` pending and the request named neither; nothing ran |
|
|
501
504
|
| `replay.cancelled` / `replay.note_kept` | A direct run's caller went away (Esc); no further step started, and the note is kept for the model's next call |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basein/runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
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",
|
package/production.md
CHANGED
|
@@ -601,11 +601,24 @@ Truncation is always marked with the original size.
|
|
|
601
601
|
- A plan whose chain has a decision keeps these protections when it stops earlier for another reason (a parked or failed step, a judgement, the budget). Its note has no intent line and no parameters, and lists the remaining steps only up to the decision. Its directive leaves the intent out. An agent sent on to the recorded half's steps would start that half's segment, and the plan would have decided after all.
|
|
602
602
|
- The report is `fell_back` (or `failed` when no step ran) with `fallbackKind: "decision"` and `fallbackStepIndex` the decision step. The decision step gets no verdict, so nothing is counted against it.
|
|
603
603
|
- When a step ran, the hand-over takes the plan's usage mark: the plan is billed up to the stop, and the agent's decision and the half it took are not on its bill. This holds for a plan the prompt started and for one an intent match started. Every other hand-over still takes no mark.
|
|
604
|
-
- The agent's own next call opens the fragment, marked `metadata.decisionOf = {scenarioId, stepIndex}
|
|
604
|
+
- The agent's own next call opens the fragment, marked `metadata.decisionOf = {scenarioId, stepIndex, request}` (`request`, the turn's prompt, redacted and cut to 4,000 characters, from 0.3.20): the service makes a calculated scenario of that way from it, calculated with the request (the service's offer1.md O2). When a plan the intent check armed, or a next scenario (below), takes that call instead, the fragment is dropped (`run.fragment_dropped`).
|
|
605
605
|
- `ranThrough` sends one entry per plan, with the plan's own position (the decision step itself for a decision). The service maps it onto the recording.
|
|
606
606
|
- `bir replay` stops in front of a decision and says so (`stopped before step N …: the plan's decision`).
|
|
607
607
|
- A runner up to 0.3.18 reads the unknown kind as `known_bad_step`: it stops there too, but its note and report name a failure.
|
|
608
608
|
|
|
609
|
+
**What can follow a decision (0.3.20, the service's offer1.md).** The first time a way is taken after a decision, the agent does it and its fragment becomes a calculated scenario of that decision. The next time:
|
|
610
|
+
|
|
611
|
+
- When a plan stops at its decision, the runner asks the service once, in the background, which calculated scenarios can follow it (`POST /scenarios/:planId/next`, body `{turnId, supportsCalls}`; at most 4, each handed out with its own ticket and `key: "decision"`). An older service, a 404 or any failure is "none". It arms each one while the agent reads the note and decides: its values are read from the turn's request and this turn's results (the reading carries `atDecision: {scenarioId, stepIndex}`), and settled. `replay.next_ready` says how many are ready.
|
|
612
|
+
- The agent's next calls are checked against them before the intent probe. A call that is a next scenario's first step, **value for value** (every argument the step's own; the pin guard's "plausibly the step" is not enough), is a hit: it is answered in its place with that scenario's directive (`replay.next_hit`), the agent calls `run_scenario`, and the scenario runs every step, that one included, once. It is booked on its own ticket. A scenario whose first step is inside a sub-task (a call row) can never be the agent's call in place: it is never compared.
|
|
613
|
+
- Each scenario is checkable as soon as the service answers; its arm runs on its own. A call waits only for the arm of a scenario that starts with the call's own tool (at most 6 s, `BIR_NEXT_HIT_WAIT_MS`), and not at all for the others. While the service has not answered yet, a call waits for that answer only, at most 1 s.
|
|
614
|
+
- When the background reading left a value of the first step empty, the call waits while it is read once more with the agent's call as `liveCall`; the service fills such a value from the call. A value read differently is never read again: an e-mail to carlos is never the recorded e-mail to olga.
|
|
615
|
+
- **A call that only reads keeps the set** (by the service's own rule, mirrored in `src/replay/reads-only.ts`: Claude Code's read tools, a name with a reading word and no writing word, a Bash command that only looks). The set ends unused (`replay.next_miss`) at the agent's first call that may change something and is no hit: `other_values` when the call has a scenario's first tool with other values, `own_way` when it starts none of them — the agent is doing a way of its own, and no scenario may start in the middle of it — and `not_ready` when the scenario that starts with that tool was not armed in time. It also ends after 5 calls that start none (`calls_limit`), or at the turn's end. A scenario not armed in time for a call that only reads misses that call only. Unused ones never run and are never reported; the agent's own work is recorded as the fragment, as before.
|
|
616
|
+
- A later decision in the same turn starts a new set: what could follow the earlier one is dropped, never checked again.
|
|
617
|
+
- A hit after the agent's own looks had opened the fragment ends that fragment there (`run.fragment_closed`, finished with what the agent did itself): the scenario's own calls, pinned in steer mode, are never recorded into it.
|
|
618
|
+
- `/health` shows the set per session: `next: {entries, armed, calls, ended}`.
|
|
619
|
+
|
|
620
|
+
**Never twice (0.3.20, offer1.md O8).** A plan an intent match arms after the agent's call — not in its place — whose steps call the same tool is not started: it would make that call a second time (issue fx2: the agent's e-mail, then the plan's own). `replay.decision code=would_repeat`. A tool that only reads, by the same rule as above (`crm_search_customers`, `Read`), may repeat. For Bash and PowerShell the call is its command: only a step that would run that very command counts.
|
|
621
|
+
|
|
609
622
|
**Measured cost.**
|
|
610
623
|
|
|
611
624
|
- A turn's cost is measured only when tokens were found and every model of the turn has a price in the runner's table. This holds for every host, Claude Code too. A model outside the table used to cost $0, and that zero was called measured.
|