diff --git a/src/functions/graph.ts b/src/functions/graph.ts
index f24e52dfd..09c23af06 100644
--- a/src/functions/graph.ts
+++ b/src/functions/graph.ts
@@ -15,6 +15,22 @@ import {
import { recordAudit } from "./audit.js";
import { logger } from "../logger.js";
+// Parse all key="value" pairs from a tag's attribute string, in any
+// order. The previous parser hard-coded attribute order
+// (type before name on , type/source/target/weight on
+// ) and silently dropped nodes/edges when the upstream
+// LLM emitted attributes in a different order — Codex in particular
+// likes to lead with `name=` (#635).
+function parseAttrs(raw: string): Record {
+ const attrs: Record = {};
+ const attrRegex = /([A-Za-z_][\w:-]*)="([^"]*)"/g;
+ let m;
+ while ((m = attrRegex.exec(raw)) !== null) {
+ attrs[m[1]] = m[2];
+ }
+ return attrs;
+}
+
function parseGraphXml(
xml: string,
observationIds: string[],
@@ -26,27 +42,25 @@ function parseGraphXml(
const edges: GraphEdge[] = [];
const now = new Date().toISOString();
- // Lazy `[^>]*?` so the self-closing alternation gets a chance before
- // greedy attribute matching consumes the trailing `/` and the regex
- // falls through to the explicit-close branch, which then runs ahead to
- // the *next* entity's `` and silently drops a node (#494
- // follow-up: greedy `[^>]*` was eating the `/` and merging two entity
- // declarations into one match).
- const entityRegex =
- /]*?(?:\/>|>([\s\S]*?)<\/entity>)/g;
- let match;
- while ((match = entityRegex.exec(xml)) !== null) {
- const type = match[1] as GraphNode["type"];
- const name = match[2];
- const propsBlock = match[3] ?? "";
- const properties: Record = {};
+ // Two passes because can be self-closing or have a body
+ // ( children). The self-closing form needs `[^>]*[^/]` on
+ // the attr group so the trailing `/` isn't swallowed into the match
+ // (root cause of #494). The explicit-close form picks up the
+ // property block.
+ const entitySelfClose = /]*?)\/>/g;
+ const entityWithBody = /]*[^/])>([\s\S]*?)<\/entity>/g;
+ const addEntity = (rawAttrs: string, propsBlock = ""): void => {
+ const attrs = parseAttrs(rawAttrs);
+ const type = attrs["type"] as GraphNode["type"] | undefined;
+ const name = attrs["name"];
+ if (!type || !name) return;
+ const properties: Record = {};
const propRegex = /([^<]*)<\/property>/g;
let propMatch;
while ((propMatch = propRegex.exec(propsBlock)) !== null) {
properties[propMatch[1]] = propMatch[2];
}
-
nodes.push({
id: generateId("gn"),
type,
@@ -55,31 +69,38 @@ function parseGraphXml(
sourceObservationIds: observationIds,
createdAt: now,
});
+ };
+
+ let match;
+ while ((match = entitySelfClose.exec(xml)) !== null) {
+ addEntity(match[1]);
+ }
+ while ((match = entityWithBody.exec(xml)) !== null) {
+ addEntity(match[1], match[2]);
}
- const relRegex =
- //g;
+ const relRegex = /]*?)\/>/g;
while ((match = relRegex.exec(xml)) !== null) {
- const type = match[1] as GraphEdge["type"];
- const sourceName = match[2];
- const targetName = match[3];
- const parsedWeight = parseFloat(match[4]);
- const weight = Number.isNaN(parsedWeight) ? 0.5 : parsedWeight;
+ const attrs = parseAttrs(match[1]);
+ const type = attrs["type"] as GraphEdge["type"] | undefined;
+ const sourceName = attrs["source"];
+ const targetName = attrs["target"];
+ if (!type || !sourceName || !targetName) continue;
+ const parsedWeight = parseFloat(attrs["weight"] ?? "");
+ const weight = Number.isFinite(parsedWeight) ? parsedWeight : 0.5;
const sourceNode = nodes.find((n) => n.name === sourceName);
const targetNode = nodes.find((n) => n.name === targetName);
-
- if (sourceNode && targetNode) {
- edges.push({
- id: generateId("ge"),
- type,
- sourceNodeId: sourceNode.id,
- targetNodeId: targetNode.id,
- weight: Math.max(0, Math.min(1, weight)),
- sourceObservationIds: observationIds,
- createdAt: now,
- });
- }
+ if (!sourceNode || !targetNode) continue;
+ edges.push({
+ id: generateId("ge"),
+ type,
+ sourceNodeId: sourceNode.id,
+ targetNodeId: targetNode.id,
+ weight: Math.max(0, Math.min(1, weight)),
+ sourceObservationIds: observationIds,
+ createdAt: now,
+ });
}
return { nodes, edges };
diff --git a/test/graph.test.ts b/test/graph.test.ts
index ec08ed8c3..ca50264ce 100644
--- a/test/graph.test.ts
+++ b/test/graph.test.ts
@@ -132,6 +132,36 @@ describe("Graph Functions", () => {
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(`
+
+typescript
+
+
+
+`);
+
+ 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("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("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] });