@siuver/omp-debug-mode 0.1.5 → 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 -48
- package/README.md +84 -31
- package/package.json +41 -41
- package/src/debug-mode.ts +240 -92
- package/src/evidence.ts +325 -169
- package/src/gate.ts +115 -51
- package/src/log-files.ts +154 -115
- package/src/machine.ts +136 -49
- package/src/main.ts +24 -24
- package/src/methodology.ts +46 -15
- package/src/state.ts +191 -42
- package/src/tools.ts +195 -9
- package/src/ui.ts +109 -69
package/src/state.ts
CHANGED
|
@@ -5,12 +5,11 @@ export const DEBUG_ENTRY = "com.omp.debug-mode.state";
|
|
|
5
5
|
export const DEBUG_CONTEXT_TYPE = "debug-mode-context";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Whose move it is.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* gate — conflating the two is what makes an unclosed round look like a pause.
|
|
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.
|
|
12
11
|
*/
|
|
13
|
-
export type Stage = "investigating" | "
|
|
12
|
+
export type Stage = "investigating" | "user_turn" | "cleaning_up";
|
|
14
13
|
|
|
15
14
|
export type EvidenceMethod = "agent_inspection" | "runtime_probe" | "user_report" | "user_artifact";
|
|
16
15
|
|
|
@@ -54,8 +53,12 @@ export interface EvidenceObservation {
|
|
|
54
53
|
addedAt: number;
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
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";
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
64
|
* One evidence round. Round membership is structural: everything scoped to a
|
|
@@ -70,9 +73,17 @@ export interface Round {
|
|
|
70
73
|
reproductionSteps: string[];
|
|
71
74
|
/** Ledger ids of probes introduced while this round was investigating. */
|
|
72
75
|
probeIds: string[];
|
|
73
|
-
/** Separate budgets: a probe nudge must not consume the closing
|
|
74
|
-
nudges: {
|
|
75
|
-
|
|
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;
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
export interface DebugSession {
|
|
@@ -110,8 +121,9 @@ export function blankRound(index: number, runId: string | null): Round {
|
|
|
110
121
|
plan: null,
|
|
111
122
|
reproductionSteps: [],
|
|
112
123
|
probeIds: [],
|
|
113
|
-
nudges: {
|
|
114
|
-
|
|
124
|
+
nudges: { handoff: 0, probes: 0 },
|
|
125
|
+
awaitingProgress: false,
|
|
126
|
+
handoff: null,
|
|
115
127
|
};
|
|
116
128
|
}
|
|
117
129
|
|
|
@@ -143,8 +155,19 @@ export function logFileFor(session: DebugSession, run: string | null = activeRun
|
|
|
143
155
|
return resolveRunLogFile(session.debugDir, run, activeRunId(session));
|
|
144
156
|
}
|
|
145
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
|
+
|
|
146
169
|
/** Probes this round introduced that are still in the ledger (i.e. still on disk). */
|
|
147
|
-
export function
|
|
170
|
+
export function roundProbeIds(session: DebugSession, round: Round = currentRound(session)): string[] {
|
|
148
171
|
return round.probeIds.filter(id => session.probes.some(probe => probe.id === id));
|
|
149
172
|
}
|
|
150
173
|
|
|
@@ -152,6 +175,13 @@ export function declaresRuntimeProbe(round: Round): boolean {
|
|
|
152
175
|
return (round.plan ?? []).some(request => request.method === "runtime_probe");
|
|
153
176
|
}
|
|
154
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
|
+
|
|
155
185
|
/** Requests whose method is satisfied by the agent alone, so they never gate on the user. */
|
|
156
186
|
export function hasNonProbeEvidence(round: Round): boolean {
|
|
157
187
|
return (round.plan ?? []).some(request => request.method !== "runtime_probe");
|
|
@@ -258,26 +288,64 @@ export function resolveRun(
|
|
|
258
288
|
}
|
|
259
289
|
|
|
260
290
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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.
|
|
265
303
|
*/
|
|
266
|
-
export function
|
|
304
|
+
export function syncCustomType<M extends { role?: string; customType?: string; content?: unknown }>(
|
|
267
305
|
messages: readonly M[],
|
|
268
306
|
customType: string,
|
|
269
|
-
|
|
307
|
+
content: string | null,
|
|
308
|
+
): M[] | null {
|
|
309
|
+
const isCopy = (message: M | undefined): boolean =>
|
|
310
|
+
message?.role === "custom" && message.customType === customType;
|
|
270
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[] = [];
|
|
271
322
|
for (let i = 0; i < messages.length; i++) {
|
|
272
|
-
const message = messages[i];
|
|
273
|
-
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);
|
|
274
330
|
}
|
|
275
|
-
|
|
276
|
-
return messages.filter((message, index) => !(message.role === "custom" && message.customType === customType && index !== last));
|
|
331
|
+
return kept;
|
|
277
332
|
}
|
|
278
333
|
|
|
279
|
-
const STAGES: readonly Stage[] = ["investigating", "
|
|
280
|
-
const
|
|
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
|
+
};
|
|
281
349
|
|
|
282
350
|
function asArray<T>(value: unknown): T[] {
|
|
283
351
|
return Array.isArray(value) ? (value as T[]) : [];
|
|
@@ -287,6 +355,12 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
287
355
|
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
|
288
356
|
}
|
|
289
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
|
+
|
|
290
364
|
function reviveRound(raw: unknown, fallbackIndex: number): Round {
|
|
291
365
|
const record = asRecord(raw);
|
|
292
366
|
const nudges = asRecord(record.nudges);
|
|
@@ -297,18 +371,26 @@ function reviveRound(raw: unknown, fallbackIndex: number): Round {
|
|
|
297
371
|
reproductionSteps: asArray<string>(record.reproductionSteps),
|
|
298
372
|
probeIds: asArray<string>(record.probeIds),
|
|
299
373
|
nudges: {
|
|
300
|
-
tags
|
|
301
|
-
|
|
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),
|
|
302
377
|
},
|
|
303
|
-
|
|
378
|
+
// Never restored: no agent turn survives a restart, so nothing is pending.
|
|
379
|
+
awaitingProgress: false,
|
|
380
|
+
handoff: reviveHandoff(record.handoff ?? record.openReason),
|
|
304
381
|
};
|
|
305
382
|
}
|
|
306
383
|
|
|
384
|
+
function numberOr(value: unknown, fallback: number): number {
|
|
385
|
+
return typeof value === "number" ? value : fallback;
|
|
386
|
+
}
|
|
387
|
+
|
|
307
388
|
/**
|
|
308
|
-
* Rebuild persisted state, tolerating the pre-`
|
|
309
|
-
* was mid-debug across an upgrade keeps
|
|
310
|
-
*
|
|
311
|
-
*
|
|
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.
|
|
312
394
|
*/
|
|
313
395
|
export function reviveState(data: unknown): DebugState {
|
|
314
396
|
const record = asRecord(data);
|
|
@@ -317,21 +399,23 @@ export function reviveState(data: unknown): DebugState {
|
|
|
317
399
|
const debugDir = typeof record.debugDir === "string" ? record.debugDir : null;
|
|
318
400
|
const probes = asArray<Probe>(record.probes);
|
|
319
401
|
const logCounts = asRecord(record.logCounts) as Record<string, number>;
|
|
320
|
-
const
|
|
321
|
-
const stage = STAGES.includes(stageRaw as Stage) ? (stageRaw as Stage) : "open";
|
|
402
|
+
const restored = restoreStage(record);
|
|
322
403
|
|
|
323
404
|
const rounds = Array.isArray(record.rounds)
|
|
324
405
|
? record.rounds.map((round, i) => reviveRound(round, i + 1))
|
|
325
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
|
+
}
|
|
326
412
|
|
|
327
413
|
return {
|
|
328
414
|
active: true,
|
|
329
|
-
|
|
330
|
-
// would strand the user waiting for a turn that will never resume.
|
|
331
|
-
stage: stage === "investigating" ? "open" : stage,
|
|
415
|
+
stage: restored.stage,
|
|
332
416
|
problem,
|
|
333
417
|
debugDir,
|
|
334
|
-
rounds:
|
|
418
|
+
rounds: resolved,
|
|
335
419
|
probes,
|
|
336
420
|
runHistory: asArray<string>(record.runHistory),
|
|
337
421
|
logCounts,
|
|
@@ -342,7 +426,18 @@ export function reviveState(data: unknown): DebugState {
|
|
|
342
426
|
};
|
|
343
427
|
}
|
|
344
428
|
|
|
345
|
-
function
|
|
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 };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Collapse the original `phase` field onto the stage name it became. */
|
|
440
|
+
function legacyPhase(phase: unknown): string {
|
|
346
441
|
if (phase === "waiting") return "awaiting_evidence";
|
|
347
442
|
if (phase === "cleanup") return "cleaning_up";
|
|
348
443
|
return "open";
|
|
@@ -360,11 +455,63 @@ function legacyRound(record: Record<string, unknown>, probes: readonly Probe[]):
|
|
|
360
455
|
plan: requests.length > 0 ? requests.map(({ round: _round, ...rest }) => rest) : null,
|
|
361
456
|
reproductionSteps: asArray<string>(record.reproductionSteps),
|
|
362
457
|
probeIds: probes.filter(probe => probe.round === index).map(probe => probe.id),
|
|
363
|
-
nudges: {
|
|
364
|
-
|
|
458
|
+
nudges: { handoff: numberOr(record.gateNudges, 0), probes: 0 },
|
|
459
|
+
awaitingProgress: false,
|
|
460
|
+
handoff: null,
|
|
365
461
|
};
|
|
366
462
|
}
|
|
367
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
|
+
|
|
368
515
|
export function blackboard(session: DebugSession, evidenceDescription = "(none)"): string {
|
|
369
516
|
const round = currentRound(session);
|
|
370
517
|
const probes = session.probes.map(p => `${p.id} (${p.file}, round ${p.round})`).join("\n ") || "(none)";
|
|
@@ -372,7 +519,9 @@ export function blackboard(session: DebugSession, evidenceDescription = "(none)"
|
|
|
372
519
|
.map(([run, n]) => `${run}: ${n}`)
|
|
373
520
|
.join(", ") || "(none yet)";
|
|
374
521
|
return `\
|
|
375
|
-
[DEBUG MODE ACTIVE — round ${round.index}]
|
|
522
|
+
[DEBUG MODE ACTIVE — round ${round.index} · stage ${session.stage}]
|
|
523
|
+
|
|
524
|
+
${stageBriefing(session)}
|
|
376
525
|
|
|
377
526
|
Problem under investigation:
|
|
378
527
|
${session.problem}
|
package/src/tools.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
-
import { describeEvidence } from "./evidence";
|
|
3
|
-
import { describeHypotheses, summarizeHypotheses } from "./log-files";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import { describeEvidence, validateEvidenceRequests } from "./evidence";
|
|
3
|
+
import { type HypothesisTally, describeHypotheses, summarizeHypotheses } from "./log-files";
|
|
4
|
+
import { HANDOFF_TOOL } from "./methodology";
|
|
4
5
|
import { type LedgerScan, describeLedger } from "./probes";
|
|
5
6
|
import {
|
|
6
7
|
type DebugState,
|
|
8
|
+
type EvidenceRequest,
|
|
7
9
|
type EvidenceView,
|
|
10
|
+
type HandoffMode,
|
|
8
11
|
activeRunId,
|
|
9
12
|
allRequests,
|
|
13
|
+
currentRound,
|
|
10
14
|
logFileFor,
|
|
11
15
|
resolveRun,
|
|
12
16
|
} from "./state";
|
|
@@ -23,19 +27,185 @@ function noMatch(kind: string, id: string, available: readonly string[]): string
|
|
|
23
27
|
return `No ${kind} matches ${JSON.stringify(id)}. Known ${kind}s: ${list}.`;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** An explicit round closure, already validated against the ledger contract. */
|
|
31
|
+
export interface HandoffRequest {
|
|
32
|
+
mode: Exclude<HandoffMode, "incomplete">;
|
|
33
|
+
steps: string[];
|
|
34
|
+
plan: EvidenceRequest[] | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type HandoffOutcome = { ok: true; summary: string } | { ok: false; error: string };
|
|
38
|
+
|
|
39
|
+
/** The four tools this package registers. Named so activation can add/remove them as a set. */
|
|
40
|
+
export const DEBUG_TOOL_NAMES = [
|
|
41
|
+
HANDOFF_TOOL,
|
|
42
|
+
"get_debug_logs",
|
|
43
|
+
"list_debug_probes",
|
|
44
|
+
"list_debug_evidence",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export type DebugToolName = (typeof DEBUG_TOOL_NAMES)[number];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The enabled-tool list that should follow debug-mode's session flag.
|
|
51
|
+
* `null` means the current set already matches, so the host must not be poked.
|
|
52
|
+
*/
|
|
53
|
+
export function nextActiveTools(current: readonly string[], debugActive: boolean): string[] | null {
|
|
54
|
+
const wanted: readonly string[] = DEBUG_TOOL_NAMES;
|
|
55
|
+
if (debugActive) {
|
|
56
|
+
if (wanted.every(name => current.includes(name))) return null;
|
|
57
|
+
return [...new Set([...current, ...wanted])];
|
|
58
|
+
}
|
|
59
|
+
if (wanted.every(name => !current.includes(name))) return null;
|
|
60
|
+
return current.filter(name => !wanted.includes(name));
|
|
61
|
+
}
|
|
62
|
+
|
|
26
63
|
export interface DebugToolDeps {
|
|
27
64
|
getState(): DebugState;
|
|
28
65
|
refreshLogCounts(): void;
|
|
29
66
|
readRunLines(run: string): string[];
|
|
30
67
|
/** Rescan the probe ledger and fold the result back into session state. */
|
|
31
68
|
syncLedger(): Promise<LedgerScan>;
|
|
69
|
+
/** Apply an explicit handoff; returns the reason when the machine refuses. */
|
|
70
|
+
handOff(request: HandoffRequest, ctx: ExtensionContext): HandoffOutcome;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Discoverable tools are removed from the top-level schema and only surfaced
|
|
75
|
+
* through tool search, so a tool the injected contract orders by name — above
|
|
76
|
+
* all the handoff, which is the only way a round reaches the user — has to be
|
|
77
|
+
* essential once it is active. `defaultInactive` keeps them out of ordinary
|
|
78
|
+
* sessions: they are registered at plugin load, but the host does not put them
|
|
79
|
+
* in the model's schema until `/debug-mode` starts (or a resumed session is
|
|
80
|
+
* already in debug mode). Both fields reached `ToolDefinition` after the
|
|
81
|
+
* version this package type-checks against, hence the spread instead of
|
|
82
|
+
* inline keys.
|
|
83
|
+
*/
|
|
84
|
+
const SESSION_TOOL: { loadMode?: "essential"; defaultInactive?: boolean } = {
|
|
85
|
+
loadMode: "essential",
|
|
86
|
+
defaultInactive: true,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Named so every `get_debug_logs` exit reports the same details shape. */
|
|
90
|
+
interface DebugLogDetails {
|
|
91
|
+
run: string | null;
|
|
92
|
+
file: string | null;
|
|
93
|
+
count: number;
|
|
94
|
+
hypotheses?: HypothesisTally[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface RejectedCall {
|
|
98
|
+
content: { type: "text"; text: string }[];
|
|
99
|
+
isError: true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Refuse to read logs while the round is with the user. Stated as a fact about
|
|
104
|
+
* the world rather than a permission error, because the model's mistake is
|
|
105
|
+
* believing a reproduction already ran, not believing it is allowed to look.
|
|
106
|
+
*/
|
|
107
|
+
function userTurnRefusal(round: number): string {
|
|
108
|
+
return (
|
|
109
|
+
`Round ${round} is still with the user and they have not run /debug-proceed, so no reproduction has run and ` +
|
|
110
|
+
"nothing has been captured since the handoff. This turn was not started by the user — it is a reminder or " +
|
|
111
|
+
"an automatic continuation. Do not analyze anything: say you are still waiting for the user and end the turn."
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A refused handoff must read as a repair instruction, not as a failure: the
|
|
117
|
+
* agent still holds the turn and can fix the call before it stops.
|
|
118
|
+
*/
|
|
119
|
+
function reject(reason: string): RejectedCall {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: `Handoff rejected — ${reason} Nothing was recorded; the round is still yours.` }],
|
|
122
|
+
isError: true,
|
|
123
|
+
};
|
|
32
124
|
}
|
|
33
125
|
|
|
34
126
|
export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void {
|
|
35
127
|
const z = pi.zod;
|
|
36
|
-
const { getState, refreshLogCounts, readRunLines, syncLedger } = deps;
|
|
128
|
+
const { getState, refreshLogCounts, readRunLines, syncLedger, handOff } = deps;
|
|
129
|
+
|
|
130
|
+
const planEntry = z.object({
|
|
131
|
+
id: z.string().describe("Request id, unique within the plan (e.g. E1)"),
|
|
132
|
+
hypothesisIds: z.array(z.string()).describe("Every hypothesis this single evidence action settles"),
|
|
133
|
+
method: z
|
|
134
|
+
.enum(["agent_inspection", "runtime_probe", "user_report", "user_artifact"])
|
|
135
|
+
.describe("Cheapest reliable method, in that priority order"),
|
|
136
|
+
title: z.string().describe("Short title for the request"),
|
|
137
|
+
rationale: z
|
|
138
|
+
.string()
|
|
139
|
+
.describe(
|
|
140
|
+
"Why this method is decisive; for user_report/user_artifact, why BOTH autonomous inspection and model-added probes cannot answer it",
|
|
141
|
+
),
|
|
142
|
+
instructions: z.array(z.string()).describe("Actionable capture/report steps"),
|
|
143
|
+
artifactHint: z.string().optional().describe("Expected file kind; only for user_artifact"),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
pi.registerTool({
|
|
147
|
+
...SESSION_TOOL,
|
|
148
|
+
name: HANDOFF_TOOL,
|
|
149
|
+
label: "Hand Off To User",
|
|
150
|
+
description:
|
|
151
|
+
"Close the current debug round and hand the session to the user. This call — not your prose — is what moves debug mode into the user's turn: it renders the steps in the user's widget, makes /debug-proceed available, and records the evidence plan. Call it as the last action of every round. An invalid argument is rejected with the exact problem so you can fix it without ending the turn.",
|
|
152
|
+
parameters: z.object({
|
|
153
|
+
mode: z
|
|
154
|
+
.enum(["reproduce", "capture", "question"])
|
|
155
|
+
.describe(
|
|
156
|
+
'"reproduce": the user must run the app so the probes record. "capture": the user only supplies a report or a file. "question": you need an answer before you can plan.',
|
|
157
|
+
),
|
|
158
|
+
steps: z
|
|
159
|
+
.array(z.string())
|
|
160
|
+
.optional()
|
|
161
|
+
.describe(
|
|
162
|
+
"Numbered actions the USER performs now (reproduce, capture, restart), one per entry. Never include agent work such as installing probes, reading logs or analyzing results. Required unless mode is question.",
|
|
163
|
+
),
|
|
164
|
+
plan: z
|
|
165
|
+
.array(planEntry)
|
|
166
|
+
.optional()
|
|
167
|
+
.describe(
|
|
168
|
+
"Evidence plan covering EVERY open hypothesis. Required unless mode is question or the round already recorded a plan.",
|
|
169
|
+
),
|
|
170
|
+
}),
|
|
171
|
+
// Deliberately the cheapest tier: the call touches no file and no command,
|
|
172
|
+
// only who owns the turn. On the `write` tier an approval policy can put a
|
|
173
|
+
// prompt in front of the one action every round is required to end with,
|
|
174
|
+
// and a declined or interrupted prompt is indistinguishable from a model
|
|
175
|
+
// that never called it.
|
|
176
|
+
approval: "read",
|
|
177
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
178
|
+
const state = getState();
|
|
179
|
+
if (!state.active) return { content: [{ type: "text", text: INACTIVE_TEXT }], isError: true };
|
|
180
|
+
const mode = params.mode as HandoffRequest["mode"];
|
|
181
|
+
const steps = (params.steps ?? []).map(step => step.trim()).filter(step => step.length > 0);
|
|
182
|
+
if (mode !== "question" && steps.length === 0) {
|
|
183
|
+
return reject(`mode "${mode}" needs steps: list what the user does now, one action per entry.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let plan: EvidenceRequest[] | null = null;
|
|
187
|
+
if (params.plan !== undefined) {
|
|
188
|
+
const validation = validateEvidenceRequests(params.plan);
|
|
189
|
+
if (!validation.ok) return reject(`${validation.error}.`);
|
|
190
|
+
plan = validation.requests;
|
|
191
|
+
}
|
|
192
|
+
if (mode !== "question" && plan === null && currentRound(state).plan === null) {
|
|
193
|
+
return reject(
|
|
194
|
+
`mode "${mode}" needs plan: this round has no evidence plan yet, so nothing would link what the user captures to a hypothesis.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const outcome = handOff({ mode, steps, plan }, ctx);
|
|
199
|
+
if (!outcome.ok) return reject(outcome.error);
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: "text", text: outcome.summary }],
|
|
202
|
+
details: { mode, steps: steps.length, plan: plan?.length ?? 0 },
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
});
|
|
37
206
|
|
|
38
207
|
pi.registerTool({
|
|
208
|
+
...SESSION_TOOL,
|
|
39
209
|
name: "get_debug_logs",
|
|
40
210
|
label: "Get Debug Logs",
|
|
41
211
|
description:
|
|
@@ -49,14 +219,25 @@ export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void
|
|
|
49
219
|
async execute(_toolCallId, params) {
|
|
50
220
|
refreshLogCounts();
|
|
51
221
|
const state = getState();
|
|
222
|
+
const noRun: DebugLogDetails = { run: null, file: null, count: 0 };
|
|
52
223
|
if (!state.active) {
|
|
53
|
-
return { content: [{ type: "text", text: INACTIVE_TEXT }], details:
|
|
224
|
+
return { content: [{ type: "text", text: INACTIVE_TEXT }], details: noRun };
|
|
225
|
+
}
|
|
226
|
+
// The round is with the user, so this turn cannot be one they started:
|
|
227
|
+
// a reply flips the stage back before any tool can run. Reading logs
|
|
228
|
+
// here would analyze a reproduction that has not happened yet.
|
|
229
|
+
if (state.stage === "user_turn") {
|
|
230
|
+
return {
|
|
231
|
+
content: [{ type: "text", text: userTurnRefusal(currentRound(state).index) }],
|
|
232
|
+
details: noRun,
|
|
233
|
+
isError: true,
|
|
234
|
+
};
|
|
54
235
|
}
|
|
55
236
|
const selection = resolveRun(params, state.runHistory, activeRunId(state), state.logCounts);
|
|
56
237
|
if (!selection.run) {
|
|
57
238
|
return {
|
|
58
239
|
content: [{ type: "text", text: `(${selection.note ?? "no debug run is available"})` }],
|
|
59
|
-
details:
|
|
240
|
+
details: noRun,
|
|
60
241
|
};
|
|
61
242
|
}
|
|
62
243
|
|
|
@@ -79,14 +260,18 @@ export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void
|
|
|
79
260
|
const body =
|
|
80
261
|
lines.join("\n") ||
|
|
81
262
|
"(no logs captured — the instrumented path may not have executed, the build may be stale, the path may be wrong, or the file append may have failed)";
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
263
|
+
const details: DebugLogDetails = {
|
|
264
|
+
run,
|
|
265
|
+
file: logFileFor(state, run),
|
|
266
|
+
count: lines.length,
|
|
267
|
+
hypotheses: tallies,
|
|
85
268
|
};
|
|
269
|
+
return { content: [{ type: "text", text: header + body }], details };
|
|
86
270
|
},
|
|
87
271
|
});
|
|
88
272
|
|
|
89
273
|
pi.registerTool({
|
|
274
|
+
...SESSION_TOOL,
|
|
90
275
|
name: "list_debug_probes",
|
|
91
276
|
label: "List Debug Probes",
|
|
92
277
|
description:
|
|
@@ -104,6 +289,7 @@ export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void
|
|
|
104
289
|
});
|
|
105
290
|
|
|
106
291
|
pi.registerTool({
|
|
292
|
+
...SESSION_TOOL,
|
|
107
293
|
name: "list_debug_evidence",
|
|
108
294
|
label: "List Debug Evidence",
|
|
109
295
|
description:
|