Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/agent-core-v2/docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ unified `ErrorCodes` const.
## Conventions (hard rules)

- **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. `throw new Error('x')` only for unreachable guards; `BugIndicatingError` when the throw site indicates a caller bug (e.g. reading a service before its `ready`); `NotImplementedError('feature')` for stubs.
- **Every domain codes ALL of its failure modes.** This includes errors raised on tool-execution paths whose message is fed back to the model (tool-input validation is a domain failure mode too) — whether a given scope (App / Workspace / Session / Agent) or the model ever sees an error is decided by event-filtered subscriptions, never by the error's type. The uncoded errors left are: `_base` infrastructure errors (DI, event, lifecycle, text, execEnv — deliberately left as plain guards / classes for now), control-flow sentinels that never leave their domain (`UserCancellationError`, `TaskCancelledError`, `TransientCloudError`, `GrepAbortedError`, `ProcessExitError`, `CompactionTruncatedError`), `CyclicDependencyError` (a documented DI wiring protection), and `PathSecurityError` (tool-path validation with its own `PathSecurityCode` taxonomy). The `ChatProviderError` L0 taxonomy is born-coded: every class extends `Error2` and computes its wire code at construction (`kosong/contract/errors.ts`), so `translateProviderError` is only the abort guard plus the foreign-error fallback.
- **Define codes in the owning domain.** A domain's codes live in `<domain>/errors.ts` next to its interfaces, exported as an `XxxErrors` descriptor — never in `_base/errors`.
- **One `code` per failure mode.** Codes read `domain.reason` (e.g. `tool.unknown_tool`). The set of valid code strings is fixed by the protocol (`KimiErrorCode`); adding a brand-new code means updating the protocol first. Renaming/removing a code is a major (breaks SDK clients).
- **Import from the facade.** Throw sites and cross-domain consumers do `import { ErrorCodes, Error2 } from '#/errors'`. A domain's own `errors.ts` references its own descriptor (`LoopErrors.codes.X`) and imports only from `#/_base/errors` (never from `#/errors`, to avoid cycles).
Expand Down Expand Up @@ -70,7 +71,7 @@ The os / persistence / wire domains show the standard shapes:

- **`os.fs` (`HostFsError`, `os/interface/hostFsErrors.ts`)** — every `IHostFileSystem` backend translates raw errnos at its boundary via the pure `toHostFsError(err, { path, op })`: `ENOENT→os.fs.not_found`, `EISDIR→os.fs.is_directory`, `ENOTDIR→os.fs.not_directory`, `EEXIST→os.fs.already_exists`, `EACCES/EPERM→os.fs.permission_denied`, `ENOTEMPTY→os.fs.not_empty`, everything else `os.fs.unknown`. `details` carries `{ path, op, errno?, syscall? }`. Documented boolean semantics (e.g. `createExclusive` returning `false` on `EEXIST`) stay booleans, not errors.
- **`os.process` (`HostProcessError`, `os/interface/hostProcess.ts`)** — `os.process.spawn_failed` (details `{ command, args?, cwd?, errno? }`) and `os.process.kill_failed`; both carry the raw error as `cause`. Kill keeps its deliberate tolerances: `ESRCH` is a silent no-op, `EPERM` degrades to `child.kill()`.
- **`storage` (`StorageError`, `persistence/interface/storage.ts`)** — `storage.not_found` / `decode_failed` / `corrupted` / `io_failed` / `locked`. ENOENT keeps its established absence semantics (`read → undefined`, `list → []`) and is *not* an error; other I/O failures become `storage.io_failed` (`retryable`). Codec parse failures become `storage.decode_failed` with `{ scope, key, format }`; append-log corruption is `AppendLogCorruptedError` (`storage.corrupted`). `storage.locked` is reserved for a store exclusively held by another process — consumers (e.g. `FileSessionIndex`) catch it explicitly and fall back to their non-read-model path with a one-time warning; there is no silent no-op degradation. (The minidb query-store backend is a multi-process `ClusterDb` and no longer throws it: peers share the store, and per-shard lock contention surfaces as a transient `LockError` instead.)
- **`storage` (`StorageError`, `persistence/interface/storage.ts`)** — `storage.not_found` / `decode_failed` / `corrupted` / `io_failed` / `locked` / `permission_denied` / `disk_full`. ENOENT keeps its established absence semantics (`read → undefined`, `list → []`) and is *not* an error; other I/O failures are mapped by errno at the backend boundary via `toStorageIoError`: `EACCES/EPERM→storage.permission_denied`, `ENOSPC→storage.disk_full`, an unexpected `ENOENT→storage.not_found`, everything else `storage.io_failed` (the only retryable one besides `storage.locked`). Codec parse failures become `storage.decode_failed` with `{ scope, key, format }`; append-log corruption is `AppendLogCorruptedError` (`storage.corrupted`). `storage.locked` is reserved for a store exclusively held by another process — consumers (e.g. `FileSessionIndex`) catch it explicitly and fall back to their non-read-model path with a one-time warning; there is no silent no-op degradation. (The minidb query-store backend is a multi-process `ClusterDb` and no longer throws it: peers share the store, and per-shard lock contention surfaces as a transient `LockError` instead.)
- **`wire` (`WireError`, `wire/errors.ts`)** — `DuplicateOpError` (`wire.duplicate_op`, a build-time bug), `CycleError` (`wire.cycle`, details carry the drain depth and a capped op-type sample), and `wire.unknown_record`: replay skips records whose Op type is absent from `OP_REGISTRY` (compatibility), reports each skip through `onUnexpectedError`, and returns `{ unknownRecords }` so the caller knows the restore was lossy.

## Serialization & boundary translation
Expand Down
37 changes: 34 additions & 3 deletions packages/agent-core-v2/src/agent/contextMemory/contextOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

import { z } from 'zod';

import { ErrorCodes, Error2 } from '#/errors';
import type { ContentPart } from '#/kosong/contract/message';
import { defineModel, type PartsTransformer } from '#/wire/model';
import type { WireRecord } from '#/wire/record';
Expand Down Expand Up @@ -238,7 +239,17 @@ export function readContextCompactedCount(record: ContextCompactionRecord): numb
if (typeof compactedCount === 'number') return compactedCount;
const legacyCount = fields['count'];
if (typeof legacyCount === 'number') return legacyCount;
throw new Error('Invalid context.apply_compaction record: missing compactedCount');
throw new Error2(
ErrorCodes.STORAGE_DECODE_FAILED,
'Invalid context.apply_compaction record: missing compactedCount',
{
details: {
recordKeys: Object.keys(record),
compactedCountType: typeof compactedCount,
countType: typeof legacyCount,
},
},
);
}

export function readContextCompactionSummary(record: ContextCompactionRecord): ContextMessage {
Expand All @@ -248,7 +259,17 @@ export function readContextCompactionSummary(record: ContextCompactionRecord): C
const summary = fields['summary'];
if (typeof summary === 'string') return createCompactionSummaryMessage(summary);
if (isContextMessage(summary)) return summary;
throw new Error('Invalid context.apply_compaction record: missing summary');
throw new Error2(
ErrorCodes.STORAGE_DECODE_FAILED,
'Invalid context.apply_compaction record: missing summary',
{
details: {
recordKeys: Object.keys(record),
summaryType: typeof summary,
contextSummaryType: typeof contextSummary,
},
},
);
}

function readContextCompactionRawSummary(record: UnknownRecord): string {
Expand All @@ -259,7 +280,17 @@ function readContextCompactionRawSummary(record: UnknownRecord): string {
if (isContextMessage(summary)) {
return textOf(summary);
}
throw new Error('Invalid context.apply_compaction record: missing summary');
throw new Error2(
ErrorCodes.STORAGE_DECODE_FAILED,
'Invalid context.apply_compaction record: missing summary',
{
details: {
recordKeys: Object.keys(record),
summaryType: typeof summary,
contextSummaryType: typeof contextSummary,
},
},
);
}

function readLegacySummaryMessage(record: UnknownRecord): ContextMessage | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { createDecorator } from '#/_base/di/instantiation';
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { BugIndicatingError } from '#/errors';

export interface AgentConversationUndoParticipant {
readonly id: string;
Expand Down Expand Up @@ -36,7 +37,7 @@ class AgentConversationUndoParticipantRegistry

register(participant: AgentConversationUndoParticipant): IDisposable {
if (this.participants.has(participant.id)) {
throw new Error(
throw new BugIndicatingError(
`Conversation undo participant "${participant.id}" is already registered`,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ import {
type LlmRequestToolSchema,
} from './llmRequestOps';
import { isAbortError } from '#/_base/utils/abort';
import { unwrapErrorCause } from '#/errors';
import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors';
import { retryErrorFields } from '#/_base/utils/retry';

const EMPTY_TOOL_PARAMETERS: Record<string, unknown> = {
Expand Down Expand Up @@ -416,7 +416,10 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
}

if (message === undefined || finish === undefined) {
throw new Error('LLM request stream ended without a finish event.');
throw new Error2(
ErrorCodes.PROVIDER_API_ERROR,
'LLM request stream ended without a finish event.',
);
}

this.usage.record(request.modelAlias, usage, request.source);
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/src/agent/loop/loopService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
options: LoopErrorHandlerRegistrationOptions = {},
): IDisposable {
if (options.before !== undefined && options.after !== undefined) {
throw new Error('Loop error handler registration cannot specify both before and after');
throw new BugIndicatingError('Loop error handler registration cannot specify both before and after');
}
this.deleteErrorHandler(handler.id);
const target = options.before ?? options.after;
Expand All @@ -588,7 +588,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
} else {
const targetIndex = this.errorHandlers.findIndex((entry) => entry.id === target);
if (targetIndex < 0) {
throw new Error(`Loop error handler target "${target}" is not registered`);
throw new BugIndicatingError(`Loop error handler target "${target}" is not registered`);
}
const insertAt = options.before !== undefined ? targetIndex : targetIndex + 1;
this.errorHandlers.splice(insertAt, 0, handler);
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-core-v2/src/agent/mcp/tools/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

import type { Tool as KosongTool } from '#/kosong/contract/tool';
import type { ITelemetryService } from '#/app/telemetry/telemetry';
import { toErrorMessage } from '#/errors';
import { Error2, ErrorCodes, toErrorMessage } from '#/errors';
import { isAbortError } from '#/_base/utils/abort';

import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult } from '#/tool/toolContract';
Expand Down Expand Up @@ -118,7 +118,8 @@ async function retryAfterReconnect(
if (context.signal.aborted || isAbortError(reconnectError)) {
throw reconnectError;
}
throw new Error(
throw new Error2(
ErrorCodes.MCP_STARTUP_FAILED,
`${toErrorMessage(failure)} (reconnecting the MCP server also failed: ${toErrorMessage(reconnectError)})`,
{ cause: reconnectError },
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import picomatch from 'picomatch';

import { Error2, ErrorCodes } from '#/errors';
import type { RunnableToolExecution } from '#/tool/toolContract';
import type { PermissionRule } from './permissionRules';

Expand Down Expand Up @@ -31,7 +32,7 @@ export interface PermissionRuleMatchInput {
export function parsePattern(pattern: string): ParsedPattern {
const trimmed = pattern.trim();
if (trimmed.length === 0) {
throw new Error('permission pattern: empty string');
throw new Error2(ErrorCodes.VALIDATION_FAILED, 'permission pattern: empty string');
}

const openIdx = trimmed.indexOf('(');
Expand All @@ -40,13 +41,13 @@ export function parsePattern(pattern: string): ParsedPattern {
}

if (!trimmed.endsWith(')')) {
throw new Error(`permission pattern: missing closing paren in "${pattern}"`);
throw new Error2(ErrorCodes.VALIDATION_FAILED, `permission pattern: missing closing paren in "${pattern}"`);
}

const toolName = trimmed.slice(0, openIdx);
const argPattern = trimmed.slice(openIdx + 1, -1);
if (toolName.length === 0) {
throw new Error(`permission pattern: empty tool name in "${pattern}"`);
throw new Error2(ErrorCodes.VALIDATION_FAILED, `permission pattern: empty tool name in "${pattern}"`);
}
if (argPattern.length === 0) {
return { toolName };
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/src/agent/plan/planService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { dirname, join } from 'pathe';
import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { unwrapErrorCause } from '#/_base/errors/errors';
import { Error2, ErrorCodes } from '#/errors';
import { generateHeroSlug } from '#/_base/utils/hero-slug';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
Expand Down Expand Up @@ -181,7 +182,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {

async enter(id = this.createPlanId(), createFile = false): Promise<void> {
if (this.isActive) {
throw new Error('Already in plan mode');
throw new Error2(ErrorCodes.SESSION_PLAN_MODE_INVALID, 'Already in plan mode');
}

const planFilePath = this.planFilePathFor(id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { IAgentStateService } from '#/agent/state/agentState';
import type { ToolUpdate } from '#/tool/toolContract';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { IEventBus } from '#/app/event/eventBus';
import { Error2, ErrorCodes } from '#/errors';

import {
IAgentShellCommandService,
Expand Down Expand Up @@ -200,7 +201,7 @@ export class AgentShellCommandService implements IAgentShellCommandService {
private ensureBashTool() {
const bash = this.toolRegistry.resolve('Bash');
if (bash === undefined) {
throw new Error('Bash tool is not registered.');
throw new Error2(ErrorCodes.INTERNAL, 'Bash tool is not registered.');
}
return bash;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/agent/task/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';
export const TaskErrors = {
codes: {
TASK_ID_EMPTY: 'task.task_id_empty',
TASK_LIMIT_EXCEEDED: 'task.limit_exceeded',
},
retryable: ['task.limit_exceeded'],
} as const satisfies ErrorDomain;

registerErrorDomain(TaskErrors);
3 changes: 2 additions & 1 deletion packages/agent-core-v2/src/agent/task/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { join } from 'pathe';

import { BugIndicatingError } from '#/errors';
import type { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import type { IFileSystemStorageService } from '#/persistence/interface/storage';

Expand Down Expand Up @@ -62,7 +63,7 @@ interface TaskOutputData {

function validateTaskId(taskId: string): void {
if (!VALID_TASK_ID.test(taskId)) {
throw new Error(`Invalid task id: "${taskId}"`);
throw new BugIndicatingError(`Invalid task id: "${taskId}"`);
}
}

Expand Down
5 changes: 4 additions & 1 deletion packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
} from '#/_base/utils/abort';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import { IEventBus } from '#/app/event/eventBus';
import { Error2, ErrorCodes } from '#/errors';
import { defineCheckpointedModel } from '#/agent/contextMemory/conversationTime';
import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants';
import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types';
Expand Down Expand Up @@ -918,7 +919,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
if (maxRunningTasks === undefined) return;
if (!detached) return;
if (this.activeTaskCount() < maxRunningTasks) return;
throw new Error('Too many background tasks are already running.');
throw new Error2(ErrorCodes.TASK_LIMIT_EXCEEDED, 'Too many background tasks are already running.', {
details: { running: this.activeTaskCount(), max: maxRunningTasks },
});
}

private activeTaskCount(): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/

import { Emitter } from '#/_base/event';
import { BugIndicatingError } from '#/errors';
import type { ToolCall } from '#/kosong/contract/message';
import type { LLMRequestTrace } from '#/kosong/contract/requestTrace';
import type {
Expand Down Expand Up @@ -112,7 +113,7 @@ export class BeforeToolExecuteEventImpl implements BeforeToolExecuteEvent {

private assertOpen(statement: string): void {
if (!this._open) {
throw new Error(`${statement} can NOT be called asynchronously`);
throw new BugIndicatingError(`${statement} can NOT be called asynchronously`);
}
}
}
Expand Down
Loading
Loading