mirror of
https://github.com/rohitg00/agentmemory.git
synced 2026-09-14 20:16:33 +08:00
436c86c86d
Closes #753. POST /agentmemory/graph/query with an unbounded body ({}) on a corpus with 11k+ nodes returned HTTP 500 'Invocation stopped' because the response payload exceeded the iii state response channel ceiling. Viewer's api() returns null on non-2xx so the failure surfaced as '0 nodes / 0 edges' on a blank canvas with no signal — same shape as #544, never covered for graph/query. Backend (src/functions/graph.ts) - mem::graph-query accepts limit + offset on every branch and applies a default cap (DEFAULT_GRAPH_QUERY_LIMIT = 500, MAX = 5000) so an empty {} body never materializes the whole graph in one invocation. - Empty-body / nodeType-only branch ranks nodes by incident-edge degree before paginating so the truncated page surfaces the densest subgraph rather than an arbitrary KV scan order. - Edges in the page response are restricted to edges with BOTH endpoints in the page; cross-page edges are dropped so the viewer doesn't render dangling lines. - Response shape extended: totalNodes, totalEdges, truncated, limit, offset. totalNodes / totalEdges reflect the unbounded result for the given filter so callers can render 'showing X of Y' without re-querying. API (src/triggers/api.ts) - api::graph-query whitelists payload fields (startNodeId, nodeType, maxDepth, query, limit, offset) per AGENTS.md security rule. No more raw req.body spread. Viewer (src/viewer/index.html) - Initial /graph/query sends an explicit limit (500) rather than {}. - Distinguishes apiPost === null (server error) from { nodes: [], edges: [] } (truly empty corpus). Failure now renders a visible 'Graph query failed' banner with a Retry button instead of the 'No graph data yet. Building...' empty state. - Truncation banner above the search box: 'Showing N of M nodes (most-connected first). The full graph is too large to render at once.' so operators know the page is bounded. - state.graph gains queryError, truncated, totalNodes, totalEdges fields. Tests (test/graph.test.ts) - Unbounded {} body caps to 500 with truncated=true on a 1,200-node seed; high-degree nodes land on page 1. - limit + offset paginate without overlap on a 50-node seed. - limit clamped above MAX returns at most MAX. - Cross-page edges excluded from page response; totalEdges still counts unbounded. 121 test files / 1330 tests pass.
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
vi.mock("../src/logger.js", () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
import { registerGraphFunction } from "../src/functions/graph.js";
|
|
import type {
|
|
CompressedObservation,
|
|
GraphNode,
|
|
GraphEdge,
|
|
GraphQueryResult,
|
|
} from "../src/types.js";
|
|
|
|
function mockKV() {
|
|
const store = new Map<string, Map<string, unknown>>();
|
|
return {
|
|
get: async <T>(scope: string, key: string): Promise<T | null> => {
|
|
return (store.get(scope)?.get(key) as T) ?? null;
|
|
},
|
|
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
|
|
if (!store.has(scope)) store.set(scope, new Map());
|
|
store.get(scope)!.set(key, data);
|
|
return data;
|
|
},
|
|
delete: async (scope: string, key: string): Promise<void> => {
|
|
store.get(scope)?.delete(key);
|
|
},
|
|
list: async <T>(scope: string): Promise<T[]> => {
|
|
const entries = store.get(scope);
|
|
return entries ? (Array.from(entries.values()) as T[]) : [];
|
|
},
|
|
};
|
|
}
|
|
|
|
function mockSdk() {
|
|
const functions = new Map<string, Function>();
|
|
return {
|
|
registerFunction: (idOrOpts: string | { id: string }, handler: Function) => {
|
|
const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id;
|
|
functions.set(id, handler);
|
|
},
|
|
registerTrigger: () => {},
|
|
trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => {
|
|
const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id;
|
|
const payload = typeof idOrInput === "string" ? data : idOrInput.payload;
|
|
const fn = functions.get(id);
|
|
if (!fn) throw new Error(`No function: ${id}`);
|
|
return fn(payload);
|
|
},
|
|
};
|
|
}
|
|
|
|
const mockProvider = {
|
|
name: "test",
|
|
compress: vi.fn().mockResolvedValue(`<entities>
|
|
<entity type="file" name="src/index.ts"><property key="path">src/index.ts</property></entity>
|
|
<entity type="function" name="main"><property key="lang">typescript</property></entity>
|
|
</entities>
|
|
<relationships>
|
|
<relationship type="uses" source="src/index.ts" target="main" weight="0.9"/>
|
|
</relationships>`),
|
|
summarize: vi.fn(),
|
|
};
|
|
|
|
const testObs: CompressedObservation = {
|
|
id: "obs_1",
|
|
sessionId: "ses_1",
|
|
timestamp: "2026-02-01T10:00:00Z",
|
|
type: "file_edit",
|
|
title: "Edit index file",
|
|
facts: ["Modified main function"],
|
|
narrative: "Updated index.ts with main function",
|
|
concepts: ["typescript", "entry-point"],
|
|
files: ["src/index.ts"],
|
|
importance: 7,
|
|
};
|
|
|
|
describe("Graph Functions", () => {
|
|
let sdk: ReturnType<typeof mockSdk>;
|
|
let kv: ReturnType<typeof mockKV>;
|
|
|
|
beforeEach(() => {
|
|
sdk = mockSdk();
|
|
kv = mockKV();
|
|
vi.clearAllMocks();
|
|
registerGraphFunction(sdk as never, kv as never, mockProvider as never);
|
|
});
|
|
|
|
it("graph-extract creates nodes and edges from XML response", async () => {
|
|
const result = (await sdk.trigger("mem::graph-extract", {
|
|
observations: [testObs],
|
|
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.nodesAdded).toBe(2);
|
|
expect(result.edgesAdded).toBe(1);
|
|
|
|
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
|
|
expect(nodes.length).toBe(2);
|
|
expect(nodes.find((n) => n.name === "src/index.ts")).toBeDefined();
|
|
expect(nodes.find((n) => n.name === "main")).toBeDefined();
|
|
|
|
const edges = await kv.list<GraphEdge>("mem:graph:edges");
|
|
expect(edges.length).toBe(1);
|
|
expect(edges[0].type).toBe("uses");
|
|
});
|
|
|
|
it("graph-extract accepts self-closing entity tags", async () => {
|
|
mockProvider.compress.mockResolvedValueOnce(`<entities>
|
|
<entity type="file" name="src/index.ts"/>
|
|
<entity type="function" name="main"><property key="lang">typescript</property></entity>
|
|
</entities>
|
|
<relationships>
|
|
<relationship type="uses" source="src/index.ts" target="main" weight="0.9"/>
|
|
</relationships>`);
|
|
|
|
const result = (await sdk.trigger("mem::graph-extract", {
|
|
observations: [testObs],
|
|
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.nodesAdded).toBe(2);
|
|
expect(result.edgesAdded).toBe(1);
|
|
|
|
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
|
|
expect(nodes.some((n) => n.name === "src/index.ts")).toBe(true);
|
|
expect(nodes.some((n) => n.name === "main")).toBe(true);
|
|
|
|
const edges = await kv.list<GraphEdge>("mem:graph:edges");
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].type).toBe("uses");
|
|
});
|
|
|
|
it("graph-extract tolerates reordered attributes (#635)", async () => {
|
|
// Codex CLI's LLM tends to emit attribute order name→type and
|
|
// source→target→type rather than the hard-coded type-first /
|
|
// type/source/target/weight sequence the old parser required.
|
|
mockProvider.compress.mockResolvedValueOnce(`<entities>
|
|
<entity name="src/index.ts" type="file"/>
|
|
<entity name="main" type="function"><property key="lang">typescript</property></entity>
|
|
</entities>
|
|
<relationships>
|
|
<relationship source="src/index.ts" target="main" type="uses" weight="0.9"/>
|
|
</relationships>`);
|
|
|
|
const result = (await sdk.trigger("mem::graph-extract", {
|
|
observations: [testObs],
|
|
})) as { success: boolean; nodesAdded: number; edgesAdded: number };
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.nodesAdded).toBe(2);
|
|
expect(result.edgesAdded).toBe(1);
|
|
|
|
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
|
|
expect(nodes.find((n) => n.name === "src/index.ts")?.type).toBe("file");
|
|
expect(nodes.find((n) => n.name === "main")?.type).toBe("function");
|
|
|
|
const edges = await kv.list<GraphEdge>("mem:graph:edges");
|
|
expect(edges).toHaveLength(1);
|
|
expect(edges[0].type).toBe("uses");
|
|
expect(edges[0].weight).toBeCloseTo(0.9, 5);
|
|
});
|
|
|
|
it("graph-query with search returns matching nodes", async () => {
|
|
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
|
|
|
const result = (await sdk.trigger("mem::graph-query", {
|
|
query: "index",
|
|
})) as GraphQueryResult;
|
|
|
|
expect(result.nodes.length).toBeGreaterThanOrEqual(1);
|
|
expect(result.nodes.some((n) => n.name.includes("index"))).toBe(true);
|
|
});
|
|
|
|
it("graph-query with startNodeId does BFS traversal", async () => {
|
|
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
|
|
|
const nodes = await kv.list<GraphNode>("mem:graph:nodes");
|
|
const fileNode = nodes.find((n) => n.name === "src/index.ts")!;
|
|
|
|
const result = (await sdk.trigger("mem::graph-query", {
|
|
startNodeId: fileNode.id,
|
|
maxDepth: 2,
|
|
})) as GraphQueryResult;
|
|
|
|
expect(result.nodes.length).toBeGreaterThanOrEqual(1);
|
|
expect(result.edges.length).toBeGreaterThanOrEqual(1);
|
|
expect(result.depth).toBe(2);
|
|
});
|
|
|
|
it("graph-stats returns counts by type", async () => {
|
|
await sdk.trigger("mem::graph-extract", { observations: [testObs] });
|
|
|
|
const result = (await sdk.trigger("mem::graph-stats", {})) as {
|
|
totalNodes: number;
|
|
totalEdges: number;
|
|
nodesByType: Record<string, number>;
|
|
edgesByType: Record<string, number>;
|
|
};
|
|
|
|
expect(result.totalNodes).toBe(2);
|
|
expect(result.totalEdges).toBe(1);
|
|
expect(result.nodesByType.file).toBe(1);
|
|
expect(result.nodesByType.function).toBe(1);
|
|
expect(result.edgesByType.uses).toBe(1);
|
|
});
|
|
|
|
it("graph-extract returns error for empty observations", async () => {
|
|
const result = (await sdk.trigger("mem::graph-extract", {
|
|
observations: [],
|
|
})) as { success: boolean; error: string };
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain("No observations");
|
|
});
|
|
|
|
// #753: an unbounded {} body used to materialize every node+edge in
|
|
// one payload, which exceeded the iii state response channel on
|
|
// large corpora (11k+ nodes) and returned HTTP 500 "Invocation
|
|
// stopped". The fix caps the page at DEFAULT_GRAPH_QUERY_LIMIT (500)
|
|
// and surfaces totalNodes / totalEdges so callers know it was
|
|
// truncated.
|
|
it("caps an unbounded graph-query body to a default page and reports totals", async () => {
|
|
// Seed a graph with more nodes than the default page size.
|
|
const NODE_COUNT = 1200;
|
|
for (let i = 0; i < NODE_COUNT; i++) {
|
|
const node: GraphNode = {
|
|
id: `n_${i.toString().padStart(4, "0")}`,
|
|
type: "concept",
|
|
name: `node-${i}`,
|
|
properties: {},
|
|
firstSeen: "2026-01-01T00:00:00Z",
|
|
lastSeen: "2026-01-01T00:00:00Z",
|
|
observationCount: 1,
|
|
} as GraphNode;
|
|
await kv.set("mem:graph:nodes", node.id, node);
|
|
}
|
|
// A few edges among the first 50 nodes so high-degree ranking has
|
|
// something to grade.
|
|
for (let i = 0; i < 50; i++) {
|
|
const edge: GraphEdge = {
|
|
id: `e_${i}`,
|
|
type: "related_to",
|
|
sourceNodeId: `n_${i.toString().padStart(4, "0")}`,
|
|
targetNodeId: `n_${((i + 1) % 50).toString().padStart(4, "0")}`,
|
|
weight: 1,
|
|
evidence: [],
|
|
firstSeen: "2026-01-01T00:00:00Z",
|
|
lastSeen: "2026-01-01T00:00:00Z",
|
|
} as GraphEdge;
|
|
await kv.set("mem:graph:edges", edge.id, edge);
|
|
}
|
|
|
|
const unbounded = (await sdk.trigger(
|
|
"mem::graph-query",
|
|
{},
|
|
)) as GraphQueryResult;
|
|
|
|
expect(unbounded.totalNodes).toBe(NODE_COUNT);
|
|
expect(unbounded.nodes.length).toBe(500);
|
|
expect(unbounded.truncated).toBe(true);
|
|
expect(unbounded.limit).toBe(500);
|
|
expect(unbounded.offset).toBe(0);
|
|
// The 50 connected nodes should be on the first page since the
|
|
// default ranks by degree.
|
|
const connectedOnPage = unbounded.nodes.filter((n) => /^n_00[0-4]\d$/.test(n.id));
|
|
expect(connectedOnPage.length).toBe(50);
|
|
});
|
|
|
|
it("honors limit and offset for paged graph-query traversal", async () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const node: GraphNode = {
|
|
id: `p_${i.toString().padStart(3, "0")}`,
|
|
type: "concept",
|
|
name: `node-${i}`,
|
|
properties: {},
|
|
firstSeen: "2026-01-01T00:00:00Z",
|
|
lastSeen: "2026-01-01T00:00:00Z",
|
|
observationCount: 1,
|
|
} as GraphNode;
|
|
await kv.set("mem:graph:nodes", node.id, node);
|
|
}
|
|
|
|
const page1 = (await sdk.trigger("mem::graph-query", {
|
|
limit: 10,
|
|
offset: 0,
|
|
})) as GraphQueryResult;
|
|
const page2 = (await sdk.trigger("mem::graph-query", {
|
|
limit: 10,
|
|
offset: 10,
|
|
})) as GraphQueryResult;
|
|
|
|
expect(page1.nodes.length).toBe(10);
|
|
expect(page2.nodes.length).toBe(10);
|
|
expect(page1.totalNodes).toBe(50);
|
|
expect(page2.totalNodes).toBe(50);
|
|
expect(page1.truncated).toBe(true);
|
|
// The two pages must not overlap.
|
|
const overlap = page1.nodes.filter((n) =>
|
|
page2.nodes.some((p) => p.id === n.id),
|
|
);
|
|
expect(overlap.length).toBe(0);
|
|
});
|
|
|
|
it("clamps an explicit limit above the cap to the cap value", async () => {
|
|
for (let i = 0; i < 10; i++) {
|
|
await kv.set("mem:graph:nodes", `c_${i}`, {
|
|
id: `c_${i}`,
|
|
type: "concept",
|
|
name: `n-${i}`,
|
|
properties: {},
|
|
firstSeen: "2026-01-01T00:00:00Z",
|
|
lastSeen: "2026-01-01T00:00:00Z",
|
|
observationCount: 1,
|
|
});
|
|
}
|
|
|
|
const huge = (await sdk.trigger("mem::graph-query", {
|
|
limit: 999999,
|
|
})) as GraphQueryResult;
|
|
expect(huge.limit).toBeLessThanOrEqual(5000);
|
|
expect(huge.nodes.length).toBe(10);
|
|
expect(huge.truncated).toBe(false);
|
|
});
|
|
|
|
it("paginate excludes edges whose endpoints fall outside the page", async () => {
|
|
for (let i = 0; i < 60; i++) {
|
|
await kv.set("mem:graph:nodes", `x_${i.toString().padStart(3, "0")}`, {
|
|
id: `x_${i.toString().padStart(3, "0")}`,
|
|
type: "concept",
|
|
name: `n-${i}`,
|
|
properties: {},
|
|
firstSeen: "2026-01-01T00:00:00Z",
|
|
lastSeen: "2026-01-01T00:00:00Z",
|
|
observationCount: 1,
|
|
});
|
|
}
|
|
// Make the first 10 nodes a tightly connected cluster so they
|
|
// rank highest by degree and land on the page deterministically.
|
|
for (let i = 0; i < 10; i++) {
|
|
const next = (i + 1) % 10;
|
|
await kv.set("mem:graph:edges", `cluster_${i}`, {
|
|
id: `cluster_${i}`,
|
|
type: "related_to",
|
|
sourceNodeId: `x_${i.toString().padStart(3, "0")}`,
|
|
targetNodeId: `x_${next.toString().padStart(3, "0")}`,
|
|
weight: 1,
|
|
evidence: [],
|
|
firstSeen: "2026-01-01T00:00:00Z",
|
|
lastSeen: "2026-01-01T00:00:00Z",
|
|
});
|
|
}
|
|
// Cross-page edge: source in the high-degree cluster (on page),
|
|
// target is an isolated node (degree 1; cluster nodes have
|
|
// degree 2 so the target ranks below the cap).
|
|
await kv.set("mem:graph:edges", "cross", {
|
|
id: "cross",
|
|
type: "related_to",
|
|
sourceNodeId: "x_005",
|
|
targetNodeId: "x_055",
|
|
weight: 1,
|
|
evidence: [],
|
|
firstSeen: "2026-01-01T00:00:00Z",
|
|
lastSeen: "2026-01-01T00:00:00Z",
|
|
});
|
|
|
|
const page = (await sdk.trigger("mem::graph-query", {
|
|
limit: 10,
|
|
offset: 0,
|
|
})) as GraphQueryResult;
|
|
// The cross-page edge should not appear in the page response —
|
|
// otherwise the viewer renders a dangling line to a node it
|
|
// doesn't have.
|
|
expect(page.edges.find((e) => e.id === "cross")).toBeUndefined();
|
|
// Cluster edges among page nodes ARE present.
|
|
expect(page.edges.filter((e) => e.id.startsWith("cluster_")).length).toBe(10);
|
|
// totalEdges counts every edge in the full result universe.
|
|
expect(page.totalEdges).toBe(11);
|
|
});
|
|
});
|