@siuver/omp-debug-mode 0.1.3 → 0.1.5
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/CHANGELOG.md +20 -0
- package/README.md +154 -94
- package/package.json +3 -3
- package/src/debug-mode.ts +350 -334
- package/src/evidence.ts +169 -0
- package/src/gate.ts +72 -25
- package/src/machine.ts +334 -0
- package/src/methodology.ts +106 -32
- package/src/probes.ts +9 -9
- package/src/state.ts +288 -36
- package/src/tools.ts +80 -6
- package/src/ui.ts +73 -24
- package/src/review-actions.ts +0 -22
package/src/evidence.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { EvidenceArtifact, EvidenceMethod, EvidenceRequest, EvidenceView } from "./state";
|
|
4
|
+
|
|
5
|
+
/** Upper bound on accepted plan entries so one model reply cannot flood the gate. */
|
|
6
|
+
export const MAX_EVIDENCE_REQUESTS = 12;
|
|
7
|
+
|
|
8
|
+
const METHODS: readonly EvidenceMethod[] = ["agent_inspection", "runtime_probe", "user_report", "user_artifact"];
|
|
9
|
+
|
|
10
|
+
export interface EvidencePlanParseResult {
|
|
11
|
+
found: boolean;
|
|
12
|
+
valid: boolean;
|
|
13
|
+
requests: EvidenceRequest[];
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
18
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isNonEmptyStringArray(value: unknown): value is string[] {
|
|
22
|
+
return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hasOnlyUniqueValues(values: readonly string[]): boolean {
|
|
26
|
+
return new Set(values).size === values.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse the `<evidence_plan>` block from a model reply. Validation is
|
|
31
|
+
* all-or-nothing: any malformed or invalid entry rejects the whole plan so a
|
|
32
|
+
* partial plan can never silently drop a hypothesis.
|
|
33
|
+
*/
|
|
34
|
+
export function parseEvidencePlan(text: string): EvidencePlanParseResult {
|
|
35
|
+
const match = /<evidence_plan>([\s\S]*?)<\/evidence_plan>/.exec(text);
|
|
36
|
+
if (!match) return { found: false, valid: false, requests: [] };
|
|
37
|
+
const fail = (error: string): EvidencePlanParseResult => ({ found: true, valid: false, requests: [], error });
|
|
38
|
+
|
|
39
|
+
let parsed: unknown;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(match[1].trim());
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return fail(`malformed JSON in <evidence_plan>: ${(error as Error).message}`);
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(parsed)) return fail("<evidence_plan> must contain a JSON array");
|
|
46
|
+
if (parsed.length === 0) return fail("<evidence_plan> array must not be empty");
|
|
47
|
+
if (parsed.length > MAX_EVIDENCE_REQUESTS) {
|
|
48
|
+
return fail(`<evidence_plan> has ${parsed.length} entries; at most ${MAX_EVIDENCE_REQUESTS} are allowed`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const requests: EvidenceRequest[] = [];
|
|
52
|
+
const seenIds = new Set<string>();
|
|
53
|
+
for (const entry of parsed) {
|
|
54
|
+
if (typeof entry !== "object" || entry === null) return fail("every evidence plan entry must be an object");
|
|
55
|
+
const record = entry as Record<string, unknown>;
|
|
56
|
+
if (!isNonEmptyString(record.id)) return fail("every evidence plan entry needs a non-empty id");
|
|
57
|
+
if (seenIds.has(record.id)) return fail(`duplicate evidence request id ${record.id}`);
|
|
58
|
+
seenIds.add(record.id);
|
|
59
|
+
if (!isNonEmptyStringArray(record.hypothesisIds) || !hasOnlyUniqueValues(record.hypothesisIds)) {
|
|
60
|
+
return fail(`request ${record.id} needs one or more unique non-empty hypothesisIds`);
|
|
61
|
+
}
|
|
62
|
+
if (!METHODS.includes(record.method as EvidenceMethod)) {
|
|
63
|
+
return fail(`request ${record.id} has unknown method ${JSON.stringify(record.method)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!isNonEmptyString(record.title)) return fail(`request ${record.id} needs a non-empty title`);
|
|
66
|
+
if (!isNonEmptyString(record.rationale)) {
|
|
67
|
+
return fail(`request ${record.id} needs a non-empty rationale explaining why this method is decisive`);
|
|
68
|
+
}
|
|
69
|
+
if (!isNonEmptyStringArray(record.instructions)) {
|
|
70
|
+
return fail(`request ${record.id} needs one or more non-empty instructions`);
|
|
71
|
+
}
|
|
72
|
+
if (record.artifactHint !== undefined && !isNonEmptyString(record.artifactHint)) {
|
|
73
|
+
return fail(`request ${record.id} has an empty artifactHint`);
|
|
74
|
+
}
|
|
75
|
+
requests.push({
|
|
76
|
+
id: record.id,
|
|
77
|
+
hypothesisIds: record.hypothesisIds,
|
|
78
|
+
method: record.method as EvidenceMethod,
|
|
79
|
+
title: record.title,
|
|
80
|
+
rationale: record.rationale,
|
|
81
|
+
instructions: record.instructions,
|
|
82
|
+
...(record.artifactHint === undefined ? {} : { artifactHint: record.artifactHint }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return { found: true, valid: true, requests };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ArtifactValidation =
|
|
89
|
+
| { ok: true; artifact: Omit<EvidenceArtifact, "id" | "addedAt" | "requestId"> }
|
|
90
|
+
| { ok: false; reason: string };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a user-supplied path and capture filesystem metadata only. The file
|
|
94
|
+
* is referenced in place: its contents are never read, copied or deleted here.
|
|
95
|
+
*/
|
|
96
|
+
export function validateEvidenceArtifact(rawPath: string, cwd: string): ArtifactValidation {
|
|
97
|
+
const trimmed = rawPath.trim();
|
|
98
|
+
if (trimmed.length === 0) return { ok: false, reason: "no evidence file path provided" };
|
|
99
|
+
const absolute = path.resolve(cwd, trimmed);
|
|
100
|
+
let stats: fs.Stats;
|
|
101
|
+
try {
|
|
102
|
+
stats = fs.statSync(absolute);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return { ok: false, reason: `cannot stat ${absolute}: ${(error as Error).message}` };
|
|
105
|
+
}
|
|
106
|
+
if (!stats.isFile()) return { ok: false, reason: `${absolute} is not a regular file` };
|
|
107
|
+
try {
|
|
108
|
+
fs.accessSync(absolute, fs.constants.R_OK);
|
|
109
|
+
} catch {
|
|
110
|
+
return { ok: false, reason: `${absolute} is not readable` };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
artifact: { path: absolute, name: path.basename(absolute), size: stats.size, mtimeMs: stats.mtimeMs },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Human-readable evidence description for the blackboard and the model-facing
|
|
120
|
+
* tool. Availability is re-checked on every call: a missing or unreadable file
|
|
121
|
+
* is never treated as captured evidence.
|
|
122
|
+
*/
|
|
123
|
+
export function describeEvidence(view: EvidenceView): string {
|
|
124
|
+
const { requests, artifacts, observations } = view;
|
|
125
|
+
if (requests.length === 0 && artifacts.length === 0 && observations.length === 0) {
|
|
126
|
+
return "(none)";
|
|
127
|
+
}
|
|
128
|
+
const lines: string[] = [];
|
|
129
|
+
for (const request of requests) {
|
|
130
|
+
const linked = artifacts.filter(artifact => artifact.requestId === request.id);
|
|
131
|
+
const reports = observations.filter(observation => observation.requestIds.includes(request.id));
|
|
132
|
+
const hint = request.artifactHint ? `, artifactHint: ${request.artifactHint}` : "";
|
|
133
|
+
const coverage =
|
|
134
|
+
request.method === "user_artifact"
|
|
135
|
+
? linked.length > 0
|
|
136
|
+
? "artifact attached"
|
|
137
|
+
: "PENDING artifact"
|
|
138
|
+
: request.method === "user_report"
|
|
139
|
+
? reports.length > 0
|
|
140
|
+
? "report submitted"
|
|
141
|
+
: "PENDING report"
|
|
142
|
+
: "agent-collected";
|
|
143
|
+
lines.push(
|
|
144
|
+
`- ${request.id} [${request.method}] ${request.title} (${coverage})${hint}\n hypotheses: ${request.hypothesisIds.join(", ")}\n rationale: ${request.rationale}\n instructions: ${request.instructions.join(" | ")}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
for (const observation of observations) {
|
|
148
|
+
lines.push(`- ${observation.id} [user observation, round ${observation.round}] ${observation.text}`);
|
|
149
|
+
}
|
|
150
|
+
for (const artifact of artifacts) {
|
|
151
|
+
let stats: fs.Stats;
|
|
152
|
+
try {
|
|
153
|
+
stats = fs.statSync(artifact.path);
|
|
154
|
+
fs.accessSync(artifact.path, fs.constants.R_OK);
|
|
155
|
+
} catch {
|
|
156
|
+
lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (file missing or unreadable)`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!stats.isFile()) {
|
|
160
|
+
lines.push(`- ${artifact.id} [artifact] ${artifact.path} — UNAVAILABLE (no longer a regular file)`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const linked = artifact.requestId ? `, request ${artifact.requestId}` : ", unlinked";
|
|
164
|
+
lines.push(
|
|
165
|
+
`- ${artifact.id} [artifact] ${artifact.path} — available${linked}, ${stats.size} bytes, mtime ${new Date(stats.mtimeMs).toISOString()}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
package/src/gate.ts
CHANGED
|
@@ -1,36 +1,83 @@
|
|
|
1
|
-
import { PROCEED_REMINDER } from "./methodology";
|
|
1
|
+
import { CLOSE_ROUND_RULES, MINIMIZE_USER_INTERVENTION, PROCEED_REMINDER } from "./methodology";
|
|
2
|
+
import type { OpenReason } from "./state";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
export const
|
|
4
|
+
/** Per-reason nudge budgets: a probe nudge must not spend the closing-tag one. */
|
|
5
|
+
export const MAX_TAG_NUDGES = 1;
|
|
6
|
+
export const MAX_PROBE_NUDGES = 1;
|
|
5
7
|
|
|
6
8
|
export const GATE_NUDGE =
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
9
|
+
"This round did not close properly. It must end with BOTH a <evidence_plan> JSON block " +
|
|
10
|
+
'({"id","hypothesisIds","method","title","rationale","instructions", optional "artifactHint"}; methods: ' +
|
|
11
|
+
"agent_inspection, runtime_probe, user_report, user_artifact) AND a <reproduction_steps> numbered list " +
|
|
12
|
+
`followed by the exact sentence "${PROCEED_REMINDER}", then stop. ` +
|
|
13
|
+
MINIMIZE_USER_INTERVENTION +
|
|
14
|
+
" Every entry needs a concrete rationale; a user_report/user_artifact rationale must name why BOTH " +
|
|
15
|
+
"autonomous inspection and model-added probes cannot answer its hypotheses. " +
|
|
16
|
+
"A user_artifact request must state the file type, path/capture instructions, how you will inspect the file, " +
|
|
17
|
+
"and why inspection/probes are inadequate. Do not start new work.";
|
|
18
|
+
|
|
19
|
+
export const PROBE_NUDGE =
|
|
20
|
+
"Your evidence plan selects runtime_probe, but no @omp-probe marker for this round exists in the working tree. " +
|
|
21
|
+
"Instrument the code now with edit/write — that is required work for this round, not a product fix — and only then " +
|
|
22
|
+
`re-emit <evidence_plan> and <reproduction_steps> followed by "${PROCEED_REMINDER}". ` +
|
|
23
|
+
CLOSE_ROUND_RULES;
|
|
10
24
|
|
|
11
25
|
export type GateDecision =
|
|
12
|
-
/** Hand control to the user and wait for a reproduction. */
|
|
13
|
-
| { kind: "gate";
|
|
26
|
+
/** Hand control to the user and wait for a reproduction/capture. */
|
|
27
|
+
| { kind: "gate"; missingPlan: boolean }
|
|
14
28
|
/** Let the agent finish the round properly before gating. */
|
|
15
|
-
| { kind: "nudge"; context: string }
|
|
16
|
-
/**
|
|
17
|
-
| { kind: "
|
|
29
|
+
| { kind: "nudge"; budget: "tags" | "probes"; context: string }
|
|
30
|
+
/** The round settled without closing: the ball is with the user, not the gate. */
|
|
31
|
+
| { kind: "open"; reason: OpenReason };
|
|
32
|
+
|
|
33
|
+
export interface GateFacts {
|
|
34
|
+
hasReproductionSteps: boolean;
|
|
35
|
+
hasEvidencePlan: boolean;
|
|
36
|
+
/** The plan selects runtime_probe for at least one hypothesis. */
|
|
37
|
+
declaresRuntimeProbe: boolean;
|
|
38
|
+
/** Probes this round introduced that the ledger still finds on disk. */
|
|
39
|
+
liveProbes: number;
|
|
40
|
+
nudges: { tags: number; probes: number };
|
|
41
|
+
}
|
|
18
42
|
|
|
19
43
|
/**
|
|
20
|
-
* Decide what a settled agent turn means
|
|
44
|
+
* Decide what a settled agent turn means.
|
|
21
45
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
46
|
+
* A round may only close when its declarations are backed by observable facts:
|
|
47
|
+
* a `runtime_probe` plan needs real `@omp-probe` markers, and both closing tags
|
|
48
|
+
* must be present. Anything short of that is either one automatic nudge or an
|
|
49
|
+
* `open` round the user can talk to — never a silent stop that looks gated.
|
|
26
50
|
*/
|
|
27
|
-
export function decideGate(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
51
|
+
export function decideGate(facts: GateFacts): GateDecision {
|
|
52
|
+
// Declared instrumentation that never reached disk: the reproduction the
|
|
53
|
+
// user is about to be asked for could not record anything.
|
|
54
|
+
if (facts.declaresRuntimeProbe && facts.liveProbes === 0) {
|
|
55
|
+
if (facts.nudges.probes < MAX_PROBE_NUDGES) return { kind: "nudge", budget: "probes", context: PROBE_NUDGE };
|
|
56
|
+
return { kind: "open", reason: "probes_missing" };
|
|
57
|
+
}
|
|
58
|
+
if (facts.hasReproductionSteps && facts.hasEvidencePlan) {
|
|
59
|
+
return { kind: "gate", missingPlan: false };
|
|
60
|
+
}
|
|
61
|
+
// Legacy/no-probe closure: reproduction steps alone still gate the turn.
|
|
62
|
+
if (facts.hasReproductionSteps && !facts.hasEvidencePlan && facts.liveProbes === 0) {
|
|
63
|
+
return { kind: "gate", missingPlan: true };
|
|
64
|
+
}
|
|
65
|
+
// Nothing was declared and nothing was instrumented — the agent is talking
|
|
66
|
+
// to the user, so leave the round open instead of inventing a gate.
|
|
67
|
+
if (facts.liveProbes === 0 && !facts.hasEvidencePlan) {
|
|
68
|
+
return { kind: "open", reason: "awaiting_reply" };
|
|
69
|
+
}
|
|
70
|
+
if (facts.nudges.tags < MAX_TAG_NUDGES) return { kind: "nudge", budget: "tags", context: GATE_NUDGE };
|
|
71
|
+
return { kind: "open", reason: "unclosed" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** User-facing explanation of why a round is open, plus what to do about it. */
|
|
75
|
+
export function describeOpenReason(reason: OpenReason, round: number): string {
|
|
76
|
+
if (reason === "probes_missing") {
|
|
77
|
+
return `Debug round ${round} declared runtime_probe but no probe reached the code, so there is nothing to reproduce yet. Reply to the agent to get it instrumented.`;
|
|
78
|
+
}
|
|
79
|
+
if (reason === "unclosed") {
|
|
80
|
+
return `Debug round ${round} stopped without a complete evidence plan and reproduction steps. Reply to the agent, or run /debug-proceed to close it with whatever evidence exists.`;
|
|
81
|
+
}
|
|
82
|
+
return `Debug round ${round} is still open — the agent is waiting on your reply, not on a reproduction.`;
|
|
36
83
|
}
|
package/src/machine.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { parseEvidencePlan } from "./evidence";
|
|
2
|
+
import { decideGate, describeOpenReason } from "./gate";
|
|
3
|
+
import {
|
|
4
|
+
CLEANUP_NUDGE,
|
|
5
|
+
buildFixedMessage,
|
|
6
|
+
buildProceedMessage,
|
|
7
|
+
buildStartMessage,
|
|
8
|
+
extractReproductionSteps,
|
|
9
|
+
} from "./methodology";
|
|
10
|
+
import {
|
|
11
|
+
type DebugSession,
|
|
12
|
+
type DebugState,
|
|
13
|
+
type EvidenceArtifact,
|
|
14
|
+
type EvidenceObservation,
|
|
15
|
+
type Probe,
|
|
16
|
+
type Round,
|
|
17
|
+
INACTIVE,
|
|
18
|
+
allRequests,
|
|
19
|
+
blankRound,
|
|
20
|
+
currentRound,
|
|
21
|
+
declaresRuntimeProbe,
|
|
22
|
+
evidenceSummary,
|
|
23
|
+
freshSession,
|
|
24
|
+
liveProbeIds,
|
|
25
|
+
pendingRequests,
|
|
26
|
+
} from "./state";
|
|
27
|
+
|
|
28
|
+
export const PROMPT_START = "debug-mode-start";
|
|
29
|
+
export const PROMPT_PROCEED = "debug-mode-proceed";
|
|
30
|
+
export const PROMPT_FIXED = "debug-mode-fixed";
|
|
31
|
+
|
|
32
|
+
/** Artifact metadata gathered by the shell; the reducer only files it. */
|
|
33
|
+
export type ArtifactCandidate = Omit<EvidenceArtifact, "id" | "addedAt" | "requestId">;
|
|
34
|
+
|
|
35
|
+
export type DebugEvent =
|
|
36
|
+
| { t: "start"; problem: string; debugDir: string; runId: string; logFile: string }
|
|
37
|
+
| { t: "turn_started" }
|
|
38
|
+
| { t: "assistant_message"; text: string }
|
|
39
|
+
| { t: "probes_found"; probes: Probe[] }
|
|
40
|
+
| { t: "ledger_synced"; probes: Probe[] }
|
|
41
|
+
| { t: "runs_observed"; runHistory: string[]; logCounts: Record<string, number> }
|
|
42
|
+
| { t: "turn_settled" }
|
|
43
|
+
| { t: "proceed"; runId: string; logCount: number; hypotheses: string; details?: string; now: number }
|
|
44
|
+
| { t: "mark_fixed" }
|
|
45
|
+
| { t: "attach_artifact"; candidate: ArtifactCandidate; requestId: string | null; now: number }
|
|
46
|
+
| { t: "abort" };
|
|
47
|
+
|
|
48
|
+
export type Effect =
|
|
49
|
+
| { kind: "notify"; level: "info" | "warning" | "error"; text: string }
|
|
50
|
+
/** Inject a prompt and start an agent turn. */
|
|
51
|
+
| { kind: "prompt"; customType: string; content: string; summary: string }
|
|
52
|
+
/** Keep the current turn going instead of settling it. */
|
|
53
|
+
| { kind: "continue"; context: string }
|
|
54
|
+
| { kind: "teardown"; outcome: "finished" | "aborted"; probesLeft: Probe[]; debugDir: string | null };
|
|
55
|
+
|
|
56
|
+
export interface Transition {
|
|
57
|
+
state: DebugState;
|
|
58
|
+
effects: Effect[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A narrowed active state. Kept as one type so no-ops can return it verbatim. */
|
|
62
|
+
type ActiveState = { active: true } & DebugSession;
|
|
63
|
+
|
|
64
|
+
function unchanged(state: DebugState): Transition {
|
|
65
|
+
return { state, effects: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function withRound(session: ActiveState, round: Round): ActiveState {
|
|
69
|
+
return { ...session, rounds: [...session.rounds.slice(0, -1), round] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The whole state machine. Pure and total: every transition is a function of
|
|
74
|
+
* the previous state plus one event, and anything that touches the filesystem,
|
|
75
|
+
* the host UI or the model is returned as an effect for the shell to apply.
|
|
76
|
+
*/
|
|
77
|
+
export function reduce(state: DebugState, event: DebugEvent): Transition {
|
|
78
|
+
if (event.t === "start") {
|
|
79
|
+
if (state.active) return unchanged(state);
|
|
80
|
+
return {
|
|
81
|
+
state: { active: true, ...freshSession(event.problem, event.debugDir, event.runId) },
|
|
82
|
+
effects: [
|
|
83
|
+
{
|
|
84
|
+
kind: "prompt",
|
|
85
|
+
customType: PROMPT_START,
|
|
86
|
+
content: buildStartMessage(event.problem, event.logFile),
|
|
87
|
+
summary: "debug mode started — hypotheses and instrumentation",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (!state.active) return unchanged(state);
|
|
93
|
+
const session: ActiveState = state;
|
|
94
|
+
|
|
95
|
+
switch (event.t) {
|
|
96
|
+
case "turn_started":
|
|
97
|
+
// A reply to an open round hands the turn back to the agent. The
|
|
98
|
+
// reproduction gate deliberately survives ordinary conversation.
|
|
99
|
+
if (session.stage !== "open" && !session.turnProduced) return unchanged(session);
|
|
100
|
+
return unchanged({
|
|
101
|
+
...session,
|
|
102
|
+
stage: session.stage === "open" ? "investigating" : session.stage,
|
|
103
|
+
turnProduced: false,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
case "assistant_message":
|
|
107
|
+
return unchanged(absorbAssistantText(session, event.text));
|
|
108
|
+
|
|
109
|
+
case "probes_found": {
|
|
110
|
+
const fresh = event.probes.filter(probe => !session.probes.some(known => known.id === probe.id));
|
|
111
|
+
if (fresh.length === 0) return unchanged(session);
|
|
112
|
+
const probes = [...session.probes, ...fresh];
|
|
113
|
+
if (session.stage !== "investigating") return unchanged({ ...session, probes });
|
|
114
|
+
const round = currentRound(session);
|
|
115
|
+
return unchanged(
|
|
116
|
+
withRound({ ...session, probes }, { ...round, probeIds: [...round.probeIds, ...fresh.map(p => p.id)] }),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "ledger_synced": {
|
|
121
|
+
const same =
|
|
122
|
+
event.probes.length === session.probes.length &&
|
|
123
|
+
event.probes.every((probe, i) => probe.id === session.probes[i]?.id);
|
|
124
|
+
return unchanged(same ? session : { ...session, probes: event.probes });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "runs_observed":
|
|
128
|
+
return unchanged({ ...session, runHistory: event.runHistory, logCounts: event.logCounts });
|
|
129
|
+
|
|
130
|
+
case "turn_settled":
|
|
131
|
+
return settle(session);
|
|
132
|
+
|
|
133
|
+
case "proceed":
|
|
134
|
+
return advance(session, event);
|
|
135
|
+
|
|
136
|
+
case "mark_fixed":
|
|
137
|
+
return startCleanup(session);
|
|
138
|
+
|
|
139
|
+
case "attach_artifact":
|
|
140
|
+
return attach(session, event);
|
|
141
|
+
|
|
142
|
+
case "abort":
|
|
143
|
+
return {
|
|
144
|
+
state: INACTIVE,
|
|
145
|
+
effects: [
|
|
146
|
+
{ kind: "teardown", outcome: "aborted", probesLeft: session.probes, debugDir: session.debugDir },
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Record the round artefacts a settled-but-not-yet-judged turn produced. */
|
|
153
|
+
function absorbAssistantText(session: ActiveState, text: string): ActiveState {
|
|
154
|
+
const round = currentRound(session);
|
|
155
|
+
const steps = session.stage === "investigating" ? extractReproductionSteps(text) : [];
|
|
156
|
+
const plan = session.stage === "investigating" ? parseEvidencePlan(text) : { found: false, valid: false, requests: [] };
|
|
157
|
+
const next: Round = {
|
|
158
|
+
...round,
|
|
159
|
+
reproductionSteps: steps.length > 0 ? steps : round.reproductionSteps,
|
|
160
|
+
plan: plan.found ? (plan.valid ? plan.requests : null) : round.plan,
|
|
161
|
+
};
|
|
162
|
+
const roundChanged = next.reproductionSteps !== round.reproductionSteps || next.plan !== round.plan;
|
|
163
|
+
if (!roundChanged) return session.turnProduced ? session : { ...session, turnProduced: true };
|
|
164
|
+
return withRound({ ...session, turnProduced: true }, next);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function settle(session: ActiveState): Transition {
|
|
168
|
+
if (session.stage === "cleaning_up") return finishCleanup(session);
|
|
169
|
+
if (session.stage !== "investigating" || !session.turnProduced) return unchanged(session);
|
|
170
|
+
|
|
171
|
+
const round = currentRound(session);
|
|
172
|
+
const decision = decideGate({
|
|
173
|
+
hasReproductionSteps: round.reproductionSteps.length > 0,
|
|
174
|
+
hasEvidencePlan: round.plan !== null,
|
|
175
|
+
declaresRuntimeProbe: declaresRuntimeProbe(round),
|
|
176
|
+
liveProbes: liveProbeIds(session, round).length,
|
|
177
|
+
nudges: round.nudges,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (decision.kind === "nudge") {
|
|
181
|
+
const nudges =
|
|
182
|
+
decision.budget === "probes"
|
|
183
|
+
? { ...round.nudges, probes: round.nudges.probes + 1 }
|
|
184
|
+
: { ...round.nudges, tags: round.nudges.tags + 1 };
|
|
185
|
+
return {
|
|
186
|
+
state: withRound(session, { ...round, nudges }),
|
|
187
|
+
effects: [{ kind: "continue", context: decision.context }],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (decision.kind === "open") {
|
|
192
|
+
return {
|
|
193
|
+
state: withRound({ ...session, stage: "open" }, { ...round, openReason: decision.reason }),
|
|
194
|
+
effects: [
|
|
195
|
+
{
|
|
196
|
+
kind: "notify",
|
|
197
|
+
level: decision.reason === "awaiting_reply" ? "info" : "warning",
|
|
198
|
+
text: describeOpenReason(decision.reason, round.index),
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const gated = withRound({ ...session, stage: "awaiting_evidence" }, { ...round, openReason: null });
|
|
205
|
+
return { state: gated, effects: [{ kind: "notify", ...gateNotice(gated, decision.missingPlan) }] };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function gateNotice(session: ActiveState, missingPlan: boolean): { level: "info" | "warning"; text: string } {
|
|
209
|
+
const round = currentRound(session);
|
|
210
|
+
if (missingPlan) {
|
|
211
|
+
return {
|
|
212
|
+
level: "warning",
|
|
213
|
+
text: `Debug round ${round.index} paused, but it added no probes and declared no evidence plan — this round cannot produce runtime evidence. Use /debug-proceed to ask for instrumentation.`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const pending = pendingRequests(session, round);
|
|
217
|
+
if (pending.length > 0) {
|
|
218
|
+
return {
|
|
219
|
+
level: "info",
|
|
220
|
+
text: `Debug round ${round.index} paused. User evidence requested (${pending.length} pending: ${pending.map(r => r.id).join(", ")}) — attach via /debug-evidence <request-id> <path>, then press Proceed.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { level: "info", text: `Debug round ${round.index} paused. Reproduce the bug, then press Proceed.` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Cleanup may only finish once the ledger is actually empty. */
|
|
227
|
+
function finishCleanup(session: ActiveState): Transition {
|
|
228
|
+
if (!session.turnProduced) return unchanged(session);
|
|
229
|
+
if (session.probes.length > 0 && session.cleanupNudges < 1) {
|
|
230
|
+
return {
|
|
231
|
+
state: { ...session, cleanupNudges: session.cleanupNudges + 1 },
|
|
232
|
+
effects: [{ kind: "continue", context: `${CLEANUP_NUDGE}\nRemaining probes: ${JSON.stringify(session.probes)}` }],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
state: INACTIVE,
|
|
237
|
+
effects: [{ kind: "teardown", outcome: "finished", probesLeft: session.probes, debugDir: session.debugDir }],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function advance(session: ActiveState, event: Extract<DebugEvent, { t: "proceed" }>): Transition {
|
|
242
|
+
const closing = currentRound(session);
|
|
243
|
+
const details = event.details?.trim() ?? "";
|
|
244
|
+
let withObservation: ActiveState = session;
|
|
245
|
+
if (details.length > 0) {
|
|
246
|
+
const reportIds = (closing.plan ?? [])
|
|
247
|
+
.filter(request => request.method === "user_report")
|
|
248
|
+
.map(request => request.id);
|
|
249
|
+
const observation: EvidenceObservation = {
|
|
250
|
+
id: `observation-${event.now.toString(36)}`,
|
|
251
|
+
requestIds: reportIds,
|
|
252
|
+
text: details,
|
|
253
|
+
round: closing.index,
|
|
254
|
+
addedAt: event.now,
|
|
255
|
+
};
|
|
256
|
+
withObservation = { ...session, observations: [...session.observations, observation] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const summary = evidenceSummary(withObservation, closing);
|
|
260
|
+
const next: ActiveState = {
|
|
261
|
+
...withObservation,
|
|
262
|
+
stage: "investigating",
|
|
263
|
+
turnProduced: false,
|
|
264
|
+
rounds: [...withObservation.rounds, blankRound(closing.index + 1, event.runId)],
|
|
265
|
+
runHistory: [...withObservation.runHistory, event.runId],
|
|
266
|
+
logCounts: { ...withObservation.logCounts, [event.runId]: 0 },
|
|
267
|
+
};
|
|
268
|
+
const label = details.length > 0 ? "proceed with user details" : "proceed";
|
|
269
|
+
return {
|
|
270
|
+
state: next,
|
|
271
|
+
effects: [
|
|
272
|
+
{
|
|
273
|
+
kind: "prompt",
|
|
274
|
+
customType: PROMPT_PROCEED,
|
|
275
|
+
content: buildProceedMessage({
|
|
276
|
+
run: closing.runId ?? "(none)",
|
|
277
|
+
logCount: event.logCount,
|
|
278
|
+
userDetails: details.length > 0 ? details : undefined,
|
|
279
|
+
hypotheses: event.hypotheses,
|
|
280
|
+
evidenceSummary: summary,
|
|
281
|
+
}),
|
|
282
|
+
summary: `${label} — analyzing run ${closing.runId ?? "(none)"} (${event.logCount} entries)`,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function startCleanup(session: ActiveState): Transition {
|
|
289
|
+
const next: ActiveState = { ...session, stage: "cleaning_up", turnProduced: false, cleanupNudges: 0 };
|
|
290
|
+
const evidenceJson = JSON.stringify({
|
|
291
|
+
requests: allRequests(session),
|
|
292
|
+
observations: session.observations,
|
|
293
|
+
artifacts: session.artifacts,
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
state: next,
|
|
297
|
+
effects: [
|
|
298
|
+
{
|
|
299
|
+
kind: "prompt",
|
|
300
|
+
customType: PROMPT_FIXED,
|
|
301
|
+
content: buildFixedMessage(JSON.stringify(session.probes), evidenceJson),
|
|
302
|
+
summary: `marked fixed — removing ${session.probes.length} probe(s)`,
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function attach(session: ActiveState, event: Extract<DebugEvent, { t: "attach_artifact" }>): Transition {
|
|
309
|
+
if (event.requestId !== null && !allRequests(session).some(request => request.id === event.requestId)) {
|
|
310
|
+
return {
|
|
311
|
+
state: session,
|
|
312
|
+
effects: [
|
|
313
|
+
{ kind: "notify", level: "error", text: `debug-mode: unknown evidence request id ${event.requestId}` },
|
|
314
|
+
],
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const existing = session.artifacts.find(artifact => artifact.path === event.candidate.path);
|
|
318
|
+
const artifact: EvidenceArtifact = existing
|
|
319
|
+
? { ...existing, ...event.candidate, requestId: event.requestId }
|
|
320
|
+
: { id: `artifact-${event.now.toString(36)}`, requestId: event.requestId, ...event.candidate, addedAt: event.now };
|
|
321
|
+
const artifacts = existing
|
|
322
|
+
? session.artifacts.map(a => (a.path === artifact.path ? artifact : a))
|
|
323
|
+
: [...session.artifacts, artifact];
|
|
324
|
+
return {
|
|
325
|
+
state: { ...session, artifacts },
|
|
326
|
+
effects: [
|
|
327
|
+
{
|
|
328
|
+
kind: "notify",
|
|
329
|
+
level: "info",
|
|
330
|
+
text: `debug-mode: attached ${artifact.id} → ${artifact.path} (${artifact.size} bytes)`,
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
};
|
|
334
|
+
}
|