pi-watchdog-supervisor 0.1.0
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/.tmp/2026-07-08_pi_watchdog_extension_requirement.md +907 -0
- package/.tmp/2026-07-08_pi_watchdog_high_level_spec.md +192 -0
- package/.tmp/2026-07-08_pi_watchdog_task01_low_level_spec.md +234 -0
- package/.tmp/2026-07-08_pi_watchdog_task02_low_level_spec.md +158 -0
- package/.tmp/2026-07-08_pi_watchdog_task03_low_level_spec.md +175 -0
- package/.tmp/2026-07-08_pi_watchdog_task04_low_level_spec.md +151 -0
- package/.tmp/2026-07-08_pi_watchdog_task05_low_level_spec.md +116 -0
- package/.tmp/2026-07-08_pi_watchdog_task06_low_level_spec.md +69 -0
- package/.tmp/detect_approach.patch +253 -0
- package/README.md +176 -0
- package/examples/AGENTS.md +27 -0
- package/examples/watchdog-supervisor.json +12 -0
- package/package.json +39 -0
- package/prompts/watchdog-agent.md +36 -0
- package/skills/watchdog-supervisor/SKILL.md +35 -0
- package/src/checker.ts +54 -0
- package/src/collector.ts +223 -0
- package/src/commands.ts +159 -0
- package/src/config.ts +80 -0
- package/src/detector.ts +150 -0
- package/src/index.ts +94 -0
- package/src/integrations/gotgenes-subagents.ts +45 -0
- package/src/lmdebug.ts +128 -0
- package/src/normalize.ts +40 -0
- package/src/registry.ts +121 -0
- package/src/store.ts +137 -0
- package/src/tools.ts +289 -0
- package/src/types.ts +80 -0
- package/test/Screenshot_20260708_220337.png +0 -0
- package/test/checker.test.ts +118 -0
- package/test/collector.test.ts +201 -0
- package/test/config.test.ts +131 -0
- package/test/detector.test.ts +224 -0
- package/test/integration.test.ts +57 -0
- package/test/lmdebug.test.ts +114 -0
- package/test/normalize.test.ts +78 -0
- package/test/registry.test.ts +149 -0
- package/test/store.test.ts +176 -0
- package/test/tools.test.ts +315 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +7 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { mergeConfig, pickKnownConfig } from './config.ts';
|
|
4
|
+
import { detectStuck, shouldAlert } from './detector.ts';
|
|
5
|
+
import type { AlertSeverity, WatchdogStore } from './store.ts';
|
|
6
|
+
import type { TargetRegistry } from './registry.ts';
|
|
7
|
+
import type { SubagentsIntegration } from './integrations/gotgenes-subagents.ts';
|
|
8
|
+
import type { StuckAnalysis, WatchdogConfig, WatchdogEvent } from './types.ts';
|
|
9
|
+
|
|
10
|
+
export type ToolDeps = {
|
|
11
|
+
store: WatchdogStore;
|
|
12
|
+
registry: TargetRegistry;
|
|
13
|
+
getIntegration: () => Promise<SubagentsIntegration>;
|
|
14
|
+
baseConfig: WatchdogConfig;
|
|
15
|
+
now: () => number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const TERMINAL_STATUSES = new Set(['completed', 'aborted', 'stopped', 'error']);
|
|
19
|
+
|
|
20
|
+
const effectiveConfig = (deps: ToolDeps): WatchdogConfig =>
|
|
21
|
+
mergeConfig(deps.baseConfig, deps.store.getConfigOverride());
|
|
22
|
+
|
|
23
|
+
const effectiveRescueMessage = (deps: ToolDeps): string =>
|
|
24
|
+
deps.store.getRescueMessage() ?? effectiveConfig(deps).rescueMessage;
|
|
25
|
+
|
|
26
|
+
const analyzeTarget = (deps: ToolDeps, targetKey: string): StuckAnalysis =>
|
|
27
|
+
detectStuck(
|
|
28
|
+
deps.store.getEvents(targetKey),
|
|
29
|
+
effectiveConfig(deps),
|
|
30
|
+
deps.now(),
|
|
31
|
+
deps.store.getLastAlert(targetKey)?.at ?? 0,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Pull the repeat count out of the repeated_llm_* evidence summaries
|
|
35
|
+
const repeatedMessageCount = (analysis: StuckAnalysis): number => {
|
|
36
|
+
const counts = analysis.evidence
|
|
37
|
+
.filter((item) => item.type === 'repeated_llm_input' || item.type === 'repeated_llm_output')
|
|
38
|
+
.map((item) => Number(item.summary.match(/repeated (\d+) times/)?.[1] ?? 0));
|
|
39
|
+
return Math.max(0, ...counts);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const executeListTargets = async (
|
|
43
|
+
deps: ToolDeps,
|
|
44
|
+
input: { includeCompleted?: boolean },
|
|
45
|
+
): Promise<string> => {
|
|
46
|
+
const integration = await deps.getIntegration();
|
|
47
|
+
if (!integration.available) {
|
|
48
|
+
return `targets unavailable: ${integration.reason}`;
|
|
49
|
+
}
|
|
50
|
+
deps.registry.syncFromRecords(integration.listAgents());
|
|
51
|
+
const targets = deps.registry
|
|
52
|
+
.list()
|
|
53
|
+
.filter((target) => input.includeCompleted === true || !TERMINAL_STATUSES.has(target.status))
|
|
54
|
+
.map((target) => {
|
|
55
|
+
const key = deps.store.resolveTargetKey(target.id);
|
|
56
|
+
const analysis = key ? analyzeTarget(deps, key) : undefined;
|
|
57
|
+
return {
|
|
58
|
+
id: target.id,
|
|
59
|
+
name: target.name,
|
|
60
|
+
kind: target.kind,
|
|
61
|
+
status: target.status,
|
|
62
|
+
lastActiveAt: new Date(target.lastActiveAt).toISOString(),
|
|
63
|
+
toolCallCount: target.toolCallCount,
|
|
64
|
+
patchCount: key
|
|
65
|
+
? deps.store.getEvents(key).filter((event) => event.type === 'edit').length
|
|
66
|
+
: 0,
|
|
67
|
+
repeatedMessageCount: analysis ? repeatedMessageCount(analysis) : 0,
|
|
68
|
+
likelyStuck: analysis?.likelyStuck ?? false,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
return JSON.stringify({ targets }, null, 2);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const executeReadEvents = (
|
|
75
|
+
deps: ToolDeps,
|
|
76
|
+
input: { targetId: string; limit?: number },
|
|
77
|
+
): string => {
|
|
78
|
+
const key = deps.store.resolveTargetKey(input.targetId);
|
|
79
|
+
if (!key) {
|
|
80
|
+
return `Unknown target: ${input.targetId}`;
|
|
81
|
+
}
|
|
82
|
+
const events = deps.store.getEvents(key, input.limit ?? 50);
|
|
83
|
+
return JSON.stringify({ targetId: key, events }, null, 2);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const executeDetectStuck = (deps: ToolDeps, input: { targetId: string }): string => {
|
|
87
|
+
const key = deps.store.resolveTargetKey(input.targetId);
|
|
88
|
+
if (!key) {
|
|
89
|
+
return `Unknown target: ${input.targetId}`;
|
|
90
|
+
}
|
|
91
|
+
const analysis = analyzeTarget(deps, key);
|
|
92
|
+
return JSON.stringify(
|
|
93
|
+
{
|
|
94
|
+
targetId: key,
|
|
95
|
+
likelyStuck: analysis.likelyStuck,
|
|
96
|
+
confidence: analysis.confidence,
|
|
97
|
+
reasons: analysis.reasons,
|
|
98
|
+
evidence: analysis.evidence,
|
|
99
|
+
evidenceKey: analysis.evidenceKey,
|
|
100
|
+
suggestedRescueMessage: effectiveRescueMessage(deps),
|
|
101
|
+
},
|
|
102
|
+
null,
|
|
103
|
+
2,
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const formatAlert = (
|
|
108
|
+
targetId: string,
|
|
109
|
+
analysis: Pick<StuckAnalysis, 'likelyStuck' | 'confidence' | 'evidence'>,
|
|
110
|
+
events: WatchdogEvent[],
|
|
111
|
+
note: string,
|
|
112
|
+
rescueMessage?: string,
|
|
113
|
+
): string => {
|
|
114
|
+
const lastCommand = [...events].reverse().find((event) => event.commandKey)?.commandKey;
|
|
115
|
+
const evidenceLines =
|
|
116
|
+
analysis.evidence.length > 0
|
|
117
|
+
? analysis.evidence.map((item) => `- ${item.summary}`).join('\n')
|
|
118
|
+
: '- (no deterministic evidence; reported by watchdog agent)';
|
|
119
|
+
return [
|
|
120
|
+
'[Watchdog Alert]',
|
|
121
|
+
'',
|
|
122
|
+
`Target: ${targetId}`,
|
|
123
|
+
`Status: ${analysis.likelyStuck ? 'likely stuck' : 'reported by watchdog'}`,
|
|
124
|
+
`Confidence: ${analysis.confidence}`,
|
|
125
|
+
'',
|
|
126
|
+
'Evidence:',
|
|
127
|
+
evidenceLines,
|
|
128
|
+
'',
|
|
129
|
+
'Note:',
|
|
130
|
+
note,
|
|
131
|
+
'',
|
|
132
|
+
'Last command:',
|
|
133
|
+
lastCommand ?? '(none recorded)',
|
|
134
|
+
'',
|
|
135
|
+
'Suggested rescue:',
|
|
136
|
+
rescueMessage ?? '',
|
|
137
|
+
].join('\n');
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const executeAlertMain = (
|
|
141
|
+
deps: ToolDeps,
|
|
142
|
+
input: { targetId: string; message: string; severity?: AlertSeverity },
|
|
143
|
+
): string => {
|
|
144
|
+
if (deps.store.isPaused()) {
|
|
145
|
+
return 'alert suppressed (watchdog paused)';
|
|
146
|
+
}
|
|
147
|
+
const key = deps.store.resolveTargetKey(input.targetId) ?? input.targetId;
|
|
148
|
+
const config = effectiveConfig(deps);
|
|
149
|
+
const now = deps.now();
|
|
150
|
+
const analysis = analyzeTarget(deps, key);
|
|
151
|
+
if (
|
|
152
|
+
analysis.likelyStuck &&
|
|
153
|
+
!shouldAlert(deps.store.getLastAlert(key), analysis, now, config.cooldownSec)
|
|
154
|
+
) {
|
|
155
|
+
return 'alert suppressed (cooldown)';
|
|
156
|
+
}
|
|
157
|
+
const alert = formatAlert(
|
|
158
|
+
key,
|
|
159
|
+
analysis,
|
|
160
|
+
deps.store.getEvents(key),
|
|
161
|
+
input.message,
|
|
162
|
+
effectiveRescueMessage(deps),
|
|
163
|
+
);
|
|
164
|
+
if (!deps.store.alert(alert, input.severity ?? 'warning')) {
|
|
165
|
+
return 'alert failed: no alert sink registered (is the main session running this extension?)';
|
|
166
|
+
}
|
|
167
|
+
if (analysis.likelyStuck) {
|
|
168
|
+
deps.store.recordAlert(key, analysis.evidenceKey, now);
|
|
169
|
+
}
|
|
170
|
+
return `alert sent to main session (severity=${input.severity ?? 'warning'})`;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const executeSteerSubagent = async (
|
|
174
|
+
deps: ToolDeps,
|
|
175
|
+
input: { targetId: string; message?: string; dryRun?: boolean },
|
|
176
|
+
): Promise<string> => {
|
|
177
|
+
const message = input.message ?? effectiveRescueMessage(deps);
|
|
178
|
+
const config = effectiveConfig(deps);
|
|
179
|
+
// Explicit dryRun wins; otherwise use steerDryRunDefault (null = dry-run)
|
|
180
|
+
const dryRun = input.dryRun ?? config.steerDryRunDefault ?? true;
|
|
181
|
+
if (dryRun) {
|
|
182
|
+
return `dry-run: would steer ${input.targetId} with:\n${message}`;
|
|
183
|
+
}
|
|
184
|
+
if (config.alertMode === 'main_only') {
|
|
185
|
+
return `steer refused: alertMode is main_only — update config (alertMode: direct_subagent or both) to allow direct steering`;
|
|
186
|
+
}
|
|
187
|
+
const agentId = deps.store.resolveAgentId(input.targetId);
|
|
188
|
+
if (!agentId) {
|
|
189
|
+
return `Unknown target: ${input.targetId} (no linked agent id)`;
|
|
190
|
+
}
|
|
191
|
+
const integration = await deps.getIntegration();
|
|
192
|
+
if (!integration.available) {
|
|
193
|
+
return `steer unavailable: ${integration.reason}`;
|
|
194
|
+
}
|
|
195
|
+
const ok = await integration.steer(agentId, message);
|
|
196
|
+
return ok ? `steered ${agentId} with rescue message` : `steer failed for ${agentId}`;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const executeConfig = (
|
|
200
|
+
deps: ToolDeps,
|
|
201
|
+
input: { action: 'get' | 'set'; config?: Partial<WatchdogConfig> },
|
|
202
|
+
): string => {
|
|
203
|
+
if (input.action === 'set') {
|
|
204
|
+
deps.store.setConfigOverride(pickKnownConfig((input.config ?? {}) as Record<string, unknown>));
|
|
205
|
+
}
|
|
206
|
+
return JSON.stringify(effectiveConfig(deps), null, 2);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }], details: undefined });
|
|
210
|
+
|
|
211
|
+
export const registerWatchdogTools = (pi: ExtensionAPI, deps: ToolDeps) => {
|
|
212
|
+
pi.registerTool({
|
|
213
|
+
name: 'watchdog_list_targets',
|
|
214
|
+
label: 'Watchdog: list targets',
|
|
215
|
+
description:
|
|
216
|
+
'List sub-agents visible to the watchdog with status and stuck signals. Completed agents are excluded unless includeCompleted is true.',
|
|
217
|
+
parameters: Type.Object({
|
|
218
|
+
includeCompleted: Type.Optional(Type.Boolean()),
|
|
219
|
+
}),
|
|
220
|
+
execute: async (_id, params) => text(await executeListTargets(deps, params)),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
pi.registerTool({
|
|
224
|
+
name: 'watchdog_read_events',
|
|
225
|
+
label: 'Watchdog: read events',
|
|
226
|
+
description: 'Read compact recent events (commands, output hashes, edits) for one target.',
|
|
227
|
+
parameters: Type.Object({
|
|
228
|
+
targetId: Type.String(),
|
|
229
|
+
limit: Type.Optional(Type.Number()),
|
|
230
|
+
}),
|
|
231
|
+
execute: async (_id, params) => text(executeReadEvents(deps, params)),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
pi.registerTool({
|
|
235
|
+
name: 'watchdog_detect_stuck',
|
|
236
|
+
label: 'Watchdog: detect stuck',
|
|
237
|
+
description:
|
|
238
|
+
'Run deterministic stuck analysis for one target: repeated LLM input/output message bodies, idle without progress.',
|
|
239
|
+
parameters: Type.Object({
|
|
240
|
+
targetId: Type.String(),
|
|
241
|
+
}),
|
|
242
|
+
execute: async (_id, params) => text(executeDetectStuck(deps, params)),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
pi.registerTool({
|
|
246
|
+
name: 'watchdog_alert_main',
|
|
247
|
+
label: 'Watchdog: alert main session',
|
|
248
|
+
description:
|
|
249
|
+
'Send a compact stuck alert to the main agent session. Respects pause state and per-target cooldown.',
|
|
250
|
+
parameters: Type.Object({
|
|
251
|
+
targetId: Type.String(),
|
|
252
|
+
message: Type.String(),
|
|
253
|
+
severity: Type.Optional(
|
|
254
|
+
Type.Union([Type.Literal('info'), Type.Literal('warning'), Type.Literal('critical')]),
|
|
255
|
+
),
|
|
256
|
+
}),
|
|
257
|
+
execute: async (_id, params) => text(executeAlertMain(deps, params)),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
pi.registerTool({
|
|
261
|
+
name: 'watchdog_steer_subagent',
|
|
262
|
+
label: 'Watchdog: steer sub-agent',
|
|
263
|
+
description:
|
|
264
|
+
'Send a rescue message to a target sub-agent. Defaults to dry-run (configurable via steerDryRunDefault); real steering requires alertMode other than main_only.',
|
|
265
|
+
parameters: Type.Object({
|
|
266
|
+
targetId: Type.String(),
|
|
267
|
+
message: Type.Optional(Type.String()),
|
|
268
|
+
dryRun: Type.Optional(Type.Boolean()),
|
|
269
|
+
}),
|
|
270
|
+
execute: async (_id, params) => text(await executeSteerSubagent(deps, params)),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
pi.registerTool({
|
|
274
|
+
name: 'watchdog_config',
|
|
275
|
+
label: 'Watchdog: config',
|
|
276
|
+
description: 'Read or update the watchdog policy (thresholds, cooldown, alertMode, rescue message).',
|
|
277
|
+
parameters: Type.Object({
|
|
278
|
+
action: Type.Union([Type.Literal('get'), Type.Literal('set')]),
|
|
279
|
+
config: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
280
|
+
}),
|
|
281
|
+
execute: async (_id, params) =>
|
|
282
|
+
text(
|
|
283
|
+
executeConfig(deps, {
|
|
284
|
+
action: params.action,
|
|
285
|
+
config: params.config as Partial<WatchdogConfig> | undefined,
|
|
286
|
+
}),
|
|
287
|
+
),
|
|
288
|
+
});
|
|
289
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type AlertMode = 'main_only' | 'direct_subagent' | 'both';
|
|
2
|
+
|
|
3
|
+
export type TargetKind = 'task' | 'watchdog' | 'unknown';
|
|
4
|
+
|
|
5
|
+
export type TargetStatus =
|
|
6
|
+
| 'queued'
|
|
7
|
+
| 'running'
|
|
8
|
+
| 'completed'
|
|
9
|
+
| 'steered'
|
|
10
|
+
| 'aborted'
|
|
11
|
+
| 'stopped'
|
|
12
|
+
| 'error';
|
|
13
|
+
|
|
14
|
+
export type WatchdogTarget = {
|
|
15
|
+
id: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
kind: TargetKind;
|
|
18
|
+
status: TargetStatus;
|
|
19
|
+
toolCallCount: number;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
lastActiveAt: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type WatchdogEventType =
|
|
25
|
+
| 'subagent_created'
|
|
26
|
+
| 'subagent_started'
|
|
27
|
+
| 'subagent_completed'
|
|
28
|
+
| 'subagent_failed'
|
|
29
|
+
| 'tool_call'
|
|
30
|
+
| 'tool_result'
|
|
31
|
+
| 'edit'
|
|
32
|
+
| 'llm_input'
|
|
33
|
+
| 'llm_output';
|
|
34
|
+
|
|
35
|
+
export type WatchdogEvent = {
|
|
36
|
+
id: string;
|
|
37
|
+
targetId: string;
|
|
38
|
+
at: number;
|
|
39
|
+
type: WatchdogEventType;
|
|
40
|
+
summary: string;
|
|
41
|
+
commandKey?: string;
|
|
42
|
+
outputHash?: string;
|
|
43
|
+
outputPreview?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type StuckEvidenceType =
|
|
47
|
+
| 'repeated_llm_input'
|
|
48
|
+
| 'repeated_llm_output'
|
|
49
|
+
| 'idle_no_progress';
|
|
50
|
+
|
|
51
|
+
export type StuckEvidence = {
|
|
52
|
+
type: StuckEvidenceType;
|
|
53
|
+
summary: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type StuckAnalysis = {
|
|
57
|
+
likelyStuck: boolean;
|
|
58
|
+
confidence: 'low' | 'medium' | 'high';
|
|
59
|
+
reasons: string[];
|
|
60
|
+
evidence: StuckEvidence[];
|
|
61
|
+
evidenceKey: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type WatchdogConfig = {
|
|
65
|
+
enabled: boolean;
|
|
66
|
+
rescueMessage: string;
|
|
67
|
+
// Identical (normalized) LLM message bodies in a row to count as a loop; 0 disables
|
|
68
|
+
llmRepeatThreshold: number;
|
|
69
|
+
idleNoProgressSec: number;
|
|
70
|
+
cooldownSec: number;
|
|
71
|
+
maxPreviewLines: number;
|
|
72
|
+
maxEventsPerAgent: number;
|
|
73
|
+
alertMode: AlertMode;
|
|
74
|
+
// Default for watchdog_steer_subagent's dryRun when the call omits it;
|
|
75
|
+
// null keeps the built-in safe default (dry-run)
|
|
76
|
+
steerDryRunDefault: boolean | null;
|
|
77
|
+
// Show the lm-debug console below the editor with the full latest
|
|
78
|
+
// sent/received LLM messages (newlines preserved, untruncated)
|
|
79
|
+
debug: boolean;
|
|
80
|
+
};
|
|
Binary file
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_CONFIG } from '../src/config.ts';
|
|
3
|
+
import { getOrCreateStore, resetStoreForTest } from '../src/store.ts';
|
|
4
|
+
import { createStuckChecker, type SendMessageLike } from '../src/checker.ts';
|
|
5
|
+
|
|
6
|
+
const SESSION = 'sess-main';
|
|
7
|
+
|
|
8
|
+
type SentMessage = { message: Record<string, unknown>; options: Record<string, unknown> };
|
|
9
|
+
|
|
10
|
+
const makePi = (sent: SentMessage[]): SendMessageLike => ({
|
|
11
|
+
sendMessage: (message, options) => {
|
|
12
|
+
sent.push({ message: message as Record<string, unknown>, options: options as Record<string, unknown> });
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const feedRepeatedResults = (
|
|
17
|
+
store: ReturnType<typeof getOrCreateStore>,
|
|
18
|
+
times: number,
|
|
19
|
+
baseAt = Date.now() - times * 1000,
|
|
20
|
+
) => {
|
|
21
|
+
for (let i = 0; i < times; i += 1) {
|
|
22
|
+
store.appendEvent(SESSION, {
|
|
23
|
+
at: baseAt + i * 1000,
|
|
24
|
+
type: 'llm_output',
|
|
25
|
+
summary: 'llm_output: same body',
|
|
26
|
+
commandKey: 'llm_output',
|
|
27
|
+
outputHash: 'hash-same',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('createStuckChecker', () => {
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
resetStoreForTest();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('sends a steer rescue message when stuck is detected', () => {
|
|
38
|
+
const store = getOrCreateStore(50);
|
|
39
|
+
const sent: SentMessage[] = [];
|
|
40
|
+
feedRepeatedResults(store, 3);
|
|
41
|
+
const check = createStuckChecker(makePi(sent), store, SESSION, DEFAULT_CONFIG);
|
|
42
|
+
check();
|
|
43
|
+
expect(sent).toHaveLength(1);
|
|
44
|
+
expect(sent[0].message.content).toContain(DEFAULT_CONFIG.rescueMessage);
|
|
45
|
+
expect(sent[0].options.deliverAs).toBe('steer');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('resets the counter after an alert: no re-alert until the loop repeats again', () => {
|
|
49
|
+
const store = getOrCreateStore(50);
|
|
50
|
+
const sent: SentMessage[] = [];
|
|
51
|
+
feedRepeatedResults(store, 3);
|
|
52
|
+
const check = createStuckChecker(makePi(sent), store, SESSION, DEFAULT_CONFIG);
|
|
53
|
+
check();
|
|
54
|
+
expect(sent).toHaveLength(1);
|
|
55
|
+
// no new events: the historical run is excluded by the alert cutoff
|
|
56
|
+
check();
|
|
57
|
+
expect(sent).toHaveLength(1);
|
|
58
|
+
// the loop keeps going: three fresh repeats re-trigger (no cooldown by default)
|
|
59
|
+
feedRepeatedResults(store, 3, Date.now() + 1000);
|
|
60
|
+
check();
|
|
61
|
+
expect(sent).toHaveLength(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not re-trigger within a positive cooldown even when the loop repeats', () => {
|
|
65
|
+
const store = getOrCreateStore(50);
|
|
66
|
+
const sent: SentMessage[] = [];
|
|
67
|
+
feedRepeatedResults(store, 3);
|
|
68
|
+
const check = createStuckChecker(makePi(sent), store, SESSION, {
|
|
69
|
+
...DEFAULT_CONFIG,
|
|
70
|
+
cooldownSec: 60,
|
|
71
|
+
});
|
|
72
|
+
check();
|
|
73
|
+
feedRepeatedResults(store, 3, Date.now() + 1000);
|
|
74
|
+
check();
|
|
75
|
+
expect(sent).toHaveLength(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('respects the pause state', () => {
|
|
79
|
+
const store = getOrCreateStore(50);
|
|
80
|
+
const sent: SentMessage[] = [];
|
|
81
|
+
feedRepeatedResults(store, 3);
|
|
82
|
+
store.setPaused(true);
|
|
83
|
+
const check = createStuckChecker(makePi(sent), store, SESSION, DEFAULT_CONFIG);
|
|
84
|
+
check();
|
|
85
|
+
expect(sent).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does nothing when the session is not stuck', () => {
|
|
89
|
+
const store = getOrCreateStore(50);
|
|
90
|
+
const sent: SentMessage[] = [];
|
|
91
|
+
const check = createStuckChecker(makePi(sent), store, SESSION, DEFAULT_CONFIG);
|
|
92
|
+
check();
|
|
93
|
+
expect(sent).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does nothing when the watchdog is disabled via config', () => {
|
|
97
|
+
const store = getOrCreateStore(50);
|
|
98
|
+
const sent: SentMessage[] = [];
|
|
99
|
+
feedRepeatedResults(store, 3);
|
|
100
|
+
const check = createStuckChecker(makePi(sent), store, SESSION, {
|
|
101
|
+
...DEFAULT_CONFIG,
|
|
102
|
+
enabled: false,
|
|
103
|
+
});
|
|
104
|
+
check();
|
|
105
|
+
expect(sent).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('prefers the runtime rescue message override', () => {
|
|
109
|
+
const store = getOrCreateStore(50);
|
|
110
|
+
const sent: SentMessage[] = [];
|
|
111
|
+
feedRepeatedResults(store, 3);
|
|
112
|
+
store.setRescueMessage('custom rescue');
|
|
113
|
+
const check = createStuckChecker(makePi(sent), store, SESSION, DEFAULT_CONFIG);
|
|
114
|
+
check();
|
|
115
|
+
expect(sent).toHaveLength(1);
|
|
116
|
+
expect(sent[0].message.content).toContain('custom rescue');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { startCollector, startLlmCollector } from '../src/collector.ts';
|
|
3
|
+
import { getOrCreateStore, resetStoreForTest } from '../src/store.ts';
|
|
4
|
+
import { DEFAULT_CONFIG } from '../src/config.ts';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
resetStoreForTest();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
type Handler = (event: unknown) => void;
|
|
11
|
+
|
|
12
|
+
// Fake pi exposing only the `on` surface the collector uses
|
|
13
|
+
const createFakePi = () => {
|
|
14
|
+
const handlers = new Map<string, Handler[]>();
|
|
15
|
+
return {
|
|
16
|
+
pi: {
|
|
17
|
+
on: (event: string, handler: Handler) => {
|
|
18
|
+
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
emit: (event: string, payload: unknown) => {
|
|
22
|
+
for (const handler of handlers.get(event) ?? []) {
|
|
23
|
+
handler(payload);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('startCollector', () => {
|
|
30
|
+
it('records bash tool_call with normalized commandKey', () => {
|
|
31
|
+
const store = getOrCreateStore(10);
|
|
32
|
+
const { pi, emit } = createFakePi();
|
|
33
|
+
startCollector(pi, store, 'sess-1', DEFAULT_CONFIG);
|
|
34
|
+
emit('tool_call', {
|
|
35
|
+
type: 'tool_call',
|
|
36
|
+
toolCallId: 'tc-1',
|
|
37
|
+
toolName: 'bash',
|
|
38
|
+
input: { command: 'rg "X" src' },
|
|
39
|
+
});
|
|
40
|
+
const events = store.getEvents('sess-1');
|
|
41
|
+
expect(events).toHaveLength(1);
|
|
42
|
+
expect(events[0]?.type).toBe('tool_call');
|
|
43
|
+
expect(events[0]?.commandKey).toBe('rg "X" src');
|
|
44
|
+
expect(events[0]?.summary).toBe('bash: rg "X" src');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('records tool_result with output hash and preview', () => {
|
|
48
|
+
const store = getOrCreateStore(10);
|
|
49
|
+
const { pi, emit } = createFakePi();
|
|
50
|
+
startCollector(pi, store, 'sess-1', DEFAULT_CONFIG);
|
|
51
|
+
emit('tool_result', {
|
|
52
|
+
type: 'tool_result',
|
|
53
|
+
toolCallId: 'tc-1',
|
|
54
|
+
toolName: 'bash',
|
|
55
|
+
input: { command: 'rg "X" src' },
|
|
56
|
+
content: [{ type: 'text', text: 'match line 1\nmatch line 2' }],
|
|
57
|
+
isError: false,
|
|
58
|
+
});
|
|
59
|
+
const events = store.getEvents('sess-1');
|
|
60
|
+
expect(events).toHaveLength(1);
|
|
61
|
+
expect(events[0]?.type).toBe('tool_result');
|
|
62
|
+
expect(events[0]?.outputHash).toMatch(/^[0-9a-f]{64}$/);
|
|
63
|
+
expect(events[0]?.outputPreview).toBe('match line 1\nmatch line 2');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('classifies edit-family tools as edit events', () => {
|
|
67
|
+
const store = getOrCreateStore(10);
|
|
68
|
+
const { pi, emit } = createFakePi();
|
|
69
|
+
startCollector(pi, store, 'sess-1', DEFAULT_CONFIG);
|
|
70
|
+
emit('tool_call', {
|
|
71
|
+
type: 'tool_call',
|
|
72
|
+
toolCallId: 'tc-2',
|
|
73
|
+
toolName: 'edit',
|
|
74
|
+
input: { path: 'src/a.ts' },
|
|
75
|
+
});
|
|
76
|
+
expect(store.getEvents('sess-1')[0]?.type).toBe('edit');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('uses first string input value as commandKey for non-bash tools', () => {
|
|
80
|
+
const store = getOrCreateStore(10);
|
|
81
|
+
const { pi, emit } = createFakePi();
|
|
82
|
+
startCollector(pi, store, 'sess-1', DEFAULT_CONFIG);
|
|
83
|
+
emit('tool_call', {
|
|
84
|
+
type: 'tool_call',
|
|
85
|
+
toolCallId: 'tc-3',
|
|
86
|
+
toolName: 'grep',
|
|
87
|
+
input: { pattern: 'TOKEN', path: 'src' },
|
|
88
|
+
});
|
|
89
|
+
expect(store.getEvents('sess-1')[0]?.commandKey).toBe('grep TOKEN');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('ignores malformed payloads without throwing', () => {
|
|
93
|
+
const store = getOrCreateStore(10);
|
|
94
|
+
const { pi, emit } = createFakePi();
|
|
95
|
+
startCollector(pi, store, 'sess-1', DEFAULT_CONFIG);
|
|
96
|
+
emit('tool_call', null);
|
|
97
|
+
emit('tool_result', { unexpected: true });
|
|
98
|
+
expect(store.getEvents('sess-1')).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('stops recording after unsubscribe', () => {
|
|
102
|
+
const store = getOrCreateStore(10);
|
|
103
|
+
const { pi, emit } = createFakePi();
|
|
104
|
+
const stop = startCollector(pi, store, 'sess-1', DEFAULT_CONFIG);
|
|
105
|
+
stop();
|
|
106
|
+
emit('tool_call', {
|
|
107
|
+
type: 'tool_call',
|
|
108
|
+
toolCallId: 'tc-4',
|
|
109
|
+
toolName: 'bash',
|
|
110
|
+
input: { command: 'ls' },
|
|
111
|
+
});
|
|
112
|
+
expect(store.getEvents('sess-1')).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('startLlmCollector', () => {
|
|
117
|
+
it('records the last payload message as llm_input with a normalized-body hash', () => {
|
|
118
|
+
const store = getOrCreateStore(10);
|
|
119
|
+
const { pi, emit } = createFakePi();
|
|
120
|
+
startLlmCollector(pi, store, 'sess-1');
|
|
121
|
+
emit('before_provider_request', {
|
|
122
|
+
type: 'before_provider_request',
|
|
123
|
+
payload: {
|
|
124
|
+
messages: [
|
|
125
|
+
{ role: 'system', content: 'sys' },
|
|
126
|
+
{ role: 'user', content: 'fix bug at 2026-07-08T14:15:23Z run #17' },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
const events = store.getEvents('sess-1');
|
|
131
|
+
expect(events).toHaveLength(1);
|
|
132
|
+
expect(events[0]?.type).toBe('llm_input');
|
|
133
|
+
expect(events[0]?.commandKey).toBe('llm_input');
|
|
134
|
+
expect(events[0]?.outputHash).toMatch(/^[0-9a-f]{64}$/);
|
|
135
|
+
expect(events[0]?.outputPreview).toContain('user: fix bug at <ts> run #<n>');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('hashes identically when bodies differ only in timestamps/ids', () => {
|
|
139
|
+
const store = getOrCreateStore(10);
|
|
140
|
+
const { pi, emit } = createFakePi();
|
|
141
|
+
startLlmCollector(pi, store, 'sess-1');
|
|
142
|
+
const send = (text: string) =>
|
|
143
|
+
emit('before_provider_request', {
|
|
144
|
+
type: 'before_provider_request',
|
|
145
|
+
payload: { messages: [{ role: 'user', content: text }] },
|
|
146
|
+
});
|
|
147
|
+
send('retry at 2026-07-08T14:15:23Z seq 17');
|
|
148
|
+
send('retry at 2026-07-08T14:16:41Z seq 42');
|
|
149
|
+
const [first, second] = store.getEvents('sess-1');
|
|
150
|
+
expect(first?.outputHash).toBe(second?.outputHash);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('records assistant message_end as llm_output including tool calls', () => {
|
|
154
|
+
const store = getOrCreateStore(10);
|
|
155
|
+
const { pi, emit } = createFakePi();
|
|
156
|
+
startLlmCollector(pi, store, 'sess-1');
|
|
157
|
+
emit('message_end', {
|
|
158
|
+
type: 'message_end',
|
|
159
|
+
message: {
|
|
160
|
+
role: 'assistant',
|
|
161
|
+
content: [
|
|
162
|
+
{ type: 'thinking', thinking: 'I need to edit the file' },
|
|
163
|
+
{ type: 'text', text: 'Editing now' },
|
|
164
|
+
{ type: 'toolCall', id: 'tc-1', name: 'edit', arguments: { path: 'a.ts' } },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const events = store.getEvents('sess-1');
|
|
169
|
+
expect(events).toHaveLength(1);
|
|
170
|
+
expect(events[0]?.type).toBe('llm_output');
|
|
171
|
+
expect(events[0]?.outputPreview).toContain('toolCall edit');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('invokes onEvent after each recorded llm event (event-driven check hook)', () => {
|
|
175
|
+
const store = getOrCreateStore(10);
|
|
176
|
+
const { pi, emit } = createFakePi();
|
|
177
|
+
let calls = 0;
|
|
178
|
+
startLlmCollector(pi, store, 'sess-1', () => {
|
|
179
|
+
calls += 1;
|
|
180
|
+
expect(store.getEvents('sess-1').length).toBe(calls);
|
|
181
|
+
});
|
|
182
|
+
emit('before_provider_request', {
|
|
183
|
+
type: 'before_provider_request',
|
|
184
|
+
payload: { messages: [{ role: 'user', content: 'go' }] },
|
|
185
|
+
});
|
|
186
|
+
emit('message_end', {
|
|
187
|
+
type: 'message_end',
|
|
188
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
189
|
+
});
|
|
190
|
+
expect(calls).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('ignores non-assistant message_end and malformed payloads', () => {
|
|
194
|
+
const store = getOrCreateStore(10);
|
|
195
|
+
const { pi, emit } = createFakePi();
|
|
196
|
+
startLlmCollector(pi, store, 'sess-1');
|
|
197
|
+
emit('message_end', { type: 'message_end', message: { role: 'toolResult', content: [] } });
|
|
198
|
+
emit('before_provider_request', { type: 'before_provider_request', payload: null });
|
|
199
|
+
expect(store.getEvents('sess-1')).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
});
|