Skip to content
Merged
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
2 changes: 1 addition & 1 deletion context-graph/actions-graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Actions Graph should consume runtime activity through Agent Context Graph when p

- **Action**: Individual actions with type-specific labels
- Labels: `ToolCall`, `ToolResult`, `Message` (plus a role label `UserMessage`/`AssistantMessage`/`SystemMessage`), `StructuredOutput`, `SubagentEvent`, `PermissionRequest`, `ErrorEvent`, `RateLimitEvent`
- Properties: `action_id`, `action_type`, `timestamp`, `status`, `duration_ms`, `parent_action_id`, `tool_name`, `is_error`, `is_mcp`, `properties` (type-specific), `metadata`
- Properties: `action_id`, `action_type`, `timestamp`, `status`, `duration_ms`, `parent_action_id`, `tool_name`, `is_error`, `is_mcp`, `text` (user and assistant messages only: the plain message text), `properties` (type-specific), `metadata`
- The session link is the `HAS_ACTION` edge, not a property — there is no `session_id` on `(:Action)`.

- **Tool**: Tool definitions
Expand Down
27 changes: 27 additions & 0 deletions context-graph/actions-graph/src/actions_graph/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,30 @@ class _BaseActionKwargs(TypedDict):
metadata: dict[str, Any]


#: The conversation turns: what the user and the assistant said, not tool traffic or system prompts.
_TURN_TYPES = (ActionType.USER_MESSAGE, ActionType.ASSISTANT_MESSAGE)


def _message_text(action: Action) -> str | None:
"""A turn's plain text, or None for anything that isn't a user or assistant message.

Stored as its own property because ``properties`` is a JSON string that
Cypher can't unpack, and both the full-text and the vector index need a
plain property to cover. Content blocks contribute their ``text`` blocks only.
"""
if not isinstance(action, Message) or action.action_type not in _TURN_TYPES:
return None
if isinstance(action.content, str):
text = action.content
else:
text = "\n".join(
block["text"]
for block in action.content
if block.get("type") == "text" and isinstance(block.get("text"), str)
)
return text or None


class ActionsGraph:
"""Store and query LLM actions and sessions in Memgraph.

Expand Down Expand Up @@ -635,6 +659,7 @@ def record_action(self, action: Action, *, container_agent_id: str | None = None
_tool_name: str | None = props.get("tool_name") or getattr(action, "tool_name", None)
_is_error: bool = bool(props.get("is_error", False))
_is_mcp: bool = bool(props.get("is_mcp", False))
text = _message_text(action)

# Create the action node
self._db.query(
Expand All @@ -650,6 +675,7 @@ def record_action(self, action: Action, *, container_agent_id: str | None = None
tool_name: $tool_name,
is_error: $is_error,
is_mcp: $is_mcp,
text: $text,
properties: $properties
}})
""",
Expand All @@ -664,6 +690,7 @@ def record_action(self, action: Action, *, container_agent_id: str | None = None
"tool_name": _tool_name,
"is_error": _is_error,
"is_mcp": _is_mcp,
"text": text,
"properties": json.dumps(props),
},
)
Expand Down
41 changes: 41 additions & 0 deletions context-graph/actions-graph/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,47 @@ def test_record_message(self, graph: ActionsGraph):
assert message.role == MessageRole.USER
assert message.action_type == ActionType.USER_MESSAGE

def test_only_turns_carry_plain_text(self, graph: ActionsGraph):
"""User and assistant messages get a plain ``text`` property the text and
vector indexes can cover; tool traffic and system prompts get none."""
graph.create_session(Session(session_id="text-session"))
user = graph.record_message(session_id="text-session", role=MessageRole.USER, content="Use uv, not pip")
assistant = graph.record_message(
session_id="text-session",
role=MessageRole.ASSISTANT,
content=[
{"type": "text", "text": "Switching to uv."},
{"type": "tool_use", "name": "Bash", "input": {"command": "uv sync"}},
{"type": "text", "text": "Done."},
],
)
system = graph.record_message(session_id="text-session", role=MessageRole.SYSTEM, content="You are helpful")
call = graph.record_tool_call(session_id="text-session", tool_name="Bash", tool_input={"command": "uv sync"})
result = graph.record_tool_result(
session_id="text-session", tool_use_id="t1", tool_name="Bash", content="Resolved 12 packages"
)

texts = {
row["id"]: row["text"]
for row in graph.db.query("MATCH (a:Action) RETURN a.action_id AS id, a.text AS text")
}
assert texts[user.action_id] == "Use uv, not pip"
assert texts[assistant.action_id] == "Switching to uv.\nDone."
assert texts[system.action_id] is None
assert texts[call.action_id] is None
assert texts[result.action_id] is None

def test_an_empty_message_has_no_text(self, graph: ActionsGraph):
graph.create_session(Session(session_id="empty-session"))
message = graph.record_message(
session_id="empty-session",
role=MessageRole.ASSISTANT,
content=[{"type": "tool_use", "name": "Read", "input": {}}],
)

rows = graph.db.query("MATCH (a:Action {action_id: $id}) RETURN a.text AS text", {"id": message.action_id})
assert rows[0]["text"] is None

def test_action_sequence(self, graph: ActionsGraph):
"""Test that actions form a sequence with FOLLOWED_BY."""
session = Session(session_id="sequence-session")
Expand Down
8 changes: 4 additions & 4 deletions context-graph/eval/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,10 @@ there is no distilled memory for it to read.
context-graph-eval run --retrieval-strategy text-search --judge-model anthropic:claude-sonnet-4-5-20250929
```

Mechanically: `text_search.ensure_turn_text_index` materializes a plain
`text` property on every `Action` (turn text lives inside `properties` as a
JSON string that Memgraph — no APOC here — cannot unpack in Cypher, so this
is done in Python), then creates a Memgraph `TEXT INDEX` over it. Each
Mechanically: `text_search.ensure_turn_text_index` creates a Memgraph
`TEXT INDEX` over `Action.text`, the plain message text `actions-graph` writes
on user and assistant messages (the full content stays inside `properties` as
a JSON string, which Cypher can't unpack). Each
question calls `text_search.search_all` directly — no agent, no Cypher
generation, no query loop — and hands the top matches to the **exact same
`answer_prompt`** the graph-agent baseline uses, so a quality difference
Expand Down
5 changes: 4 additions & 1 deletion context-graph/eval/src/context_graph_eval/retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ def graph_schema(graph: ReadOnlyGraph) -> str:
#: into the agent's prompt. Length is what separates an enum from a sentence.
_MAX_DISTINCT_VALUES = 6
_MAX_SCHEMA_VALUE_LENGTH = 40
#: Properties that hold content by definition: a turn's message text, an entity's
#: name. Never sampled, however short and few their values are.
_CONTENT_KEYS = frozenset({"text"})

#: Bounds the introspection itself, so describing the graph cannot become more
#: expensive than querying it.
Expand Down Expand Up @@ -328,7 +331,7 @@ def _describe_properties(graph: ReadOnlyGraph, labels: list[str]) -> list[str]:
described.append(f" {key}: JSON string, keys: {', '.join(json_keys)} (values are free text)")
continue

if _is_enumerable(sample):
if key not in _CONTENT_KEYS and _is_enumerable(sample):
described.append(f" {key}: {', '.join(sorted(str(v) for v in sample))}")
else:
described.append(f" {key}: free text (search it, do not match it exactly)")
Expand Down
2 changes: 1 addition & 1 deletion context-graph/eval/src/context_graph_eval/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class BatchReport:
scored: list[Scored] = field(default_factory=list)
reconciled: int = 0
reconcile_failures: int = 0
#: Turns text_search.ensure_turn_text_index materialized and indexed.
#: Turns text_search.ensure_turn_text_index indexed.
#: Always 0 for retrieval_strategy="graph-agent", which never calls it.
indexed_turns: int = 0

Expand Down
30 changes: 6 additions & 24 deletions context-graph/eval/src/context_graph_eval/text_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
the best possible text-search baseline".
"""

import json
import re
import time
from dataclasses import dataclass
Expand Down Expand Up @@ -50,18 +49,16 @@ def query(self, cypher: str, params: dict[str, Any] | None = None, /) -> list[di

@dataclass(frozen=True)
class Indexed:
"""What indexing wrote."""
"""What indexing covered."""

turns: int


def ensure_turn_text_index(graph: "ActionsGraph") -> Indexed:
"""Materialize a plain-text ``text`` property on every turn, index it, and prove search runs.
"""Index every turn's ``text``, and prove search runs.

Turn text lives inside ``Action.properties`` as a JSON string, which
Memgraph (no APOC) cannot unpack in Cypher -- so ``content`` is extracted
in Python and written back as a plain property the index can cover.
Actions with no ``content`` string, such as tool calls, are skipped.
``actions-graph`` writes ``text`` on user and assistant messages when it
records them, so tool calls and other actions are never indexed.

Ends with a probe through the same :func:`_search` call retrieval makes.
A search that cannot run fails every question the same way, and the
Expand All @@ -73,22 +70,7 @@ def ensure_turn_text_index(graph: "ActionsGraph") -> Indexed:
rejects the query retrieval would send.
"""
db = graph.db
rows = db.query("MATCH (a:Action) WHERE a.text IS NULL RETURN a.action_id AS action_id, a.properties AS properties")

materialized = []
for row in rows:
try:
content = json.loads(row["properties"] or "{}").get("content")
except ValueError:
content = None
if isinstance(content, str) and content:
materialized.append({"action_id": row["action_id"], "text": content})

if materialized:
db.query(
"UNWIND $rows AS row MATCH (a:Action {action_id: row.action_id}) SET a.text = row.text",
{"rows": materialized},
)
turns = db.query("MATCH (a:Action) WHERE a.text IS NOT NULL RETURN count(a) AS n")[0]["n"]

if not _index_exists(db):
db.query(f"CREATE TEXT INDEX {TEXT_INDEX_NAME} ON :Action(text);")
Expand All @@ -99,7 +81,7 @@ def ensure_turn_text_index(graph: "ActionsGraph") -> Indexed:
_search(db, "probe", limit=1)
except Exception as exc:
raise RuntimeError(f"text search cannot run on this Memgraph: {exc}") from exc
return Indexed(turns=len(materialized))
return Indexed(turns=turns)


def _index_exists(db: _Queryable) -> bool:
Expand Down
4 changes: 2 additions & 2 deletions context-graph/eval/tests/test_text_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ async def complete(self, prompt: str) -> str:


def test_indexing_skips_actions_with_no_content(eval_graph: ActionsGraph):
"""A tool-call Action's properties has no 'content' key -- materializing a
'text' property for it would index noise the question was never about."""
"""A tool call has no turn text, so it is never indexed: it would be noise
the question was never about."""
_plant(eval_graph, "s1", role=MessageRole.USER, content="I adopted a beagle named Max")
eval_graph.record_tool_call(session_id="s1", tool_name="Read", tool_input={"file_path": "notes.md"})

Expand Down
Loading