-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. local std = require("std") local M = {} M.kind = { transport = "transport", protocol = "protocol", schema = "schema", tool = "tool", cancelled = "cancelled", budget_exhausted = "budget_exhausted", policy = "policy", persistence = "persistence", internal = "internal", } M.code = { cancelled = "cancelled", internal = "internal_error", unknown = "unknown_error", active_turn = "active_turn", } -- Default code per kind, so an error created with a known kind but no explicit -- code gets a meaningful code instead of the generic "unknown_error". local KIND_DEFAULT_CODES = { transport = "transport_error", protocol = "protocol_error", schema = "schema_error", tool = "tool_error", cancelled = M.code.cancelled, budget_exhausted = "budget_exhausted", policy = "policy_error", persistence = "persistence_error", internal = M.code.internal, } local STABLE_FIELDS = { kind = true, code = true, message = true, retryable = true, origin = true, data = true, cause = true, } local function default_code(kind) return KIND_DEFAULT_CODES[kind] or M.code.unknown end function M.new(opts) opts = opts or {} local kind = opts.kind or M.kind.internal local err = { __agent_error = true, kind = kind, code = opts.code or default_code(kind), message = opts.message or "An unexpected error occurred", retryable = opts.retryable == true, origin = opts.origin or "runtime", } if opts.data ~= nil then err.data = opts.data end if opts.cause ~= nil then err.cause = opts.cause end return err end function M.cancelled(origin, data, cause) return M.new({ kind = M.kind.cancelled, code = M.code.cancelled, message = "cancelled", origin = origin or "runtime", data = data, cause = cause, }) end function M.internal(message, origin, data, cause) return M.new({ kind = M.kind.internal, code = M.code.internal, message = message or "An unexpected error occurred", origin = origin or "runtime", data = data, cause = cause, }) end function M.is_agent_error(value) return type(value) == "table" and value.__agent_error == true end function M.is_cancelled(value) if value == "cancelled" then return true end if type(value) ~= "table" then return false end if value.cancelled == true then return true end return value.kind == M.kind.cancelled or value.code == M.code.cancelled end function M.normalize(raw, defaults) defaults = defaults or {} if M.is_cancelled(raw) then local data = defaults.data if type(raw) == "table" and raw ~= data and raw.kind ~= M.kind.cancelled and raw.code ~= M.code.cancelled then data = data or std.tbl.copy(raw) end return M.cancelled(defaults.origin, data, defaults.cause) end if M.is_agent_error(raw) then local normalized = std.tbl.copy(raw) normalized.__agent_error = true if defaults.kind ~= nil and normalized.kind == nil then normalized.kind = defaults.kind end if defaults.code ~= nil and normalized.code == nil then normalized.code = defaults.code end if defaults.message ~= nil and normalized.message == nil then normalized.message = defaults.message end if defaults.origin ~= nil and normalized.origin == nil then normalized.origin = defaults.origin end if defaults.retryable ~= nil and normalized.retryable == nil then normalized.retryable = defaults.retryable == true end if defaults.data ~= nil and normalized.data == nil then normalized.data = defaults.data end if defaults.cause ~= nil and normalized.cause == nil then normalized.cause = defaults.cause end if normalized.kind == nil then normalized.kind = M.kind.internal end if normalized.code == nil then normalized.code = default_code(normalized.kind) end if normalized.message == nil then normalized.message = "An unexpected error occurred" end if normalized.retryable == nil then normalized.retryable = false end if normalized.origin == nil then normalized.origin = "runtime" end return normalized end if type(raw) == "string" then return M.new({ kind = defaults.kind, code = defaults.code, message = raw, retryable = defaults.retryable, origin = defaults.origin, data = defaults.data, cause = defaults.cause, }) end if type(raw) == "table" then local opts = { kind = raw.kind or defaults.kind, code = raw.code or defaults.code, -- Built-in tools report the human-readable reason in `detail` -- (with `error` holding a short code); fall back to it so the -- message is actionable instead of the generic default. message = raw.message or raw.detail or defaults.message, retryable = raw.retryable, origin = raw.origin or defaults.origin, data = raw.data or defaults.data, cause = raw.cause or defaults.cause, } local unknown = {} for key, value in pairs(raw) do if not STABLE_FIELDS[key] and key ~= "__agent_error" then unknown[key] = value end end if next(unknown) ~= nil and opts.cause == nil then opts.cause = unknown elseif opts.cause == nil then opts.cause = raw end return M.new(opts) end return M.new({ kind = defaults.kind, code = defaults.code, message = defaults.message, retryable = defaults.retryable, origin = defaults.origin, data = defaults.data, cause = raw ~= nil and raw or defaults.cause, }) end function M.to_table(value) local err = M.normalize(value) return { kind = err.kind, code = err.code, message = err.message, retryable = err.retryable, origin = err.origin, data = err.data, cause = err.cause, } end return M