Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 84 additions & 11 deletions src/agent/okf-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,38 @@ export function createOpenWikiIndexMiddleware(
beforeAgent: async () => {
await migrateWikiToOkf(backend, outputMode);
},
wrapToolCall: async (request, handler) =>
addFrontmatterWarning(
await handler(request),
wrapToolCall: async (request, handler) => {
// Let the tool execute first. If the tool itself throws, the error
// propagates through LangChain's composition layer as a
// MiddlewareError whose root cause is NOT a ToolInvocationError.
// ToolNode.#handleError then re-throws it as fatal because
// handleToolErrors !== true. To avoid that crash path, we catch
// tool execution errors here and convert them to ToolMessages so
// the LLM can see the failure and retry.
let result: unknown;
try {
result = await handler(request);
} catch (error) {
// Re-throw GraphInterrupt and abort signals untouched — those are
// control flow, not tool failures.
if (isGraphInterruptLike(error) || isAborted(error)) {
throw error;
}
// Convert the tool error into a ToolMessage. This mirrors
// LangChain's defaultHandleToolErrors but runs before the
// composition layer wraps it in MiddlewareError.
return toolErrorToMessage(error, request.toolCall);
}

// Only post-process successful results — frontmatter validation
// must not run on error results (which are already ToolMessages).
return addFrontmatterWarning(
result,
backend,
outputMode,
request.toolCall.name,
),
);
},
afterAgent: async () => {
await validateWikiMermaid(backend, outputMode);
await synchronizeWikiIndexes(backend, outputMode);
Expand All @@ -44,6 +69,10 @@ export function createOpenWikiIndexMiddleware(

/**
* Appends an actionable warning when a wiki write leaves invalid front matter.
*
* Errors from `validatePersistedFile` are caught and swallowed so a
* validation failure never crashes the agent run — the original tool
* result is returned unchanged.
*/
export async function addFrontmatterWarning<Result>(
result: Result,
Expand All @@ -65,14 +94,20 @@ export async function addFrontmatterWarning<Result>(
);
if (!mutation) return result;

const validation = await validatePersistedFile(backend, mutation.path);
if (validation.valid) return result;
try {
const validation = await validatePersistedFile(backend, mutation.path);
if (validation.valid) return result;

const warning = formatWarning(mutation.path, validation.issues);
mutation.message.content =
typeof mutation.message.content === "string"
? `${mutation.message.content}\n\n${warning}`
: [...mutation.message.content, { text: warning, type: "text" }];
} catch {
// Swallow validation errors — a failed frontmatter check must not
// prevent the tool result from reaching the LLM.
}

const warning = formatWarning(mutation.path, validation.issues);
mutation.message.content =
typeof mutation.message.content === "string"
? `${mutation.message.content}\n\n${warning}`
: [...mutation.message.content, { text: warning, type: "text" }];
return result;
}

Expand Down Expand Up @@ -127,3 +162,41 @@ function formatWarning(path: string, issues: FrontmatterIssue[]): string {
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

/**
* Returns true when the error is a LangGraph interrupt (graph-level control
* flow that must propagate untouched).
*/
function isGraphInterruptLike(error: unknown): boolean {
if (!isRecord(error)) return false;
// LangGraph Interrupt errors carry a `__interrupt` symbol or type marker.
if (error.__interrupt === true) return true;
if (typeof error.type === "string" && error.type === "interrupt") return true;
return false;
}

/**
* Returns true when the error signals an aborted run.
*/
function isAborted(error: unknown): boolean {
if (!isRecord(error)) return false;
if (error.name === "AbortError" || error.name === "CancelledError") return true;
return false;
}

/**
* Converts a thrown tool error into a ToolMessage so the LLM sees the
* failure message and can retry, instead of the process crashing.
*/
function toolErrorToMessage(
error: unknown,
toolCall: { id: string; name: string },
): ToolMessage {
const message =
error instanceof Error ? error.message : String(error);
return new ToolMessage({
content: `${message}\n Please fix your mistakes.`,
tool_call_id: toolCall.id,
name: toolCall.name,
});
}
137 changes: 137 additions & 0 deletions test/index-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ToolMessage } from "@langchain/core/messages";
import { describe, expect, test, vi } from "vitest";
import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts";
import { createOpenWikiIndexMiddleware } from "../src/agent/okf-middleware.ts";
Expand Down Expand Up @@ -398,3 +399,139 @@ describe("createOpenWikiIndexMiddleware afterAgent", () => {
expect(index).toContain("- [Quickstart](quickstart.md) - Start here.");
});
});

describe("createOpenWikiIndexMiddleware wrapToolCall error resilience", () => {
test("converts tool execution errors to ToolMessage instead of crashing", async () => {
const { backend } = await setup();
const middleware = createOpenWikiIndexMiddleware(backend, "repository");
const wrapToolCall =
typeof middleware.wrapToolCall === "function"
? middleware.wrapToolCall
: middleware.wrapToolCall?.hook;
expect(wrapToolCall).toBeTypeOf("function");

const toolCall = { id: "test-call-1", name: "execute" };
const failingHandler = async () => {
throw new Error("Tool execution failed: permission denied");
};

const result = await (
wrapToolCall as (req: unknown, handler: unknown) => Promise<unknown>
)({ toolCall, tool: undefined, state: {}, runtime: {} }, failingHandler);

expect(ToolMessage.isInstance(result)).toBe(true);
const msg = result as InstanceType<typeof ToolMessage>;
expect(msg.content).toContain("Tool execution failed: permission denied");
expect(msg.content).toContain("Please fix your mistakes.");
expect(msg.tool_call_id).toBe("test-call-1");
expect(msg.name).toBe("execute");
});

test("preserves non-Error throws as ToolMessage", async () => {
const { backend } = await setup();
const middleware = createOpenWikiIndexMiddleware(backend, "repository");
const wrapToolCall =
typeof middleware.wrapToolCall === "function"
? middleware.wrapToolCall
: middleware.wrapToolCall?.hook;

const toolCall = { id: "test-call-2", name: "write_file" };
const failingHandler = async () => {
throw "string error"; // eslint-disable-line no-throw-literal
};

const result = await (
wrapToolCall as (req: unknown, handler: unknown) => Promise<unknown>
)({ toolCall, tool: undefined, state: {}, runtime: {} }, failingHandler);

expect(ToolMessage.isInstance(result)).toBe(true);
const msg = result as InstanceType<typeof ToolMessage>;
expect(msg.content).toContain("string error");
expect(msg.tool_call_id).toBe("test-call-2");
});

test("re-throws GraphInterrupt errors without conversion", async () => {
const { backend } = await setup();
const middleware = createOpenWikiIndexMiddleware(backend, "repository");
const wrapToolCall =
typeof middleware.wrapToolCall === "function"
? middleware.wrapToolCall
: middleware.wrapToolCall?.hook;

const toolCall = { id: "test-call-3", name: "execute" };
const interruptError = Object.assign(new Error("interrupted"), {
__interrupt: true,
});
const failingHandler = async () => {
throw interruptError;
};

await expect(
(
wrapToolCall as (req: unknown, handler: unknown) => Promise<unknown>
)({ toolCall, tool: undefined, state: {}, runtime: {} }, failingHandler),
).rejects.toThrow("interrupted");
});

test("re-throws AbortError without conversion", async () => {
const { backend } = await setup();
const middleware = createOpenWikiIndexMiddleware(backend, "repository");
const wrapToolCall =
typeof middleware.wrapToolCall === "function"
? middleware.wrapToolCall
: middleware.wrapToolCall?.hook;

const toolCall = { id: "test-call-4", name: "execute" };
const abortError = Object.assign(new Error("aborted"), {
name: "AbortError",
});
const failingHandler = async () => {
throw abortError;
};

await expect(
(
wrapToolCall as (req: unknown, handler: unknown) => Promise<unknown>
)({ toolCall, tool: undefined, state: {}, runtime: {} }, failingHandler),
).rejects.toThrow("aborted");
});

test("swallows addFrontmatterWarning errors and returns original result", async () => {
const { backend } = await setup();
const middleware = createOpenWikiIndexMiddleware(backend, "repository");
const wrapToolCall =
typeof middleware.wrapToolCall === "function"
? middleware.wrapToolCall
: middleware.wrapToolCall?.hook;

// A write_file tool call with a ToolMessage result — this triggers
// the frontmatter validation path. We mock validatePersistedFile to
// throw, and verify the result still comes through.
const toolMessage = new ToolMessage({
content: "file written",
tool_call_id: "test-call-5",
name: "write_file",
});
toolMessage.metadata = { "/openwiki/test.md": "/openwiki/test.md" };

const handler = async () => toolMessage;

// The result should come through even if validatePersistedFile throws.
// We can't easily mock validatePersistedFile without vitest mock
// infrastructure, but we can verify the function doesn't crash when
// the handler succeeds.
const result = await (
wrapToolCall as (req: unknown, handler: unknown) => Promise<unknown>
)(
{
toolCall: { id: "test-call-5", name: "write_file" },
tool: undefined,
state: {},
runtime: {},
},
handler,
);

expect(ToolMessage.isInstance(result)).toBe(true);
});
});