@siuver/omp-debug-mode 0.1.4 → 0.1.6
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 +73 -35
- package/README.md +98 -24
- package/package.json +41 -41
- package/src/debug-mode.ts +463 -409
- package/src/evidence.ts +325 -196
- package/src/gate.ts +138 -45
- package/src/log-files.ts +154 -115
- package/src/machine.ts +421 -0
- package/src/main.ts +24 -24
- package/src/methodology.ts +74 -17
- package/src/probes.ts +9 -9
- package/src/state.ts +392 -84
- package/src/tools.ts +232 -30
- package/src/ui.ts +120 -56
package/src/state.ts
CHANGED
|
@@ -4,7 +4,14 @@ export const DEBUG_ENTRY = "com.omp.debug-mode.state";
|
|
|
4
4
|
/** Custom message that carries the blackboard + methodology into the model. */
|
|
5
5
|
export const DEBUG_CONTEXT_TYPE = "debug-mode-context";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Whose move it is, and nothing else. There is exactly one user stage: a turn
|
|
9
|
+
* the agent has handed back is the user's regardless of how well the agent
|
|
10
|
+
* closed the round, so command legality never depends on protocol compliance.
|
|
11
|
+
*/
|
|
12
|
+
export type Stage = "investigating" | "user_turn" | "cleaning_up";
|
|
13
|
+
|
|
14
|
+
export type EvidenceMethod = "agent_inspection" | "runtime_probe" | "user_report" | "user_artifact";
|
|
8
15
|
|
|
9
16
|
export interface Probe {
|
|
10
17
|
id: string;
|
|
@@ -12,8 +19,6 @@ export interface Probe {
|
|
|
12
19
|
round: number;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
export type EvidenceMethod = "agent_inspection" | "runtime_probe" | "user_report" | "user_artifact";
|
|
16
|
-
|
|
17
22
|
export interface EvidenceRequest {
|
|
18
23
|
/** Model-authored request identifier, unique within a plan. */
|
|
19
24
|
id: string;
|
|
@@ -27,7 +32,6 @@ export interface EvidenceRequest {
|
|
|
27
32
|
instructions: string[];
|
|
28
33
|
/** Expected file kind when method is user_artifact. */
|
|
29
34
|
artifactHint?: string;
|
|
30
|
-
round: number;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export interface EvidenceArtifact {
|
|
@@ -49,47 +53,186 @@ export interface EvidenceObservation {
|
|
|
49
53
|
addedAt: number;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* How the agent handed the turn back. This is wording and guidance only: every
|
|
58
|
+
* mode is the same `user_turn` stage and allows the same commands, so a mode
|
|
59
|
+
* the agent got wrong can never make a legitimate command illegal.
|
|
60
|
+
*/
|
|
61
|
+
export type HandoffMode = "reproduce" | "capture" | "question" | "incomplete";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One evidence round. Round membership is structural: everything scoped to a
|
|
65
|
+
* round lives here instead of being filtered out of a session-wide array by a
|
|
66
|
+
* mutable counter, so starting a round cannot forget to reset a field.
|
|
67
|
+
*/
|
|
68
|
+
export interface Round {
|
|
69
|
+
index: number;
|
|
57
70
|
runId: string | null;
|
|
71
|
+
/** Parsed `<evidence_plan>`; null until a valid plan arrives. */
|
|
72
|
+
plan: EvidenceRequest[] | null;
|
|
73
|
+
reproductionSteps: string[];
|
|
74
|
+
/** Ledger ids of probes introduced while this round was investigating. */
|
|
75
|
+
probeIds: string[];
|
|
76
|
+
/** Separate budgets: a probe nudge must not consume the closing one. */
|
|
77
|
+
nudges: { handoff: number; probes: number };
|
|
78
|
+
/**
|
|
79
|
+
* A reminder went out and no tool call has landed since. One reminder per
|
|
80
|
+
* round of visible progress: a model that answers every reminder with more
|
|
81
|
+
* prose would otherwise spend the whole budget and delay the user by three
|
|
82
|
+
* turns to reach the same handoff.
|
|
83
|
+
*/
|
|
84
|
+
awaitingProgress: boolean;
|
|
85
|
+
/** Set while the round sits with the user; null while the agent has the turn. */
|
|
86
|
+
handoff: HandoffMode | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DebugSession {
|
|
90
|
+
stage: Stage;
|
|
91
|
+
problem: string;
|
|
92
|
+
debugDir: string | null;
|
|
93
|
+
/** Every round in order; the last entry is the current one. */
|
|
94
|
+
rounds: Round[];
|
|
95
|
+
/** Session-wide probe ledger; a probe outlives the round that created it. */
|
|
96
|
+
probes: Probe[];
|
|
58
97
|
/** Every run in creation order; the last entry is always the active run. */
|
|
59
98
|
runHistory: string[];
|
|
60
|
-
probes: Probe[];
|
|
61
|
-
debugDir: string | null;
|
|
62
99
|
logCounts: Record<string, number>;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
gateNudges: number;
|
|
100
|
+
artifacts: EvidenceArtifact[];
|
|
101
|
+
observations: EvidenceObservation[];
|
|
102
|
+
/** An assistant message landed during the current agent turn. */
|
|
103
|
+
turnProduced: boolean;
|
|
104
|
+
/** How often cleanup was sent back to finish removing probes. */
|
|
105
|
+
cleanupNudges: number;
|
|
70
106
|
}
|
|
71
107
|
|
|
72
|
-
|
|
108
|
+
/**
|
|
109
|
+
* `active` is the discriminant, so the cheap `if (!state.active) return` guard
|
|
110
|
+
* in hot handlers doubles as the type narrowing and there is no second source
|
|
111
|
+
* of truth for "is debug mode running".
|
|
112
|
+
*/
|
|
113
|
+
export type DebugState = { active: false } | ({ active: true } & DebugSession);
|
|
114
|
+
|
|
115
|
+
export const INACTIVE: DebugState = { active: false };
|
|
116
|
+
|
|
117
|
+
export function blankRound(index: number, runId: string | null): Round {
|
|
73
118
|
return {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
round: 0,
|
|
78
|
-
runId: null,
|
|
79
|
-
runHistory: [],
|
|
80
|
-
probes: [],
|
|
81
|
-
debugDir: null,
|
|
82
|
-
logCounts: {},
|
|
83
|
-
hasRoundContent: false,
|
|
84
|
-
cleanupReady: false,
|
|
119
|
+
index,
|
|
120
|
+
runId,
|
|
121
|
+
plan: null,
|
|
85
122
|
reproductionSteps: [],
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
123
|
+
probeIds: [],
|
|
124
|
+
nudges: { handoff: 0, probes: 0 },
|
|
125
|
+
awaitingProgress: false,
|
|
126
|
+
handoff: null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function freshSession(problem: string, debugDir: string | null, runId: string | null): DebugSession {
|
|
131
|
+
return {
|
|
132
|
+
stage: "investigating",
|
|
133
|
+
problem,
|
|
134
|
+
debugDir,
|
|
135
|
+
rounds: [blankRound(1, runId)],
|
|
136
|
+
probes: [],
|
|
137
|
+
runHistory: runId ? [runId] : [],
|
|
138
|
+
logCounts: runId ? { [runId]: 0 } : {},
|
|
139
|
+
artifacts: [],
|
|
140
|
+
observations: [],
|
|
141
|
+
turnProduced: false,
|
|
142
|
+
cleanupNudges: 0,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function currentRound(session: DebugSession): Round {
|
|
147
|
+
return session.rounds[session.rounds.length - 1] ?? blankRound(1, null);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function activeRunId(session: DebugSession): string | null {
|
|
151
|
+
return currentRound(session).runId;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function logFileFor(session: DebugSession, run: string | null = activeRunId(session)): string | null {
|
|
155
|
+
return resolveRunLogFile(session.debugDir, run, activeRunId(session));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Every probe the ledger still finds on disk, session-wide. Instrumentation
|
|
160
|
+
* deliberately outlives the round that installed it — the methodology keeps
|
|
161
|
+
* probes in place across a fix so the verification round can reuse them — so a
|
|
162
|
+
* round-scoped count would report "nothing instrumented" on exactly the rounds
|
|
163
|
+
* that are best instrumented.
|
|
164
|
+
*/
|
|
165
|
+
export function liveProbeCount(session: DebugSession): number {
|
|
166
|
+
return session.probes.length;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Probes this round introduced that are still in the ledger (i.e. still on disk). */
|
|
170
|
+
export function roundProbeIds(session: DebugSession, round: Round = currentRound(session)): string[] {
|
|
171
|
+
return round.probeIds.filter(id => session.probes.some(probe => probe.id === id));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function declaresRuntimeProbe(round: Round): boolean {
|
|
175
|
+
return (round.plan ?? []).some(request => request.method === "runtime_probe");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The plan asks the user to report or capture something themselves. */
|
|
179
|
+
export function needsUserCapture(round: Round): boolean {
|
|
180
|
+
return (round.plan ?? []).some(
|
|
181
|
+
request => request.method === "user_report" || request.method === "user_artifact",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Requests whose method is satisfied by the agent alone, so they never gate on the user. */
|
|
186
|
+
export function hasNonProbeEvidence(round: Round): boolean {
|
|
187
|
+
return (round.plan ?? []).some(request => request.method !== "runtime_probe");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Round requests that still need something from the user: a `user_artifact`
|
|
192
|
+
* request until a linked artifact exists, a `user_report` request until an
|
|
193
|
+
* observation names it. Agent-collected methods never create user work.
|
|
194
|
+
*/
|
|
195
|
+
export function pendingRequests(session: DebugSession, round: Round = currentRound(session)): EvidenceRequest[] {
|
|
196
|
+
return (round.plan ?? []).filter(request => {
|
|
197
|
+
if (request.method === "user_artifact") {
|
|
198
|
+
return !session.artifacts.some(artifact => artifact.requestId === request.id);
|
|
199
|
+
}
|
|
200
|
+
if (request.method === "user_report") {
|
|
201
|
+
return !session.observations.some(observation => observation.requestIds.includes(request.id));
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Every request the session has ever declared, newest round last. */
|
|
208
|
+
export function allRequests(session: DebugSession): EvidenceRequest[] {
|
|
209
|
+
return session.rounds.flatMap(round => round.plan ?? []);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Decoupled evidence projection so ledger formatting never depends on the state shape. */
|
|
213
|
+
export interface EvidenceView {
|
|
214
|
+
requests: readonly EvidenceRequest[];
|
|
215
|
+
observations: readonly EvidenceObservation[];
|
|
216
|
+
artifacts: readonly EvidenceArtifact[];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function evidenceView(session: DebugSession, round?: Round | null): EvidenceView {
|
|
220
|
+
return {
|
|
221
|
+
requests: round === undefined ? allRequests(session) : (round?.plan ?? []),
|
|
222
|
+
observations: session.observations,
|
|
223
|
+
artifacts: session.artifacts,
|
|
90
224
|
};
|
|
91
225
|
}
|
|
92
226
|
|
|
227
|
+
/** One-line evidence status used by messages, the gate and the UI. */
|
|
228
|
+
export function evidenceSummary(session: DebugSession, round: Round = currentRound(session)): string {
|
|
229
|
+
const requests = round.plan ?? [];
|
|
230
|
+
if (requests.length === 0) return "no evidence requests this round";
|
|
231
|
+
const pending = pendingRequests(session, round);
|
|
232
|
+
const methods = requests.map(request => `${request.id}:${request.method}`).join(", ");
|
|
233
|
+
return `${requests.length} request(s) [${methods}], ${pending.length} pending user action(s), ${session.artifacts.length} artifact(s), ${session.observations.length} observation(s)`;
|
|
234
|
+
}
|
|
235
|
+
|
|
93
236
|
/** Order run ids by round number, then by the base36 creation stamp. */
|
|
94
237
|
export function compareRunIds(a: string, b: string): number {
|
|
95
238
|
const parse = (id: string): [number, string] => {
|
|
@@ -144,88 +287,253 @@ export function resolveRun(
|
|
|
144
287
|
return { run: currentRun, note: null };
|
|
145
288
|
}
|
|
146
289
|
|
|
147
|
-
export function logFileFor(s: DebugState, run = s.runId): string | null {
|
|
148
|
-
return resolveRunLogFile(s.debugDir, run, s.runId);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
290
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
291
|
+
* Leave the context holding exactly one, current copy of a custom-type
|
|
292
|
+
* injection: drop every stale copy and rewrite the survivor's content.
|
|
293
|
+
*
|
|
294
|
+
* Both halves are load-bearing. `before_agent_start` appends a fresh copy every
|
|
295
|
+
* prompt, so keeping all of them would grow the context without bound — but it
|
|
296
|
+
* does not fire at all when the host resumes the agent loop itself, so a turn
|
|
297
|
+
* started by a reminder or a nudge would otherwise read whatever the previous
|
|
298
|
+
* turn was told. Rewriting here, where the request is actually assembled, is
|
|
299
|
+
* the only point that no path into the model can skip.
|
|
300
|
+
*
|
|
301
|
+
* Returns null when the context already says the right thing, so an unchanged
|
|
302
|
+
* request is passed through untouched.
|
|
156
303
|
*/
|
|
157
|
-
export function
|
|
304
|
+
export function syncCustomType<M extends { role?: string; customType?: string; content?: unknown }>(
|
|
158
305
|
messages: readonly M[],
|
|
159
306
|
customType: string,
|
|
160
|
-
|
|
307
|
+
content: string | null,
|
|
308
|
+
): M[] | null {
|
|
309
|
+
const isCopy = (message: M | undefined): boolean =>
|
|
310
|
+
message?.role === "custom" && message.customType === customType;
|
|
161
311
|
let last = -1;
|
|
312
|
+
let copies = 0;
|
|
313
|
+
for (let i = 0; i < messages.length; i++) {
|
|
314
|
+
if (!isCopy(messages[i])) continue;
|
|
315
|
+
last = i;
|
|
316
|
+
copies++;
|
|
317
|
+
}
|
|
318
|
+
if (last < 0) return null;
|
|
319
|
+
const rewrite = content !== null && messages[last]?.content !== content;
|
|
320
|
+
if (copies === 1 && !rewrite) return null;
|
|
321
|
+
const kept: M[] = [];
|
|
162
322
|
for (let i = 0; i < messages.length; i++) {
|
|
163
|
-
const message = messages[i];
|
|
164
|
-
if (message
|
|
323
|
+
const message = messages[i] as M;
|
|
324
|
+
if (!isCopy(message)) {
|
|
325
|
+
kept.push(message);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (i !== last) continue;
|
|
329
|
+
kept.push(rewrite ? ({ ...message, content } as M) : message);
|
|
165
330
|
}
|
|
166
|
-
|
|
167
|
-
return messages.filter((message, index) => !(message.role === "custom" && message.customType === customType && index !== last));
|
|
331
|
+
return kept;
|
|
168
332
|
}
|
|
169
333
|
|
|
334
|
+
const STAGES: readonly Stage[] = ["investigating", "user_turn", "cleaning_up"];
|
|
335
|
+
const HANDOFF_MODES: readonly HandoffMode[] = ["reproduce", "capture", "question", "incomplete"];
|
|
336
|
+
|
|
337
|
+
/** Pre-`user_turn` stage names and the handoff each one implied. */
|
|
338
|
+
const LEGACY_STAGES: Readonly<Record<string, { stage: Stage; handoff: HandoffMode | null }>> = {
|
|
339
|
+
open: { stage: "user_turn", handoff: null },
|
|
340
|
+
awaiting_evidence: { stage: "user_turn", handoff: "reproduce" },
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** Pre-`handoff` reasons, mapped onto the mode that carries the same guidance. */
|
|
344
|
+
const LEGACY_HANDOFFS: Readonly<Record<string, HandoffMode>> = {
|
|
345
|
+
awaiting_reply: "question",
|
|
346
|
+
probes_missing: "incomplete",
|
|
347
|
+
unclosed: "incomplete",
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
function asArray<T>(value: unknown): T[] {
|
|
351
|
+
return Array.isArray(value) ? (value as T[]) : [];
|
|
352
|
+
}
|
|
170
353
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
354
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
355
|
+
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function reviveHandoff(raw: unknown): HandoffMode | null {
|
|
359
|
+
if (typeof raw !== "string") return null;
|
|
360
|
+
if (HANDOFF_MODES.includes(raw as HandoffMode)) return raw as HandoffMode;
|
|
361
|
+
return LEGACY_HANDOFFS[raw] ?? null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function reviveRound(raw: unknown, fallbackIndex: number): Round {
|
|
365
|
+
const record = asRecord(raw);
|
|
366
|
+
const nudges = asRecord(record.nudges);
|
|
177
367
|
return {
|
|
178
|
-
|
|
179
|
-
|
|
368
|
+
index: typeof record.index === "number" ? record.index : fallbackIndex,
|
|
369
|
+
runId: typeof record.runId === "string" ? record.runId : null,
|
|
370
|
+
plan: Array.isArray(record.plan) ? (record.plan as EvidenceRequest[]) : null,
|
|
371
|
+
reproductionSteps: asArray<string>(record.reproductionSteps),
|
|
372
|
+
probeIds: asArray<string>(record.probeIds),
|
|
373
|
+
nudges: {
|
|
374
|
+
// `tags` is the pre-tool budget name, when prose was still a closure.
|
|
375
|
+
handoff: numberOr(nudges.handoff, numberOr(nudges.tags, 0)),
|
|
376
|
+
probes: numberOr(nudges.probes, 0),
|
|
377
|
+
},
|
|
378
|
+
// Never restored: no agent turn survives a restart, so nothing is pending.
|
|
379
|
+
awaitingProgress: false,
|
|
380
|
+
handoff: reviveHandoff(record.handoff ?? record.openReason),
|
|
180
381
|
};
|
|
181
382
|
}
|
|
182
383
|
|
|
384
|
+
function numberOr(value: unknown, fallback: number): number {
|
|
385
|
+
return typeof value === "number" ? value : fallback;
|
|
386
|
+
}
|
|
387
|
+
|
|
183
388
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
389
|
+
* Rebuild persisted state, tolerating the pre-`user_turn` stage names and the
|
|
390
|
+
* pre-`rounds` layout so a session that was mid-debug across an upgrade keeps
|
|
391
|
+
* working. Anything unrecognised — including a persisted `investigating`, since
|
|
392
|
+
* no agent turn survives a restore — resolves to `user_turn`, because the user
|
|
393
|
+
* must always be able to act on a restored investigation.
|
|
187
394
|
*/
|
|
188
|
-
export function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
395
|
+
export function reviveState(data: unknown): DebugState {
|
|
396
|
+
const record = asRecord(data);
|
|
397
|
+
if (record.active !== true) return INACTIVE;
|
|
398
|
+
const problem = typeof record.problem === "string" ? record.problem : "";
|
|
399
|
+
const debugDir = typeof record.debugDir === "string" ? record.debugDir : null;
|
|
400
|
+
const probes = asArray<Probe>(record.probes);
|
|
401
|
+
const logCounts = asRecord(record.logCounts) as Record<string, number>;
|
|
402
|
+
const restored = restoreStage(record);
|
|
403
|
+
|
|
404
|
+
const rounds = Array.isArray(record.rounds)
|
|
405
|
+
? record.rounds.map((round, i) => reviveRound(round, i + 1))
|
|
406
|
+
: [legacyRound(record, probes)];
|
|
407
|
+
const resolved = rounds.length > 0 ? rounds : [blankRound(1, null)];
|
|
408
|
+
const last = resolved[resolved.length - 1] as Round;
|
|
409
|
+
if (restored.stage === "user_turn" && last.handoff === null) {
|
|
410
|
+
resolved[resolved.length - 1] = { ...last, handoff: restored.handoff ?? "incomplete" };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
active: true,
|
|
415
|
+
stage: restored.stage,
|
|
416
|
+
problem,
|
|
417
|
+
debugDir,
|
|
418
|
+
rounds: resolved,
|
|
419
|
+
probes,
|
|
420
|
+
runHistory: asArray<string>(record.runHistory),
|
|
421
|
+
logCounts,
|
|
422
|
+
artifacts: asArray<EvidenceArtifact>(record.artifacts ?? record.evidenceArtifacts),
|
|
423
|
+
observations: asArray<EvidenceObservation>(record.observations ?? record.evidenceObservations),
|
|
424
|
+
turnProduced: false,
|
|
425
|
+
cleanupNudges: typeof record.cleanupNudges === "number" ? record.cleanupNudges : 0,
|
|
426
|
+
};
|
|
199
427
|
}
|
|
200
428
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
429
|
+
function restoreStage(record: Record<string, unknown>): { stage: Stage; handoff: HandoffMode | null } {
|
|
430
|
+
const raw = typeof record.stage === "string" ? record.stage : legacyPhase(record.phase);
|
|
431
|
+
const legacy = LEGACY_STAGES[raw];
|
|
432
|
+
if (legacy) return legacy;
|
|
433
|
+
// A persisted `investigating` cannot be resumed: no agent turn survives a
|
|
434
|
+
// restore, so honouring it would strand the user with every command refused.
|
|
435
|
+
if (raw !== "investigating" && STAGES.includes(raw as Stage)) return { stage: raw as Stage, handoff: null };
|
|
436
|
+
return { stage: "user_turn", handoff: null };
|
|
208
437
|
}
|
|
209
438
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
439
|
+
/** Collapse the original `phase` field onto the stage name it became. */
|
|
440
|
+
function legacyPhase(phase: unknown): string {
|
|
441
|
+
if (phase === "waiting") return "awaiting_evidence";
|
|
442
|
+
if (phase === "cleanup") return "cleaning_up";
|
|
443
|
+
return "open";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Collapse the pre-`rounds` fields into the single round they described. */
|
|
447
|
+
function legacyRound(record: Record<string, unknown>, probes: readonly Probe[]): Round {
|
|
448
|
+
const index = typeof record.round === "number" && record.round > 0 ? record.round : 1;
|
|
449
|
+
const requests = asArray<EvidenceRequest & { round?: number }>(record.evidenceRequests).filter(
|
|
450
|
+
request => request.round === undefined || request.round === index,
|
|
451
|
+
);
|
|
452
|
+
return {
|
|
453
|
+
index,
|
|
454
|
+
runId: typeof record.runId === "string" ? record.runId : null,
|
|
455
|
+
plan: requests.length > 0 ? requests.map(({ round: _round, ...rest }) => rest) : null,
|
|
456
|
+
reproductionSteps: asArray<string>(record.reproductionSteps),
|
|
457
|
+
probeIds: probes.filter(probe => probe.round === index).map(probe => probe.id),
|
|
458
|
+
nudges: { handoff: numberOr(record.gateNudges, 0), probes: 0 },
|
|
459
|
+
awaitingProgress: false,
|
|
460
|
+
handoff: null,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The closing requirements of the current round, as observed facts. */
|
|
465
|
+
export function roundFacts(session: DebugSession, round: Round = currentRound(session)): string {
|
|
466
|
+
const plan = round.plan ? `${round.plan.length} request(s)` : "MISSING";
|
|
467
|
+
const steps = round.reproductionSteps.length > 0 ? `${round.reproductionSteps.length} step(s)` : "MISSING";
|
|
468
|
+
return `evidence plan: ${plan}, reproduction steps: ${steps}, live probes: ${liveProbeCount(session)}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Whose turn it is and what closing the round still requires, stated to the
|
|
473
|
+
* model. The model drives these transitions, so leaving the stage out is what
|
|
474
|
+
* lets it announce a reproduction while the round is still its own.
|
|
475
|
+
*/
|
|
476
|
+
export function stageBriefing(session: DebugSession): string {
|
|
477
|
+
const round = currentRound(session);
|
|
478
|
+
const facts = `Round ${round.index} facts — ${roundFacts(session, round)}.`;
|
|
479
|
+
if (session.stage === "cleaning_up") {
|
|
480
|
+
return `Turn owner: AGENT (cleanup). Remove every probe, verify the ledger is empty, then summarize. ${facts}`;
|
|
481
|
+
}
|
|
482
|
+
if (session.stage === "user_turn") {
|
|
483
|
+
// Written to be true whichever way this turn started. The context is
|
|
484
|
+
// assembled before a user message is announced, so this text cannot know
|
|
485
|
+
// which case it is in — but the model can see its own transcript, so the
|
|
486
|
+
// test it is given is one it can actually apply.
|
|
487
|
+
//
|
|
488
|
+
// Three triggers land here, and only the first one is the user's: a reply,
|
|
489
|
+
// the request that follows the `hand_off_to_user` call in the same turn
|
|
490
|
+
// (a tool call never ends a turn, so this one is unavoidable), and a host
|
|
491
|
+
// continuation. The last two need the same answer, so they are described
|
|
492
|
+
// together rather than as a list of causes to match against.
|
|
493
|
+
return (
|
|
494
|
+
`Turn owner: USER. Round ${round.index} was handed back as "${round.handoff ?? "incomplete"}" and is NOT closed. ${facts} ` +
|
|
495
|
+
"The user has not run /debug-proceed. Check what started this turn before you do anything else. " +
|
|
496
|
+
"If a user message is part of it, the round is yours again: answer it, then call hand_off_to_user to " +
|
|
497
|
+
"re-close the round — never assume the earlier handoff still stands. " +
|
|
498
|
+
"If nothing in this turn came from the user, nothing has changed since the handoff: you are either " +
|
|
499
|
+
"finishing the turn in which you just called hand_off_to_user, or you were resumed by a reminder or " +
|
|
500
|
+
"another automatic continuation. Either way the user has not acted, no reproduction has run, and no new " +
|
|
501
|
+
"observation exists. Do not read logs, do not analyze, do not resume the plan. Write one short line " +
|
|
502
|
+
"addressed to the user — what to do now, and that /debug-proceed comes after they reproduce (or " +
|
|
503
|
+
"/debug-done if the bug is already fixed) — then end the turn. Never report " +
|
|
504
|
+
"that you were reminded and never restate this briefing back to them."
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return (
|
|
508
|
+
`Turn owner: AGENT. Round ${round.index} is yours and you must close it before you stop. ${facts} ` +
|
|
509
|
+
"Close it by calling hand_off_to_user: that call is what moves the session into the user's hands. " +
|
|
510
|
+
"Prose telling the user to reproduce does not change the state, and a turn that stops without that call is " +
|
|
511
|
+
"sent straight back to you to make it."
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function blackboard(session: DebugSession, evidenceDescription = "(none)"): string {
|
|
516
|
+
const round = currentRound(session);
|
|
517
|
+
const probes = session.probes.map(p => `${p.id} (${p.file}, round ${p.round})`).join("\n ") || "(none)";
|
|
518
|
+
const counts = Object.entries(session.logCounts)
|
|
213
519
|
.map(([run, n]) => `${run}: ${n}`)
|
|
214
520
|
.join(", ") || "(none yet)";
|
|
215
521
|
return `\
|
|
216
|
-
[DEBUG MODE ACTIVE — round ${
|
|
522
|
+
[DEBUG MODE ACTIVE — round ${round.index} · stage ${session.stage}]
|
|
523
|
+
|
|
524
|
+
${stageBriefing(session)}
|
|
217
525
|
|
|
218
526
|
Problem under investigation:
|
|
219
|
-
${
|
|
527
|
+
${session.problem}
|
|
220
528
|
|
|
221
529
|
Deployed probes (ground truth, maintained by the extension):
|
|
222
530
|
${probes}
|
|
223
531
|
|
|
224
|
-
Current run log file (absolute path): ${logFileFor(
|
|
532
|
+
Current run log file (absolute path): ${logFileFor(session) ?? "(not initialized)"}
|
|
225
533
|
Runtime probes MUST append JSONL directly to that exact file using the target environment's native file APIs.
|
|
226
534
|
Console output such as Unity Debug.Log may supplement diagnostics but is never the runtime evidence for this workflow. Never ask the user to transcribe console output.
|
|
227
535
|
Logs by run: ${counts}
|
|
228
|
-
Current run id: ${
|
|
536
|
+
Current run id: ${round.runId ?? "(not started)"}
|
|
229
537
|
|
|
230
538
|
Evidence method priority (MINIMIZE USER INTERVENTION):
|
|
231
539
|
1. agent_inspection — reuse existing logs/files and agent tools
|