diff --git a/src/index.ts b/src/index.ts index fba3bd6f..40a911a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -224,6 +224,10 @@ async function main() { if (isGraphExtractionEnabled()) { registerGraphFunction(sdk, kv, provider); console.log(`[agentmemory] Knowledge graph: extraction enabled`); + } else { + console.log( + `[agentmemory] Knowledge graph: disabled (set GRAPH_EXTRACTION_ENABLED=true in the agentmemory host environment or ~/.agentmemory/.env, then restart)`, + ); } registerConsolidationPipelineFunction(sdk, kv, provider); diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 6a65f233..eb179e29 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -77,6 +77,18 @@ function graphDisabledResponse(): Response { }); } +function graphRequestFailedResponse(functionId: string, err: unknown): Response { + const message = err instanceof Error ? err.message : String(err); + return { + status_code: 500, + body: { + error: "Knowledge graph request failed", + functionId, + message, + }, + }; +} + function consolidationDisabledResponse(): Response { return flagDisabledResponse({ error: "Consolidation pipeline not enabled", @@ -1060,11 +1072,13 @@ export function registerApiTriggers( ): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; + if (!isGraphExtractionEnabled()) return graphDisabledResponse(); + const functionId = "mem::graph-query"; try { - const result = await sdk.trigger({ function_id: "mem::graph-query", payload: req.body || {} }); + const result = await sdk.trigger({ function_id: functionId, payload: req.body || {} }); return { status_code: 200, body: result }; - } catch { - return graphDisabledResponse(); + } catch (err) { + return graphRequestFailedResponse(functionId, err); } }, ); @@ -1078,11 +1092,13 @@ export function registerApiTriggers( async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; + if (!isGraphExtractionEnabled()) return graphDisabledResponse(); + const functionId = "mem::graph-stats"; try { - const result = await sdk.trigger({ function_id: "mem::graph-stats", payload: {} }); + const result = await sdk.trigger({ function_id: functionId, payload: {} }); return { status_code: 200, body: result }; - } catch { - return graphDisabledResponse(); + } catch (err) { + return graphRequestFailedResponse(functionId, err); } }, ); @@ -1096,6 +1112,7 @@ export function registerApiTriggers( async (req: ApiRequest<{ observations: unknown[] }>): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; + if (!isGraphExtractionEnabled()) return graphDisabledResponse(); if ( !Array.isArray(req.body?.observations) || req.body.observations.length === 0 @@ -1105,11 +1122,12 @@ export function registerApiTriggers( body: { error: "observations array is required" }, }; } + const functionId = "mem::graph-extract"; try { - const result = await sdk.trigger({ function_id: "mem::graph-extract", payload: req.body }); + const result = await sdk.trigger({ function_id: functionId, payload: req.body }); return { status_code: 200, body: result }; - } catch { - return graphDisabledResponse(); + } catch (err) { + return graphRequestFailedResponse(functionId, err); } }, ); diff --git a/test/api-graph.test.ts b/test/api-graph.test.ts new file mode 100644 index 00000000..49638520 --- /dev/null +++ b/test/api-graph.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerApiTriggers } from "../src/triggers/api.js"; + +type ApiResponse = { + status_code: number; + body: unknown; +}; + +type ApiHandler = (req: { + body?: unknown; + headers?: Record; +}) => Promise; + +function createSdk( + triggerImpl: (input: { function_id: string; payload: unknown }) => Promise, +) { + const functions = new Map(); + const sdk = { + registerFunction: vi.fn((id: string, handler: ApiHandler) => { + functions.set(id, handler); + }), + registerTrigger: vi.fn(), + trigger: vi.fn(triggerImpl), + }; + + return { sdk, functions }; +} + +describe("graph REST endpoints", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + { apiFunction: "api::graph-query", request: { headers: {} } }, + { apiFunction: "api::graph-stats", request: { headers: {} } }, + { apiFunction: "api::graph-extract", request: { headers: {} } }, + ])("returns disabled response for $apiFunction when graph extraction is off", async ({ + apiFunction, + request, + }) => { + vi.stubEnv("GRAPH_EXTRACTION_ENABLED", "false"); + const { sdk, functions } = createSdk(async () => ({ ok: true })); + + registerApiTriggers(sdk as never, {} as never); + const response = await functions.get(apiFunction)!(request); + + expect(response.status_code).toBe(503); + expect(response.body).toMatchObject({ + error: "Knowledge graph not enabled", + flag: "GRAPH_EXTRACTION_ENABLED", + }); + expect(sdk.trigger).not.toHaveBeenCalled(); + }); + + it("returns graph stats when graph extraction is on", async () => { + vi.stubEnv("GRAPH_EXTRACTION_ENABLED", "true"); + const stats = { success: true, nodes: 2, edges: 1 }; + const { sdk, functions } = createSdk(async () => stats); + + registerApiTriggers(sdk as never, {} as never); + const response = await functions.get("api::graph-stats")!({ headers: {} }); + + expect(response).toEqual({ status_code: 200, body: stats }); + expect(sdk.trigger).toHaveBeenCalledWith({ + function_id: "mem::graph-stats", + payload: {}, + }); + }); + + it.each([ + { + apiFunction: "api::graph-query", + memFunction: "mem::graph-query", + request: { headers: {}, body: { query: "index" } }, + }, + { + apiFunction: "api::graph-stats", + memFunction: "mem::graph-stats", + request: { headers: {} }, + }, + { + apiFunction: "api::graph-extract", + memFunction: "mem::graph-extract", + request: { headers: {}, body: { observations: [{ id: "obs-1" }] } }, + }, + ])("reports $memFunction trigger failures separately from disabled graph extraction", async ({ + apiFunction, + memFunction, + request, + }) => { + vi.stubEnv("GRAPH_EXTRACTION_ENABLED", "true"); + const { sdk, functions } = createSdk(async () => { + throw new Error(`iii::engine Function not found: ${memFunction}`); + }); + + registerApiTriggers(sdk as never, {} as never); + const response = await functions.get(apiFunction)!(request); + + expect(response.status_code).toBe(500); + expect(response.body).toMatchObject({ + error: "Knowledge graph request failed", + functionId: memFunction, + message: `iii::engine Function not found: ${memFunction}`, + }); + }); +});