diff --git a/CHANGELOG.md b/CHANGELOG.md index 22995e6..0584305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to Drawbridge are documented here. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.2.0] — 2026-04-09 + +### Added +- **Tool error enricher** — three-hook system that intercepts MCP tool failures, classifies by category/severity, and appends structured recovery guidance to the LLM context window +- Circuit breaker blocking tool calls after 3 consecutive failures per tool +- Three-layer template cascade: tool-specific > category > global fallback +- Sensitive parameter redaction and 800-char enrichment cap +- Tool name normalization for OpenClaw MCP server prefixes +- Counter compensation in `tool_result_persist` for content-based error detection +- 178 extension tests (was 68) + +### Fixed +- Migrated all hook registrations from legacy `api.registerHook()` to typed `api.on()` — hooks were registered but never dispatched in live runtime +- Changed `gateway:stop` hook name to `gateway_stop` (typed hook system uses underscores) +- Added `EAI_AGAIN` to server_unreachable error pattern +- Synchronous handler invariant: `tool_result_persist` no longer returns a Promise + ## [1.1.1] — 2026-04-02 ### Fixed diff --git a/extensions/drawbridge/__tests__/helpers.ts b/extensions/drawbridge/__tests__/helpers.ts index bf01275..133824e 100644 --- a/extensions/drawbridge/__tests__/helpers.ts +++ b/extensions/drawbridge/__tests__/helpers.ts @@ -11,7 +11,20 @@ import type { ResolvedConfig } from "../src/config.js"; import { resolveConfig } from "../src/config.js"; import type { PluginState } from "../src/pipeline-factory.js"; import { LogSink } from "../src/audit-sink.js"; -import type { HookContext, BeforeDispatchContext, BeforeDispatchEvent, MessageReceivedEvent, MessageSendingEvent, LlmOutputEvent } from "../src/types/openclaw.js"; +import type { + HookContext, + BeforeDispatchContext, + BeforeDispatchEvent, + MessageReceivedEvent, + MessageSendingEvent, + LlmOutputEvent, + ToolResultPersistEvent, + ToolResultPersistContext, + AfterToolCallEvent, + AfterToolCallContext, + BeforeToolCallEvent, + BeforeToolCallContext, +} from "../src/types/openclaw.js"; // --------------------------------------------------------------------------- // Mock ClawMoat engine @@ -196,3 +209,68 @@ export function makeLlmOutputEvent(overrides?: Partial): LlmOutp ...overrides, }; } + +// --------------------------------------------------------------------------- +// Tool error enricher event/context builders +// --------------------------------------------------------------------------- + +export function makeToolResultPersistEvent( + overrides?: Partial & { message?: Record }, +): ToolResultPersistEvent { + return { + message: { + isError: true, + content: [{ type: "text", text: "Request timeout" }], + }, + isSynthetic: false, + ...overrides, + }; +} + +export function makeToolResultPersistCtx( + overrides?: Partial, +): ToolResultPersistContext { + return { + sessionKey: "test-session-key", + toolName: "memory_search", + ...overrides, + }; +} + +export function makeAfterToolCallEvent( + overrides?: Partial, +): AfterToolCallEvent { + return { + toolName: "memory_search", + params: { query: "test query", namespace: "personal" }, + error: "Request timeout", + ...overrides, + }; +} + +export function makeAfterToolCallCtx( + overrides?: Partial, +): AfterToolCallContext { + return { + sessionKey: "test-session-key", + ...overrides, + }; +} + +export function makeBeforeToolCallEvent( + overrides?: Partial, +): BeforeToolCallEvent { + return { + toolName: "memory_search", + ...overrides, + }; +} + +export function makeBeforeToolCallCtx( + overrides?: Partial, +): BeforeToolCallContext { + return { + sessionKey: "test-session-key", + ...overrides, + }; +} diff --git a/extensions/drawbridge/__tests__/tool-error-enricher.test.ts b/extensions/drawbridge/__tests__/tool-error-enricher.test.ts new file mode 100644 index 0000000..407cf60 --- /dev/null +++ b/extensions/drawbridge/__tests__/tool-error-enricher.test.ts @@ -0,0 +1,1121 @@ +/** + * Tool Error Enricher — comprehensive test suite. + * + * Tests P0 requirements: error classification, severity, template cascade, + * circuit breaker, parameter redaction, session cleanup, fail-open, and + * the synchronous-write invariant. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { + createToolErrorEnricher, + classifyErrorCategory, + classifySeverity, + redactParams, + extractToolNameFromMessage, + normalizeToolName, + GUARD_TRUNCATION_SUFFIX, + MAX_ATTEMPTS, + MAX_ENRICHMENT_CHARS, + REMEDIATION_TOOLS, +} from "../src/hooks/tool-error-enricher.js"; +import type { ToolErrorEnricher } from "../src/hooks/tool-error-enricher.js"; +import { + makeToolResultPersistEvent, + makeToolResultPersistCtx, + makeAfterToolCallEvent, + makeAfterToolCallCtx, + makeBeforeToolCallEvent, + makeBeforeToolCallCtx, +} from "./helpers.js"; + +// --------------------------------------------------------------------------- +// Shared enricher instance — reset before each test +// --------------------------------------------------------------------------- + +let enricher: ToolErrorEnricher; + +beforeEach(() => { + enricher = createToolErrorEnricher(); +}); + +// =========================================================================== +// Error category detection +// =========================================================================== + +describe("classifyErrorCategory", () => { + it.each([ + ["Request timeout", "timeout"], + ["timed out waiting for response", "timeout"], + ["ETIMEDOUT", "timeout"], + ["deadline exceeded", "timeout"], + ] as const)("detects timeout: %s", (input, expected) => { + expect(classifyErrorCategory(input)).toBe(expected); + }); + + it.each([ + ["HTTP 429 Too Many Requests", "rate_limit"], + ["rate limit exceeded", "rate_limit"], + ["too many requests", "rate_limit"], + ["request throttled", "rate_limit"], + ] as const)("detects rate_limit: %s", (input, expected) => { + expect(classifyErrorCategory(input)).toBe(expected); + }); + + it.each([ + ["HTTP 401 Unauthorized", "auth_failure"], + ["HTTP 403 Forbidden", "auth_failure"], + ["unauthorized access", "auth_failure"], + ["forbidden resource", "auth_failure"], + ["auth token expired", "auth_failure"], + ["invalid credential", "auth_failure"], + ["authentication failed", "auth_failure"], + ["failed to authenticate with server", "auth_failure"], + ] as const)("detects auth_failure: %s", (input, expected) => { + expect(classifyErrorCategory(input)).toBe(expected); + }); + + it.each([ + ["ECONNREFUSED", "server_unreachable"], + ["ENOTFOUND", "server_unreachable"], + ["getaddrinfo ENOTFOUND postgres", "server_unreachable"], + ["EHOSTUNREACH", "server_unreachable"], + ["network error occurred", "server_unreachable"], + ["connection refused", "server_unreachable"], + ["fetch failed", "server_unreachable"], + ] as const)("detects server_unreachable: %s", (input, expected) => { + expect(classifyErrorCategory(input)).toBe(expected); + }); + + it.each([ + ["invalid params", "validation"], + ["required field missing", "validation"], + ["missing parameter: query", "validation"], + ["must be a string", "validation"], + ["must have at least one item", "validation"], + ["schema validation failed", "validation"], + ["expected number", "validation"], + ] as const)("detects validation: %s", (input, expected) => { + expect(classifyErrorCategory(input)).toBe(expected); + }); + + it("falls back to unknown for unrecognized errors", () => { + expect(classifyErrorCategory("something went wrong")).toBe("unknown"); + expect(classifyErrorCategory("")).toBe("unknown"); + }); +}); + +// =========================================================================== +// Severity classifier +// =========================================================================== + +describe("classifySeverity", () => { + it("returns transient for 1st timeout", () => { + expect(classifySeverity("timeout", 1, false)).toBe("transient"); + }); + + it("returns transient for 1st rate_limit", () => { + expect(classifySeverity("rate_limit", 1, false)).toBe("transient"); + }); + + it("returns recoverable for auth_failure (never transient)", () => { + expect(classifySeverity("auth_failure", 1, false)).toBe("recoverable"); + }); + + it("returns recoverable for 2nd consecutive failure", () => { + expect(classifySeverity("timeout", 2, false)).toBe("recoverable"); + }); + + it("returns terminal at MAX_ATTEMPTS", () => { + expect(classifySeverity("timeout", MAX_ATTEMPTS, false)).toBe("terminal"); + }); + + it("returns terminal for server_unreachable at attempt >= 2", () => { + expect(classifySeverity("server_unreachable", 2, false)).toBe("terminal"); + }); + + it("returns recoverable for validation (always needs param echo)", () => { + expect(classifySeverity("validation", 1, false)).toBe("recoverable"); + expect(classifySeverity("validation", 2, false)).toBe("recoverable"); + }); + + it("returns recoverable for truncated input regardless of category", () => { + expect(classifySeverity("timeout", 1, true)).toBe("recoverable"); + expect(classifySeverity("rate_limit", 1, true)).toBe("recoverable"); + }); + + it("returns recoverable for truncated input even at MAX_ATTEMPTS", () => { + // Spec classifier order: isTruncated is checked before attemptCount. + // Truncated text at MAX_ATTEMPTS still returns "recoverable" — can't reliably classify. + // The before_tool_call circuit breaker still blocks (reads Map, not severity). + expect(classifySeverity("timeout", MAX_ATTEMPTS, true)).toBe("recoverable"); + }); + + it("returns recoverable for unknown category", () => { + expect(classifySeverity("unknown", 1, false)).toBe("recoverable"); + }); +}); + +// =========================================================================== +// Parameter redaction +// =========================================================================== + +describe("redactParams", () => { + it("redacts global sensitive params", () => { + const result = redactParams({ token: "secret-123", query: "test" }); + expect(result).toContain("token: [REDACTED]"); + expect(result).toContain('query: "test"'); + expect(result).not.toContain("secret-123"); + }); + + it("redacts case-insensitively", () => { + const result = redactParams({ ApiKey: "sk-abc", PASSWORD: "hunter2" }); + expect(result).toContain("ApiKey: [REDACTED]"); + expect(result).toContain("PASSWORD: [REDACTED]"); + }); + + it("echoes non-sensitive params verbatim", () => { + const result = redactParams({ namespace: "personal", max_results: 5 }); + expect(result).toContain('namespace: "personal"'); + expect(result).toContain("max_results: 5"); + }); + + it("handles empty params", () => { + expect(redactParams({})).toBe(""); + }); + + it("redacts all known sensitive param names", () => { + const sensitive = ["token", "password", "apiKey", "api_key", "secret", "authorization", "credentials", "bearer", "session_token"]; + for (const key of sensitive) { + const result = redactParams({ [key]: "value" }); + expect(result).toContain("[REDACTED]"); + expect(result).not.toContain('"value"'); + } + }); +}); + +// =========================================================================== +// extractToolNameFromMessage +// =========================================================================== + +describe("extractToolNameFromMessage", () => { + it("extracts name from content array block", () => { + const msg = { content: [{ type: "tool_result", name: "memory_search", text: "error" }] }; + expect(extractToolNameFromMessage(msg)).toBe("memory_search"); + }); + + it("extracts toolName from content array block", () => { + const msg = { content: [{ type: "tool_result", toolName: "memory_query", text: "error" }] }; + expect(extractToolNameFromMessage(msg)).toBe("memory_query"); + }); + + it("returns undefined for string content", () => { + const msg = { content: "Request timeout" }; + expect(extractToolNameFromMessage(msg)).toBeUndefined(); + }); + + it("returns undefined when content array has no tool-identifying blocks", () => { + const msg = { content: [{ type: "text", text: "error" }] }; + expect(extractToolNameFromMessage(msg)).toBeUndefined(); + }); + + it("returns undefined for tool_use_id without name (opaque ID)", () => { + const msg = { content: [{ type: "tool_result", tool_use_id: "toolu_abc123", text: "error" }] }; + expect(extractToolNameFromMessage(msg)).toBeUndefined(); + }); + + it("returns undefined for undefined content", () => { + expect(extractToolNameFromMessage({})).toBeUndefined(); + }); + + it("returns undefined for null content", () => { + expect(extractToolNameFromMessage({ content: null })).toBeUndefined(); + }); + + it("falls back to top-level toolName on message", () => { + const msg = { toolName: "memory_fetch", content: "error" }; + expect(extractToolNameFromMessage(msg)).toBe("memory_fetch"); + }); +}); + +// =========================================================================== +// Template cascade +// =========================================================================== + +describe("template cascade", () => { + function enrichWithError(toolName: string, errorText: string, attempt = 1): string | undefined { + const { handleAfterToolCall, handleToolResultPersist } = enricher._handlers; + // Seed the attempt map + const ctx = { sessionKey: "test-key" }; + for (let i = 0; i < attempt; i++) { + handleAfterToolCall( + { toolName, params: { query: "test" }, error: errorText }, + ctx, + ); + } + + const result = handleToolResultPersist( + makeToolResultPersistEvent({ + message: { isError: true, content: [{ type: "text", text: errorText }] }, + }), + { sessionKey: "test-key", toolName }, + ); + + if (!result) return undefined; + const content = result.message.content as Array<{ type: string; text: string }>; + return content[content.length - 1]?.text; + } + + it("uses tool-specific template for memory_search source_type validation", () => { + const text = enrichWithError("memory_search", "invalid params: source_type not supported"); + expect(text).toContain("memory_search does not support source_type"); + expect(text).toContain("memory_query"); + }); + + it("uses tool-specific template for memory_query metadata_filter error", () => { + const text = enrichWithError("memory_query", "invalid metadata_filter operator $foo"); + expect(text).toContain("metadata_filter supports comparison operators"); + }); + + it("uses tool-specific template for memory_traverse depth error", () => { + const text = enrichWithError("memory_traverse", "invalid depth: must be 1-3"); + expect(text).toContain("depth must be 1-3"); + }); + + it("uses tool-specific template for memory_evaluate_process timeout", () => { + const text = enrichWithError("memory_evaluate_process", "Request timeout"); + expect(text).toContain("long-running (up to 5 minutes)"); + }); + + it("uses category template for retrieval tool timeout (2nd attempt)", () => { + const text = enrichWithError("memory_search", "Request timeout", 2); + expect(text).toContain("Retry with narrower query"); + expect(text).toContain("memory_query"); + }); + + it("uses category template for mutation tool validation", () => { + const text = enrichWithError("memory_ingest", "invalid params: text required"); + expect(text).toContain("memory_ingest requires either text or image_data"); + }); + + it("uses category template for utility tool timeout", () => { + const text = enrichWithError("memory_list", "Request timeout"); + // 1st attempt is transient — global template + // Check it still gets a reasonable template + expect(text).toBeDefined(); + expect(text).toContain("Retry"); + }); + + it("uses global template for unknown error category", () => { + const text = enrichWithError("memory_search", "kaboom zort plonk"); + expect(text).toContain("TOOL FAILURE"); + expect(text).toContain("Retry once"); + }); + + it("uses terminal template at MAX_ATTEMPTS", () => { + const text = enrichWithError("memory_search", "Request timeout", MAX_ATTEMPTS); + expect(text).toContain("CIRCUIT OPEN"); + expect(text).toContain("Do NOT retry"); + expect(text).toContain("Inform the user"); + }); +}); + +// =========================================================================== +// Circuit breaker (before_tool_call) +// =========================================================================== + +describe("circuit breaker", () => { + it("blocks at MAX_ATTEMPTS consecutive failures", () => { + const { handleAfterToolCall, handleBeforeToolCall } = enricher._handlers; + const ctx = { sessionKey: "session-1" }; + + // Fail 3 times + for (let i = 0; i < MAX_ATTEMPTS; i++) { + handleAfterToolCall( + { toolName: "memory_search", error: "timeout" }, + ctx, + ); + } + + const result = handleBeforeToolCall({ toolName: "memory_search" }, ctx); + expect(result.block).toBe(true); + expect(result.blockReason).toContain("memory_search"); + expect(result.blockReason).toContain("failed"); + expect(result.blockReason).toContain("Inform the user"); + }); + + it("does NOT block when attempts < MAX_ATTEMPTS", () => { + const { handleAfterToolCall, handleBeforeToolCall } = enricher._handlers; + const ctx = { sessionKey: "session-1" }; + + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + + const result = handleBeforeToolCall({ toolName: "memory_search" }, ctx); + expect(result.block).toBeUndefined(); + }); + + it("does NOT block non-remediation-map tools", () => { + const { handleBeforeToolCall } = enricher._handlers; + const result = handleBeforeToolCall( + { toolName: "some_other_tool" }, + { sessionKey: "session-1" }, + ); + expect(result.block).toBeUndefined(); + }); + + it("resets after successful call", () => { + const { handleAfterToolCall, handleBeforeToolCall, handleToolResultPersist } = enricher._handlers; + const ctx = { sessionKey: "session-1" }; + + // Fail twice + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + + // Succeed — after_tool_call preserves counter, tool_result_persist resets it + handleAfterToolCall({ toolName: "memory_search" }, ctx); + handleToolResultPersist( + { message: { isError: false, content: [{ type: "text", text: "Results: 5" }] }, isSynthetic: false }, + { sessionKey: "session-1", toolName: "memory_search" }, + ); + + // Fail once more — should not be blocked (counter was reset) + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + + const result = handleBeforeToolCall({ toolName: "memory_search" }, ctx); + expect(result.block).toBeUndefined(); + }); +}); + +// =========================================================================== +// after_tool_call counter +// =========================================================================== + +describe("after_tool_call counter", () => { + it("increments on error", () => { + const { handleAfterToolCall } = enricher._handlers; + const ctx = { sessionKey: "s1" }; + + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + expect(enricher._attemptMap.get("s1::memory_search")?.attempts).toBe(1); + + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + expect(enricher._attemptMap.get("s1::memory_search")?.attempts).toBe(2); + }); + + it("preserves counter when event.error is undefined (ambiguous)", () => { + const { handleAfterToolCall } = enricher._handlers; + const ctx = { sessionKey: "s1" }; + + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + // event.error undefined — could be content-wrapped error or true success. + // after_tool_call preserves counter; tool_result_persist decides. + handleAfterToolCall({ toolName: "memory_search" }, ctx); + + expect(enricher._attemptMap.get("s1::memory_search")?.attempts).toBe(2); + }); + + it("resets to 0 via tool_result_persist on confirmed success", () => { + const { handleAfterToolCall, handleToolResultPersist } = enricher._handlers; + const ctx = { sessionKey: "s1" }; + + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + expect(enricher._attemptMap.get("s1::memory_search")?.attempts).toBe(2); + + // Successful tool result — tool_result_persist resets the counter + handleAfterToolCall({ toolName: "memory_search" }, ctx); + handleToolResultPersist( + { message: { isError: false, content: [{ type: "text", text: "Query Confidence: 0.85" }] }, isSynthetic: false }, + { sessionKey: "s1", toolName: "memory_search" }, + ); + + expect(enricher._attemptMap.get("s1::memory_search")?.attempts).toBe(0); + }); + + it("stashes params for echoing", () => { + const { handleAfterToolCall } = enricher._handlers; + const ctx = { sessionKey: "s1" }; + const params = { query: "test", namespace: "personal" }; + + handleAfterToolCall({ toolName: "memory_search", params, error: "timeout" }, ctx); + expect(enricher._attemptMap.get("s1::memory_search")?.lastParams).toEqual(params); + }); + + it("ignores non-remediation-map tools", () => { + const { handleAfterToolCall } = enricher._handlers; + handleAfterToolCall( + { toolName: "some_other_tool", error: "timeout" }, + { sessionKey: "s1" }, + ); + expect(enricher._attemptMap.size).toBe(0); + }); +}); + +// =========================================================================== +// tool_result_persist enrichment +// =========================================================================== + +describe("tool_result_persist enrichment", () => { + it("skips when isError is false and no details.status error", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { isError: false, content: [{ type: "text", text: "success" }] }, + }), + makeToolResultPersistCtx(), + ); + expect(result).toBeUndefined(); + }); + + it("enriches when isError is false but details.status is 'error' (OpenClaw MCP wrapping)", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { + isError: false, + content: [{ type: "text", text: '{"status": "error", "tool": "vigil-harbor__memory_search", "error": "fetch failed"}' }], + details: { status: "error", tool: "vigil-harbor__memory_search", error: "fetch failed" }, + }, + }), + makeToolResultPersistCtx(), + ); + expect(result).toBeDefined(); + const content = result!.message.content as Array<{ type: string; text: string }>; + // Enrichment appended + expect(content.length).toBe(2); + // Uses details.error for classification (cleaner than JSON content) + expect(content[1]!.text).toContain("SERVER UNREACHABLE"); + }); + + it("enriches when content starts with 'Error:' (MCP app-level error as content)", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { + isError: false, + content: [{ type: "text", text: 'Error: search failed\nDetails: getaddrinfo ENOTFOUND postgres\nQuery: "test"\nSuggestion: verify container is running' }], + details: { mcpServer: "vigil-harbor", mcpTool: "memory_search" }, + }, + }), + makeToolResultPersistCtx(), + ); + expect(result).toBeDefined(); + const content = result!.message.content as Array<{ type: string; text: string }>; + expect(content.length).toBe(2); + // ENOTFOUND classified as server_unreachable + expect(content[1]!.text).toContain("SERVER UNREACHABLE"); + }); + + it("does NOT trigger content detection on successful results containing 'error' mid-text", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { + isError: false, + content: [{ type: "text", text: "Found 3 records. No errors detected in the dataset." }], + details: { mcpServer: "vigil-harbor", mcpTool: "memory_search" }, + }, + }), + makeToolResultPersistCtx(), + ); + expect(result).toBeUndefined(); + }); + + it("compensates attempt counter when after_tool_call missed the error", () => { + // No prior after_tool_call — counter is unset + expect(enricher._attemptMap.size).toBe(0); + + enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { + isError: false, + content: [{ type: "text", text: '{"status": "error", "error": "fetch failed"}' }], + details: { status: "error", error: "fetch failed" }, + }, + }), + makeToolResultPersistCtx(), + ); + + // Counter should have been compensated to 1 + expect(enricher._attemptMap.get("test-session-key::memory_search")?.attempts).toBe(1); + }); + + it("accumulates counter on repeated content-based failures", () => { + const { handleAfterToolCall, handleToolResultPersist } = enricher._handlers; + const ctx = { sessionKey: "content-repeat" }; + const contentErrorMsg = { + isError: false, + content: [{ type: "text", text: "Error: search failed\nDetails: connection refused" }], + details: { mcpServer: "vigil-harbor", mcpTool: "memory_search" }, + }; + + // Three consecutive content-based failures (event.error always undefined) + for (let i = 1; i <= 3; i++) { + handleAfterToolCall({ toolName: "memory_search" }, ctx); + handleToolResultPersist({ message: contentErrorMsg, isSynthetic: false }, { sessionKey: "content-repeat", toolName: "memory_search" }); + expect(enricher._attemptMap.get("content-repeat::memory_search")?.attempts).toBe(i); + } + }); + + it("skips when isSynthetic is true", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ isSynthetic: true }), + makeToolResultPersistCtx(), + ); + expect(result).toBeUndefined(); + }); + + it("skips non-remediation-map tools", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent(), + makeToolResultPersistCtx({ toolName: "some_other_tool" }), + ); + expect(result).toBeUndefined(); + }); + + it("skips when sessionKey is undefined", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent(), + makeToolResultPersistCtx({ sessionKey: undefined }), + ); + expect(result).toBeUndefined(); + }); + + it("appends enrichment text block to content array", () => { + // Seed attempt map + enricher._handlers.handleAfterToolCall( + makeAfterToolCallEvent(), + makeAfterToolCallCtx(), + ); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent(), + makeToolResultPersistCtx(), + ); + + expect(result).toBeDefined(); + const content = result!.message.content as Array<{ type: string; text: string }>; + // Original content preserved + enrichment appended + expect(content.length).toBe(2); + expect(content[0]!.text).toBe("Request timeout"); // original + expect(content[1]!.text).toContain("memory_search"); // enrichment + }); + + it("preserves original error text", () => { + enricher._handlers.handleAfterToolCall(makeAfterToolCallEvent(), makeAfterToolCallCtx()); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent(), + makeToolResultPersistCtx(), + ); + + const content = result!.message.content as Array<{ type: string; text: string }>; + expect(content[0]!.text).toBe("Request timeout"); + }); + + it("merges details.enricher metadata additively", () => { + enricher._handlers.handleAfterToolCall(makeAfterToolCallEvent(), makeAfterToolCallCtx()); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { + isError: true, + content: [{ type: "text", text: "Request timeout" }], + details: { existing: "data" }, + }, + }), + makeToolResultPersistCtx(), + ); + + const details = result!.message.details as Record; + expect(details.existing).toBe("data"); // preserved + expect(details.enricher).toBeDefined(); + + const enricherDetails = details.enricher as Record; + expect(enricherDetails.severity).toBeDefined(); + expect(enricherDetails.attempt).toBeDefined(); + expect(enricherDetails.maxAttempts).toBe(MAX_ATTEMPTS); + expect(enricherDetails.errorCategory).toBeDefined(); + expect(enricherDetails.templateSource).toBeDefined(); + expect(enricherDetails.toolName).toBe("memory_search"); + }); + + it("falls back to extracting toolName from message body", () => { + enricher._handlers.handleAfterToolCall( + makeAfterToolCallEvent({ toolName: "memory_query" }), + makeAfterToolCallCtx(), + ); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { + isError: true, + content: [{ type: "tool_result", name: "memory_query", text: "invalid params" }], + }, + }), + makeToolResultPersistCtx({ toolName: undefined }), // no toolName in ctx + ); + + expect(result).toBeDefined(); + const details = result!.message.details as Record; + expect((details.enricher as Record).toolName).toBe("memory_query"); + }); + + it("handles string content (not array)", () => { + enricher._handlers.handleAfterToolCall(makeAfterToolCallEvent(), makeAfterToolCallCtx()); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { isError: true, content: "Request timeout" }, + }), + makeToolResultPersistCtx(), + ); + + expect(result).toBeDefined(); + const content = result!.message.content as Array<{ type: string; text: string }>; + expect(content.length).toBe(2); + expect(content[0]!.text).toBe("Request timeout"); + }); +}); + +// =========================================================================== +// Sync-handler guarantee +// =========================================================================== + +describe("sync-handler guarantee", () => { + it("tool_result_persist handler returns a plain object, not a Promise", () => { + enricher._handlers.handleAfterToolCall(makeAfterToolCallEvent(), makeAfterToolCallCtx()); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent(), + makeToolResultPersistCtx(), + ); + + // Must not be a Promise — handler is synchronous + expect(result).not.toBeInstanceOf(Promise); + }); + + it("before_tool_call handler returns a plain object, not a Promise", () => { + const result = enricher._handlers.handleBeforeToolCall( + makeBeforeToolCallEvent(), + makeBeforeToolCallCtx(), + ); + expect(result).not.toBeInstanceOf(Promise); + }); +}); + +// =========================================================================== +// Session cleanup +// =========================================================================== + +describe("session cleanup", () => { + it("clears all entries for a sessionKey", () => { + const { handleAfterToolCall, handleSessionCleanup } = enricher._handlers; + const ctx = { sessionKey: "session-A" }; + + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + handleAfterToolCall({ toolName: "memory_query", error: "timeout" }, ctx); + expect(enricher._attemptMap.size).toBe(2); + + handleSessionCleanup("session-A"); + expect(enricher._attemptMap.size).toBe(0); + }); + + it("does not affect other sessions", () => { + const { handleAfterToolCall, handleSessionCleanup } = enricher._handlers; + + handleAfterToolCall( + { toolName: "memory_search", error: "timeout" }, + { sessionKey: "session-A" }, + ); + handleAfterToolCall( + { toolName: "memory_search", error: "timeout" }, + { sessionKey: "session-B" }, + ); + expect(enricher._attemptMap.size).toBe(2); + + handleSessionCleanup("session-A"); + expect(enricher._attemptMap.size).toBe(1); + expect(enricher._attemptMap.has("session-B::memory_search")).toBe(true); + }); +}); + +// =========================================================================== +// sessionKey undefined — no-op guards +// =========================================================================== + +describe("sessionKey undefined guards", () => { + it("after_tool_call no-ops when sessionKey is undefined", () => { + enricher._handlers.handleAfterToolCall( + { toolName: "memory_search", error: "timeout" }, + { sessionKey: undefined }, + ); + expect(enricher._attemptMap.size).toBe(0); + }); + + it("tool_result_persist no-ops when sessionKey is undefined", () => { + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent(), + { sessionKey: undefined, toolName: "memory_search" }, + ); + expect(result).toBeUndefined(); + }); + + it("before_tool_call returns empty (allows call) when sessionKey is undefined", () => { + const result = enricher._handlers.handleBeforeToolCall( + { toolName: "memory_search" }, + { sessionKey: undefined }, + ); + expect(result).toEqual({}); + }); +}); + +// =========================================================================== +// Fail-open guarantees +// =========================================================================== + +describe("fail-open guarantees", () => { + it("after_tool_call swallows exceptions", () => { + // Force an error by providing a getter that throws + const badEvent = { + get toolName(): string { throw new Error("boom"); }, + } as unknown as Parameters[0]; + + // Should not throw + expect(() => { + enricher._handlers.handleAfterToolCall(badEvent, { sessionKey: "s1" }); + }).not.toThrow(); + }); + + it("tool_result_persist returns undefined on exception", () => { + // Poison the attempt map to trigger an error during template resolution + const key = "test-key::memory_search"; + enricher._attemptMap.set(key, { + attempts: 1, + // lastParams getter that throws + get lastParams(): never { throw new Error("boom"); }, + lastError: "timeout", + lastTimestamp: Date.now(), + } as unknown as import("../src/hooks/tool-error-enricher.js").AttemptEntry); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent(), + makeToolResultPersistCtx({ sessionKey: "test-key" }), + ); + + // Fail-open: returns undefined (original message unmodified) + expect(result).toBeUndefined(); + }); + + it("before_tool_call returns empty object on exception", () => { + // Poison the attempt map + const key = "s1::memory_search"; + Object.defineProperty(enricher._attemptMap, "get", { + value: () => { throw new Error("boom"); }, + }); + + const result = enricher._handlers.handleBeforeToolCall( + { toolName: "memory_search" }, + { sessionKey: "s1" }, + ); + + expect(result).toEqual({}); + }); +}); + +// =========================================================================== +// Enrichment budget +// =========================================================================== + +describe("enrichment budget", () => { + it("all enrichment text stays under MAX_ENRICHMENT_CHARS", () => { + const tools = [...REMEDIATION_TOOLS]; + const errorTexts = [ + "Request timeout", + "HTTP 429 Too Many Requests", + "HTTP 401 Unauthorized", + "ECONNREFUSED", + "invalid params: source_type not supported in memory_search. Also metadata_filter operator $foo unknown. depth must be positive.", + "something completely unexpected happened and this is a rather long error message that keeps going", + ]; + + for (const tool of tools) { + for (const errorText of errorTexts) { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + // Fresh enricher for each combo + const e = createToolErrorEnricher(); + const ctx = { sessionKey: "budget-test" }; + + for (let i = 0; i < attempt; i++) { + e._handlers.handleAfterToolCall( + { toolName: tool, params: { query: "test", namespace: "personal", max_results: 10, source_type: "document", tags: ["a", "b"] }, error: errorText }, + ctx, + ); + } + + const result = e._handlers.handleToolResultPersist( + { + message: { isError: true, content: [{ type: "text", text: errorText }] }, + isSynthetic: false, + }, + { sessionKey: "budget-test", toolName: tool }, + ); + + if (result) { + const content = result.message.content as Array<{ type: string; text: string }>; + const enrichmentText = content[content.length - 1]!.text; + expect(enrichmentText.length).toBeLessThanOrEqual(MAX_ENRICHMENT_CHARS); + } + } + } + } + }); +}); + +// =========================================================================== +// Integration: full lifecycle +// =========================================================================== + +describe("integration: full lifecycle", () => { + it("call fails 3x → circuit breaks → new session unaffected", () => { + const { handleAfterToolCall, handleBeforeToolCall, handleToolResultPersist, handleSessionCleanup } = enricher._handlers; + const ctx1 = { sessionKey: "session-1" }; + const ctx2 = { sessionKey: "session-2" }; + + // Session 1: fail 3 times + for (let i = 0; i < MAX_ATTEMPTS; i++) { + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx1); + } + + // Session 1: circuit broken + const blocked = handleBeforeToolCall({ toolName: "memory_search" }, ctx1); + expect(blocked.block).toBe(true); + + // Session 2: unaffected + const allowed = handleBeforeToolCall({ toolName: "memory_search" }, ctx2); + expect(allowed.block).toBeUndefined(); + + // Session 1: cleanup + handleSessionCleanup("session-1"); + const afterCleanup = handleBeforeToolCall({ toolName: "memory_search" }, ctx1); + expect(afterCleanup.block).toBeUndefined(); + }); + + it("enrichment output evolves with attempt count", () => { + const { handleAfterToolCall, handleToolResultPersist } = enricher._handlers; + const ctx = { sessionKey: "evolve-test" }; + const errorText = "Request timeout"; + + // Attempt 1: transient + handleAfterToolCall({ toolName: "memory_search", error: errorText }, ctx); + const r1 = handleToolResultPersist( + { message: { isError: true, content: [{ type: "text", text: errorText }] }, isSynthetic: false }, + { sessionKey: "evolve-test", toolName: "memory_search" }, + ); + const t1 = (r1!.message.content as Array<{ text: string }>).at(-1)!.text; + expect(t1).toContain("Retry once"); + expect((r1!.message.details as Record).enricher).toMatchObject({ severity: "transient", attempt: 1 }); + + // Attempt 2: recoverable (category template) + handleAfterToolCall({ toolName: "memory_search", error: errorText }, ctx); + const r2 = handleToolResultPersist( + { message: { isError: true, content: [{ type: "text", text: errorText }] }, isSynthetic: false }, + { sessionKey: "evolve-test", toolName: "memory_search" }, + ); + const t2 = (r2!.message.content as Array<{ text: string }>).at(-1)!.text; + expect(t2).toContain("narrower query"); + expect((r2!.message.details as Record).enricher).toMatchObject({ severity: "recoverable", attempt: 2 }); + + // Attempt 3: terminal + handleAfterToolCall({ toolName: "memory_search", error: errorText }, ctx); + const r3 = handleToolResultPersist( + { message: { isError: true, content: [{ type: "text", text: errorText }] }, isSynthetic: false }, + { sessionKey: "evolve-test", toolName: "memory_search" }, + ); + const t3 = (r3!.message.content as Array<{ text: string }>).at(-1)!.text; + expect(t3).toContain("CIRCUIT OPEN"); + expect(t3).toContain("Do NOT retry"); + expect((r3!.message.details as Record).enricher).toMatchObject({ severity: "terminal", attempt: 3 }); + }); + + it("counter-ahead limitation: circuit trips if counter is ahead of transcript", () => { + // Simulates the before_message_write blocking scenario. + // after_tool_call increments the counter, but the enriched message + // is blocked by before_message_write and never persisted. + // The counter is now one ahead — conservative error. + const { handleAfterToolCall, handleBeforeToolCall } = enricher._handlers; + const ctx = { sessionKey: "counter-ahead" }; + + // 2 real failures (both reach transcript) + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + + // 3rd failure — after_tool_call increments counter to 3, + // but imagine before_message_write blocks the enriched message. + // The counter is at 3 even though only 2 errors are in the transcript. + handleAfterToolCall({ toolName: "memory_search", error: "timeout" }, ctx); + + // Circuit is now open — this is the conservative error. + // A 4th call would be blocked. + const result = handleBeforeToolCall({ toolName: "memory_search" }, ctx); + expect(result.block).toBe(true); + // Acknowledged: the circuit tripped after the 3rd counter increment, + // even though only 2 errors may be in the transcript. This is safer + // than tripping one attempt late. + }); +}); + +// =========================================================================== +// Tool name normalization (MCP server prefix stripping) +// =========================================================================== + +describe("normalizeToolName", () => { + it("returns unprefixed name as-is", () => { + expect(normalizeToolName("memory_search")).toBe("memory_search"); + }); + + it("strips MCP server prefix", () => { + expect(normalizeToolName("vigil-harbor__memory_search")).toBe("memory_search"); + expect(normalizeToolName("vigil-harbor__memory_status")).toBe("memory_status"); + expect(normalizeToolName("vigil-harbor__memory_ingest")).toBe("memory_ingest"); + }); + + it("returns original if unprefixed name is not in remediation set", () => { + expect(normalizeToolName("vigil-harbor__some_other_tool")).toBe("vigil-harbor__some_other_tool"); + }); + + it("returns original for non-prefixed unknown tools", () => { + expect(normalizeToolName("some_other_tool")).toBe("some_other_tool"); + }); +}); + +// =========================================================================== +// Prefixed tool names (live OpenClaw MCP format) +// =========================================================================== + +describe("prefixed tool names", () => { + it("after_tool_call increments counter for prefixed tool name", () => { + enricher._handlers.handleAfterToolCall( + { toolName: "vigil-harbor__memory_search", error: "fetch failed" }, + { sessionKey: "prefix-test" }, + ); + // Stored under normalized key + expect(enricher._attemptMap.get("prefix-test::memory_search")?.attempts).toBe(1); + }); + + it("tool_result_persist enriches prefixed tool errors", () => { + // Content-based error: event.error is undefined in after_tool_call + enricher._handlers.handleAfterToolCall( + { toolName: "vigil-harbor__memory_search" }, + { sessionKey: "prefix-test" }, + ); + + const result = enricher._handlers.handleToolResultPersist( + makeToolResultPersistEvent({ + message: { + isError: false, + content: [{ type: "text", text: 'Error: search failed\nDetails: getaddrinfo ENOTFOUND postgres' }], + details: { mcpServer: "vigil-harbor", mcpTool: "memory_search" }, + }, + }), + { sessionKey: "prefix-test", toolName: "vigil-harbor__memory_search" }, + ); + + expect(result).toBeDefined(); + const content = result!.message.content as Array<{ type: string; text: string }>; + expect(content.length).toBe(2); + expect(content[1]!.text).toContain("SERVER UNREACHABLE"); + }); + + it("before_tool_call blocks prefixed tool after MAX_ATTEMPTS", () => { + const ctx = { sessionKey: "prefix-block" }; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + enricher._handlers.handleAfterToolCall( + { toolName: "vigil-harbor__memory_search", error: "timeout" }, + ctx, + ); + } + const result = enricher._handlers.handleBeforeToolCall( + { toolName: "vigil-harbor__memory_search" }, + ctx, + ); + expect(result.block).toBe(true); + }); +}); + +// =========================================================================== +// Remediation map completeness +// =========================================================================== + +describe("remediation map", () => { + it("contains exactly 13 vigil-harbor MCP tools", () => { + expect(REMEDIATION_TOOLS.size).toBe(13); + }); + + it("includes all expected tools", () => { + const expected = [ + "memory_search", "memory_query", "memory_traverse", "memory_fetch", + "memory_ingest", "memory_link", "memory_delete", + "memory_list", "memory_sources", "memory_status", + "memory_embed_text", "memory_evaluate_process", "memory_changes", + ]; + for (const tool of expected) { + expect(REMEDIATION_TOOLS.has(tool)).toBe(true); + } + }); +}); + +// =========================================================================== +// Truncation detection +// =========================================================================== + +describe("truncation detection", () => { + it("defaults to recoverable severity when truncation suffix detected", () => { + const { handleAfterToolCall, handleToolResultPersist } = enricher._handlers; + const truncatedError = `Some error text that was cut off${GUARD_TRUNCATION_SUFFIX}`; + + handleAfterToolCall( + { toolName: "memory_search", error: truncatedError }, + { sessionKey: "trunc-test" }, + ); + + const result = handleToolResultPersist( + { + message: { isError: true, content: [{ type: "text", text: truncatedError }] }, + isSynthetic: false, + }, + { sessionKey: "trunc-test", toolName: "memory_search" }, + ); + + expect(result).toBeDefined(); + const details = (result!.message.details as Record).enricher as Record; + expect(details.severity).toBe("recoverable"); + }); + + it("warns about imminent block when truncated AND at MAX_ATTEMPTS", () => { + const { handleAfterToolCall, handleToolResultPersist } = enricher._handlers; + const truncatedError = `timeout something${GUARD_TRUNCATION_SUFFIX}`; + + for (let i = 0; i < MAX_ATTEMPTS; i++) { + handleAfterToolCall( + { toolName: "memory_search", error: truncatedError }, + { sessionKey: "trunc-max" }, + ); + } + + const result = handleToolResultPersist( + { + message: { isError: true, content: [{ type: "text", text: truncatedError }] }, + isSynthetic: false, + }, + { sessionKey: "trunc-max", toolName: "memory_search" }, + ); + + expect(result).toBeDefined(); + const details = (result!.message.details as Record).enricher as Record; + // Severity stays recoverable (can't classify truncated text) + expect(details.severity).toBe("recoverable"); + // But enrichment text warns about the imminent block + const content = result!.message.content as Array<{ type: string; text: string }>; + const enrichmentText = content[content.length - 1]!.text; + expect(enrichmentText).toContain("will be blocked"); + }); +}); diff --git a/extensions/drawbridge/package.json b/extensions/drawbridge/package.json index 0a68bec..532e5bd 100644 --- a/extensions/drawbridge/package.json +++ b/extensions/drawbridge/package.json @@ -1,6 +1,6 @@ { "name": "@vigil-harbor/openclaw-drawbridge", - "version": "1.0.0", + "version": "1.2.0", "type": "module", "description": "OpenClaw plugin — session-aware content sanitization via ClawMoat + Drawbridge", "exports": { @@ -11,6 +11,9 @@ }, "main": "dist/index.js", "types": "dist/index.d.ts", + "openclaw": { + "extensions": ["./dist/index.js"] + }, "scripts": { "build": "tsc", "test": "vitest run", diff --git a/extensions/drawbridge/src/hooks/tool-error-enricher.ts b/extensions/drawbridge/src/hooks/tool-error-enricher.ts new file mode 100644 index 0000000..cd5b621 --- /dev/null +++ b/extensions/drawbridge/src/hooks/tool-error-enricher.ts @@ -0,0 +1,768 @@ +/** + * Tool Error Enricher — P0 + * + * Three-hook system that intercepts MCP tool errors, classifies them, + * appends structured recovery guidance to the LLM context window, + * and circuit-breaks after MAX_ATTEMPTS consecutive failures. + * + * Architecture: + * after_tool_call → increment counter, stash params (sync-first write) + * tool_result_persist → classify error, resolve template, enrich message (SYNC) + * before_tool_call → circuit breaker check (SYNC) + * session_end / before_reset → cleanup attempt Map + * + * The attemptMap is created in the factory closure, independent of PluginState. + * No async init required. + * + * Synchronous-write invariant: + * after_tool_call MUST update the Map synchronously before any await. + * OpenClaw dispatches after_tool_call with void (not awaited), but the + * handler body before its first await executes synchronously within the + * event loop microtask. A future refactor introducing an await before the + * Map write would silently break the enricher. Enforced by code + test. + * + * before_message_write counter-ahead limitation: + * The after_tool_call counter increments before tool_result_persist enriches. + * If another plugin's before_message_write blocks the enriched message, the + * counter is one ahead of the transcript. This is a CONSERVATIVE error — + * circuit breaker trips one attempt early (safer than late). Accepted. + */ + +import type { + ToolResultPersistEvent, + ToolResultPersistContext, + AfterToolCallEvent, + AfterToolCallContext, + BeforeToolCallEvent, + BeforeToolCallContext, + BeforeToolCallResult, + SessionLifecycleEvent, + SessionLifecycleContext, +} from "../types/openclaw.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Placeholder — update when compaction-safeguard.ts lands. */ +export const GUARD_TRUNCATION_SUFFIX = "... [truncated]"; + +export const MAX_ATTEMPTS = 3; +export const MAX_ENRICHMENT_CHARS = 800; +const ENRICHER_PRIORITY = 50; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Severity = "transient" | "recoverable" | "terminal"; + +export type ErrorCategory = + | "timeout" + | "rate_limit" + | "auth_failure" + | "server_unreachable" + | "validation" + | "unknown"; + +type TemplateCascadeSource = "global" | "category" | "tool"; + +export interface AttemptEntry { + attempts: number; + lastParams: Record; + lastError: string; + lastTimestamp: number; +} + +interface EnrichmentResult { + text: string; + severity: Severity; + errorCategory: ErrorCategory; + templateSource: TemplateCascadeSource; + attempt: number; +} + +// --------------------------------------------------------------------------- +// Remediation map — only these tools are enriched +// --------------------------------------------------------------------------- + +export const REMEDIATION_TOOLS: ReadonlySet = new Set([ + "memory_search", + "memory_query", + "memory_traverse", + "memory_fetch", + "memory_ingest", + "memory_link", + "memory_delete", + "memory_list", + "memory_sources", + "memory_status", + "memory_embed_text", + "memory_evaluate_process", + "memory_changes", +]); + +// --------------------------------------------------------------------------- +// Tool categories +// --------------------------------------------------------------------------- + +const RETRIEVAL_TOOLS = new Set([ + "memory_search", + "memory_query", + "memory_traverse", + "memory_fetch", +]); + +const MUTATION_TOOLS = new Set([ + "memory_ingest", + "memory_link", + "memory_delete", +]); + +const UTILITY_TOOLS = new Set([ + "memory_list", + "memory_sources", + "memory_status", + "memory_embed_text", + "memory_evaluate_process", + "memory_changes", +]); + +type ToolCategory = "retrieval" | "mutation" | "utility"; + +/** + * Strip MCP server prefix from tool names. + * OpenClaw prefixes MCP tools with the server name: "vigil-harbor__memory_search". + * Returns the unprefixed name if it matches a remediation tool, otherwise the original. + */ +export function normalizeToolName(toolName: string): string { + if (REMEDIATION_TOOLS.has(toolName)) return toolName; + const sep = toolName.lastIndexOf("__"); + if (sep >= 0) { + const unprefixed = toolName.slice(sep + 2); + if (REMEDIATION_TOOLS.has(unprefixed)) return unprefixed; + } + return toolName; +} + +function getToolCategory(toolName: string): ToolCategory | undefined { + const normalized = normalizeToolName(toolName); + if (RETRIEVAL_TOOLS.has(normalized)) return "retrieval"; + if (MUTATION_TOOLS.has(normalized)) return "mutation"; + if (UTILITY_TOOLS.has(normalized)) return "utility"; + return undefined; +} + +// --------------------------------------------------------------------------- +// Sensitive parameter redaction +// --------------------------------------------------------------------------- + +const GLOBAL_SENSITIVE_PARAMS: ReadonlySet = new Set([ + "token", + "password", + "apikey", + "api_key", + "secret", + "authorization", + "credentials", + "bearer", + "session_token", +]); + +export function redactParams(params: Record): string { + const parts: string[] = []; + for (const [key, value] of Object.entries(params)) { + if (GLOBAL_SENSITIVE_PARAMS.has(key.toLowerCase())) { + parts.push(`${key}: [REDACTED]`); + } else { + parts.push(`${key}: ${JSON.stringify(value)}`); + } + } + return parts.join(", "); +} + +// --------------------------------------------------------------------------- +// Error category detection +// --------------------------------------------------------------------------- + +const CATEGORY_PATTERNS: ReadonlyArray<[ErrorCategory, RegExp]> = [ + ["timeout", /timeout|timed?\s*out|etimedout|deadline\s*exceeded/i], + ["rate_limit", /429|rate\s*limit|too\s*many\s*requests|throttle/i], + ["auth_failure", /401|403|unauthorized|forbidden|auth|credential/i], + ["server_unreachable", /econnrefused|enotfound|eai_again|ehostunreach|network\s*error|connection\s*refused|fetch\s*failed/i], + ["validation", /invalid|required|missing|must\s*be|must\s*have|schema|expected/i], +]; + +export function classifyErrorCategory(errorText: string): ErrorCategory { + for (const [category, pattern] of CATEGORY_PATTERNS) { + if (pattern.test(errorText)) return category; + } + return "unknown"; +} + +// --------------------------------------------------------------------------- +// Severity classifier +// --------------------------------------------------------------------------- + +export function classifySeverity( + category: ErrorCategory, + attemptCount: number, + isTruncated: boolean, +): Severity { + // 1. Truncated input → can't reliably classify + if (isTruncated) return "recoverable"; + // 2. Max attempts reached → terminal regardless + if (attemptCount >= MAX_ATTEMPTS) return "terminal"; + // 3. Auth failure → never transient (don't retry auth) + if (category === "auth_failure") return "recoverable"; + // 4. Server unreachable after 2+ attempts → terminal + if (category === "server_unreachable" && attemptCount >= 2) return "terminal"; + // 5. First timeout or rate limit → transient + if ((category === "timeout" || category === "rate_limit") && attemptCount === 1) return "transient"; + // 6. Validation → always needs param echo + if (category === "validation") return "recoverable"; + // 7. Default + return "recoverable"; +} + +// --------------------------------------------------------------------------- +// Template cascade: tool-specific > category > global +// --------------------------------------------------------------------------- + +// --- Global templates --- + +function globalTemplate( + toolName: string, + category: ErrorCategory, + errorText: string, + severity: Severity, + echoedParams: string, +): string | undefined { + switch (category) { + case "timeout": + if (severity === "transient") { + return `TOOL TIMEOUT: ${toolName} did not respond in time. Retry once with same params.`; + } + return `TOOL TIMEOUT: ${toolName} timed out. ERROR: ${errorText}. RECOVERY: (1) Retry once with same params. (2) If retry fails, inform the user.`; + case "rate_limit": + if (severity === "transient") { + return `RATE LIMITED: ${toolName} returned 429. Wait briefly, then retry once.`; + } + return `RATE LIMITED: ${toolName} returned 429. RECOVERY: (1) Wait briefly and retry. (2) If still failing, inform the user.`; + case "auth_failure": + return `AUTH FAILURE: ${toolName} returned authentication error. CAUSE: MCP server credentials may have expired. RECOVERY: Inform the user that ${toolName} is unavailable due to auth failure. Do not retry.`; + case "server_unreachable": + return `SERVER UNREACHABLE: ${toolName} MCP server is not responding. CAUSE: Server may be down or network interrupted. RECOVERY: Inform the user that ${toolName} is unavailable.`; + case "validation": + return `INVALID PARAMS for ${toolName}: ${echoedParams}. ERROR: ${errorText}. RECOVERY: Fix parameter and retry.`; + case "unknown": + return `TOOL FAILURE: ${toolName} returned an error. ERROR: ${errorText}. RECOVERY: (1) Retry once with same params. (2) If retry fails, inform the user.`; + default: + return undefined; + } +} + +// --- Category templates --- + +function categoryTemplate( + toolName: string, + category: ErrorCategory, + toolCategory: ToolCategory, + errorText: string, + severity: Severity, + echoedParams: string, +): string | undefined { + if (toolCategory === "retrieval") { + if (category === "timeout" && severity !== "transient") { + return `TOOL TIMEOUT: ${toolName} timed out. RECOVERY: (1) Retry with narrower query (reduce max_results, add time_after/time_before filters). (2) If query-based, try memory_query with source_type/tags filters instead. (3) If still failing, skip retrieval and respond from available context.`; + } + if (category === "validation") { + return `INVALID PARAMS for ${toolName}: ${echoedParams}. ERROR: ${errorText}. RECOVERY: Fix parameter and retry. NOTE: memory_search uses semantic query; use memory_query for filtered retrieval by source_type, tags, or metadata.`; + } + } + + if (toolCategory === "mutation") { + if (category === "timeout" && severity !== "transient") { + return `TOOL TIMEOUT: ${toolName} timed out. RECOVERY: (1) Retry once — mutation may have completed server-side (idempotent by content hash for ingest). (2) If still failing, inform user the operation may need manual completion.`; + } + if (category === "validation") { + return `INVALID PARAMS for ${toolName}: ${echoedParams}. ERROR: ${errorText}. NOTE: memory_ingest requires either text or image_data (mutually exclusive). memory_link requires both source_id and target_id as valid UUIDs.`; + } + } + + if (toolCategory === "utility") { + if (category === "timeout") { + return `TOOL TIMEOUT: ${toolName} timed out. RECOVERY: These are lightweight read operations. Timeout likely indicates MCP server issue. Retry once. If failing, inform user and proceed without this data.`; + } + } + + return undefined; +} + +// --- Tool-specific templates --- + +function toolSpecificTemplate( + toolName: string, + category: ErrorCategory, + errorText: string, + echoedParams: string, +): string | undefined { + if (toolName === "memory_search" && category === "validation") { + if (/source_type|source_system/i.test(errorText)) { + return `INVALID PARAMS for memory_search: ${echoedParams}. memory_search does not support source_type or source_system filtering in query params. Use memory_query for filtered retrieval by type, tags, or metadata. memory_search is for semantic/fuzzy search by query text.`; + } + } + + if (toolName === "memory_query" && category === "validation") { + if (/metadata_filter|operator|\$gt|\$gte|\$lt|\$lte|\$ne|\$in/i.test(errorText)) { + return `INVALID PARAMS for memory_query: ${echoedParams}. metadata_filter supports comparison operators: $gt, $gte, $lt, $lte, $ne, $in. Ensure operator values match expected types (numbers for $gt/$lt, arrays for $in).`; + } + } + + if (toolName === "memory_traverse" && category === "validation") { + if (/depth/i.test(errorText)) { + return `INVALID PARAMS for memory_traverse: ${echoedParams}. depth must be 1-3. For deep relationship exploration, chain multiple traverse calls at depth 1.`; + } + } + + if (toolName === "memory_evaluate_process" && category === "timeout") { + return `TOOL TIMEOUT: memory_evaluate_process is long-running (up to 5 minutes). This timeout may be expected. Retry with same params. Inform user this operation takes time.`; + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// Template resolution — cascade with 800-char safety net +// --------------------------------------------------------------------------- + +function resolveTemplate( + toolName: string, + category: ErrorCategory, + severity: Severity, + attempt: number, + errorText: string, + params: Record, +): EnrichmentResult { + const echoedParams = redactParams(params); + const toolCategory = getToolCategory(toolName); + + // Terminal severity overrides all templates + if (severity === "terminal") { + const text = `ATTEMPT ${attempt} OF ${MAX_ATTEMPTS} — CIRCUIT OPEN. ${toolName} has failed ${attempt} consecutive times (${category}). Do NOT retry. Inform the user that ${toolName} is currently unavailable and suggest manual intervention.`; + return { text: truncate(text), severity, errorCategory: category, templateSource: "global", attempt }; + } + + // 1. Tool-specific + const toolTpl = toolSpecificTemplate(toolName, category, errorText, echoedParams); + if (toolTpl !== undefined) { + return { text: truncate(toolTpl), severity, errorCategory: category, templateSource: "tool", attempt }; + } + + // 2. Category + if (toolCategory !== undefined) { + const catTpl = categoryTemplate(toolName, category, toolCategory, errorText, severity, echoedParams); + if (catTpl !== undefined) { + return { text: truncate(catTpl), severity, errorCategory: category, templateSource: "category", attempt }; + } + } + + // 3. Global + const gTpl = globalTemplate(toolName, category, errorText, severity, echoedParams); + if (gTpl !== undefined) { + return { text: truncate(gTpl), severity, errorCategory: category, templateSource: "global", attempt }; + } + + // Fallback (should not reach — global covers all categories) + const fallback = `TOOL FAILURE: ${toolName} returned an error. ERROR: ${errorText}. Retry once or inform the user.`; + return { text: truncate(fallback), severity, errorCategory: category, templateSource: "global", attempt }; +} + +/** Hard truncation safety net — guards against unexpectedly large param echoes. */ +function truncate(text: string): string { + if (text.length <= MAX_ENRICHMENT_CHARS) return text; + return text.slice(0, MAX_ENRICHMENT_CHARS - 3) + "..."; +} + +// --------------------------------------------------------------------------- +// Tool name extraction from message body +// --------------------------------------------------------------------------- + +export function extractToolNameFromMessage(message: Record): string | undefined { + const content = message.content; + + // content is an array of blocks + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block === "object") { + const b = block as Record; + // Check for name or toolName fields — do NOT derive from tool_use_id (opaque) + if (typeof b.name === "string" && b.name.length > 0) return b.name; + if (typeof b.toolName === "string" && b.toolName.length > 0) return b.toolName; + } + } + } + + // content is a plain object with name/toolName + if (content && typeof content === "object" && !Array.isArray(content)) { + const c = content as Record; + if (typeof c.name === "string" && c.name.length > 0) return c.name; + if (typeof c.toolName === "string" && c.toolName.length > 0) return c.toolName; + } + + // Check top-level message fields + if (typeof message.toolName === "string" && message.toolName.length > 0) { + return message.toolName; + } + if (typeof message.name === "string" && message.name.length > 0) { + return message.name; + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export interface ToolErrorEnricher { + registerHooks(api: { + on: ( + hookName: string, + handler: (...args: unknown[]) => unknown, + opts?: { priority?: number }, + ) => void; + }): void; + + /** Exposed for testing — direct handler access. */ + readonly _handlers: { + handleAfterToolCall(event: AfterToolCallEvent, ctx: AfterToolCallContext): void; + handleToolResultPersist( + event: ToolResultPersistEvent, + ctx: ToolResultPersistContext, + ): { message: Record } | undefined; + handleBeforeToolCall( + event: BeforeToolCallEvent, + ctx: BeforeToolCallContext, + ): BeforeToolCallResult; + handleSessionCleanup(sessionKey: string): void; + }; + + /** Exposed for testing — read attempt Map state. */ + readonly _attemptMap: Map; +} + +export function createToolErrorEnricher(): ToolErrorEnricher { + const attemptMap = new Map(); + + // ----------------------------------------------------------------------- + // after_tool_call — increment/reset counter, stash params + // CRITICAL: Map.set() MUST execute before any await — synchronous-write invariant. + // The early returns above are safe: tool_result_persist bails on the same guards. + // ----------------------------------------------------------------------- + function handleAfterToolCall( + event: AfterToolCallEvent, + ctx: AfterToolCallContext, + ): void { + try { + if (!ctx.sessionKey) return; + const toolName = normalizeToolName(event.toolName); + if (!REMEDIATION_TOOLS.has(toolName)) return; + + const key = `${ctx.sessionKey}::${toolName}`; + + if (event.error !== undefined) { + // Error path — increment counter (synchronous-first write) + const existing = attemptMap.get(key); + attemptMap.set(key, { + attempts: (existing?.attempts ?? 0) + 1, + lastParams: event.params ?? {}, + lastError: String(event.error), + lastTimestamp: Date.now(), + }); + } else { + // Ambiguous: event.error undefined could be true success or a + // content-wrapped MCP error. Stash params but preserve the + // existing counter — tool_result_persist is the authoritative + // detector and will either increment (on content/details errors) + // or reset to 0 (on confirmed success). + const existing = attemptMap.get(key); + attemptMap.set(key, { + attempts: existing?.attempts ?? 0, + lastParams: event.params ?? {}, + lastError: existing?.lastError ?? "", + lastTimestamp: Date.now(), + }); + } + } catch { + // Fail-open — swallow exceptions + } + } + + // ----------------------------------------------------------------------- + // tool_result_persist — classify error, resolve template, enrich (SYNC) + // This handler MUST be fully synchronous — no await, no Promises. + // ----------------------------------------------------------------------- + function handleToolResultPersist( + event: ToolResultPersistEvent, + ctx: ToolResultPersistContext, + ): { message: Record } | undefined { + try { + // Guard: skip synthetic results + if (event.isSynthetic === true) return undefined; + + // Guard: skip non-errors — three detection layers: + // 1. isError flag (standard MCP protocol errors) + // 2. details.status === "error" (OpenClaw transport failure wrapping) + // 3. Content starts with "Error:" (MCP servers returning app errors as content) + const isErrorFlag = (event.message as { isError?: boolean }).isError === true; + const msgDetails = (event.message.details ?? {}) as Record; + const detailsError = msgDetails.status === "error"; + let contentError = false; + if (!isErrorFlag && !detailsError) { + const raw = event.message.content; + let lead = ""; + if (typeof raw === "string") { + lead = raw; + } else if (Array.isArray(raw) && raw.length > 0) { + const first = raw[0] as Record; + if (typeof first?.text === "string") lead = first.text; + } + contentError = lead.startsWith("Error:"); + } + if (!isErrorFlag && !detailsError && !contentError) { + // True success — reset counter (after_tool_call deferred this). + if (ctx.sessionKey) { + const rawTool = ctx.toolName ?? extractToolNameFromMessage(event.message); + if (rawTool) { + const tool = normalizeToolName(rawTool); + const k = `${ctx.sessionKey}::${tool}`; + const existing = attemptMap.get(k); + if (existing && existing.attempts > 0) { + existing.attempts = 0; + existing.lastError = ""; + } + } + } + return undefined; + } + + // Guard: skip when sessionKey is undefined + if (!ctx.sessionKey) return undefined; + + // Resolve tool name (from ctx, then from message body), strip MCP prefix + const rawToolName = ctx.toolName ?? extractToolNameFromMessage(event.message); + if (!rawToolName) return undefined; + const toolName = normalizeToolName(rawToolName); + + // Guard: skip non-remediation-map tools + if (!REMEDIATION_TOOLS.has(toolName)) return undefined; + + // Read attempt state + const key = `${ctx.sessionKey}::${toolName}`; + let entry = attemptMap.get(key); + + // Counter management for content/details errors — after_tool_call + // couldn't detect these (event.error was undefined), so we increment + // here. For isError:true errors after_tool_call already incremented. + if (detailsError || contentError) { + attemptMap.set(key, { + attempts: (entry?.attempts ?? 0) + 1, + lastParams: entry?.lastParams ?? {}, + lastError: typeof msgDetails.error === "string" ? msgDetails.error : "", + lastTimestamp: Date.now(), + }); + entry = attemptMap.get(key); + } + + const attempt = entry?.attempts ?? 1; + const lastParams = entry?.lastParams ?? {}; + + // Extract raw error text + const rawContent = event.message.content; + let errorText = ""; + if (typeof rawContent === "string") { + errorText = rawContent; + } else if (Array.isArray(rawContent)) { + const texts = rawContent + .filter((b): b is { type: string; text: string } => + b && typeof b === "object" && typeof (b as Record).text === "string", + ) + .map((b) => b.text); + errorText = texts.join(" "); + } + + // Prefer details.error for cleaner text when content is a JSON wrapper + if (typeof msgDetails.error === "string" && msgDetails.error) { + if (!errorText || errorText.startsWith("{")) { + errorText = msgDetails.error; + } + } + + // Truncation detection + const isTruncated = errorText.includes(GUARD_TRUNCATION_SUFFIX); + + // Classify + const category = classifyErrorCategory(errorText); + const severity = classifySeverity(category, attempt, isTruncated); + + // Resolve template + const enrichment = resolveTemplate(toolName, category, severity, attempt, errorText, lastParams); + + // Truncated text at MAX_ATTEMPTS: severity stays "recoverable" (can't classify + // truncated text reliably), but warn the LLM that the circuit breaker will + // block the next invocation. + if (isTruncated && attempt >= MAX_ATTEMPTS) { + enrichment.text = truncate( + enrichment.text + ` WARNING: This is attempt ${attempt} of ${MAX_ATTEMPTS}. The next invocation of ${toolName} will be blocked.`, + ); + } + + // Build enriched message — additive to content, merge details + const msg = event.message; + const existingContent = msg.content; + const contentArray: unknown[] = Array.isArray(existingContent) + ? [...existingContent] + : [{ type: "text", text: String(existingContent ?? "") }]; + contentArray.push({ type: "text", text: enrichment.text }); + + const existingDetails = (msg.details ?? {}) as Record; + const details = { + ...existingDetails, + enricher: { + severity: enrichment.severity, + attempt: enrichment.attempt, + maxAttempts: MAX_ATTEMPTS, + errorCategory: enrichment.errorCategory, + templateSource: enrichment.templateSource, + toolName, + }, + }; + + return { message: { ...msg, content: contentArray, details } }; + } catch (err) { + // Fail-open — log and return original message unmodified + console.warn( + "[drawbridge:tool_error_enricher] Fail-open in tool_result_persist:", + String((err as Error)?.message ?? err ?? "unknown error").slice(0, 200), + ); + return undefined; + } + } + + // ----------------------------------------------------------------------- + // before_tool_call — circuit breaker + // ----------------------------------------------------------------------- + function handleBeforeToolCall( + event: BeforeToolCallEvent, + ctx: BeforeToolCallContext, + ): BeforeToolCallResult { + try { + if (!ctx.sessionKey) return {}; + const toolName = normalizeToolName(event.toolName); + if (!REMEDIATION_TOOLS.has(toolName)) return {}; + + const key = `${ctx.sessionKey}::${toolName}`; + const entry = attemptMap.get(key); + if (!entry || entry.attempts < MAX_ATTEMPTS) return {}; + + return { + block: true, + blockReason: + `${toolName} has failed ${entry.attempts} consecutive times ` + + `(${classifyErrorCategory(entry.lastError)}). ` + + `Inform the user that ${toolName} is currently unavailable and ` + + `suggest checking the MCP server or network connectivity.`, + }; + } catch { + // Fail-open — allow tool call + return {}; + } + } + + // ----------------------------------------------------------------------- + // Session cleanup — clear entries for a given sessionKey. + // O(n) scan over Map — bounded at 13 remediation tools per session. + // If the Map extends beyond vigil-harbor tools in the future (P2), + // consider a secondary index by sessionKey. + // ----------------------------------------------------------------------- + function handleSessionCleanup(sessionKey: string): void { + try { + const prefix = `${sessionKey}::`; + // Collect keys before deleting — avoids mutation during iteration. + // ES6 Map spec guarantees delete-during-keys() is safe, but collecting + // first is unambiguous and the set is bounded at 13 remediation tools. + const toDelete: string[] = []; + for (const key of attemptMap.keys()) { + if (key.startsWith(prefix)) toDelete.push(key); + } + for (const key of toDelete) attemptMap.delete(key); + } catch { + // Fail-open — swallow + } + } + + return { + registerHooks(api) { + api.on( + "after_tool_call", + (event: unknown, ctx: unknown) => { + handleAfterToolCall( + event as AfterToolCallEvent, + ctx as AfterToolCallContext, + ); + }, + ); + + api.on( + "tool_result_persist", + (event: unknown, ctx: unknown) => { + return handleToolResultPersist( + event as ToolResultPersistEvent, + ctx as ToolResultPersistContext, + ); + }, + { priority: ENRICHER_PRIORITY }, + ); + + api.on( + "before_tool_call", + (event: unknown, ctx: unknown) => { + return handleBeforeToolCall( + event as BeforeToolCallEvent, + ctx as BeforeToolCallContext, + ); + }, + ); + + api.on( + "session_end", + (event: unknown, ctx: unknown) => { + const sessionKey = + (event as SessionLifecycleEvent)?.sessionKey ?? + (ctx as SessionLifecycleContext)?.sessionKey; + if (sessionKey) handleSessionCleanup(sessionKey); + }, + ); + + api.on( + "before_reset", + (event: unknown, ctx: unknown) => { + const sessionKey = + (event as SessionLifecycleEvent)?.sessionKey ?? + (ctx as SessionLifecycleContext)?.sessionKey; + if (sessionKey) handleSessionCleanup(sessionKey); + }, + ); + }, + + _handlers: { + handleAfterToolCall, + handleToolResultPersist, + handleBeforeToolCall, + handleSessionCleanup, + }, + + _attemptMap: attemptMap, + }; +} diff --git a/extensions/drawbridge/src/index.ts b/extensions/drawbridge/src/index.ts index deb2aa2..b9500d1 100644 --- a/extensions/drawbridge/src/index.ts +++ b/extensions/drawbridge/src/index.ts @@ -18,6 +18,7 @@ import { handleBeforeDispatch } from "./hooks/before-dispatch.js"; import { handleMessageSending } from "./hooks/message-sending.js"; import { handleLlmOutput } from "./hooks/llm-output.js"; import { handleGatewayStop } from "./hooks/gateway-stop.js"; +import { createToolErrorEnricher } from "./hooks/tool-error-enricher.js"; import type { VigilHarborIngestFn, AlertNotifyFn } from "./audit-sink.js"; export type { DrawbridgePluginConfig } from "./config.js"; @@ -63,13 +64,13 @@ export function createDrawbridgePlugin(opts?: CreatePluginOptions) { description: "Session-aware content sanitization via ClawMoat + Drawbridge", register(api: { - registerHook: ( - event: string, + on: ( + hookName: string, handler: (...args: unknown[]) => unknown, - opts?: { name?: string }, + opts?: { priority?: number }, ) => void; }) { - api.registerHook( + api.on( "message_received", async (event: unknown, ctx: unknown) => { const state = await getState(); @@ -80,10 +81,9 @@ export function createDrawbridgePlugin(opts?: CreatePluginOptions) { ctx as Parameters[2], ); }, - { name: "drawbridge:message_received" }, ); - api.registerHook( + api.on( "before_dispatch", async (event: unknown, ctx: unknown) => { const state = await getState(); @@ -94,10 +94,9 @@ export function createDrawbridgePlugin(opts?: CreatePluginOptions) { ctx as Parameters[2], ); }, - { name: "drawbridge:before_dispatch" }, ); - api.registerHook( + api.on( "message_sending", async (event: unknown, ctx: unknown) => { const state = await getState(); @@ -108,10 +107,9 @@ export function createDrawbridgePlugin(opts?: CreatePluginOptions) { ctx as Parameters[2], ); }, - { name: "drawbridge:message_sending" }, ); - api.registerHook( + api.on( "llm_output", async (event: unknown, ctx: unknown) => { const state = await getState(); @@ -122,11 +120,10 @@ export function createDrawbridgePlugin(opts?: CreatePluginOptions) { ctx as Parameters[2], ); }, - { name: "drawbridge:llm_output" }, ); - api.registerHook( - "gateway:stop", + api.on( + "gateway_stop", async () => { // Only tear down if init already happened — never trigger lazy init during shutdown if (!stateP) return; @@ -134,8 +131,14 @@ export function createDrawbridgePlugin(opts?: CreatePluginOptions) { if (!state) return; handleGatewayStop(state); }, - { name: "drawbridge:gateway_stop" }, ); + + // Tool error enricher — independent of PluginState (no async init needed) + const enricher = createToolErrorEnricher(); + enricher.registerHooks(api); }, }; } + +// Default export for OpenClaw plugin loader +export default createDrawbridgePlugin(); diff --git a/extensions/drawbridge/src/types/openclaw.ts b/extensions/drawbridge/src/types/openclaw.ts index 0940216..6c10fe8 100644 --- a/extensions/drawbridge/src/types/openclaw.ts +++ b/extensions/drawbridge/src/types/openclaw.ts @@ -70,3 +70,55 @@ export interface LlmOutputEvent { export interface GatewayStopEvent { reason?: string; } + +// --------------------------------------------------------------------------- +// Tool error enricher hook events +// --------------------------------------------------------------------------- + +/** Event shape for `tool_result_persist` — intercepts tool result messages before transcript write. */ +export interface ToolResultPersistEvent { + /** AgentMessage — duck-typed. Check `(message as { isError?: boolean }).isError`. */ + message: Record; + /** Synthetic results are fabricated by guard/repair for orphaned tool calls — not real errors. */ + isSynthetic?: boolean; +} + +export interface ToolResultPersistContext { + sessionKey?: string; + toolName?: string; +} + +/** Event shape for `after_tool_call` — fires after tool execution (void, fire-and-forget). */ +export interface AfterToolCallEvent { + toolName: string; + params?: Record; + /** Defined when the tool call failed. Undefined on success. */ + error?: string; +} + +export interface AfterToolCallContext { + sessionKey?: string; +} + +/** Event shape for `before_tool_call` — fires before tool invocation (sequential, can block). */ +export interface BeforeToolCallEvent { + toolName: string; +} + +export interface BeforeToolCallContext { + sessionKey?: string; +} + +export interface BeforeToolCallResult { + block?: boolean; + blockReason?: string; +} + +/** Event shape for `session_end` / `before_reset` — cleanup hooks. */ +export interface SessionLifecycleEvent { + sessionKey?: string; +} + +export interface SessionLifecycleContext { + sessionKey?: string; +} diff --git a/package.json b/package.json index 7ea9b71..a3108f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vigil-harbor/clawmoat-drawbridge", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "description": "Session-aware content sanitization pipeline powered by ClawMoat. Standalone library — wire into any agent pipeline.", "exports": {