pi-subagents-j0k3r 1.1.2 → 1.2.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/CHANGELOG.md +9 -0
- package/README.md +4 -4
- package/index.ts +89 -5
- package/package.json +1 -1
- package/skills/subagents-configuration/SKILL.md +2 -2
- package/src/config.ts +21 -2
- package/src/error-metadata.ts +336 -0
- package/src/history.ts +24 -2
- package/src/manager.ts +43 -3
- package/src/render-debug.ts +139 -0
- package/src/runner.ts +105 -18
- package/src/thread-view.ts +5 -1
- package/src/tools.ts +16 -8
- package/src/types.ts +61 -1
- package/src/ui.ts +88 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
### Added
|
|
6
|
+
- Added opt-in render diagnostics with bounded JSONL logging for subagent UI and completion rendering.
|
|
7
|
+
- Added structured, versioned subagent execution errors covering provider failures, context limits, timeouts, stalls, cancellations, fallback attempts, persistence, tool responses, and history UI.
|
|
8
|
+
|
|
5
9
|
### Fixed
|
|
6
10
|
- Fixed manual task-mode background handoff so it frees the chat only when the user explicitly sends the running subagent to background.
|
|
7
11
|
- Fixed background completion delivery so notifications arrive while the main agent continues working without triggering an extra follow-up turn.
|
|
12
|
+
- Increased the default task timeout to 20 minutes and inactivity timeout to 4 minutes.
|
|
13
|
+
- Preserved machine-readable failure details while retaining backward-compatible human-readable error messages.
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
- Added rendering investigation and solution notes covering terminal synchronization, viewport stability, and renderer trade-offs.
|
|
8
17
|
|
|
9
18
|
## 1.1.0 - 2026-06-27
|
|
10
19
|
|
package/README.md
CHANGED
|
@@ -139,8 +139,8 @@ Example:
|
|
|
139
139
|
{
|
|
140
140
|
"default_model": "anthropic/claude-sonnet-4-5",
|
|
141
141
|
"default_effort": "medium",
|
|
142
|
-
"timeout_ms":
|
|
143
|
-
"stall_timeout_ms":
|
|
142
|
+
"timeout_ms": 1200000,
|
|
143
|
+
"stall_timeout_ms": 240000,
|
|
144
144
|
"max_concurrency": 5,
|
|
145
145
|
"session_resources": "lean",
|
|
146
146
|
"history_panel_shortcut": "ctrl+,",
|
|
@@ -173,8 +173,8 @@ Example:
|
|
|
173
173
|
| `default_model` | current orchestrator model | Fallback model for all subagents. Format: `provider/model-id`. |
|
|
174
174
|
| `default_effort` | current orchestrator effort | Fallback thinking effort. Also accepts `default_thinking_level` or `thinkingLevel`. |
|
|
175
175
|
| `model_profiles` | `{}` | Per-agent model/effort overrides scoped to matching definitions. Project-local profiles apply to project-local definitions; global profiles apply to global definitions. |
|
|
176
|
-
| `timeout_ms` | `
|
|
177
|
-
| `stall_timeout_ms` | `
|
|
176
|
+
| `timeout_ms` | `1200000` | Total timeout per subagent task (20 minutes). |
|
|
177
|
+
| `stall_timeout_ms` | `240000` | Inactivity timeout for a subagent session (4 minutes). |
|
|
178
178
|
| `max_concurrency` | `5` | Max concurrent subagent tasks per cwd/config pair. |
|
|
179
179
|
| `session_resources` | `lean` | SDK resource loading mode. `lean` uses the subagent markdown body as the nested session system prompt, skips skills, prompt templates, themes, and context files, and loads extensions in tools-only/safety-hook mode so allowlisted extension tools remain available without startup context injection. Use explicit `full` only when a subagent intentionally needs the full Pi resource set. Also accepts camelCase `sessionResources`. |
|
|
180
180
|
| `history_panel_shortcut` | `ctrl+,` | OpenCode-mode shortcut used to open the subagents history/detail panel. Accepts `ctrl+<letter>` or `ctrl+,` and also accepts camelCase `historyPanelShortcut`. |
|
package/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { readSubagentsConfig, subagentSourceWarnings } from './src/config.js';
|
|
|
3
3
|
import { registerSubagentTools, triggerClaudeBackgroundHandoff } from './src/tools.js';
|
|
4
4
|
import { runSubagentModelsCommand } from './src/model-profiles-ui.js';
|
|
5
5
|
import { SubagentsHistoryPanel } from './src/ui.js';
|
|
6
|
+
import { createSubagentsRenderLogger } from './src/render-debug.js';
|
|
7
|
+
import { safeErrorMetadataDetails } from './src/error-metadata.js';
|
|
6
8
|
import type { SubagentTask } from './src/types.js';
|
|
7
9
|
|
|
8
10
|
type ClaudeBackgroundWidgetEntry = {
|
|
@@ -119,6 +121,32 @@ function setMouseTracking(tui: any, enabled: boolean): void {
|
|
|
119
121
|
write(enabled ? '\u001b[?1000h\u001b[?1006h' : '\u001b[?1006l\u001b[?1000l');
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
function subagentsPanelMouseWheelDelta(data: string): -1 | 1 | undefined {
|
|
125
|
+
const sgr = data.match(/^\u001b\[<(\d+);\d+;\d+M$/);
|
|
126
|
+
const urxvt = data.match(/^\u001b\[(\d+);\d+;\d+M$/);
|
|
127
|
+
const button = sgr || urxvt ? Number((sgr ?? urxvt)![1]) : data.startsWith('\u001b[M') && data.length >= 6 ? data.charCodeAt(3) - 32 : undefined;
|
|
128
|
+
if (button === undefined || !Number.isFinite(button) || (button & 64) === 0) return undefined;
|
|
129
|
+
return (button & 1) === 0 ? -1 : 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function classifySubagentsPanelInput(data: string, matchesPanelKey: (data: string, key: string) => boolean): { category: string; action: string } {
|
|
133
|
+
if (matchesPanelKey(data, 'escape') || matchesPanelKey(data, 'ctrl+c') || matchesPanelKey(data, 'q')) return { category: 'lifecycle', action: 'close' };
|
|
134
|
+
if (matchesPanelKey(data, 'ctrl+o') || data === '\u000f') return { category: 'display', action: 'toggle_expand' };
|
|
135
|
+
if (matchesPanelKey(data, 'detailCancel')) return { category: 'task', action: 'cancel_selected' };
|
|
136
|
+
const wheel = subagentsPanelMouseWheelDelta(data);
|
|
137
|
+
if (wheel === -1) return { category: 'scroll', action: 'up' };
|
|
138
|
+
if (wheel === 1) return { category: 'scroll', action: 'down' };
|
|
139
|
+
if (matchesPanelKey(data, 'right')) return { category: 'navigation', action: 'right' };
|
|
140
|
+
if (matchesPanelKey(data, 'left')) return { category: 'navigation', action: 'left' };
|
|
141
|
+
if (matchesPanelKey(data, 'down')) return { category: 'navigation', action: 'down' };
|
|
142
|
+
if (matchesPanelKey(data, 'up')) return { category: 'navigation', action: 'up' };
|
|
143
|
+
if (matchesPanelKey(data, 'pageDown')) return { category: 'scroll', action: 'page_down' };
|
|
144
|
+
if (matchesPanelKey(data, 'pageUp')) return { category: 'scroll', action: 'page_up' };
|
|
145
|
+
if (matchesPanelKey(data, 'home')) return { category: 'scroll', action: 'home' };
|
|
146
|
+
if (matchesPanelKey(data, 'end')) return { category: 'scroll', action: 'end' };
|
|
147
|
+
return { category: 'other', action: 'unmatched' };
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
function toolFromRegistry(registry: any, name: string): unknown {
|
|
123
151
|
if (!registry) return undefined;
|
|
124
152
|
if (typeof registry.get === 'function') return registry.get(name);
|
|
@@ -290,6 +318,11 @@ export function completionMessage(task: any): string {
|
|
|
290
318
|
].join('\n');
|
|
291
319
|
}
|
|
292
320
|
|
|
321
|
+
function safeCompletionErrorMetadata(task: any): Record<string, unknown> | undefined {
|
|
322
|
+
if (!task?.error_metadata) return undefined;
|
|
323
|
+
return safeErrorMetadataDetails(task.error_metadata as any);
|
|
324
|
+
}
|
|
325
|
+
|
|
293
326
|
export function sendSubagentCompletionMessage(pi: any, task: any): void {
|
|
294
327
|
pi.sendMessage?.({
|
|
295
328
|
customType: 'subagent-completion',
|
|
@@ -307,6 +340,7 @@ export function sendSubagentCompletionMessage(pi: any, task: any): void {
|
|
|
307
340
|
usage: task.usage,
|
|
308
341
|
result: task.result,
|
|
309
342
|
error: task.error,
|
|
343
|
+
error_metadata: safeCompletionErrorMetadata(task),
|
|
310
344
|
},
|
|
311
345
|
},
|
|
312
346
|
}, {
|
|
@@ -438,12 +472,16 @@ export default function subagentsExtension(pi: any): void {
|
|
|
438
472
|
await ctx.ui.custom(
|
|
439
473
|
(tui: any, theme: any, _keybindings: any, done: () => void) => {
|
|
440
474
|
setMouseTracking(tui, true);
|
|
475
|
+
const config = readSubagentsConfig(cwd);
|
|
476
|
+
const renderLogger = createSubagentsRenderLogger({ cwd, sessionId, config: config.render_debug });
|
|
477
|
+
let nextRenderReason = 'initial';
|
|
478
|
+
let renderCycle = 0;
|
|
441
479
|
const close = () => {
|
|
442
480
|
if (refresh) clearInterval(refresh);
|
|
481
|
+
renderLogger.log({ event: 'panel_disposed' });
|
|
443
482
|
setMouseTracking(tui, false);
|
|
444
483
|
done();
|
|
445
484
|
};
|
|
446
|
-
const config = readSubagentsConfig(cwd);
|
|
447
485
|
const baseMatchesKey = createSubagentsPanelKeyMatcher(_keybindings);
|
|
448
486
|
const panel = new SubagentsHistoryPanel(
|
|
449
487
|
() => manager.listSessionTasks(cwd, sessionId).slice(0, 100),
|
|
@@ -475,13 +513,59 @@ export default function subagentsExtension(pi: any): void {
|
|
|
475
513
|
(id: string) => manager.cancel(id, 'cancelled from subagents detail view'),
|
|
476
514
|
config.detail_cancel_shortcut ?? 'x',
|
|
477
515
|
);
|
|
516
|
+
renderLogger.log({ event: 'panel_created' });
|
|
517
|
+
renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
|
|
478
518
|
activePanelCancelSelected = () => panel.cancelSelectedActiveTask();
|
|
479
|
-
activePanelRequestRender = () =>
|
|
480
|
-
|
|
519
|
+
activePanelRequestRender = () => {
|
|
520
|
+
nextRenderReason = 'external';
|
|
521
|
+
renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
|
|
522
|
+
tui.requestRender?.();
|
|
523
|
+
};
|
|
524
|
+
refresh = setInterval(() => {
|
|
525
|
+
nextRenderReason = 'interval';
|
|
526
|
+
renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
|
|
527
|
+
tui.requestRender?.();
|
|
528
|
+
}, 1000);
|
|
481
529
|
return {
|
|
482
|
-
render: (width: number) =>
|
|
530
|
+
render: (width: number) => {
|
|
531
|
+
const reason = nextRenderReason;
|
|
532
|
+
const currentRenderCycle = ++renderCycle;
|
|
533
|
+
renderLogger.log({
|
|
534
|
+
event: 'render_started',
|
|
535
|
+
reason,
|
|
536
|
+
renderCycle: currentRenderCycle,
|
|
537
|
+
dimensions: {
|
|
538
|
+
stdoutColumns: process.stdout.columns,
|
|
539
|
+
stdoutRows: process.stdout.rows,
|
|
540
|
+
renderWidth: width,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
const startedAt = process.hrtime.bigint();
|
|
544
|
+
const lines = panel.render(width);
|
|
545
|
+
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
|
546
|
+
renderLogger.log({
|
|
547
|
+
event: 'render_completed',
|
|
548
|
+
reason,
|
|
549
|
+
renderCycle: currentRenderCycle,
|
|
550
|
+
durationMs,
|
|
551
|
+
dimensions: {
|
|
552
|
+
stdoutColumns: process.stdout.columns,
|
|
553
|
+
stdoutRows: process.stdout.rows,
|
|
554
|
+
renderWidth: width,
|
|
555
|
+
},
|
|
556
|
+
state: panel.getRenderDebugState(),
|
|
557
|
+
});
|
|
558
|
+
nextRenderReason = 'external';
|
|
559
|
+
return lines;
|
|
560
|
+
},
|
|
483
561
|
invalidate: () => panel.invalidate(),
|
|
484
|
-
handleInput: (data: string) => {
|
|
562
|
+
handleInput: (data: string) => {
|
|
563
|
+
renderLogger.log({ event: 'input_received', input: classifySubagentsPanelInput(data, baseMatchesKey) });
|
|
564
|
+
nextRenderReason = 'input';
|
|
565
|
+
renderLogger.log({ event: 'render_requested', reason: nextRenderReason });
|
|
566
|
+
panel.handleInput(data);
|
|
567
|
+
tui.requestRender?.();
|
|
568
|
+
},
|
|
485
569
|
};
|
|
486
570
|
},
|
|
487
571
|
undefined,
|
package/package.json
CHANGED
|
@@ -120,8 +120,8 @@ Recommended `subagents.json` starter:
|
|
|
120
120
|
```json
|
|
121
121
|
{
|
|
122
122
|
"mode": "opencode",
|
|
123
|
-
"timeout_ms":
|
|
124
|
-
"stall_timeout_ms":
|
|
123
|
+
"timeout_ms": 1200000,
|
|
124
|
+
"stall_timeout_ms": 240000,
|
|
125
125
|
"max_concurrency": 5,
|
|
126
126
|
"debug": false,
|
|
127
127
|
"session_resources": "lean",
|
package/src/config.ts
CHANGED
|
@@ -5,11 +5,12 @@ import type { ModelRef, SubagentDefinition, SubagentDefinitionScope, SubagentMod
|
|
|
5
5
|
|
|
6
6
|
const DEFAULT_TOOLS = ['read', 'memory_context', 'memory_search', 'memory_recall', 'memory_get'];
|
|
7
7
|
const DEFAULT_MAX_CONCURRENCY = 5;
|
|
8
|
-
const DEFAULT_TIMEOUT_MS =
|
|
9
|
-
const DEFAULT_STALL_TIMEOUT_MS =
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
|
|
9
|
+
const DEFAULT_STALL_TIMEOUT_MS = 4 * 60 * 1000;
|
|
10
10
|
const DEFAULT_BACKGROUND_HANDOFF_SHORTCUT = 'ctrl+h';
|
|
11
11
|
const DEFAULT_HISTORY_PANEL_SHORTCUT = 'ctrl+,';
|
|
12
12
|
const DEFAULT_DETAIL_CANCEL_SHORTCUT = 'x';
|
|
13
|
+
const DEFAULT_RENDER_DEBUG_LOG_PATH = path.join(os.tmpdir(), 'pi-subagents-render.jsonl');
|
|
13
14
|
const BLOCKED_SUBAGENT_TOOLS = new Set([
|
|
14
15
|
'subagent_run',
|
|
15
16
|
'subagent_list_agents',
|
|
@@ -141,6 +142,23 @@ function parseBoolean(value: any, fallback = false): boolean {
|
|
|
141
142
|
return fallback;
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
function parseRenderDebugPartial(value: unknown): { enabled?: boolean; path?: string } {
|
|
146
|
+
if (!isPlainObject(value)) return {};
|
|
147
|
+
const partial: { enabled?: boolean; path?: string } = {};
|
|
148
|
+
if (value.enabled === true) partial.enabled = true;
|
|
149
|
+
else if (value.enabled === false) partial.enabled = false;
|
|
150
|
+
if (typeof value.path === 'string' && value.path.trim()) partial.path = value.path.trim();
|
|
151
|
+
return partial;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseRenderDebugConfig(globalRaw: Record<string, unknown>, projectRaw: Record<string, unknown>): SubagentsConfig['render_debug'] {
|
|
155
|
+
const globalPartial = parseRenderDebugPartial(globalRaw.render_debug ?? globalRaw.renderDebug);
|
|
156
|
+
const projectPartial = parseRenderDebugPartial(projectRaw.render_debug ?? projectRaw.renderDebug);
|
|
157
|
+
const merged = { ...globalPartial, ...projectPartial };
|
|
158
|
+
if (merged.enabled !== true) return undefined;
|
|
159
|
+
return { enabled: true, path: merged.path ?? DEFAULT_RENDER_DEBUG_LOG_PATH };
|
|
160
|
+
}
|
|
161
|
+
|
|
144
162
|
function parseModelProfile(value: unknown): SubagentModelProfile | undefined {
|
|
145
163
|
if (!isPlainObject(value)) return undefined;
|
|
146
164
|
const profile: SubagentModelProfile = {};
|
|
@@ -196,6 +214,7 @@ export function readSubagentsConfig(cwd: string): SubagentsConfig {
|
|
|
196
214
|
history_panel_shortcut: parseCtrlShortcut(raw.history_panel_shortcut ?? raw.historyPanelShortcut, DEFAULT_HISTORY_PANEL_SHORTCUT),
|
|
197
215
|
detail_cancel_shortcut: parseDetailShortcut(raw.detail_cancel_shortcut ?? raw.detailCancelShortcut),
|
|
198
216
|
debug: parseBoolean(raw.debug, false),
|
|
217
|
+
render_debug: parseRenderDebugConfig(globalRaw, projectRaw),
|
|
199
218
|
};
|
|
200
219
|
}
|
|
201
220
|
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { sanitizeInteractionTransportText } from './interaction-channel.js';
|
|
2
|
+
import type { SubagentErrorAttemptRole, SubagentErrorCategory, SubagentErrorMetadata, SubagentErrorPhase, UsageStats } from './types.js';
|
|
3
|
+
|
|
4
|
+
const MESSAGE_LIMIT = 1024;
|
|
5
|
+
const CODE_LIMIT = 128;
|
|
6
|
+
const SOURCE_LIMIT = 256;
|
|
7
|
+
const LAST_ACTIVITY_LIMIT = 512;
|
|
8
|
+
const DETAILS_KEY_LIMIT = 64;
|
|
9
|
+
const DETAILS_VALUE_LIMIT = 512;
|
|
10
|
+
const DETAILS_LIMIT = 16;
|
|
11
|
+
const ATTEMPTS_LIMIT = 2;
|
|
12
|
+
const CAUSE_DEPTH_LIMIT = 2;
|
|
13
|
+
|
|
14
|
+
const RETRYABLE_DEFAULTS: Record<SubagentErrorCategory, boolean> = {
|
|
15
|
+
total_timeout: false,
|
|
16
|
+
stall_timeout: false,
|
|
17
|
+
cancelled: false,
|
|
18
|
+
empty_response_no_tools: false,
|
|
19
|
+
empty_response_after_tools: false,
|
|
20
|
+
context_overflow: false,
|
|
21
|
+
provider_api_error: true,
|
|
22
|
+
provider_auth_error: false,
|
|
23
|
+
provider_rate_limit: true,
|
|
24
|
+
provider_network_error: true,
|
|
25
|
+
tool_failure: false,
|
|
26
|
+
fallback_failed: false,
|
|
27
|
+
unknown_fallback: false,
|
|
28
|
+
malformed_thrown_value: false,
|
|
29
|
+
serialization_failure: false,
|
|
30
|
+
unknown: false,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const CATEGORY_SET = new Set<SubagentErrorCategory>(Object.keys(RETRYABLE_DEFAULTS) as SubagentErrorCategory[]);
|
|
34
|
+
const PHASE_SET = new Set<SubagentErrorPhase>(['runner_invoke', 'runner_session', 'assistant_final', 'tool_execution', 'manager', 'user', 'serializer']);
|
|
35
|
+
const ROLE_SET = new Set<SubagentErrorAttemptRole>(['primary', 'fallback']);
|
|
36
|
+
|
|
37
|
+
function limitCodePoints(value: string | undefined, limit: number): string | undefined {
|
|
38
|
+
if (value === undefined) return undefined;
|
|
39
|
+
const chars = Array.from(value);
|
|
40
|
+
return chars.length > limit ? `${chars.slice(0, Math.max(0, limit - 1)).join('')}…` : value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function asciiSafe(value: string | undefined, limit: number): string | undefined {
|
|
44
|
+
if (!value) return undefined;
|
|
45
|
+
const bounded = limitCodePoints(value.replace(/[^\x20-\x7E]+/g, '_'), limit);
|
|
46
|
+
return bounded || undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function redactText(value: string | undefined, limit: number): string | undefined {
|
|
50
|
+
if (!value) return undefined;
|
|
51
|
+
let text = sanitizeInteractionTransportText(String(value));
|
|
52
|
+
text = text
|
|
53
|
+
.replace(/authorization\s*:\s*bearer\s+[A-Za-z0-9._\-]+/gi, 'Authorization: [redacted]')
|
|
54
|
+
.replace(/bearer\s+[A-Za-z0-9._\-]+/gi, 'Bearer [redacted]')
|
|
55
|
+
.replace(/\b(sk|pk|rk)-[A-Za-z0-9._\-]+\b/g, '[redacted]')
|
|
56
|
+
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted]')
|
|
57
|
+
.replace(/(?:^|\s)(?:\/[A-Za-z0-9._-]+)+/g, (match) => match.replace(/\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*/g, '[redacted]'))
|
|
58
|
+
.replace(/\b(?:prompt|system prompt|user prompt|prompt text)\s*:[^|\n\r]*/gi, '[redacted]')
|
|
59
|
+
.replace(/\b(?:system|user|assistant)\s*:[^|\n\r]*/gi, '[redacted]')
|
|
60
|
+
.replace(/SECRET_FILE_BODY[\w-]*/g, '[redacted]')
|
|
61
|
+
.replace(/file contents?[^|\n\r]*/gi, '[redacted]')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim();
|
|
64
|
+
return limitCodePoints(text, limit);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeCategory(value: unknown): SubagentErrorCategory {
|
|
68
|
+
return typeof value === 'string' && CATEGORY_SET.has(value as SubagentErrorCategory)
|
|
69
|
+
? value as SubagentErrorCategory
|
|
70
|
+
: 'unknown';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizePhase(value: unknown): SubagentErrorPhase | undefined {
|
|
74
|
+
return typeof value === 'string' && PHASE_SET.has(value as SubagentErrorPhase)
|
|
75
|
+
? value as SubagentErrorPhase
|
|
76
|
+
: undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeRole(value: unknown): SubagentErrorAttemptRole | undefined {
|
|
80
|
+
return typeof value === 'string' && ROLE_SET.has(value as SubagentErrorAttemptRole)
|
|
81
|
+
? value as SubagentErrorAttemptRole
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeUsage(value: unknown): UsageStats | undefined {
|
|
86
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
87
|
+
const usage = value as Partial<UsageStats>;
|
|
88
|
+
return {
|
|
89
|
+
input: Number(usage.input ?? 0),
|
|
90
|
+
output: Number(usage.output ?? 0),
|
|
91
|
+
cacheRead: Number(usage.cacheRead ?? 0),
|
|
92
|
+
cacheWrite: Number(usage.cacheWrite ?? 0),
|
|
93
|
+
cost: Number(usage.cost ?? 0),
|
|
94
|
+
contextTokens: Number(usage.contextTokens ?? 0),
|
|
95
|
+
turns: Number(usage.turns ?? 0),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeDetails(value: unknown): Record<string, string> | undefined {
|
|
100
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
101
|
+
const details: Record<string, string> = {};
|
|
102
|
+
for (const [rawKey, rawValue] of Object.entries(value).slice(0, DETAILS_LIMIT)) {
|
|
103
|
+
const key = asciiSafe(rawKey, DETAILS_KEY_LIMIT);
|
|
104
|
+
const text = redactText(typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue), DETAILS_VALUE_LIMIT);
|
|
105
|
+
if (!key || !text) continue;
|
|
106
|
+
details[key] = text;
|
|
107
|
+
}
|
|
108
|
+
return Object.keys(details).length ? details : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function safeMessage(category: SubagentErrorCategory, details?: Record<string, string>): string {
|
|
112
|
+
switch (category) {
|
|
113
|
+
case 'total_timeout':
|
|
114
|
+
return details?.timeout_ms ? `timed out after ${details.timeout_ms}ms` : 'timed out';
|
|
115
|
+
case 'stall_timeout':
|
|
116
|
+
return details?.stall_timeout_ms ? `Subagent stalled for ${details.stall_timeout_ms}ms without final response.` : 'Subagent stalled without final response.';
|
|
117
|
+
case 'cancelled':
|
|
118
|
+
return `Subagent cancelled: ${details?.cancel_reason ?? 'cancelled'}`;
|
|
119
|
+
case 'empty_response_no_tools':
|
|
120
|
+
return 'Subagent finished without a final response.';
|
|
121
|
+
case 'empty_response_after_tools':
|
|
122
|
+
return 'Subagent completed tool execution but did not produce a final response.';
|
|
123
|
+
case 'fallback_failed':
|
|
124
|
+
return 'Subagent fallback failed.';
|
|
125
|
+
case 'unknown_fallback':
|
|
126
|
+
return 'Subagent fallback unavailable after model failure.';
|
|
127
|
+
case 'serialization_failure':
|
|
128
|
+
return 'Subagent error metadata could not be serialized safely.';
|
|
129
|
+
default:
|
|
130
|
+
return category.replace(/_/g, ' ');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function normalizeErrorMetadata(input: Partial<SubagentErrorMetadata> & { category: SubagentErrorCategory } | Partial<SubagentErrorMetadata>): SubagentErrorMetadata {
|
|
135
|
+
try {
|
|
136
|
+
return normalizeErrorMetadataInternal(input, 0);
|
|
137
|
+
} catch {
|
|
138
|
+
return {
|
|
139
|
+
version: 1,
|
|
140
|
+
category: 'serialization_failure',
|
|
141
|
+
message: 'Subagent error metadata could not be serialized safely.',
|
|
142
|
+
retryable: false,
|
|
143
|
+
phase: 'serializer',
|
|
144
|
+
partial_result_available: false,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeErrorMetadataInternal(input: Partial<SubagentErrorMetadata> | undefined, depth: number): SubagentErrorMetadata {
|
|
150
|
+
const category = normalizeCategory(input?.category);
|
|
151
|
+
const details = normalizeDetails(input?.details);
|
|
152
|
+
const message = redactText(typeof input?.message === 'string' ? input.message : safeMessage(category, details), MESSAGE_LIMIT)
|
|
153
|
+
?? safeMessage(category, details);
|
|
154
|
+
const attempts = Array.isArray(input?.attempts)
|
|
155
|
+
? input.attempts
|
|
156
|
+
.slice(0, ATTEMPTS_LIMIT)
|
|
157
|
+
.map((attempt, index) => {
|
|
158
|
+
const normalized = normalizeErrorMetadataInternal(attempt, depth + 1);
|
|
159
|
+
const role = normalizeRole(attempt?.role) ?? (index === 0 ? 'primary' : 'fallback');
|
|
160
|
+
return { ...normalized, role };
|
|
161
|
+
})
|
|
162
|
+
: undefined;
|
|
163
|
+
const cause = depth < CAUSE_DEPTH_LIMIT && input?.cause
|
|
164
|
+
? normalizeErrorMetadataInternal(input.cause, depth + 1)
|
|
165
|
+
: undefined;
|
|
166
|
+
return {
|
|
167
|
+
version: 1,
|
|
168
|
+
category,
|
|
169
|
+
message,
|
|
170
|
+
retryable: typeof input?.retryable === 'boolean' ? input.retryable : RETRYABLE_DEFAULTS[category],
|
|
171
|
+
phase: normalizePhase(input?.phase),
|
|
172
|
+
code: asciiSafe(typeof input?.code === 'string' ? input.code : category, CODE_LIMIT),
|
|
173
|
+
role: normalizeRole(input?.role),
|
|
174
|
+
source: input?.source ? {
|
|
175
|
+
provider: redactText(input.source.provider, SOURCE_LIMIT),
|
|
176
|
+
model: redactText(input.source.model, SOURCE_LIMIT),
|
|
177
|
+
tool: redactText(input.source.tool, SOURCE_LIMIT),
|
|
178
|
+
operation: redactText(input.source.operation, SOURCE_LIMIT),
|
|
179
|
+
} : undefined,
|
|
180
|
+
cause,
|
|
181
|
+
attempts: attempts?.length ? attempts : undefined,
|
|
182
|
+
usage_at_failure: normalizeUsage(input?.usage_at_failure),
|
|
183
|
+
last_activity: redactText(input?.last_activity, LAST_ACTIVITY_LIMIT),
|
|
184
|
+
partial_result_available: Boolean(input?.partial_result_available),
|
|
185
|
+
task_id: redactText(input?.task_id, SOURCE_LIMIT),
|
|
186
|
+
parent_session_id: redactText(input?.parent_session_id, SOURCE_LIMIT),
|
|
187
|
+
details,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function deriveErrorString(metadata: SubagentErrorMetadata): string {
|
|
192
|
+
const normalized = normalizeErrorMetadata(metadata);
|
|
193
|
+
return safeMessage(normalized.category, normalized.details) === 'unknown'
|
|
194
|
+
? normalized.message
|
|
195
|
+
: safeMessage(normalized.category, normalized.details);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function classifyThrownError(error: unknown, context: { phase?: SubagentErrorPhase; provider?: string; model?: string; operation?: string; retryable?: boolean } = {}): SubagentErrorMetadata {
|
|
199
|
+
const rawMessage = error instanceof Error
|
|
200
|
+
? error.message
|
|
201
|
+
: typeof error === 'string'
|
|
202
|
+
? error
|
|
203
|
+
: error && typeof error === 'object' && typeof (error as { message?: unknown }).message === 'string'
|
|
204
|
+
? String((error as { message: unknown }).message)
|
|
205
|
+
: String(error);
|
|
206
|
+
const message = rawMessage || 'Unknown subagent failure';
|
|
207
|
+
const lower = message.toLowerCase();
|
|
208
|
+
const errorClass = error instanceof Error ? error.constructor.name : typeof error;
|
|
209
|
+
let category: SubagentErrorCategory = 'unknown';
|
|
210
|
+
if (!(error instanceof Error) && typeof error !== 'string') category = 'malformed_thrown_value';
|
|
211
|
+
else if (/auth|api key|invalid key|unauthori[sz]ed|forbidden|401|403|credential/.test(lower)) category = 'provider_auth_error';
|
|
212
|
+
else if (/context|token|maximum|length/.test(lower)) category = 'context_overflow';
|
|
213
|
+
else if (/rate.?limit|quota|429|too many requests/.test(lower)) category = 'provider_rate_limit';
|
|
214
|
+
else if (/econnreset|enotfound|network|socket|timeout|timed out|connection/.test(lower)) category = 'provider_network_error';
|
|
215
|
+
else if (error instanceof Error) category = 'provider_api_error';
|
|
216
|
+
return normalizeErrorMetadata({
|
|
217
|
+
category,
|
|
218
|
+
message,
|
|
219
|
+
retryable: context.retryable,
|
|
220
|
+
phase: context.phase,
|
|
221
|
+
source: {
|
|
222
|
+
provider: context.provider,
|
|
223
|
+
model: context.model,
|
|
224
|
+
operation: context.operation,
|
|
225
|
+
},
|
|
226
|
+
partial_result_available: false,
|
|
227
|
+
details: { error_class: errorClass },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function classifyAssistantFailure(input: {
|
|
232
|
+
stopReason?: string;
|
|
233
|
+
errorMessage?: string;
|
|
234
|
+
sawToolActivity?: boolean;
|
|
235
|
+
provider?: string;
|
|
236
|
+
model?: string;
|
|
237
|
+
}): SubagentErrorMetadata | undefined {
|
|
238
|
+
if (input.stopReason === 'error' || input.errorMessage) {
|
|
239
|
+
return classifyThrownError(new Error(input.errorMessage ?? 'Assistant error'), {
|
|
240
|
+
phase: 'assistant_final',
|
|
241
|
+
provider: input.provider,
|
|
242
|
+
model: input.model,
|
|
243
|
+
operation: 'session.prompt',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (input.sawToolActivity) {
|
|
247
|
+
return normalizeErrorMetadata({
|
|
248
|
+
category: 'empty_response_after_tools',
|
|
249
|
+
message: 'Subagent completed tool execution but did not produce a final response.',
|
|
250
|
+
phase: 'assistant_final',
|
|
251
|
+
partial_result_available: false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return normalizeErrorMetadata({
|
|
255
|
+
category: 'empty_response_no_tools',
|
|
256
|
+
message: 'Subagent finished without a final response.',
|
|
257
|
+
phase: 'assistant_final',
|
|
258
|
+
partial_result_available: false,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function classifyFallbackFailure(primary: SubagentErrorMetadata, fallback?: SubagentErrorMetadata): SubagentErrorMetadata {
|
|
263
|
+
return normalizeErrorMetadata({
|
|
264
|
+
category: fallback ? 'fallback_failed' : 'unknown_fallback',
|
|
265
|
+
message: fallback ? 'Subagent fallback failed.' : 'Subagent fallback unavailable after model failure.',
|
|
266
|
+
retryable: false,
|
|
267
|
+
partial_result_available: false,
|
|
268
|
+
attempts: fallback
|
|
269
|
+
? [{ ...normalizeErrorMetadata(primary), role: 'primary' }, { ...normalizeErrorMetadata(fallback), role: 'fallback' }]
|
|
270
|
+
: [{ ...normalizeErrorMetadata(primary), role: 'primary' }],
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function enrichErrorMetadata(metadata: SubagentErrorMetadata, snapshot: {
|
|
275
|
+
usage_at_failure?: UsageStats;
|
|
276
|
+
last_activity?: string;
|
|
277
|
+
partial_result_available?: boolean;
|
|
278
|
+
task_id?: string;
|
|
279
|
+
parent_session_id?: string;
|
|
280
|
+
}): SubagentErrorMetadata {
|
|
281
|
+
return normalizeErrorMetadata({
|
|
282
|
+
...metadata,
|
|
283
|
+
usage_at_failure: snapshot.usage_at_failure ?? metadata.usage_at_failure,
|
|
284
|
+
last_activity: snapshot.last_activity ?? metadata.last_activity,
|
|
285
|
+
partial_result_available: snapshot.partial_result_available ?? metadata.partial_result_available,
|
|
286
|
+
task_id: snapshot.task_id ?? metadata.task_id,
|
|
287
|
+
parent_session_id: snapshot.parent_session_id ?? metadata.parent_session_id,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function serializeErrorMetadata(metadata: unknown): string | null {
|
|
292
|
+
try {
|
|
293
|
+
return JSON.stringify(normalizeErrorMetadata(metadata as Partial<SubagentErrorMetadata>));
|
|
294
|
+
} catch {
|
|
295
|
+
return JSON.stringify(normalizeErrorMetadata({
|
|
296
|
+
category: 'serialization_failure',
|
|
297
|
+
message: 'Subagent error metadata could not be serialized safely.',
|
|
298
|
+
phase: 'serializer',
|
|
299
|
+
partial_result_available: false,
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function parseErrorMetadata(json: unknown): SubagentErrorMetadata | undefined {
|
|
305
|
+
try {
|
|
306
|
+
if (typeof json !== 'string' || !json.trim()) return undefined;
|
|
307
|
+
return normalizeErrorMetadata(JSON.parse(json) as Partial<SubagentErrorMetadata>);
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function safeErrorMetadataDetails(metadata: SubagentErrorMetadata): Record<string, unknown> {
|
|
314
|
+
const normalized = normalizeErrorMetadata(metadata);
|
|
315
|
+
return {
|
|
316
|
+
version: normalized.version,
|
|
317
|
+
category: normalized.category,
|
|
318
|
+
retryable: normalized.retryable,
|
|
319
|
+
phase: normalized.phase,
|
|
320
|
+
code: normalized.code,
|
|
321
|
+
source: normalized.source,
|
|
322
|
+
partial_result_available: normalized.partial_result_available,
|
|
323
|
+
details: normalized.details,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export class SubagentStructuredError extends Error {
|
|
328
|
+
readonly error_metadata: SubagentErrorMetadata;
|
|
329
|
+
|
|
330
|
+
constructor(metadata: SubagentErrorMetadata) {
|
|
331
|
+
const normalized = normalizeErrorMetadata(metadata);
|
|
332
|
+
super(deriveErrorString(normalized));
|
|
333
|
+
this.name = 'SubagentStructuredError';
|
|
334
|
+
this.error_metadata = normalized;
|
|
335
|
+
}
|
|
336
|
+
}
|
package/src/history.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
+
import { parseErrorMetadata, serializeErrorMetadata } from './error-metadata.js';
|
|
5
6
|
import { boundThreadSnapshot } from './thread-view.js';
|
|
6
7
|
import type { SubagentTask, SubagentThreadSnapshot } from './types.js';
|
|
7
8
|
|
|
@@ -97,6 +98,8 @@ export class SubagentHistoryStore {
|
|
|
97
98
|
effort_source TEXT,
|
|
98
99
|
fallback_used INTEGER,
|
|
99
100
|
error TEXT,
|
|
101
|
+
error_metadata_json TEXT,
|
|
102
|
+
error_category TEXT,
|
|
100
103
|
result TEXT,
|
|
101
104
|
thread_snapshot_json TEXT
|
|
102
105
|
);
|
|
@@ -114,18 +117,32 @@ export class SubagentHistoryStore {
|
|
|
114
117
|
CREATE INDEX IF NOT EXISTS idx_subagent_events_task ON subagent_events(task_id, created_at);
|
|
115
118
|
`);
|
|
116
119
|
ensureColumn(db, 'subagent_tasks', 'system_prompt', 'TEXT');
|
|
120
|
+
ensureColumn(db, 'subagent_tasks', 'error_metadata_json', 'TEXT');
|
|
121
|
+
ensureColumn(db, 'subagent_tasks', 'error_category', 'TEXT');
|
|
117
122
|
this.dbs.set(file, db);
|
|
118
123
|
return db;
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
upsertTask(cwd: string, task: SubagentTask): void {
|
|
127
|
+
let errorMetadataJson: string | null = null;
|
|
128
|
+
let errorCategory: string | null = null;
|
|
129
|
+
if (task.error_metadata !== undefined) {
|
|
130
|
+
try {
|
|
131
|
+
errorMetadataJson = serializeErrorMetadata(task.error_metadata);
|
|
132
|
+
errorCategory = parseErrorMetadata(errorMetadataJson)?.category ?? null;
|
|
133
|
+
} catch {
|
|
134
|
+
errorMetadataJson = null;
|
|
135
|
+
errorCategory = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
this.db(cwd).prepare(`
|
|
123
140
|
INSERT INTO subagent_tasks (
|
|
124
141
|
id, cwd, agent, mode, status, task, context, created_at, session_id, started_at, ended_at,
|
|
125
142
|
last_activity_at, last_activity, output_preview, prompt, system_prompt, transcript,
|
|
126
143
|
usage_input, usage_output, usage_cache_read, usage_cache_write, usage_cost, usage_context_tokens, usage_turns,
|
|
127
|
-
model, effort, model_source, effort_source, fallback_used, error, result, thread_snapshot_json
|
|
128
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
144
|
+
model, effort, model_source, effort_source, fallback_used, error, error_metadata_json, error_category, result, thread_snapshot_json
|
|
145
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
129
146
|
ON CONFLICT(id) DO UPDATE SET
|
|
130
147
|
status=excluded.status,
|
|
131
148
|
session_id=excluded.session_id,
|
|
@@ -150,6 +167,8 @@ export class SubagentHistoryStore {
|
|
|
150
167
|
effort_source=excluded.effort_source,
|
|
151
168
|
fallback_used=excluded.fallback_used,
|
|
152
169
|
error=excluded.error,
|
|
170
|
+
error_metadata_json=excluded.error_metadata_json,
|
|
171
|
+
error_category=excluded.error_category,
|
|
153
172
|
result=excluded.result,
|
|
154
173
|
thread_snapshot_json=excluded.thread_snapshot_json
|
|
155
174
|
`).run(
|
|
@@ -183,6 +202,8 @@ export class SubagentHistoryStore {
|
|
|
183
202
|
value(task.effort_source),
|
|
184
203
|
task.fallback_used === undefined ? null : task.fallback_used ? 1 : 0,
|
|
185
204
|
value(task.error),
|
|
205
|
+
errorMetadataJson,
|
|
206
|
+
errorCategory,
|
|
186
207
|
value(task.result),
|
|
187
208
|
snapshotJson(task.thread_snapshot),
|
|
188
209
|
);
|
|
@@ -248,6 +269,7 @@ function rowToTask(row: any, options: HistoryReadOptions = {}): SubagentTask {
|
|
|
248
269
|
effort_source: row.effort_source ?? undefined,
|
|
249
270
|
fallback_used: row.fallback_used === null || row.fallback_used === undefined ? undefined : Boolean(row.fallback_used),
|
|
250
271
|
error: row.error ?? undefined,
|
|
272
|
+
error_metadata: parseErrorMetadata(row.error_metadata_json),
|
|
251
273
|
result: row.result ?? undefined,
|
|
252
274
|
thread_snapshot: options.includeSnapshots === false ? undefined : parseSnapshotJson(row.thread_snapshot_json),
|
|
253
275
|
};
|