pi-subagents-j0k3r 1.1.3 → 1.2.1

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 CHANGED
@@ -1,10 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.2.1 - 2026-07-12
4
+
5
+ ### Fixed
6
+ - Stabilized `/subagents` rendering with a full-screen overlay that prevents parent-chat flicker and reserves space for the panel border.
7
+
8
+ ## 1.2.0 - 2026-07-12
9
+
10
+ ### Added
11
+ - Added opt-in render diagnostics with bounded JSONL logging for subagent UI and completion rendering.
12
+ - Added structured, versioned subagent execution errors covering provider failures, context limits, timeouts, stalls, cancellations, fallback attempts, persistence, tool responses, and history UI.
4
13
 
5
14
  ### Fixed
6
15
  - Fixed manual task-mode background handoff so it frees the chat only when the user explicitly sends the running subagent to background.
7
16
  - Fixed background completion delivery so notifications arrive while the main agent continues working without triggering an extra follow-up turn.
17
+ - Increased the default task timeout to 20 minutes and inactivity timeout to 4 minutes.
18
+ - Preserved machine-readable failure details while retaining backward-compatible human-readable error messages.
19
+
20
+ ### Documentation
21
+ - Added rendering investigation and solution notes covering terminal synchronization, viewport stability, and renderer trade-offs.
8
22
 
9
23
  ## 1.1.0 - 2026-06-27
10
24
 
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": 600000,
143
- "stall_timeout_ms": 120000,
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` | `600000` | Total timeout per subagent task. |
177
- | `stall_timeout_ms` | `120000` | Inactivity timeout for a subagent session. |
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),
@@ -469,22 +507,68 @@ export default function subagentsExtension(pi: any): void {
469
507
  showImages: ctx?.showImages,
470
508
  imageWidthCells: ctx?.imageWidthCells,
471
509
  },
472
- () => Math.max(12, process.stdout.rows || 42),
510
+ () => Math.max(12, (process.stdout.rows || 42) - 2),
473
511
  (id: string) => manager.getTask(id, cwd),
474
512
  selectedTaskId,
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 = () => tui.requestRender?.();
480
- refresh = setInterval(() => tui.requestRender?.(), 1000);
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) => panel.render(width),
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) => { panel.handleInput(data); tui.requestRender?.(); },
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
- undefined,
571
+ { overlay: true, overlayOptions: { anchor: 'top-left', width: '100%', maxHeight: '100%', margin: 0 } },
488
572
  );
489
573
  } finally {
490
574
  activePanelCancelSelected = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-j0k3r",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "Installable Pi package that adds markdown-defined subagents, delegated task tools, history, and model profiles.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -120,8 +120,8 @@ Recommended `subagents.json` starter:
120
120
  ```json
121
121
  {
122
122
  "mode": "opencode",
123
- "timeout_ms": 600000,
124
- "stall_timeout_ms": 120000,
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 = 10 * 60 * 1000;
9
- const DEFAULT_STALL_TIMEOUT_MS = 2 * 60 * 1000;
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
+ }