From 03cf20700e4f5ba66eea09c4962e5d230d1d608d Mon Sep 17 00:00:00 2001 From: barry <91018388+barry166@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:28:10 +0800 Subject: [PATCH] Centralize unknown-error formatting across agentctx surfaces Issue #89 called out repeated unknown-error stringification helpers across hooks, extraction, MCP, and CLI surfaces. This change adds one shared helper, switches the duplicated and inlined call sites to it, and adds a small regression test for Error and non-Error throwables.\n\nConstraint: Keep behavior unchanged while removing the duplicated helper logic\nRejected: Leave remaining inline sites untouched | would keep the same single-purpose duplication inside the issue scope\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Reuse describeError for future unknown-error reporting instead of adding local ternaries\nTested: npm test --workspace @agentctxhq/agentctx; npm run typecheck --workspace @agentctxhq/agentctx; npm run build --workspace @agentctxhq/agentctx; npm run lint\nNot-tested: End-to-end npm package install on a fresh machine --- packages/agentctx/src/cli.ts | 4 +++- packages/agentctx/src/cli/node-support.ts | 4 +++- packages/agentctx/src/consolidate/run.ts | 3 ++- packages/agentctx/src/errors.ts | 3 +++ packages/agentctx/src/extract/ingest.ts | 7 ++----- packages/agentctx/src/extract/run.ts | 7 ++----- packages/agentctx/src/hooks/lifecycle.ts | 7 ++----- packages/agentctx/src/hooks/session-start.ts | 9 +++------ packages/agentctx/src/hooks/user-prompt-submit.ts | 7 ++----- packages/agentctx/src/mcp/server.ts | 3 ++- packages/agentctx/src/mcp/tools.ts | 3 ++- packages/agentctx/test/cli.test.ts | 6 ++++++ 12 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 packages/agentctx/src/errors.ts diff --git a/packages/agentctx/src/cli.ts b/packages/agentctx/src/cli.ts index 97cdbb7..732b04c 100644 --- a/packages/agentctx/src/cli.ts +++ b/packages/agentctx/src/cli.ts @@ -6,6 +6,8 @@ * agentctx must never break a Claude Code session (issue 2/7). The hook * dispatcher and everything else load lazily. */ +import { describeError } from "./errors.js"; + const argv = process.argv.slice(2); if (argv[0] === "hook") { @@ -24,7 +26,7 @@ if (argv[0] === "hook") { process.exitCode = code; }) .catch((error) => { - console.error(`agentctx: ${error instanceof Error ? error.message : String(error)}`); + console.error(`agentctx: ${describeError(error)}`); process.exitCode = 1; }); } diff --git a/packages/agentctx/src/cli/node-support.ts b/packages/agentctx/src/cli/node-support.ts index 6c4639f..1fd4137 100644 --- a/packages/agentctx/src/cli/node-support.ts +++ b/packages/agentctx/src/cli/node-support.ts @@ -1,3 +1,5 @@ +import { describeError } from "../errors.js"; + /** * Node support matrix (OQ-1, ADR-003). * @@ -44,7 +46,7 @@ export function unsupportedNodeReason(version: string = process.versions.node): * missing binding (no prebuild existed and install skipped the compile). */ export function describeNativeLoadError(error: unknown): string | null { - const message = error instanceof Error ? error.message : String(error); + const message = describeError(error); const abiMismatch = message.includes("NODE_MODULE_VERSION"); // ERR_DLOPEN_FAILED is raised for any native module; only claim it when // the message names better-sqlite3 — generic advice would be wrong advice. diff --git a/packages/agentctx/src/consolidate/run.ts b/packages/agentctx/src/consolidate/run.ts index 9b5c1dc..a801605 100644 --- a/packages/agentctx/src/consolidate/run.ts +++ b/packages/agentctx/src/consolidate/run.ts @@ -19,6 +19,7 @@ import { existsSync } from "node:fs"; import type { Database } from "better-sqlite3"; import type { CliEnv } from "../cli/env.js"; import { loadConfig } from "../config.js"; +import { describeError } from "../errors.js"; import { openDatabase } from "../storage/db.js"; import { resolveProjectId } from "../storage/namespace.js"; import { GLOBAL_PROJECT_ID, type RecordType } from "../storage/types.js"; @@ -54,7 +55,7 @@ export async function runConsolidate(env: CliEnv, _args: string[] = []): Promise } } catch (error) { // Detached background pass: never escalate (SPEC §8 rung 5). - env.io.err(`agentctx consolidate: ${error instanceof Error ? error.message : String(error)}`); + env.io.err(`agentctx consolidate: ${describeError(error)}`); } return 0; } diff --git a/packages/agentctx/src/errors.ts b/packages/agentctx/src/errors.ts new file mode 100644 index 0000000..b08d1ea --- /dev/null +++ b/packages/agentctx/src/errors.ts @@ -0,0 +1,3 @@ +export function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/agentctx/src/extract/ingest.ts b/packages/agentctx/src/extract/ingest.ts index 577633d..099ab10 100644 --- a/packages/agentctx/src/extract/ingest.ts +++ b/packages/agentctx/src/extract/ingest.ts @@ -14,6 +14,7 @@ * count-threshold transition itself runs in `agentctx consolidate`. */ import type { Database } from "better-sqlite3"; +import { describeError } from "../errors.js"; import { getRecord, insertRecord } from "../storage/records.js"; import { BODY_MAX_CHARS, @@ -175,7 +176,7 @@ function ingestCandidate( stats.written++; } catch (error) { stats.dropped++; - log(`ingest: dropped ${candidate.type} entry: ${describe(error)}`); + log(`ingest: dropped ${candidate.type} entry: ${describeError(error)}`); } } @@ -226,7 +227,3 @@ function clampTitle(text: string): string { function bullets(items: string[]): string { return items.map((item) => `- ${item}`).join("\n"); } - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agentctx/src/extract/run.ts b/packages/agentctx/src/extract/run.ts index 32d8faf..47b7420 100644 --- a/packages/agentctx/src/extract/run.ts +++ b/packages/agentctx/src/extract/run.ts @@ -14,6 +14,7 @@ import { join } from "node:path"; import { parseArgs } from "node:util"; import type { CliEnv } from "../cli/env.js"; import { loadConfig } from "../config.js"; +import { describeError } from "../errors.js"; import { openDatabase } from "../storage/db.js"; import { resolveProjectId } from "../storage/namespace.js"; import { type FetchLike, requestCompletion } from "./api.js"; @@ -59,7 +60,7 @@ export async function runExtract( try { return await extract(env, sessionId, transcriptPath, values["no-llm"] === true, deps, log); } catch (error) { - log(`extract failed for session ${sessionId}: ${describe(error)}`); + log(`extract failed for session ${sessionId}: ${describeError(error)}`); return 0; // never a session error (SPEC §6) } } @@ -190,7 +191,3 @@ function makeLog(env: CliEnv): (message: string) => void { } }; } - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agentctx/src/hooks/lifecycle.ts b/packages/agentctx/src/hooks/lifecycle.ts index d93dadb..eb8eda6 100644 --- a/packages/agentctx/src/hooks/lifecycle.ts +++ b/packages/agentctx/src/hooks/lifecycle.ts @@ -10,6 +10,7 @@ import { existsSync, rmSync } from "node:fs"; import { join } from "node:path"; import { CONFIG_FILE_NAME, DEFAULT_CONFIG, loadConfig } from "../config.js"; +import { describeError } from "../errors.js"; import { openDatabase } from "../storage/db.js"; import { resolveProjectId } from "../storage/namespace.js"; import { dedupFilePath } from "./dedup.js"; @@ -51,7 +52,7 @@ export async function runSessionEnd(env: HookEnv, payload: HookPayload): Promise db.close(); } } catch (error) { - env.log(`session-end: bookkeeping failed: ${describe(error)}`); + env.log(`session-end: bookkeeping failed: ${describeError(error)}`); } } } @@ -79,7 +80,3 @@ function effectiveConfig(env: HookEnv): { llm: boolean } { return DEFAULT_CONFIG; } } - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agentctx/src/hooks/session-start.ts b/packages/agentctx/src/hooks/session-start.ts index 508dc44..e6b48c0 100644 --- a/packages/agentctx/src/hooks/session-start.ts +++ b/packages/agentctx/src/hooks/session-start.ts @@ -12,6 +12,7 @@ * re-counted in `sessions.tokens_injected` because it is re-paid. */ import { existsSync } from "node:fs"; +import { describeError } from "../errors.js"; import { openDatabase } from "../storage/db.js"; import { resolveProjectId } from "../storage/namespace.js"; import { listRecords } from "../storage/records.js"; @@ -60,7 +61,7 @@ function profileOnlyFallback(env: HookEnv, projectId: string): string { db.close(); } } catch (error) { - env.log(`session-start: profile fallback failed: ${describe(error)}`); + env.log(`session-start: profile fallback failed: ${describeError(error)}`); return ""; } } @@ -83,10 +84,6 @@ function accountInjection( db.close(); } } catch (error) { - env.log(`session-start: token accounting failed: ${describe(error)}`); + env.log(`session-start: token accounting failed: ${describeError(error)}`); } } - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agentctx/src/hooks/user-prompt-submit.ts b/packages/agentctx/src/hooks/user-prompt-submit.ts index 9ce951b..c6927e0 100644 --- a/packages/agentctx/src/hooks/user-prompt-submit.ts +++ b/packages/agentctx/src/hooks/user-prompt-submit.ts @@ -8,6 +8,7 @@ * Every failure path degrades to "inject nothing" — never an error. */ import { existsSync } from "node:fs"; +import { describeError } from "../errors.js"; import { openDatabase } from "../storage/db.js"; import { resolveProjectId } from "../storage/namespace.js"; import { type SearchHit, searchRecords } from "../storage/search.js"; @@ -65,7 +66,7 @@ export async function runUserPromptSubmit(env: HookEnv, payload: HookPayload): P at: env.now().toISOString(), }); } catch (error) { - env.log(`user-prompt-submit: token accounting failed: ${describe(error)}`); + env.log(`user-prompt-submit: token accounting failed: ${describeError(error)}`); } } } finally { @@ -110,7 +111,3 @@ export function formatInjection( } return { text, ids, tokens: estimateTokens(text) }; } - -function describe(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/agentctx/src/mcp/server.ts b/packages/agentctx/src/mcp/server.ts index a89faac..05c3ebe 100644 --- a/packages/agentctx/src/mcp/server.ts +++ b/packages/agentctx/src/mcp/server.ts @@ -13,6 +13,7 @@ * nothing throws raw into the channel. */ import { createInterface } from "node:readline"; +import { describeError } from "../errors.js"; import { VERSION } from "../version.js"; import { type ToolContext, type ToolDefinition, callTool } from "./tools.js"; @@ -72,7 +73,7 @@ export function serveMcp(options: McpServerOptions): Promise { } catch (err) { // Defense in depth — handlers are expected to capture their own // failures; anything that escapes is logged and answered, never thrown. - log(`agentctx mcp: ${err instanceof Error ? err.message : String(err)}`); + log(`agentctx mcp: ${describeError(err)}`); send(error(idOf(message), INVALID_REQUEST, "internal error")); } }); diff --git a/packages/agentctx/src/mcp/tools.ts b/packages/agentctx/src/mcp/tools.ts index 91efc27..6469520 100644 --- a/packages/agentctx/src/mcp/tools.ts +++ b/packages/agentctx/src/mcp/tools.ts @@ -14,6 +14,7 @@ import { existsSync, readFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import type { Database } from "better-sqlite3"; import { buildSyncReport } from "../consolidate/drift.js"; +import { describeError } from "../errors.js"; import { PROFILE_TITLES } from "../profile/detect.js"; import { getRecord, insertRecord, listRecords, supersedeRecord } from "../storage/records.js"; import { searchRecords } from "../storage/search.js"; @@ -461,7 +462,7 @@ export function callTool( return { payload: { error: err.message }, isError: true }; } return { - payload: { error: err instanceof Error ? err.message : String(err) }, + payload: { error: describeError(err) }, isError: true, }; } diff --git a/packages/agentctx/test/cli.test.ts b/packages/agentctx/test/cli.test.ts index 7b34a2f..30186e5 100644 --- a/packages/agentctx/test/cli.test.ts +++ b/packages/agentctx/test/cli.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from "vitest"; +import { describeError } from "../src/errors.js"; import { VERSION } from "../src/index.js"; describe("package surface", () => { it("exposes the current version", () => { expect(VERSION).toMatch(/^\d+\.\d+\.\d+$/); }); + + it("describes Error and non-Error throwables consistently", () => { + expect(describeError(new Error("boom"))).toBe("boom"); + expect(describeError("plain failure")).toBe("plain failure"); + }); });