Skip to content
1 change: 1 addition & 0 deletions docs/error-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ screen, debug log) keep the full detail.
| `PHW_AGENT_RESOURCE_MISSING` | agent | `[ERROR-RESOURCE-MISSING]` — setup resource unavailable | yes |
| `PHW_AGENT_RATE_LIMIT` | agent | LLM gateway rate limit | yes |
| `PHW_AGENT_API_ERROR` | agent | other API failure during the agent run | yes |
| `PHW_AGENT_MODULE_MISSING` | agent | a wizard dependency was missing from the npx download; the user must delete the cached download and rerun | no |
| `PHW_AGENT_YARA_VIOLATION` | agent | security scanner terminated the run | no |
| `PHW_AGENT_NO_PROGRESS` | agent | agent ended with zero tool calls | case-by-case |
| `PHW_AGENT_INCOMPLETE_TASKS` | agent | agent stopped with planned tasks open | case-by-case |
Expand Down
4 changes: 4 additions & 0 deletions src/lib/agent/runner/harness/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '@lib/constants';
import { analytics } from '@utils/analytics';
import { AgentErrorType } from '@lib/agent/agent-interface';
import { isModuleNotFoundError } from '@lib/errors/module-missing';
import { AgentSignals, REMARK_INSTRUCTION } from '@lib/agent/signals';
import { AgentOutputSignals } from '@lib/agent/output-signals';
import { assembleCommandments } from '../../switchboard/commandments';
Expand Down Expand Up @@ -639,6 +640,9 @@ export const piBackend: AgentHarness = {
captureAborted();

const lower = message.toLowerCase();
if (isModuleNotFoundError(err)) {
return { error: AgentErrorType.MODULE_MISSING, message };
}
Comment thread
posthog[bot] marked this conversation as resolved.
if (lower.includes('rate limit') || lower.includes('429')) {
return { error: AgentErrorType.RATE_LIMIT, message };
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/agent/runner/harness/pi/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
renderToolInventory,
} from '@lib/agent/agent-prompt-loader';
import { AgentErrorType } from '@lib/agent/agent-interface';
import { isModuleNotFoundError } from '@lib/errors/module-missing';
import { REMARK_INSTRUCTION } from '@lib/agent/signals';
import { AgentOutputSignals } from '@lib/agent/output-signals';
import { TaskStatus } from '../../sequence/orchestrator/queue';
Expand Down Expand Up @@ -508,6 +509,9 @@ export async function runPiTask(inputs: TaskRunInputs): Promise<AgentResult> {
}
captureAborted();
const lower = message.toLowerCase();
if (isModuleNotFoundError(err)) {
return { error: AgentErrorType.MODULE_MISSING, message };
}
Comment thread
posthog[bot] marked this conversation as resolved.
if (lower.includes('rate limit') || lower.includes('429')) {
return { error: AgentErrorType.RATE_LIMIT, message };
}
Expand Down
21 changes: 21 additions & 0 deletions src/lib/agent/runner/sequence/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
registerCleanup,
} from '../../../../utils/wizard-abort';
import { ErrorCodes, AGENT_ERROR_CODE } from '@lib/errors';
import { formatModuleMissingMessage } from '@lib/errors/module-missing';
import { analytics } from '../../../../utils/analytics';
import {
formatScanReport,
Expand Down Expand Up @@ -280,6 +281,26 @@ export async function runLinearProgram(
});
}

if (agentResult.error === AgentErrorType.MODULE_MISSING) {
analytics.wizardCapture('agent module missing', {
integration: config.integrationLabel,
error_type: AgentErrorType.MODULE_MISSING,
error_message: agentResult.message,
});
await wizardAbort({
code: AGENT_ERROR_CODE[AgentErrorType.MODULE_MISSING],
message: formatModuleMissingMessage(agentResult.message ?? ''),
error: new WizardError(
`Dependency missing from the npx download: ${agentResult.message}`,
{
integration: config.integrationLabel,
error_type: AgentErrorType.MODULE_MISSING,
},
AGENT_ERROR_CODE[AgentErrorType.MODULE_MISSING],
),
});
}

if (
agentResult.error === AgentErrorType.RATE_LIMIT ||
agentResult.error === AgentErrorType.API_ERROR
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { logToFile } from '@utils/debug';
import { ringTerminalBell } from '@utils/terminal-bell';
import { wizardAbort, WizardError } from '@utils/wizard-abort';
import { ErrorCodes } from '@lib/errors';
import { formatModuleMissingMessage } from '@lib/errors/module-missing';
import { AgentErrorType } from '../../../agent-interface';
import type { ProgramConfig } from '@lib/programs/program-step';
import type { BootstrapResult, ProgramRun } from '../../shared/types';
import {
Expand Down Expand Up @@ -760,6 +762,10 @@ export async function runOrchestrator(
// Prompt-frontmatter model wins over the switchboard pick (§3.6 of the
// switchboard plan) — the switchboard's model is the fallback when the
// prompt is silent.
// A half-written npx download breaks the same import in every agent, so the
// first path that reports it — seed or task — stands for the whole run. The
// executor discards task results, so the value is collected here.
let moduleMissing: string | undefined;
const seedPick = resolveHarness(switchboardCtx, 'seed');
const seedHarness = requireTaskHarness(seedPick);
const seedModel = promptModelFor(seedPrompt, seedPick.harness);
Expand All @@ -785,6 +791,9 @@ export async function runOrchestrator(
seedResult.message ?? ''
}`,
);
if (seedResult.error === AgentErrorType.MODULE_MISSING) {
moduleMissing ??= seedResult.message ?? '';
}
}
analytics.wizardCapture('orchestrator seeded', {
task_count: store.list().length,
Expand Down Expand Up @@ -998,7 +1007,7 @@ export async function runOrchestrator(
const taskPick = resolveHarness(switchboardCtx, task.type);
const taskHarness = requireTaskHarness(taskPick);
const taskModel = taskModelSpec(registry, task, taskPick.harness);
await taskHarness.runTask({
const taskResult = await taskHarness.runTask({
session,
programConfig,
boot,
Expand All @@ -1020,6 +1029,9 @@ export async function runOrchestrator(
harness: taskPick.harness,
},
});
if (taskResult.error === AgentErrorType.MODULE_MISSING) {
moduleMissing ??= taskResult.message ?? '';
}
} finally {
// Durable skills a task installed are irrelevant to later tasks — and
// the sdk harness auto-loads .claude/skills into every agent — so sweep
Expand Down Expand Up @@ -1117,6 +1129,35 @@ export async function runOrchestrator(
// A failed optional task is exempt: reported per-task, never run-failing.
const verdict = drainVerdict(store.list());
const blocked = verdict.blocked;

// A run that failed because a wizard dependency never loaded gets the cache
// repair command instead of the generic "report this to us" text — no retry
// heals a corrupt extraction. A run that finished anyway keeps its result.
if (
moduleMissing !== undefined &&
(verdict.requiredFailedTypes.length > 0 ||
blocked > 0 ||
summary.total === 0)
) {
analytics.wizardCapture('agent module missing', {
integration: programConfig.id,
error_type: AgentErrorType.MODULE_MISSING,
error_message: moduleMissing,
});
await wizardAbort({
code: ErrorCodes.AgentModuleMissing,
message: formatModuleMissingMessage(moduleMissing),
error: new WizardError(
`Dependency missing from the npx download: ${moduleMissing}`,
{
integration: programConfig.id,
error_type: AgentErrorType.MODULE_MISSING,
},
ErrorCodes.AgentModuleMissing,
),
});
}

if (verdict.requiredFailedTypes.length > 0 || blocked > 0) {
const failedTypes = verdict.requiredFailedTypes.join(', ');
const whatFailed = failedTypes
Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export enum AgentErrorType {
RATE_LIMIT = 'WIZARD_RATE_LIMIT',
/** Generic API error */
API_ERROR = 'WIZARD_API_ERROR',
/** A wizard dependency is absent — a half-written npx download */
MODULE_MISSING = 'WIZARD_MODULE_MISSING',
/** YARA scanner detected a security violation */
YARA_VIOLATION = 'WIZARD_YARA_VIOLATION',
/** Agent intentionally aborted the program (emitted [ABORT] <reason>) */
Expand Down
126 changes: 126 additions & 0 deletions src/lib/errors/__tests__/module-missing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
formatModuleMissingMessage,
isModuleNotFoundError,
} from '../module-missing';

describe('isModuleNotFoundError', () => {
it('recognises the Node error code', () => {
const err = Object.assign(new Error('boom'), {
code: 'ERR_MODULE_NOT_FOUND',
});
expect(isModuleNotFoundError(err)).toBe(true);
});

// A dynamic import rethrown across a boundary keeps the text, not the code.
it('recognises the message alone', () => {
expect(
isModuleNotFoundError(
new Error(
"Cannot find package '/Users/a/.npm/_npx/9f2/node_modules/chalk/index.js'",
),
),
).toBe(true);
});

it('leaves an ordinary API failure alone', () => {
expect(isModuleNotFoundError(new Error('502 Bad Gateway'))).toBe(false);
});

// An npx cache hash is 16 hex characters, so it can contain "429" — which the
// harness's rate-limit substring test would otherwise claim first.
it('recognises a cache hash that looks like a rate limit', () => {
expect(
isModuleNotFoundError(
new Error(
"Cannot find package '/Users/a/.npm/_npx/429abc0d15e7f318/node_modules/chalk/index.js'",
),
),
).toBe(true);
});
});

describe('formatModuleMissingMessage', () => {
it('names the exact download to delete', () => {
const message = formatModuleMissingMessage(
"Cannot find package '/Users/a/.npm/_npx/9f2/node_modules/chalk/index.js'",
);
expect(message).toContain('rm -rf "/Users/a/.npm/_npx/9f2"');
});

// A Windows profile is routinely `C:\Users\John Smith`, and a POSIX home can
// hold a space too. Capturing from the last space left a relative path, and
// `rm -rf` on a relative path matches nothing and exits 0 — the user believes
// the cache is clear, reruns, and hits the identical failure.
it('keeps a POSIX cache path that contains a space whole', () => {
expect(
formatModuleMissingMessage(
"Cannot find package 'chalk' imported from /Users/First Last/.npm/_npx/9f2abc1234567890/node_modules/pi/dist/index.js",
),
).toContain('rm -rf "/Users/First Last/.npm/_npx/9f2abc1234567890"');
});

it('keeps a Windows cache path that contains a space whole', () => {
expect(
formatModuleMissingMessage(
"Cannot find package 'chalk' imported from C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def\\node_modules\\x.js",
),
).toContain(
'rm -rf "C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def"',
);
});

// Half a path is worse than none, so a fragment with no root is not printed.
it('falls back rather than naming a path it cannot root', () => {
const message = formatModuleMissingMessage(
"Cannot find module 'foo/_npx/9f2/node_modules/x'",
);
expect(message).not.toContain('rm -rf "foo/_npx/9f2"');
expect(message).toMatch(/rm -rf "\/[^"]*_npx"/);
});

// Every wizard command reaches this message, so it must not name one.
it('asks for the same command again rather than the default flow', () => {
const message = formatModuleMissingMessage('Cannot find module x');
expect(message).toContain('run the same wizard command again');
expect(message).not.toContain('npx @posthog/wizard@latest');
});

it('falls back to the whole npx cache', () => {
expect(formatModuleMissingMessage('Cannot find module x')).toContain(
'_npx',
);
});

// `process.platform` is the OS, not the shell: a Windows user may be at a
// Command Prompt, where `Remove-Item` is not a command at all.
describe('on Windows', () => {
const originalPlatform = process.platform;

beforeEach(() => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true,
});
});

afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
writable: true,
});
});

it('labels a removal command for each shell', () => {
const dir =
'C:\\Users\\John Smith\\AppData\\Local\\npm-cache\\_npx\\abc1234567890def';
const message = formatModuleMissingMessage(
`Cannot find package 'chalk' imported from ${dir}\\node_modules\\x.js`,
);
expect(message).toContain(
`PowerShell: Remove-Item -Recurse -Force "${dir}"`,
);
expect(message).toContain(`Command Prompt: rmdir /s /q "${dir}"`);
});
});
});
1 change: 1 addition & 0 deletions src/lib/errors/agent-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const AGENT_ERROR_CODE: Record<AgentErrorType, ErrorCode> = {
[AgentErrorType.RESOURCE_MISSING]: ErrorCodes.AgentResourceMissing,
[AgentErrorType.RATE_LIMIT]: ErrorCodes.AgentRateLimit,
[AgentErrorType.API_ERROR]: ErrorCodes.AgentApiError,
[AgentErrorType.MODULE_MISSING]: ErrorCodes.AgentModuleMissing,
[AgentErrorType.YARA_VIOLATION]: ErrorCodes.AgentYaraViolation,
[AgentErrorType.ABORT]: ErrorCodes.AgentAbort,
[AgentErrorType.NO_PROGRESS]: ErrorCodes.AgentNoProgress,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/errors/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ export const ERROR_CATALOG: Record<ErrorCode, ErrorCatalogEntry> = {
retry: 'yes',
description: 'The agent hit an API error other than a rate limit.',
},
[ErrorCodes.AgentModuleMissing]: {
group: 'agent',
retry: 'no',
description:
'A wizard dependency was absent from the npx download. The user must delete the npx cache and run again.',
},
[ErrorCodes.AgentYaraViolation]: {
group: 'agent',
retry: 'no',
Expand Down
1 change: 1 addition & 0 deletions src/lib/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const ErrorCodes = {
AgentResourceMissing: 'PHW_AGENT_RESOURCE_MISSING',
AgentRateLimit: 'PHW_AGENT_RATE_LIMIT',
AgentApiError: 'PHW_AGENT_API_ERROR',
AgentModuleMissing: 'PHW_AGENT_MODULE_MISSING',
Comment thread
posthog[bot] marked this conversation as resolved.
AgentYaraViolation: 'PHW_AGENT_YARA_VIOLATION',
AgentNoProgress: 'PHW_AGENT_NO_PROGRESS',
AgentIncompleteTasks: 'PHW_AGENT_INCOMPLETE_TASKS',
Expand Down
Loading
Loading