Files
openclaw/src/agents/tool-result-error.ts
Peter Steinberger f53346944d feat: correlate native search outcomes in audit history (#98704)
* feat: correlate native search outcomes in audit history

Metadata-only audit ledger for agent runs and tool actions in the shared
state DB: stable event identity, closed action/status/error vocabularies,
one-way-hashed tool-call ids, never-inferred terminal outcomes for native
web-search (explicit completed/failed only; otherwise unknown), bounded
retention, audit.list gateway RPC and openclaw audit CLI. Squashed from
the 82-commit audit stack for replay onto current main.

* feat(audit): add audit.enabled config gate (default on)

The metadata-only audit ledger records by default: an audit trail enabled
only after an incident cannot explain the incident, and the rows are
strictly less sensitive than the transcripts every install already
stores. audit.enabled=false stops new writes at the gateway subscription
seam; audit.list and openclaw audit keep serving existing records until
they expire. Documented in the configuration reference, protocol page,
and CLI reference.

* fix: repair full-matrix CI findings after rebase

- break the dynamic-tools/dynamic-tool-execution import cycle by
  extracting resolveCodexToolAbortTerminalReason into a leaf module
- restore main's session-worktree protocol exports lost in the
  index.ts auto-merge
- register the audit event writer worker as a knip entry point
- docs table formatting; subagent wait-cancellation test scoped to its
  audit intent (outcome + timing) and advanced past main's new
  lifecycle-timeout retry grace
2026-07-06 12:30:12 +01:00

165 lines
5.2 KiB
TypeScript

import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { formatErrorMessage } from "../infra/errors.js";
const TOOL_TIMEOUT_ERROR_CODES = new Set([
"ERR_TIMEOUT",
"ESOCKETTIMEDOUT",
"ETIMEDOUT",
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_HEADERS_TIMEOUT",
]);
function readToolErrorField(error: object, key: string): unknown {
try {
return key in error ? (error as Record<string, unknown>)[key] : undefined;
} catch {
return undefined;
}
}
function hasStructuredToolTimeoutIdentity(error: unknown): boolean {
const pending = [error];
const seen = new Set<unknown>();
while (pending.length > 0 && seen.size < 8) {
const current = pending.shift();
if (!current || typeof current !== "object" || seen.has(current)) {
continue;
}
seen.add(current);
const name = readToolErrorField(current, "name");
if (name === "TimeoutError") {
return true;
}
const code = readToolErrorField(current, "code");
if (typeof code === "string" && TOOL_TIMEOUT_ERROR_CODES.has(code.trim().toUpperCase())) {
return true;
}
for (const key of ["reason", "status"] as const) {
const value = readToolErrorField(current, key);
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "timeout" || normalized === "timed_out") {
return true;
}
if (value && typeof value === "object") {
pending.push(value);
}
}
const cause = readToolErrorField(current, "cause");
if (cause && typeof cause === "object") {
pending.push(cause);
}
}
return false;
}
export function readToolResultDetails(result: unknown): Record<string, unknown> | undefined {
if (!result || typeof result !== "object") {
return undefined;
}
try {
const details = readToolErrorField(result, "details");
return details && typeof details === "object" && !Array.isArray(details)
? (details as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
export function readToolResultStatus(result: unknown): string | undefined {
const details = readToolResultDetails(result);
return normalizeOptionalLowercaseString(
details ? readToolErrorField(details, "status") : undefined,
);
}
export function isToolResultError(result: unknown): boolean {
const details = readToolResultDetails(result);
const normalized = readToolResultStatus(result);
const ok = details ? readToolErrorField(details, "ok") : undefined;
const success = details ? readToolErrorField(details, "success") : undefined;
const explicitlySuccessful = ok === true || success === true;
if (ok === false || success === false) {
return true;
}
const hasFailureStatus =
normalized === "error" ||
normalized === "failed" ||
normalized === "failure" ||
normalized === "timeout" ||
normalized === "timed_out" ||
normalized === "blocked" ||
normalized === "denied" ||
normalized === "forbidden" ||
normalized === "unavailable" ||
normalized === "approval-unavailable" ||
normalized === "disabled" ||
normalized === "aborted" ||
normalized === "cancelled" ||
normalized === "canceled" ||
normalized === "killed" ||
normalized === "invalid";
if (hasFailureStatus && !explicitlySuccessful) {
return true;
}
const timedOut = details ? readToolErrorField(details, "timedOut") : undefined;
const error = details ? readToolErrorField(details, "error") : undefined;
if (timedOut === true || Boolean(error)) {
return true;
}
const exitCode = details ? readToolErrorField(details, "exitCode") : undefined;
return typeof exitCode === "number" && Number.isFinite(exitCode) && exitCode !== 0;
}
export type ToolResultFailureKind = "blocked" | "cancelled" | "failed" | "timed_out";
/** Classify a thrown tool error without inferring cancellation from message text. */
export function resolveToolExecutionErrorKind(error: unknown): "failed" | "timed_out" {
try {
return hasStructuredToolTimeoutIdentity(error) ? "timed_out" : "failed";
} catch {
return "failed";
}
}
/** Format a redacted tool error without allowing hostile getters to escape observability. */
export function formatToolExecutionErrorMessage(error: unknown, fallback: string): string {
try {
return formatErrorMessage(error) || fallback;
} catch {
return fallback;
}
}
/** Classify a resolved structured tool result through the shared terminal contract. */
export function resolveToolResultFailureKind(result: unknown): ToolResultFailureKind | undefined {
if (!isToolResultError(result)) {
return undefined;
}
const status = readToolResultStatus(result);
if (
status === "blocked" ||
status === "denied" ||
status === "forbidden" ||
status === "disabled" ||
status === "approval-unavailable"
) {
return "blocked";
}
const details = readToolResultDetails(result);
const timedOut = details ? readToolErrorField(details, "timedOut") : undefined;
if (timedOut === true || status === "timeout" || status === "timed_out") {
return "timed_out";
}
if (
status === "aborted" ||
status === "cancelled" ||
status === "canceled" ||
status === "killed"
) {
return "cancelled";
}
return "failed";
}