src / core / errors.ts
src / core / errors.ts
export type AgenticErrorCode =
| "INVALID_INPUT"
| "OUTSIDE_WORKSPACE"
| "PROTECTED_PATH"
| "NOT_FOUND"
| "BINARY_FILE"
| "FILE_TOO_LARGE"
| "EDIT_CONFLICT"
| "TRANSACTION_STATE"
| "PROCESS_DISABLED"
| "PROCESS_LIMIT"
| "EXECUTABLE_DENIED"
| "TIMEOUT"
| "AGENT_DISABLED"
| "AGENT_LIMIT"
| "APPROVAL_REQUIRED"
| "APPROVAL_DENIED"
| "INTERNAL";
export class AgenticError extends Error {
public readonly code: AgenticErrorCode;
public readonly details?: Record<string, unknown>;
public constructor(
code: AgenticErrorCode,
message: string,
details?: Record<string, unknown>,
) {
super(message);
this.name = "AgenticError";
this.code = code;
this.details = details;
}
}
export function asError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
export function errorSummary(value: unknown): {
code: string;
message: string;
details?: Record<string, unknown>;
} {
const error = asError(value);
if (error instanceof AgenticError) {
return { code: error.code, message: error.message, details: error.details };
}
return { code: "INTERNAL", message: error.message };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Rewrites every absolute workspace path in a message to `<workspace>`-relative
* form.
*
* A raw Node errno message embeds the absolute path it failed on — `EISDIR:
* illegal operation on a directory, open
* 'C:\Users\alice\projects\app\notes.md.agentic-tmp_ab12'` — and anything that
* reaches a `ToolEnvelope`, a persisted plan or the journal is read by the
* model and shipped to whatever the chat is compacted into. Wrapped errors say
* what they mean in workspace terms already; this is the net under everything
* that is not wrapped.
*
* Both separator spellings of each root are matched, case-insensitively on
* Windows where the same directory has many casings.
*/
export function redactWorkspacePaths(message: string, ...roots: string[]): string {
const variants = new Set<string>();
for (const root of roots) {
const trimmed = root?.trim();
if (!trimmed) continue;
variants.add(trimmed);
variants.add(trimmed.replace(/\\/g, "/"));
variants.add(trimmed.replace(/\//g, "\\"));
}
let result = message;
for (const variant of [...variants].sort((a, b) => b.length - a.length)) {
result = result.replace(
new RegExp(escapeRegExp(variant), process.platform === "win32" ? "gi" : "g"),
"<workspace>",
);
}
return result;
}
export function isAbortError(value: unknown): boolean {
if (!(value instanceof Error)) return false;
return (
value.name === "AbortError" ||
/aborted|abort signal|cancell?ed/i.test(value.message)
);
}
/**
* The reply handed back to a model whose tool call failed argument validation.
*
* The SDK formats a zod parse failure as
* `Failed to parse arguments for tool "X": [\n {\n "code": …`, so taking
* the first line — as both call sites used to — always yielded the prefix
* ending in `[` and nothing else: no field, no expected type, nothing the model
* could act on. Collapsing whitespace instead keeps the actual complaint inside
* the same 300-character budget. Used by the sub-agent orchestrator and by the
* live harness, which must give a model the same feedback a real run does.
*/
export function invalidToolRequestFeedback(message: string): string {
const detail = message.replace(/\s+/g, " ").trim().slice(0, 300) || "unknown error";
return `Invalid tool request: ${detail}. Correct the arguments and retry.`;
}
/**
* The whole `handleInvalidToolRequest(error, request)` contract, in one place.
*
* The SDK attaches a returned value to the failed tool call **only when the
* request itself parsed**. When `request` is `undefined` the model's output
* could not be parsed as a tool call at all, so there is nothing to attach the
* result to: LM Studio logs "Please avoid returning a result when the second
* parameter of the callback is undefined" and the model receives nothing.
* Both call sites used to ignore the second parameter and always return
* feedback, which meant the corrective text was silently discarded exactly when
* the model needed it most. Seen live in an installed S9 run: the sub-agent
* emitted `Unterminated string in JSON at position 521`, got no correction, and
* the next prompt overflowed the context window.
*
* Following the SDK's own documented default we throw when the request did not
* parse. That ends the `act()` call with a named cause the run state (or the
* live harness result) records, instead of continuing blind against a model
* that cannot be told what went wrong.
*/
export function invalidToolRequestReply(
error: { message: string },
request: unknown,
): string {
if (request === undefined) {
const detail = error.message.replace(/\s+/g, " ").trim().slice(0, 300) || "unknown error";
throw new AgenticError(
"INVALID_INPUT",
`The model emitted a tool call that could not be parsed, so no correction can be attached to it: ${detail}`,
);
}
return invalidToolRequestFeedback(error.message);
}
export type AgenticErrorCode =
| "INVALID_INPUT"
| "OUTSIDE_WORKSPACE"
| "PROTECTED_PATH"
| "NOT_FOUND"
| "BINARY_FILE"
| "FILE_TOO_LARGE"
| "EDIT_CONFLICT"
| "TRANSACTION_STATE"
| "PROCESS_DISABLED"
| "PROCESS_LIMIT"
| "EXECUTABLE_DENIED"
| "TIMEOUT"
| "AGENT_DISABLED"
| "AGENT_LIMIT"
| "APPROVAL_REQUIRED"
| "APPROVAL_DENIED"
| "INTERNAL";
export class AgenticError extends Error {
public readonly code: AgenticErrorCode;
public readonly details?: Record<string, unknown>;
public constructor(
code: AgenticErrorCode,
message: string,
details?: Record<string, unknown>,
) {
super(message);
this.name = "AgenticError";
this.code = code;
this.details = details;
}
}
export function asError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
export function errorSummary(value: unknown): {
code: string;
message: string;
details?: Record<string, unknown>;
} {
const error = asError(value);
if (error instanceof AgenticError) {
return { code: error.code, message: error.message, details: error.details };
}
return { code: "INTERNAL", message: error.message };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Rewrites every absolute workspace path in a message to `<workspace>`-relative
* form.
*
* A raw Node errno message embeds the absolute path it failed on — `EISDIR:
* illegal operation on a directory, open
* 'C:\Users\alice\projects\app\notes.md.agentic-tmp_ab12'` — and anything that
* reaches a `ToolEnvelope`, a persisted plan or the journal is read by the
* model and shipped to whatever the chat is compacted into. Wrapped errors say
* what they mean in workspace terms already; this is the net under everything
* that is not wrapped.
*
* Both separator spellings of each root are matched, case-insensitively on
* Windows where the same directory has many casings.
*/
export function redactWorkspacePaths(message: string, ...roots: string[]): string {
const variants = new Set<string>();
for (const root of roots) {
const trimmed = root?.trim();
if (!trimmed) continue;
variants.add(trimmed);
variants.add(trimmed.replace(/\\/g, "/"));
variants.add(trimmed.replace(/\//g, "\\"));
}
let result = message;
for (const variant of [...variants].sort((a, b) => b.length - a.length)) {
result = result.replace(
new RegExp(escapeRegExp(variant), process.platform === "win32" ? "gi" : "g"),
"<workspace>",
);
}
return result;
}
export function isAbortError(value: unknown): boolean {
if (!(value instanceof Error)) return false;
return (
value.name === "AbortError" ||
/aborted|abort signal|cancell?ed/i.test(value.message)
);
}
/**
* The reply handed back to a model whose tool call failed argument validation.
*
* The SDK formats a zod parse failure as
* `Failed to parse arguments for tool "X": [\n {\n "code": …`, so taking
* the first line — as both call sites used to — always yielded the prefix
* ending in `[` and nothing else: no field, no expected type, nothing the model
* could act on. Collapsing whitespace instead keeps the actual complaint inside
* the same 300-character budget. Used by the sub-agent orchestrator and by the
* live harness, which must give a model the same feedback a real run does.
*/
export function invalidToolRequestFeedback(message: string): string {
const detail = message.replace(/\s+/g, " ").trim().slice(0, 300) || "unknown error";
return `Invalid tool request: ${detail}. Correct the arguments and retry.`;
}
/**
* The whole `handleInvalidToolRequest(error, request)` contract, in one place.
*
* The SDK attaches a returned value to the failed tool call **only when the
* request itself parsed**. When `request` is `undefined` the model's output
* could not be parsed as a tool call at all, so there is nothing to attach the
* result to: LM Studio logs "Please avoid returning a result when the second
* parameter of the callback is undefined" and the model receives nothing.
* Both call sites used to ignore the second parameter and always return
* feedback, which meant the corrective text was silently discarded exactly when
* the model needed it most. Seen live in an installed S9 run: the sub-agent
* emitted `Unterminated string in JSON at position 521`, got no correction, and
* the next prompt overflowed the context window.
*
* Following the SDK's own documented default we throw when the request did not
* parse. That ends the `act()` call with a named cause the run state (or the
* live harness result) records, instead of continuing blind against a model
* that cannot be told what went wrong.
*/
export function invalidToolRequestReply(
error: { message: string },
request: unknown,
): string {
if (request === undefined) {
const detail = error.message.replace(/\s+/g, " ").trim().slice(0, 300) || "unknown error";
throw new AgenticError(
"INVALID_INPUT",
`The model emitted a tool call that could not be parsed, so no correction can be attached to it: ${detail}`,
);
}
return invalidToolRequestFeedback(error.message);
}