* refactor(agent-core-v2): replace defineOp/Model with Event2 dispatch and replayable states - replace defineOp/Op/OpDescriptor/toEvent and defineModel/defineCheckpointedModel with Event2 subclasses: durable classes declare static durable + schema, serialize() keeps the wire record shape byte-frozen, transient classes stay off the journal - define states via defineState(...).replayable(...).on(Event2, fold): immer produceWithPatches folds with atomic prepare/commit, .undoable() trait drives prompt-submit checkpoints and context.undo, ephemeral kv keys keep imperative set - degrade IWireService to a journal adapter; the agent event dispatcher owns the pipeline (fold -> set -> appendRecord -> publish) and silent restore - align downstream event surfaces: kap-server WS envelope timestamp from event.time, klient event schemas gain time, node-sdk/acp-server/print wiring updated - rewrite gen-wire-manifest/gen-state-manifest for the unified registry and replace the op-uniqueness lint with event-uniqueness * fix(ci): repair Event2 prompt and media projections - restore prompt admission and session media materialization - align transcript, WS, SDK, and replayable media state projections - update affected tests and generated state manifest * fix(ci): update prompt event and projection expectations - update snapshots for the durable prompt.accepted event - normalize prompt.steered media in transcript projections
9.7 KiB
errors
Error infrastructure for agent-core-v2: base classes, the per-domain code contract, the public
ErrorCodesfacade, wire serialization, and the conventions domains follow when raising errors.
Base classes and serialization are centralized in _base/errors; error codes
are decentralized — each domain owns an errors.ts that contributes its
codes and metadata, and the src/errors.ts facade aggregates them into the
unified ErrorCodes const.
Where things live
src/_base/errors/errors.ts: base classes —Error2,ExpectedError,ErrorNoTelemetry,BugIndicatingError,NotImplementedError, plus theisError2guard andunwrapErrorCause.src/_base/errors/codes.ts: theErrorDomaincontract, theErrorCodetype (aliased to the protocol'sKimiErrorCode), the runtime registry (registerErrorDomain/errorInfo/isErrorCode), and the domain-independentCoreErrors(internal,not_implemented).src/_base/errors/serialize.ts:ErrorPayload,isCodedError,toErrorPayload,fromErrorPayload,makeErrorPayload. Reads retryability from the registry viaerrorInfo. The wire-facing names (KimiErrorPayload,toKimiErrorPayload) mirror the protocol contract and keep their names even though the in-process class isError2.src/_base/errors/errorMessage.ts:toErrorMessage(error, verbose?)for logs/CLI.src/_base/errors/unexpectedError.ts:onUnexpectedError/setUnexpectedErrorHandler/safelyCallListener.src/<domain>/errors.ts: each domain'sXxxErrorsdescriptor (codes + retryable list + per-code info overrides), self-registered on import.src/errors.ts: the facade — imports every domain'serrors.ts(triggering registration), builds the unifiedErrorCodesconst, and re-exports all error primitives. This is the import throw sites use.
Conventions (hard rules)
- Throw a coded error, not a bare string.
throw new Error2(ErrorCodes.X, …).throw new Error('x')only for unreachable guards;BugIndicatingErrorwhen the throw site indicates a caller bug (e.g. reading a service before itsready);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:
_baseinfrastructure 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), andPathSecurityError(tool-path validation with its ownPathSecurityCodetaxonomy). TheChatProviderErrorL0 taxonomy is born-coded: every class extendsError2and computes its wire code at construction (kosong/contract/errors.ts), sotranslateProviderErroris only the abort guard plus the foreign-error fallback. - Define codes in the owning domain. A domain's codes live in
<domain>/errors.tsnext to its interfaces, exported as anXxxErrorsdescriptor — never in_base/errors. - One
codeper failure mode. Codes readdomain.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 ownerrors.tsreferences its own descriptor (LoopErrors.codes.X) and imports only from#/_base/errors(never from#/errors, to avoid cycles). - Translate foreign errors at the boundary. Provider/HTTP, fs, MCP errors are caught at the domain boundary and re-thrown as the domain's coded error.
_base/errorsnever imports a business domain. - Translation is idempotent. A translator (
toHostFsError,toStorageIoError, …) returns its input unchanged when it is already the domain's error type, so layered boundaries never double-wrap. The original error always goes tocause. detailsis structured and JSON-serializable;messageis a short human sentence. Paths, errnos, syscalls, scope/key, line numbers go intodetails; the message must stay readable without them.- Cancellation passes through untranslated. A translation boundary that can see a cancellation-class error (
UserCancellationErrorfrom_base/utils/abort) rethrows it as-is. fs/process translation never encounters cancellation, so those translators do not check for it — apply the rule only at boundaries that actually can. - Classify wrapped foreign errors via
unwrapErrorCause. Predicates that branch on raw shapes (errno, provider status) testunwrapErrorCause(error), since boundary-translated errors carry the raw error ascause. - Branch on
code, neverinstanceof, across the wire. Class identity does not survive serialization. In-process,instanceof Error2/isCodedErrorare fine.
Adding a domain error (recipe)
In <domain>/errors.ts:
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors';
export const ToolErrors = {
codes: {
UNKNOWN_TOOL: 'tool.unknown_tool',
EXECUTION_FAILED: 'tool.execution_failed',
},
retryable: ['tool.execution_failed'],
info: {
'tool.unknown_tool': {
title: 'Unknown tool',
retryable: false,
public: true,
action: 'Check the tool name passed by the model.',
},
},
} as const satisfies ErrorDomain;
registerErrorDomain(ToolErrors);
Then wire it into the facade in src/errors.ts: import ToolErrors, add
...ToolErrors.codes to the ErrorCodes spread, and re-export it. The
satisfies ErrorDomain guarantees every code value is a protocol-known
ErrorCode, and registerErrorDomain makes its metadata available to
serialization.
Domain tiers in practice
The os / persistence / wire domains show the standard shapes:
os.fs(HostFsError,os/interface/hostFsErrors.ts) — everyIHostFileSystembackend translates raw errnos at its boundary via the puretoHostFsError(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 elseos.fs.unknown.detailscarries{ path, op, errno?, syscall? }. Documented boolean semantics (e.g.createExclusivereturningfalseonEEXIST) stay booleans, not errors.os.process(HostProcessError,os/interface/hostProcess.ts) —os.process.spawn_failed(details{ command, args?, cwd?, errno? }) andos.process.kill_failed; both carry the raw error ascause. Kill keeps its deliberate tolerances:ESRCHis a silent no-op,EPERMdegrades tochild.kill().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 viatoStorageIoError:EACCES/EPERM→storage.permission_denied,ENOSPC→storage.disk_full, an unexpectedENOENT→storage.not_found, everything elsestorage.io_failed(the only retryable one besidesstorage.locked). Codec parse failures becomestorage.decode_failedwith{ scope, key, format }; append-log corruption isAppendLogCorruptedError(storage.corrupted).storage.lockedis 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-processClusterDband no longer throws it: peers share the store, and per-shard lock contention surfaces as a transientLockErrorinstead.)wire(WireError,wire/errors.ts) —wire.unknown_record: restore skips records whose durable event type is absent from the folded registry (compatibility) and reports each skip throughonUnexpectedError;wire.migration_missingcovers journals that predate the migration chain. The siblingevent/statedomains ownevent.duplicate_event(a build-time bug),state.duplicate_fold,state.durability_mismatch, andCycleError(state.cycle, details carry the drain depth and a capped event-type sample).
Serialization & boundary translation
toErrorPayload(error): any coded error (incl. deserialized shapes) → its code +retryablefromerrorInfo; anything else →internal.fromErrorPayload(payload): rehydrates anError2for in-processinstanceof/isCodedErroruse at the SDK/RPC boundary.isCodedError(error): structural guard (checkscodeagainst the registry), so it works for bothError2instances and plain objects revived from a payload.- The registry is populated when the facade is imported (the package
index.tsre-exports it); tests that import a single domain get that domain's codes via its self-registration.errorInfofalls back to{ title: code, retryable, public: true }for any unregistered code.
References
packages/agent-core-v2/src/_base/errors/— contract, registry, base classes, serialization.packages/agent-core-v2/src/errors.ts— the aggregating facade.packages/protocol/src/events.ts— the canonicalKimiErrorCodewire union.