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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Memgraph graph.

| Package | Role |
|---|---|
| `agent-context-graph` | Event hub. Normalizes runtime hooks / SDK activity into a shared Event Protocol and routes it to graph connectors. |
| `agent-context-graph` | Event hub. Normalizes runtime hooks / SDK activity into a shared Event Protocol and routes it to graph connectors. Also serves components' read tools (e.g. sessions-graph's `recall`) to the harness's model over stdio MCP (`agent-context-graph mcp`). |
| `actions-graph` | Records tool calls/results/messages/subagent activity as `(:Action)`/`(:Agent)` nodes — observability, not memory. |
| `skills-graph` | Tracks Agent-Skills-spec `(:Skill)` usage per session. |
| `sessions-graph` | Owns `(:User)`/`(:Session)`, durable `(:Memory)` writes/recall, and session reconciliation into `(:Episode)` + extracted entities. |
Expand Down
4 changes: 4 additions & 0 deletions context-graph/agent-context-graph/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ Python object implementing `RuntimeCLIPlugin`. Package publishes it via `agent_c
Not a **Runtime Plugin**: plugin installs host-facing files; registration lets Agent Context Graph discover runtime support via `importlib.metadata.entry_points()`. Adding one needs no central registry change.
_Avoid_: Runtime Plugin (already means distribution package), Runtime Adapter (a Runtime Registration *references* one via `adapter_class`, isn't one)

**Tool Registration**:
Object implementing `Tool` (`tools.py`): a name, a description the model reads, an input schema, the connector whose graph it reads, an optional session-start hint, and `call(arguments, config)`. A graph component publishes it via the `agent_context_graph.tools` entry-point group; `agent-context-graph mcp` serves every registered tool, and the CLI runs one by name. Identity + connection come from **Hook Configuration**, never from tool arguments.
_Avoid_: MCP tool (the protocol it is served over, not the thing), Graph Connector (writes events; a tool reads)

## Relationships

- **Agent Context Graph** belongs to broader **Context Graph** family.
Expand Down
59 changes: 57 additions & 2 deletions context-graph/agent-context-graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ Runtime plugins are the distribution layer for host-specific hook wiring. They i
For command-hook runtimes such as Codex and Claude Code, prefer a user-level tool install:

```bash
uv tool install agent-context-graph --with "skills-graph[agent-context-graph]"
uv tool install "agent-context-graph[mcp]" --with "skills-graph[agent-context-graph]"
```

The `mcp` extra is what `agent-context-graph mcp` serves [recall](#recall-memory-for-the-harnesss-model) with.

Or use the plugin bootstrap scripts; they fall back to `uvx` if the tool is not installed yet.

For SDK usage inside an application:
Expand Down Expand Up @@ -209,6 +211,8 @@ OK memgraph: reachable
OK connector:skills-graph: installed=...; memgraph=reachable
OK connector:actions-graph: installed=...; memgraph=reachable
OK connector:sessions-graph: installed=...; memgraph=reachable
OK embeddings: BAAI/bge-small-en-v1.5 (384 dimensions) inside Memgraph
OK mcp: serves recall
OK runtime:claude-code: strict hook smoke passed
```

Expand All @@ -234,13 +238,19 @@ anthropic_api_key = ""

[reconcile]
auto_reconcile = true

[recall]
embedding_model = "BAAI/bge-small-en-v1.5"
turns_k = "8"
```

`[llm]` and `[reconcile]` are only relevant if you enable sessions-graph's
auto-trigger reconciliation (see
[sessions-graph § reconciliation](../sessions-graph/README.md#session-reconciliation)).
`[reconcile]` is omitted entirely from a freshly-bootstrapped file — absent
means "never configured," distinct from an explicit `auto_reconcile = false`.
`[recall]` is optional too: without it, recall runs with the widths the
benchmark measured; see [Recall](#recall-memory-for-the-harnesss-model).

Manage it with:

Expand All @@ -249,7 +259,8 @@ agent-context-graph config show
agent-context-graph config get memgraph.url
agent-context-graph config set <key> <value>
# keys: identity.user_id, memgraph.{url,user,password,database},
# llm.{openai_api_key,anthropic_api_key}, reconcile.auto_reconcile
# llm.{openai_api_key,anthropic_api_key}, reconcile.auto_reconcile,
# recall.embedding_model
```

Environment variables (`MEMGRAPH_URL`, `MEMGRAPH_USER`, `MEMGRAPH_PASSWORD`, `MEMGRAPH_DATABASE`, `AGENT_CONTEXT_GRAPH_USER_ID`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) are consulted **only at bootstrap time** — if set, `bootstrap` persists them into the config file. Exporting them later has no effect on running hooks; use `config set` instead.
Expand All @@ -261,6 +272,50 @@ Unlike the keys above, nobody has that env var exported for an unrelated
reason — it's only ever set via `agent-context-graph config set
reconcile.auto_reconcile true`.

### Recall: memory for the harness's model

The hooks write sessions into Memgraph; recall reads them back. With
sessions-graph installed, `agent-context-graph mcp` serves a `recall(question)`
tool over stdio MCP, and the Codex and Claude Code plugins bundle that server,
so installing a plugin is all it takes. The tool returns context, not an answer:
the dated messages and facts from the user's own sessions that match the
question, behind a header with the rules for reading them. The harness's model
answers from those rows.

At session start the hook adds one line to the model's context saying the tool
exists. It never pushes retrieved content; the model calls `recall` when a
question needs memory.

The same search from a shell:

```bash
agent-context-graph recall "what did we decide about the deploy window?"
agent-context-graph recall --json "..." # the turns and facts as JSON
```

Whose memory is searched comes from `identity.user_id` in the config file, never
from the tool call, so a model can only read its own user's sessions.
`[recall]` overrides the search's lanes and widths, all optional:

| Key | Default | What it sets |
|---|---|---|
| `embedding_model` | `BAAI/bge-small-en-v1.5` | The model Memgraph embeds with; set with `config set recall.embedding_model` |
| `lanes` | all five | Comma-separated subset of `turns,text,entities,facts,user_facts` |
| `turns_k`, `text_k` | `8`, `8` | Messages found by vector and by text search |
| `entities_k`, `edges_per_entity` | `15`, `6` | Entities matched, and facts read from each |
| `facts_k`, `fact_turns_k` | `15`, `8` | Facts matched, and the messages they were read from |
| `user_fact_types`, `user_facts_k` | `2`, `30` | Relation types gathered whole, for counting questions |
| `turn_chars` | `1500` | Characters shown per message |

Recall needs Memgraph with MAGE (`memgraph/memgraph-mage`) for its vector lanes;
without it, it answers from text search and says so. `doctor` checks both
(`embeddings` and `mcp`).

**What the model sees.** A tool result carries the rendered text only. Claude
Code and Codex both show the model a result's `structuredContent` JSON
*instead of* its text when both are present, and the text is the form recall
was benchmarked in; the JSON is what `--json` prints.

### OpenAI Codex Plugin

Codex hook configuration can be installed as a user-level Codex plugin.
Expand Down
4 changes: 4 additions & 0 deletions context-graph/agent-context-graph/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ claude-code = "agent_context_graph.adapters.claude_code:PLUGIN"
claude = [
"claude-agent-sdk>=0.1.0",
]
mcp = [
"mcp>=1.23.0",
]
openai = [
"openai-agents>=0.1.0",
]
test = [
"mcp>=1.23.0",
"pytest>=9.0.3",
"pytest-asyncio>=0.24.0",
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

import os
import stat
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -92,6 +92,9 @@ class HookConfig:
auto_reconcile: bool | None = _RECONCILE_DEFAULTS["auto_reconcile"]
#: The model recall embeds with; None means the consumer's own default.
embedding_model: str | None = None
#: Every other ``[recall]`` key (lanes and widths), as written: the recall
#: tool validates them, this module only keeps them across rewrites.
recall_settings: dict[str, str] = field(default_factory=dict)


def load_config() -> HookConfig:
Expand Down Expand Up @@ -234,6 +237,7 @@ def write_config(
anthropic_api_key=final_anthropic_api_key,
auto_reconcile=final_auto_reconcile,
embedding_model=final_embedding_model,
recall_settings=existing.recall_settings,
)

path = config_file()
Expand Down Expand Up @@ -266,8 +270,8 @@ def write_full_config(
``OPENAI_API_KEY``/`MEMGRAPH_PASSWORD` set) — it is only ever set via
``config set reconcile.auto_reconcile``. Re-running bootstrap must not
silently revert it to off, so ``auto_reconcile`` is preserved from the
existing file unless explicitly given here. ``embedding_model`` is
preserved the same way: it, too, is only ever set via ``config set``.
existing file unless explicitly given here. ``[recall]`` is preserved the
same way: it is only ever set via ``config set`` or by editing the file.
"""
global _cached_config

Expand All @@ -284,6 +288,7 @@ def write_full_config(
anthropic_api_key=anthropic_api_key,
auto_reconcile=final_auto_reconcile,
embedding_model=existing.embedding_model,
recall_settings=existing.recall_settings,
)

path = config_file()
Expand Down Expand Up @@ -335,6 +340,7 @@ def _read_config_file() -> HookConfig:
anthropic_api_key=llm.get("anthropic_api_key", _LLM_DEFAULTS["anthropic_api_key"]),
auto_reconcile=parse_bool_flag(auto_reconcile_raw) if auto_reconcile_raw is not None else None,
embedding_model=recall.get("embedding_model") or None,
recall_settings={key: value for key, value in recall.items() if key != "embedding_model"},
)


Expand Down Expand Up @@ -378,14 +384,15 @@ def _render_config(
anthropic_api_key: str,
auto_reconcile: bool | None,
embedding_model: str | None = None,
recall_settings: dict[str, str] | None = None,
) -> str:
"""Render the full config file content.

The ``[reconcile]`` section is omitted entirely when ``auto_reconcile`` is
``None`` (never configured), so a fresh read of the file resolves it back
to ``None`` rather than a concrete ``false`` — see
:func:`resolve_auto_reconcile` for why that distinction matters.
``[recall]`` is likewise omitted while ``embedding_model`` is unset.
``[recall]`` is likewise omitted while it holds nothing.
"""
lines = [
"# Context Graph hook configuration",
Expand All @@ -407,8 +414,9 @@ def _render_config(
]
if auto_reconcile is not None:
lines += ["", "[reconcile]", f"auto_reconcile = {'true' if auto_reconcile else 'false'}"]
if embedding_model:
lines += ["", "[recall]", f'embedding_model = "{embedding_model}"']
recall = {"embedding_model": embedding_model, **(recall_settings or {})} if embedding_model else recall_settings
if recall:
lines += ["", "[recall]", *(f'{key} = "{value}"' for key, value in recall.items())]
lines.append("")
return "\n".join(lines)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
doctor Check runtime hook dependencies and Memgraph connectivity.
setup <runtime> Configure an agent runtime.
hook <command> Configure or run command hooks.
mcp Serve the registered tools (recall, ...) over stdio MCP.
recall <question> Search your own past sessions; --json for the data.
"""


Expand All @@ -49,6 +51,12 @@ def main(argv: Sequence[str] | None = None) -> int:
if command == "doctor":
return _doctor(args[1:])

if command == "mcp":
return _mcp(args[1:])

if command == "recall":
return _recall(args[1:])

if command == "hook":
from agent_context_graph.hooks.cli import main as hook_main

Expand Down Expand Up @@ -193,6 +201,43 @@ def _config(argv: list[str]) -> int:
return 2


def _mcp(argv: list[str]) -> int:
argparse.ArgumentParser(
prog="agent-context-graph mcp", description="Serve the registered tools over stdio MCP."
).parse_args(argv)
try:
from agent_context_graph.mcp_server import serve
except ImportError as exc:
print(f"agent-context-graph mcp needs the mcp extra: uv tool install 'agent-context-graph[mcp]' ({exc})",
file=sys.stderr) # fmt: skip
return 1
return serve()


def _recall(argv: list[str]) -> int:
from agent_context_graph.adapters._identity import load_config
from agent_context_graph.tools import ToolError, load_tools

parser = argparse.ArgumentParser(
prog="agent-context-graph recall", description="Search your own past sessions, as the recall tool does."
)
parser.add_argument("question", nargs="+", help="What to look for, as you would ask it.")
parser.add_argument("--json", action="store_true", help="Print the turns and facts as JSON.")
args = parser.parse_args(argv)

tool = load_tools().get("recall")
if tool is None:
print("No recall tool is installed: it comes with sessions-graph[agent-context-graph].", file=sys.stderr)
return 1
try:
result = tool.call({"question": " ".join(args.question)}, load_config())
except ToolError as exc:
print(str(exc), file=sys.stderr)
return 1
print(json.dumps(result.structured, indent=2) if args.json else result.text)
return 0


def _bootstrap(argv: list[str]) -> int:
from agent_context_graph.hooks.runtime_plugin import load_runtime_plugins

Expand Down Expand Up @@ -270,7 +315,7 @@ def _bootstrap(argv: list[str]) -> int:
print(f"OK uv: {uv}")
print(f"OK memgraph: bolt://{host}:{port} reachable")

install_cmd = [uv, "tool", "install", "agent-context-graph"]
install_cmd = [uv, "tool", "install", "agent-context-graph[mcp]"]
for connector in connectors:
requirement = _connector_requirement(connector)
if requirement is None:
Expand Down Expand Up @@ -379,6 +424,7 @@ def _doctor(argv: list[str]) -> int:
checks.append(_check_connector(connector))
if any(connector.strip().replace("_", "-") == "sessions-graph" for connector in connectors):
checks.append(_check_embeddings())
checks.append(_check_mcp())
checks.append(_check_runtime(args.runtime, connectors))

ok = all(check["ok"] for check in checks)
Expand Down Expand Up @@ -572,6 +618,25 @@ def _check_embeddings() -> _CheckResult:
driver.close()


def _check_mcp() -> _CheckResult:
"""Whether ``agent-context-graph mcp`` can start and serves recall."""
from agent_context_graph.tools import load_tools

try:
import mcp # noqa: F401 -- only whether the extra is installed
except ImportError:
return {
"name": "mcp",
"ok": False,
"detail": "the mcp extra is missing, so the recall tool can't be served — "
"run: uv tool install 'agent-context-graph[mcp]' --with 'sessions-graph[agent-context-graph]'",
}
names = sorted(load_tools())
if "recall" not in names:
return {"name": "mcp", "ok": False, "detail": f"no recall tool registered (tools: {names or 'none'})"}
return {"name": "mcp", "ok": True, "detail": f"serves {', '.join(names)}"}


def _check_runtime(runtime: str, connectors: list[str]) -> _CheckResult:
try:
from agent_context_graph.hooks.runner import create_link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,41 @@ def run_hook(plugin: RuntimeCLIPlugin, argv: Sequence[str] | None = None) -> int
link = create_link(connector_names, memgraph_env=memgraph_env)
adapter = plugin.adapter_class(link, session_id=args.session_id)
adapter.handle_payload(payload)
response = plugin.response_for_payload(payload)
if response is not None:
print(json.dumps(response))
_print_response(plugin, payload, connector_names)
except Exception as exc:
strict_env = os.environ.get(f"{_env_prefix(plugin.name)}_STRICT") == "1"
if args.strict or strict_env:
raise
response = plugin.response_for_payload(payload)
if response is not None:
print(json.dumps(response))
_print_response(plugin, payload, connector_names)
_debug_log(plugin.name, f"agent-context-graph {plugin.name} hook skipped: {exc}")
return 0


def _print_response(plugin: RuntimeCLIPlugin, payload: dict[str, Any], connector_names: list[str]) -> None:
response = plugin.response_for_payload(payload) or {}
if payload.get("hook_event_name") == "SessionStart":
response.update(session_start_context(connector_names))
if response:
print(json.dumps(response))


def session_start_context(connector_names: list[str]) -> dict[str, Any]:
"""The SessionStart output telling the model which memory tools it has; empty when none.

One line per tool, never retrieved content: the model calls the tool when
a question needs memory (#394). Claude Code and Codex read the same shape.
"""
from agent_context_graph.tools import session_hints

try:
hints = session_hints(connector_names)
except Exception: # A tool that fails to load must not fail the hook.
return {}
if not hints:
return {}
return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "\n".join(hints)}}


def _add_skills_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
try:
from skills_graph import SkillGraph
Expand Down
Loading
Loading