From 196fd01896c3f750327f8494ea69497c17cd21bd Mon Sep 17 00:00:00 2001 From: antejavor Date: Thu, 30 Apr 2026 16:37:30 +0200 Subject: [PATCH 1/3] make codex operational. --- .gitignore | 6 +- context-graph/agent-context-graph/README.md | 123 +++++++ .../agent-context-graph/pyproject.toml | 3 + .../src/agent_context_graph/adapters/codex.py | 317 ++++++++++++++++++ .../src/agent_context_graph/hooks/__init__.py | 1 + .../src/agent_context_graph/hooks/cli.py | 48 +++ .../tests/test_codex_adapter.py | 146 ++++++++ .../tests/test_hook_cli.py | 19 ++ context-graph/skills-graph/README.md | 6 +- .../src/skills_graph/connector.py | 86 ++++- .../skills-graph/tests/test_connector.py | 62 ++++ 11 files changed, 799 insertions(+), 18 deletions(-) create mode 100644 context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py create mode 100644 context-graph/agent-context-graph/src/agent_context_graph/hooks/__init__.py create mode 100644 context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py create mode 100644 context-graph/agent-context-graph/tests/test_codex_adapter.py create mode 100644 context-graph/agent-context-graph/tests/test_hook_cli.py create mode 100644 context-graph/skills-graph/tests/test_connector.py diff --git a/.gitignore b/.gitignore index 074328bb..f3ee7667 100644 --- a/.gitignore +++ b/.gitignore @@ -178,5 +178,9 @@ cython_debug/ .vscode/ -# Project specfic files +# Local agent hook configuration +/.codex/ +/.claude/settings.local.json + +# Project specific files /enterprise-context/sic-agent/sic-scrapper/output/* diff --git a/context-graph/agent-context-graph/README.md b/context-graph/agent-context-graph/README.md index 6178b1d0..08bde3e4 100644 --- a/context-graph/agent-context-graph/README.md +++ b/context-graph/agent-context-graph/README.md @@ -112,6 +112,128 @@ result = await Runner.run( adapter.end_session() ``` +### Command Hook Runtimes + +Some agent applications run hooks as external commands instead of in-process SDK callbacks. Runtime adapters should keep the product-specific JSON mapping at the edge, emit the shared `Event` protocol, and leave graph persistence in connectors such as `SkillGraphConnector`. + +The installed command is runtime-dispatched: + +```bash +agent-context-graph-hook [runtime options] +``` + +Implemented: + +| Runtime | Adapter | Hook Shape | +|---------|---------|------------| +| OpenAI Codex | `CodexHooksAdapter` | Command receives one JSON object on `stdin` | + +Planned: + +| Runtime | Adapter | Notes | +|---------|---------|-------| +| Claude Code | `ClaudeCodeHooksAdapter` | TODO: command-hook adapter for Claude Code JSON input/output and `.claude/settings.local.json` setup | + +### OpenAI Codex Hooks + +Codex hook configuration is local environment wiring, so this repository ignores `.codex/`. Each developer should create their own local `.codex` files or use a user-level Codex config. + +1. Make `skills-graph` able to reach Memgraph, then initialize and seed your skill graph once: + +```bash +export MEMGRAPH_URL="bolt://localhost:7687" +export MEMGRAPH_USER="" +export MEMGRAPH_PASSWORD="" +``` + +```python +from skills_graph import SkillGraph + +skills = SkillGraph() +skills.setup() +``` + +2. Install the hook command and the graph connector in the same Python environment: + +```bash +python -m venv ~/.venvs/agent-context-graph-hooks +~/.venvs/agent-context-graph-hooks/bin/python -m pip install \ + "agent-context-graph" \ + "skills-graph[agent-context-graph]" +``` + +For source development in this workspace, use this command instead of the venv binary: + +```bash +cd /path/to/ai-toolkit +uv run --package skills-graph --extra agent-context-graph \ + python -m agent_context_graph.hooks.cli codex --connector skills-graph +``` + +3. Enable Codex hooks in your local workspace: + +```toml +# .codex/config.toml +[features] +codex_hooks = true +``` + +4. Point Codex at the hook command. Replace `COMMAND` with the absolute command from step 2, for example `/Users/me/.venvs/agent-context-graph-hooks/bin/agent-context-graph-hook codex --connector skills-graph`. + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume|clear", + "hooks": [{ "type": "command", "command": "COMMAND", "timeout": 30 }] + } + ], + "UserPromptSubmit": [ + { + "hooks": [{ "type": "command", "command": "COMMAND", "timeout": 30 }] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [{ "type": "command", "command": "COMMAND", "timeout": 30 }] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [{ "type": "command", "command": "COMMAND", "timeout": 30 }] + } + ], + "PermissionRequest": [ + { + "hooks": [{ "type": "command", "command": "COMMAND", "timeout": 30 }] + } + ], + "Stop": [ + { + "hooks": [{ "type": "command", "command": "COMMAND", "timeout": 30 }] + } + ] + } +} +``` + +The adapter records Codex `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PermissionRequest`, and `Stop` payloads. MCP tool names such as `mcp__skills__get_skill` are normalized by `skills-graph` to the underlying `get_skill` operation. + +Smoke test the command: + +```bash +printf '{"hook_event_name":"Stop","session_id":"test"}' | COMMAND +``` + +The expected output is: + +```json +{"continue": true} +``` + ### Multiple Graph Components ```python @@ -158,6 +280,7 @@ All SDK adapters emit SDK-agnostic `Event` dataclasses: |---------|-----|----------------| | `ClaudeAdapter` | Claude Agent SDK | Dict of `HookMatcher` callbacks | | `OpenAIAdapter` | OpenAI Agents SDK | `RunHooksBase` subclass | +| `CodexHooksAdapter` | OpenAI Codex | Command hooks reading JSON from stdin | ### Graph Connectors diff --git a/context-graph/agent-context-graph/pyproject.toml b/context-graph/agent-context-graph/pyproject.toml index 36095456..863c2938 100644 --- a/context-graph/agent-context-graph/pyproject.toml +++ b/context-graph/agent-context-graph/pyproject.toml @@ -13,6 +13,9 @@ classifiers = [ ] dependencies = [] +[project.scripts] +agent-context-graph-hook = "agent_context_graph.hooks.cli:main" + [project.optional-dependencies] claude = [ "claude-agent-sdk>=0.1.0", diff --git a/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py b/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py new file mode 100644 index 00000000..919406e1 --- /dev/null +++ b/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py @@ -0,0 +1,317 @@ +"""OpenAI Codex hooks adapter for agent-context-graph. + +Codex hooks are command-based: Codex invokes a configured command with the +hook payload on stdin. This adapter translates those JSON payloads into the +common Event protocol used by AgentLink. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import TYPE_CHECKING, Any + +from agent_context_graph.events import ( + MessageEvent, + SessionEndEvent, + SessionStartEvent, + ToolEndEvent, + ToolStartEvent, +) +from agent_context_graph.link import AgentLink +from agent_context_graph.protocols import SDKAdapter + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from agent_context_graph.events import Event + +_SOURCE = "codex" +_DEFAULT_COMMAND = "agent-context-graph-hook codex" +_SUPPORTED_HOOKS = ( + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "Stop", +) + +# TODO: When adding Claude Code command hooks, extract the shared stdin +# loading, connector construction, and CLI runner into a command-hook helper +# module. Keep product-specific payload mapping and stdout response semantics +# in each runtime adapter. + + +class CodexHooksAdapter(SDKAdapter): + """Adapter that converts OpenAI Codex hook payloads into graph events. + + Args: + link: The AgentLink hub to emit events to. + session_id: Optional override for all emitted event session ids. + """ + + def __init__(self, link: AgentLink, session_id: str | None = None) -> None: + self._link = link + self._session_id = session_id + + def get_sdk_hooks(self) -> dict[str, list[dict[str, Any]]]: + """Return a hooks.json-compatible config skeleton. + + Command paths are deployment-specific, so callers that need a custom + command should use :func:`build_hooks_config`. + """ + return build_hooks_config(_DEFAULT_COMMAND) + + def handle_payload(self, payload: dict[str, Any]) -> list[Event]: + """Translate and emit a Codex hook payload. + + Returns the emitted events, which is mostly useful for tests and custom + command runners. + """ + hook_event_name = payload.get("hook_event_name") + event = self._event_from_payload(hook_event_name, payload) + if event is None: + return [] + self._link.emit(event) + return [event] + + def _event_from_payload(self, hook_event_name: Any, payload: dict[str, Any]) -> Event | None: + session_id = self._session_id or str(payload.get("session_id") or "") + metadata = _metadata_from_payload(payload) + + if hook_event_name == "SessionStart": + return SessionStartEvent( + session_id=session_id, + source_sdk=_SOURCE, + model=_string_or_none(payload.get("model")), + working_directory=_string_or_none(payload.get("cwd")), + metadata=metadata, + ) + + if hook_event_name == "UserPromptSubmit": + return MessageEvent( + session_id=session_id, + source_sdk=_SOURCE, + role="user", + content=payload.get("prompt", ""), + model=_string_or_none(payload.get("model")), + metadata=metadata, + ) + + if hook_event_name == "PreToolUse": + return ToolStartEvent( + session_id=session_id, + source_sdk=_SOURCE, + tool_name=str(payload.get("tool_name") or ""), + tool_input=payload.get("tool_input"), + tool_use_id=_string_or_none(payload.get("tool_use_id")), + metadata=metadata, + ) + + if hook_event_name == "PostToolUse": + tool_response = payload.get("tool_response") + result, is_error, error_message = _extract_tool_result(tool_response) + if "tool_input" in payload: + metadata["tool_input"] = payload.get("tool_input") + return ToolEndEvent( + session_id=session_id, + source_sdk=_SOURCE, + tool_name=str(payload.get("tool_name") or ""), + tool_use_id=_string_or_none(payload.get("tool_use_id")), + result=result, + is_error=is_error, + error_message=error_message, + metadata=metadata, + ) + + if hook_event_name == "PermissionRequest": + content = str(payload.get("tool_name") or "permission_request") + return MessageEvent( + session_id=session_id, + source_sdk=_SOURCE, + role="system", + content=content, + metadata=metadata, + ) + + if hook_event_name == "Stop": + return SessionEndEvent( + session_id=session_id, + source_sdk=_SOURCE, + status="completed", + metadata=metadata, + ) + + return None + + +CodexAdapter = CodexHooksAdapter + + +def build_hooks_config(command: str, *, timeout: int = 30) -> dict[str, list[dict[str, Any]]]: + """Build a Codex hooks config using *command* for every supported hook.""" + config: dict[str, list[dict[str, Any]]] = {} + for hook_name in _SUPPORTED_HOOKS: + entry: dict[str, Any] = { + "hooks": [ + { + "type": "command", + "command": command, + "timeout": timeout, + } + ] + } + if hook_name == "SessionStart": + entry["matcher"] = "startup|resume|clear" + elif hook_name in {"PreToolUse", "PostToolUse"}: + entry["matcher"] = "*" + config[hook_name] = [entry] + return config + + +def load_payload(stream: Any | None = None) -> dict[str, Any]: + """Read one Codex hook payload from a text stream.""" + if stream is None: + stream = sys.stdin + raw = stream.read() + if not raw.strip(): + return {} + payload = json.loads(raw) + if not isinstance(payload, dict): + msg = "Codex hook payload must be a JSON object" + raise TypeError(msg) + return payload + + +def create_link(connector_names: Iterable[str] = ()) -> AgentLink: + """Create an AgentLink with optional connectors named by CLI/config.""" + link = AgentLink() + for connector_name in connector_names: + normalized = connector_name.strip().replace("-", "_") + if not normalized: + continue + if normalized == "skills_graph": + _add_skills_graph_connector(link) + else: + msg = f"Unsupported connector: {connector_name}" + raise ValueError(msg) + return link + + +def response_for_payload(payload: dict[str, Any]) -> dict[str, Any] | None: + """Return hook JSON response, when Codex expects one.""" + if payload.get("hook_event_name") == "Stop": + return {"continue": True} + return None + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Bridge OpenAI Codex hooks to agent-context-graph.") + parser.add_argument( + "--connector", + action="append", + default=None, + help="Graph connector to enable. Currently supported: skills-graph.", + ) + parser.add_argument( + "--session-id", + default=None, + help="Override the session id from the Codex hook payload.", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Return a non-zero status if the hook payload cannot be recorded.", + ) + args = parser.parse_args(argv) + + connector_names = args.connector + if connector_names is None: + connector_names = _connectors_from_env() + + payload: dict[str, Any] = {} + try: + payload = load_payload() + link = create_link(connector_names) + adapter = CodexHooksAdapter(link, session_id=args.session_id) + adapter.handle_payload(payload) + response = response_for_payload(payload) + if response is not None: + print(json.dumps(response)) + except Exception as exc: + if args.strict or os.environ.get("AGENT_CONTEXT_GRAPH_CODEX_STRICT") == "1": + raise + response = response_for_payload(payload) + if response is not None: + print(json.dumps(response)) + _debug_log(f"agent-context-graph Codex hook skipped: {exc}") + return 0 + + +def _add_skills_graph_connector(link: AgentLink) -> None: + try: + from skills_graph import SkillGraph + from skills_graph.connector import SkillGraphConnector + except ImportError as exc: + msg = "skills-graph is required for the skills-graph Codex connector" + raise ImportError(msg) from exc + + graph = SkillGraph() + link.add_connector(SkillGraphConnector(graph)) + + +def _connectors_from_env() -> list[str]: + value = os.environ.get("AGENT_CONTEXT_GRAPH_CODEX_CONNECTORS", "") + return [part.strip() for part in value.split(",") if part.strip()] + + +def _metadata_from_payload(payload: dict[str, Any]) -> dict[str, Any]: + metadata: dict[str, Any] = {} + for key in ( + "cwd", + "source", + "transcript_path", + "turn_id", + "permission_mode", + "tool_name", + "tool_input", + "tool_use_id", + "reason", + "decision", + "stop_hook_active", + ): + if key in payload and payload.get(key) is not None: + metadata[key] = payload.get(key) + return metadata + + +def _extract_tool_result(tool_response: Any) -> tuple[Any, bool, str | None]: + if not isinstance(tool_response, dict): + return tool_response, False, None + + is_error = bool( + tool_response.get("is_error", False) + or tool_response.get("error") + or tool_response.get("exit_code") not in (None, 0) + ) + error_message = tool_response.get("error") or tool_response.get("stderr") + result = tool_response.get("content", tool_response) + return result, is_error, _string_or_none(error_message) + + +def _string_or_none(value: Any) -> str | None: + if value is None: + return None + return str(value) + + +def _debug_log(message: str) -> None: + if os.environ.get("AGENT_CONTEXT_GRAPH_CODEX_DEBUG") == "1": + print(message, file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/context-graph/agent-context-graph/src/agent_context_graph/hooks/__init__.py b/context-graph/agent-context-graph/src/agent_context_graph/hooks/__init__.py new file mode 100644 index 00000000..b85f7b5a --- /dev/null +++ b/context-graph/agent-context-graph/src/agent_context_graph/hooks/__init__.py @@ -0,0 +1 @@ +"""Command hook entry points for agent-context-graph runtimes.""" diff --git a/context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py b/context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py new file mode 100644 index 00000000..9fce6b79 --- /dev/null +++ b/context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py @@ -0,0 +1,48 @@ +"""Generic command hook CLI for agent-context-graph runtimes.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + +_HELP = """usage: agent-context-graph-hook [runtime options] + +Bridge agent command hooks to agent-context-graph. + +Runtimes: + codex Run the OpenAI Codex command-hook adapter. + claude-code Reserved for a future Claude Code hook adapter. +""" + + +def main(argv: Sequence[str] | None = None) -> int: + """Dispatch to a runtime-specific command hook adapter.""" + args = list(argv) if argv is not None else sys.argv[1:] + if not args: + print(_HELP) + return 2 + if args[0] in {"-h", "--help"}: + print(_HELP) + return 0 + + runtime = args[0] + runtime_args = args[1:] + if runtime == "codex": + from agent_context_graph.adapters.codex import main as codex_main + + return codex_main(runtime_args) + + if runtime in {"claude-code", "claude_code"}: + print("Claude Code hooks are not implemented yet.", file=sys.stderr) + return 2 + + print(f"Unknown hook runtime: {runtime}", file=sys.stderr) + print(_HELP) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/context-graph/agent-context-graph/tests/test_codex_adapter.py b/context-graph/agent-context-graph/tests/test_codex_adapter.py new file mode 100644 index 00000000..b9cee84e --- /dev/null +++ b/context-graph/agent-context-graph/tests/test_codex_adapter.py @@ -0,0 +1,146 @@ +"""Tests for the OpenAI Codex hooks adapter.""" + +import io + +from agent_context_graph import AgentLink +from agent_context_graph.adapters.codex import CodexHooksAdapter, build_hooks_config, load_payload, response_for_payload +from agent_context_graph.events import Event, EventType +from agent_context_graph.protocols import GraphConnector + + +class _RecordingConnector(GraphConnector): + def __init__(self): + self.events: list[Event] = [] + + def on_event(self, event: Event) -> None: + self.events.append(event) + + +def test_session_start_payload_emits_session_start_event(): + link = AgentLink() + rec = _RecordingConnector() + link.add_connector(rec) + + adapter = CodexHooksAdapter(link) + adapter.handle_payload( + { + "hook_event_name": "SessionStart", + "session_id": "s1", + "model": "gpt-5.4", + "cwd": "/repo", + "source": "startup", + } + ) + + assert len(rec.events) == 1 + event = rec.events[0] + assert event.event_type == EventType.SESSION_START + assert event.source_sdk == "codex" + assert event.session_id == "s1" + assert event.model == "gpt-5.4" + assert event.working_directory == "/repo" + assert event.metadata["source"] == "startup" + + +def test_user_prompt_payload_emits_message_event(): + link = AgentLink() + rec = _RecordingConnector() + link.add_connector(rec) + + adapter = CodexHooksAdapter(link) + adapter.handle_payload( + { + "hook_event_name": "UserPromptSubmit", + "session_id": "s1", + "prompt": "Use the cypher skill", + "cwd": "/repo", + } + ) + + event = rec.events[0] + assert event.event_type == EventType.MESSAGE + assert event.role == "user" + assert event.content == "Use the cypher skill" + assert event.source_sdk == "codex" + + +def test_tool_payloads_emit_tool_events(): + link = AgentLink() + rec = _RecordingConnector() + link.add_connector(rec) + + adapter = CodexHooksAdapter(link) + adapter.handle_payload( + { + "hook_event_name": "PreToolUse", + "session_id": "s1", + "tool_name": "mcp__skills__get_skill", + "tool_input": {"name": "cypher-basics"}, + "tool_use_id": "tu-1", + } + ) + adapter.handle_payload( + { + "hook_event_name": "PostToolUse", + "session_id": "s1", + "tool_name": "mcp__skills__get_skill", + "tool_response": {"content": "skill body"}, + "tool_use_id": "tu-1", + } + ) + + assert [event.event_type for event in rec.events] == [EventType.TOOL_START, EventType.TOOL_END] + assert rec.events[0].tool_name == "mcp__skills__get_skill" + assert rec.events[0].tool_input == {"name": "cypher-basics"} + assert rec.events[1].result == "skill body" + + +def test_post_tool_use_error_result_marks_error(): + link = AgentLink() + rec = _RecordingConnector() + link.add_connector(rec) + + adapter = CodexHooksAdapter(link) + adapter.handle_payload( + { + "hook_event_name": "PostToolUse", + "session_id": "s1", + "tool_name": "Bash", + "tool_response": {"exit_code": 2, "stderr": "nope"}, + } + ) + + event = rec.events[0] + assert event.event_type == EventType.TOOL_END + assert event.is_error + assert event.error_message == "nope" + + +def test_stop_payload_emits_session_end_and_json_response(): + link = AgentLink() + rec = _RecordingConnector() + link.add_connector(rec) + + payload = {"hook_event_name": "Stop", "session_id": "s1"} + adapter = CodexHooksAdapter(link) + adapter.handle_payload(payload) + + assert rec.events[0].event_type == EventType.SESSION_END + assert response_for_payload(payload) == {"continue": True} + + +def test_build_hooks_config_uses_command_for_supported_hooks(): + config = build_hooks_config("python hook.py") + + assert "SessionStart" in config + assert "PreToolUse" in config + assert config["SessionStart"][0]["matcher"] == "startup|resume|clear" + assert config["PreToolUse"][0]["matcher"] == "*" + assert "matcher" not in config["Stop"][0] + assert config["PreToolUse"][0]["hooks"][0]["command"] == "python hook.py" + + +def test_load_payload_requires_json_object(): + payload = load_payload(io.StringIO('{"hook_event_name": "Stop"}')) + + assert payload == {"hook_event_name": "Stop"} diff --git a/context-graph/agent-context-graph/tests/test_hook_cli.py b/context-graph/agent-context-graph/tests/test_hook_cli.py new file mode 100644 index 00000000..8173e1e0 --- /dev/null +++ b/context-graph/agent-context-graph/tests/test_hook_cli.py @@ -0,0 +1,19 @@ +"""Tests for the generic command hook CLI.""" + +import io + +from agent_context_graph.hooks.cli import main + + +def test_generic_cli_dispatches_codex_hook(monkeypatch, capsys): + monkeypatch.setattr("sys.stdin", io.StringIO('{"hook_event_name":"Stop","session_id":"s1"}')) + + assert main(["codex"]) == 0 + + assert capsys.readouterr().out.strip() == '{"continue": true}' + + +def test_generic_cli_requires_runtime(capsys): + assert main([]) == 2 + + assert "runtime" in capsys.readouterr().out diff --git a/context-graph/skills-graph/README.md b/context-graph/skills-graph/README.md index 471c5666..cd05149b 100644 --- a/context-graph/skills-graph/README.md +++ b/context-graph/skills-graph/README.md @@ -14,7 +14,7 @@ A small library to persist, retrieve and evolve AI skills in [Memgraph](https:// ## Quick Start ```python -from skill_graph import SkillGraph, Skill +from skills_graph import SkillGraph, Skill # Connect (uses MEMGRAPH_URL, MEMGRAPH_USER, MEMGRAPH_PASSWORD env vars by default) sg = SkillGraph() @@ -51,6 +51,10 @@ sg.update_skill("memgraph-cypher", content="updated content", tags=["cypher"]) sg.delete_skill("memgraph-cypher") ``` +## Codex Hook Integration + +When used through `agent-context-graph`'s Codex hook adapter, `SkillGraphConnector` records direct tool names like `get_skill` and Codex MCP-style names like `mcp__skills__get_skill`. Search/list tool results can be Python lists or JSON tool content containing skill objects with `name` fields. + ## Installation ```bash diff --git a/context-graph/skills-graph/src/skills_graph/connector.py b/context-graph/skills-graph/src/skills_graph/connector.py index 962d3e0e..849e7ff4 100644 --- a/context-graph/skills-graph/src/skills_graph/connector.py +++ b/context-graph/skills-graph/src/skills_graph/connector.py @@ -20,6 +20,7 @@ from __future__ import annotations +import json from typing import TYPE_CHECKING, Any from agent_context_graph.events import Event, EventType, ToolEndEvent, ToolStartEvent @@ -76,7 +77,7 @@ def supports(self, event: Event) -> bool: if event.event_type not in _SUPPORTED_EVENTS: return False if isinstance(event, ToolStartEvent | ToolEndEvent): - return event.tool_name in self._skill_tool_names + return self._operation_name(event.tool_name) in self._skill_tool_names return False def on_event(self, event: Event) -> None: @@ -96,36 +97,89 @@ def _on_tool_start(self, event: ToolStartEvent) -> None: self._record_skill_access( session_id=event.session_id, skill_name=skill_name, - action=event.tool_name, + action=self._operation_name(event.tool_name), timestamp=event.timestamp, ) def _on_tool_end(self, event: ToolEndEvent) -> None: """A search/list tool returned — record which skills appeared.""" - if event.tool_name not in {"list_skills", "search_skills", "search_by_tags", "search_by_name"}: + operation_name = self._operation_name(event.tool_name) + if operation_name not in {"list_skills", "search_skills", "search_by_tags", "search_by_name"}: return - if not isinstance(event.result, list): - return - for item in event.result: - name = item.get("name") if isinstance(item, dict) else None - if name: - self._record_skill_access( - session_id=event.session_id, - skill_name=name, - action=f"{event.tool_name}_result", - timestamp=event.timestamp, - ) + for name in self._extract_result_skill_names(event.result): + self._record_skill_access( + session_id=event.session_id, + skill_name=name, + action=f"{operation_name}_result", + timestamp=event.timestamp, + ) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @staticmethod - def _extract_skill_name(tool_input: Any) -> str | None: + def _operation_name(tool_name: str) -> str: + """Return the local tool name from direct or Codex MCP-style names.""" + if tool_name.startswith("mcp__"): + return tool_name.rsplit("__", maxsplit=1)[-1] + return tool_name + + @classmethod + def _extract_skill_name(cls, tool_input: Any) -> str | None: """Pull the skill name out of the tool's input dict.""" if not isinstance(tool_input, dict): return None - return tool_input.get("name") or tool_input.get("skill_name") or tool_input.get("pattern") + for key in ("name", "skill_name", "skill", "pattern"): + value = tool_input.get(key) + if isinstance(value, str) and value: + return value + for nested_key in ("arguments", "params", "input"): + nested = tool_input.get(nested_key) + nested_name = cls._extract_skill_name(nested) + if nested_name: + return nested_name + return None + + @classmethod + def _extract_result_skill_names(cls, result: Any) -> list[str]: + """Pull skill names out of direct Python results or JSON tool content.""" + if isinstance(result, str): + parsed = cls._parse_json_result(result) + if parsed is not None: + return cls._extract_result_skill_names(parsed) + return [] + + if isinstance(result, list): + names: list[str] = [] + for item in result: + names.extend(cls._extract_result_skill_names(item)) + return names + + if isinstance(result, dict): + name = result.get("name") + if isinstance(name, str) and name: + return [name] + names: list[str] = [] + for key in ("skills", "results", "items", "content"): + if key in result: + names.extend(cls._extract_result_skill_names(result[key])) + text = result.get("text") + if isinstance(text, str): + names.extend(cls._extract_result_skill_names(text)) + return names + + name = getattr(result, "name", None) + if isinstance(name, str) and name: + return [name] + return [] + + @staticmethod + def _parse_json_result(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return None def _record_skill_access( self, diff --git a/context-graph/skills-graph/tests/test_connector.py b/context-graph/skills-graph/tests/test_connector.py new file mode 100644 index 00000000..9a07ef21 --- /dev/null +++ b/context-graph/skills-graph/tests/test_connector.py @@ -0,0 +1,62 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("agent_context_graph", reason="agent-context-graph not installed") + +from agent_context_graph.events import ToolEndEvent, ToolStartEvent +from skills_graph.connector import SkillGraphConnector + + +def _connector(): + graph = SimpleNamespace(_db=MagicMock()) + return SkillGraphConnector(graph), graph + + +def test_mcp_tool_name_is_treated_as_skill_tool(): + connector, graph = _connector() + event = ToolStartEvent( + session_id="s1", + tool_name="mcp__skills__get_skill", + tool_input={"arguments": {"name": "cypher-basics"}}, + timestamp="2026-04-30T00:00:00+00:00", + ) + + assert connector.supports(event) + + connector.on_event(event) + + params = graph._db.query.call_args.kwargs["params"] + assert params["session_id"] == "s1" + assert params["skill_name"] == "cypher-basics" + assert params["action"] == "get_skill" + + +def test_mcp_search_result_records_nested_json_skill_names(): + connector, graph = _connector() + event = ToolEndEvent( + session_id="s1", + tool_name="mcp__skills__list_skills", + result={"content": [{"text": '[{"name": "s1"}, {"name": "s2"}]'}]}, + timestamp="2026-04-30T00:00:00+00:00", + ) + + assert connector.supports(event) + + connector.on_event(event) + + params = [call.kwargs["params"] for call in graph._db.query.call_args_list] + assert [param["skill_name"] for param in params] == ["s1", "s2"] + assert {param["action"] for param in params} == {"list_skills_result"} + + +def test_non_skill_mcp_tool_is_ignored(): + connector, _graph = _connector() + event = ToolStartEvent( + session_id="s1", + tool_name="mcp__filesystem__read_file", + tool_input={"path": "README.md"}, + ) + + assert not connector.supports(event) From a9a029716ebdd6d8f0217697d1148ce5bd3d20e3 Mon Sep 17 00:00:00 2001 From: antejavor Date: Mon, 4 May 2026 12:47:57 +0200 Subject: [PATCH 2/3] Update cli changes and add hook helper. --- context-graph/agent-context-graph/README.md | 36 ++++-- .../agent-context-graph/pyproject.toml | 2 +- .../src/agent_context_graph/adapters/codex.py | 2 +- .../src/agent_context_graph/cli.py | 40 ++++++ .../src/agent_context_graph/hooks/cli.py | 118 +++++++++++++++++- .../tests/test_hook_cli.py | 60 ++++++++- 6 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 context-graph/agent-context-graph/src/agent_context_graph/cli.py diff --git a/context-graph/agent-context-graph/README.md b/context-graph/agent-context-graph/README.md index 08bde3e4..7176a425 100644 --- a/context-graph/agent-context-graph/README.md +++ b/context-graph/agent-context-graph/README.md @@ -119,7 +119,7 @@ Some agent applications run hooks as external commands instead of in-process SDK The installed command is runtime-dispatched: ```bash -agent-context-graph-hook [runtime options] +agent-context-graph hook [options] ``` Implemented: @@ -167,18 +167,38 @@ For source development in this workspace, use this command instead of the venv b ```bash cd /path/to/ai-toolkit uv run --package skills-graph --extra agent-context-graph \ - python -m agent_context_graph.hooks.cli codex --connector skills-graph + python -m agent_context_graph.cli hook run codex --connector skills-graph ``` -3. Enable Codex hooks in your local workspace: +3. Generate private Codex hook config in the workspace: -```toml -# .codex/config.toml -[features] -codex_hooks = true +```bash +agent-context-graph hook init codex --connector skills-graph +``` + +For source development in this workspace: + +```bash +uv run --package skills-graph --extra agent-context-graph \ + python -m agent_context_graph.cli hook init codex --connector skills-graph +``` + +The wizard writes local, ignored files: + +```text +.codex/config.toml +.codex/hooks.json +``` + +It refuses to overwrite existing generated files unless you pass `--force`. + +The generated config enables Codex hooks and points all supported Codex hook events at a command like: + +```bash +agent-context-graph hook run codex --connector skills-graph ``` -4. Point Codex at the hook command. Replace `COMMAND` with the absolute command from step 2, for example `/Users/me/.venvs/agent-context-graph-hooks/bin/agent-context-graph-hook codex --connector skills-graph`. +The resulting `.codex/hooks.json` has this shape: ```json { diff --git a/context-graph/agent-context-graph/pyproject.toml b/context-graph/agent-context-graph/pyproject.toml index 863c2938..6d6e41fc 100644 --- a/context-graph/agent-context-graph/pyproject.toml +++ b/context-graph/agent-context-graph/pyproject.toml @@ -14,7 +14,7 @@ classifiers = [ dependencies = [] [project.scripts] -agent-context-graph-hook = "agent_context_graph.hooks.cli:main" +agent-context-graph = "agent_context_graph.cli:main" [project.optional-dependencies] claude = [ diff --git a/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py b/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py index 919406e1..b8ee4387 100644 --- a/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py +++ b/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py @@ -29,7 +29,7 @@ from agent_context_graph.events import Event _SOURCE = "codex" -_DEFAULT_COMMAND = "agent-context-graph-hook codex" +_DEFAULT_COMMAND = "agent-context-graph hook run codex" _SUPPORTED_HOOKS = ( "SessionStart", "UserPromptSubmit", diff --git a/context-graph/agent-context-graph/src/agent_context_graph/cli.py b/context-graph/agent-context-graph/src/agent_context_graph/cli.py new file mode 100644 index 00000000..0e326c3f --- /dev/null +++ b/context-graph/agent-context-graph/src/agent_context_graph/cli.py @@ -0,0 +1,40 @@ +"""Top-level CLI for agent-context-graph.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + +_HELP = """usage: agent-context-graph [options] + +Commands: + hook Configure or run command hooks. +""" + + +def main(argv: Sequence[str] | None = None) -> int: + """Dispatch top-level agent-context-graph commands.""" + args = list(argv) if argv is not None else sys.argv[1:] + if not args: + print(_HELP) + return 2 + if args[0] in {"-h", "--help"}: + print(_HELP) + return 0 + + command = args[0] + if command == "hook": + from agent_context_graph.hooks.cli import main as hook_main + + return hook_main(args[1:]) + + print(f"Unknown command: {command}", file=sys.stderr) + print(_HELP) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py b/context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py index 9fce6b79..c104c977 100644 --- a/context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py +++ b/context-graph/agent-context-graph/src/agent_context_graph/hooks/cli.py @@ -2,18 +2,26 @@ from __future__ import annotations +import argparse +import json +import shlex +import shutil import sys +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence -_HELP = """usage: agent-context-graph-hook [runtime options] +_HELP = """usage: agent-context-graph hook [options] Bridge agent command hooks to agent-context-graph. +Commands: + init codex Generate private Codex hook config. + run codex Run the OpenAI Codex command-hook adapter. + Runtimes: - codex Run the OpenAI Codex command-hook adapter. claude-code Reserved for a future Claude Code hook adapter. """ @@ -28,8 +36,26 @@ def main(argv: Sequence[str] | None = None) -> int: print(_HELP) return 0 - runtime = args[0] + command = args[0] + if command == "init": + return _init(args[1:]) + + if command == "run": + if len(args) == 1: + print("usage: agent-context-graph hook run [options]", file=sys.stderr) + return 2 + runtime = args[1] + runtime_args = args[2:] + return _run_runtime(runtime, runtime_args) + + # Backward-compatible module form: + # python -m agent_context_graph.hooks.cli codex --connector skills-graph + runtime = command runtime_args = args[1:] + return _run_runtime(runtime, runtime_args) + + +def _run_runtime(runtime: str, runtime_args: list[str]) -> int: if runtime == "codex": from agent_context_graph.adapters.codex import main as codex_main @@ -44,5 +70,91 @@ def main(argv: Sequence[str] | None = None) -> int: return 2 +def _init(argv: list[str]) -> int: + if not argv: + print("usage: agent-context-graph hook init [options]", file=sys.stderr) + return 2 + + runtime = argv[0] + if runtime == "codex": + return _init_codex(argv[1:]) + + if runtime in {"claude-code", "claude_code"}: + print("Claude Code hook setup is not implemented yet.", file=sys.stderr) + return 2 + + print(f"Unknown hook runtime for init: {runtime}", file=sys.stderr) + return 2 + + +def _init_codex(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Generate private OpenAI Codex hook config.") + parser.add_argument( + "--connector", + action="append", + default=None, + help="Graph connector to enable. Defaults to skills-graph.", + ) + parser.add_argument( + "--project-dir", + default=".", + help="Project directory where .codex config should be generated.", + ) + parser.add_argument( + "--hook-command", + default=None, + help="Full command to place in hooks.json. Defaults to this installed CLI.", + ) + parser.add_argument( + "--timeout", + type=int, + default=30, + help="Codex hook timeout in seconds.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing .codex/config.toml or .codex/hooks.json.", + ) + args = parser.parse_args(argv) + + connectors = args.connector or ["skills-graph"] + project_dir = Path(args.project_dir).expanduser().resolve() + codex_dir = project_dir / ".codex" + config_path = codex_dir / "config.toml" + hooks_path = codex_dir / "hooks.json" + + existing = [path for path in (config_path, hooks_path) if path.exists()] + if existing and not args.force: + names = ", ".join(str(path) for path in existing) + print(f"Refusing to overwrite existing Codex config: {names}", file=sys.stderr) + print("Re-run with --force to replace generated files.", file=sys.stderr) + return 1 + + from agent_context_graph.adapters.codex import build_hooks_config + + hook_command = args.hook_command or _default_hook_command("codex", connectors) + codex_dir.mkdir(parents=True, exist_ok=True) + config_path.write_text("[features]\ncodex_hooks = true\n", encoding="utf-8") + hooks_path.write_text( + json.dumps({"hooks": build_hooks_config(hook_command, timeout=args.timeout)}, indent=2) + "\n", + encoding="utf-8", + ) + + print(f"Wrote {config_path}") + print(f"Wrote {hooks_path}") + print(f"Hook command: {hook_command}") + return 0 + + +def _default_hook_command(runtime: str, connectors: list[str]) -> str: + executable = shutil.which("agent-context-graph") + base = [executable] if executable else [sys.executable, "-m", "agent_context_graph.cli"] + args = [*base, "hook", "run", runtime] + for connector in connectors: + args.extend(["--connector", connector]) + return shlex.join(args) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/context-graph/agent-context-graph/tests/test_hook_cli.py b/context-graph/agent-context-graph/tests/test_hook_cli.py index 8173e1e0..c6f72ca1 100644 --- a/context-graph/agent-context-graph/tests/test_hook_cli.py +++ b/context-graph/agent-context-graph/tests/test_hook_cli.py @@ -1,14 +1,24 @@ """Tests for the generic command hook CLI.""" import io +import json +from agent_context_graph.cli import main as top_level_main from agent_context_graph.hooks.cli import main def test_generic_cli_dispatches_codex_hook(monkeypatch, capsys): monkeypatch.setattr("sys.stdin", io.StringIO('{"hook_event_name":"Stop","session_id":"s1"}')) - assert main(["codex"]) == 0 + assert main(["run", "codex"]) == 0 + + assert capsys.readouterr().out.strip() == '{"continue": true}' + + +def test_top_level_cli_dispatches_hook_run(monkeypatch, capsys): + monkeypatch.setattr("sys.stdin", io.StringIO('{"hook_event_name":"Stop","session_id":"s1"}')) + + assert top_level_main(["hook", "run", "codex"]) == 0 assert capsys.readouterr().out.strip() == '{"continue": true}' @@ -16,4 +26,50 @@ def test_generic_cli_dispatches_codex_hook(monkeypatch, capsys): def test_generic_cli_requires_runtime(capsys): assert main([]) == 2 - assert "runtime" in capsys.readouterr().out + assert "command" in capsys.readouterr().out + + +def test_init_codex_writes_private_config(tmp_path, capsys): + assert ( + main( + [ + "init", + "codex", + "--project-dir", + str(tmp_path), + "--connector", + "skills-graph", + "--hook-command", + "/venv/bin/agent-context-graph hook run codex --connector skills-graph", + ] + ) + == 0 + ) + + assert (tmp_path / ".codex" / "config.toml").read_text() == "[features]\ncodex_hooks = true\n" + hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text()) + command = hooks["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + assert command == "/venv/bin/agent-context-graph hook run codex --connector skills-graph" + assert hooks["hooks"]["SessionStart"][0]["matcher"] == "startup|resume|clear" + assert "Wrote" in capsys.readouterr().out + + +def test_init_codex_refuses_to_overwrite_without_force(tmp_path, capsys): + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "config.toml").write_text("existing", encoding="utf-8") + + assert main(["init", "codex", "--project-dir", str(tmp_path)]) == 1 + + assert (codex_dir / "config.toml").read_text() == "existing" + assert "Refusing to overwrite" in capsys.readouterr().err + + +def test_init_codex_force_overwrites(tmp_path): + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "config.toml").write_text("existing", encoding="utf-8") + + assert main(["init", "codex", "--project-dir", str(tmp_path), "--force"]) == 0 + + assert (codex_dir / "config.toml").read_text() == "[features]\ncodex_hooks = true\n" From b3abf8b3a8847e706c1351ec960e7a180bdda546 Mon Sep 17 00:00:00 2001 From: antejavor Date: Mon, 4 May 2026 14:30:10 +0200 Subject: [PATCH 3/3] Update config. --- .../src/agent_context_graph/adapters/codex.py | 2 ++ .../src/skills_graph/connector.py | 25 +++++++++++-------- .../skills-graph/tests/test_connector.py | 25 +++++++++++++++++++ .../skills-graph/tests/test_connector_e2e.py | 19 +++++++++----- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py b/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py index b8ee4387..883578b2 100644 --- a/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py +++ b/context-graph/agent-context-graph/src/agent_context_graph/adapters/codex.py @@ -259,6 +259,8 @@ def _add_skills_graph_connector(link: AgentLink) -> None: msg = "skills-graph is required for the skills-graph Codex connector" raise ImportError(msg) from exc + # Codex command hooks run in a fresh process for each hook invocation, so + # each call builds its own short-lived SkillGraph/Memgraph connection. graph = SkillGraph() link.add_connector(SkillGraphConnector(graph)) diff --git a/context-graph/skills-graph/src/skills_graph/connector.py b/context-graph/skills-graph/src/skills_graph/connector.py index 849e7ff4..ff5d8eba 100644 --- a/context-graph/skills-graph/src/skills_graph/connector.py +++ b/context-graph/skills-graph/src/skills_graph/connector.py @@ -34,6 +34,7 @@ EventType.TOOL_START, EventType.TOOL_END, } +_MAX_RESULT_DEPTH = 10 class SkillGraphConnector(GraphConnector): @@ -52,7 +53,9 @@ class SkillGraphConnector(GraphConnector): DEFAULT_SKILL_TOOLS: frozenset[str] = frozenset( { "get_skill", + "add_skill", "update_skill", + "delete_skill", "list_skills", "search_skills", "search_by_tags", @@ -120,7 +123,7 @@ def _on_tool_end(self, event: ToolEndEvent) -> None: @staticmethod def _operation_name(tool_name: str) -> str: - """Return the local tool name from direct or Codex MCP-style names.""" + """Return the operation from direct or MCP ``mcp____`` names.""" if tool_name.startswith("mcp__"): return tool_name.rsplit("__", maxsplit=1)[-1] return tool_name @@ -142,32 +145,34 @@ def _extract_skill_name(cls, tool_input: Any) -> str | None: return None @classmethod - def _extract_result_skill_names(cls, result: Any) -> list[str]: + def _extract_result_skill_names(cls, result: Any, *, _depth: int = 0) -> list[str]: """Pull skill names out of direct Python results or JSON tool content.""" + if _depth > _MAX_RESULT_DEPTH: + return [] + if isinstance(result, str): parsed = cls._parse_json_result(result) if parsed is not None: - return cls._extract_result_skill_names(parsed) + return cls._extract_result_skill_names(parsed, _depth=_depth + 1) return [] + extracted_names: list[str] = [] if isinstance(result, list): - names: list[str] = [] for item in result: - names.extend(cls._extract_result_skill_names(item)) - return names + extracted_names.extend(cls._extract_result_skill_names(item, _depth=_depth + 1)) + return extracted_names if isinstance(result, dict): name = result.get("name") if isinstance(name, str) and name: return [name] - names: list[str] = [] for key in ("skills", "results", "items", "content"): if key in result: - names.extend(cls._extract_result_skill_names(result[key])) + extracted_names.extend(cls._extract_result_skill_names(result[key], _depth=_depth + 1)) text = result.get("text") if isinstance(text, str): - names.extend(cls._extract_result_skill_names(text)) - return names + extracted_names.extend(cls._extract_result_skill_names(text, _depth=_depth + 1)) + return extracted_names name = getattr(result, "name", None) if isinstance(name, str) and name: diff --git a/context-graph/skills-graph/tests/test_connector.py b/context-graph/skills-graph/tests/test_connector.py index 9a07ef21..bb411cc1 100644 --- a/context-graph/skills-graph/tests/test_connector.py +++ b/context-graph/skills-graph/tests/test_connector.py @@ -60,3 +60,28 @@ def test_non_skill_mcp_tool_is_ignored(): ) assert not connector.supports(event) + + +def test_add_and_delete_skill_tools_are_supported_by_default(): + connector, _graph = _connector() + + assert connector.supports(ToolStartEvent(session_id="s1", tool_name="add_skill", tool_input={"name": "s1"})) + assert connector.supports(ToolStartEvent(session_id="s1", tool_name="delete_skill", tool_input={"name": "s1"})) + + +def test_deeply_nested_results_stop_at_depth_limit(): + result = {"content": []} + current = result["content"] + for _ in range(20): + nested = {"content": []} + current.append(nested) + current = nested["content"] + current.append({"name": "too-deep"}) + + assert SkillGraphConnector._extract_result_skill_names(result) == [] + + +def test_result_extraction_still_reads_reasonable_nested_json(): + result = {"content": [{"text": '{"results": [{"name": "s1"}]}'}]} + + assert SkillGraphConnector._extract_result_skill_names(result) == ["s1"] diff --git a/context-graph/skills-graph/tests/test_connector_e2e.py b/context-graph/skills-graph/tests/test_connector_e2e.py index 159bbf88..9bdc5a88 100644 --- a/context-graph/skills-graph/tests/test_connector_e2e.py +++ b/context-graph/skills-graph/tests/test_connector_e2e.py @@ -165,8 +165,8 @@ def test_non_skill_tool_ignored(wired): assert rows[0]["cnt"] == 0 -def test_add_skill_tool_ignored_by_default(wired): - """add_skill runs before a Skill exists, so it is not tracked by default.""" +def test_add_skill_tool_supported_but_missing_skill_is_not_recorded(wired): + """add_skill is recognized, but pre-tool tracking cannot match a Skill that does not exist yet.""" sg = wired["sg"] adapter = wired["adapter"] @@ -187,8 +187,8 @@ def test_add_skill_tool_ignored_by_default(wired): assert rows[0]["cnt"] == 0 -def test_delete_skill_tool_ignored_by_default(wired): - """delete_skill removes the Skill node that USED_SKILL depends on, so it is not tracked by default.""" +def test_delete_skill_tool_records_existing_skill(wired): + """delete_skill is recognized while the Skill still exists at pre-tool time.""" sg = wired["sg"] adapter = wired["adapter"] @@ -207,8 +207,15 @@ def test_delete_skill_tool_ignored_by_default(wired): ) ) - rows = sg._db.query("MATCH ()-[r:USED_SKILL]->() RETURN count(r) AS cnt") - assert rows[0]["cnt"] == 0 + rows = sg._db.query( + """ + MATCH (:Session {session_id: $sid})-[r:USED_SKILL]->(:Skill {name: $name}) + RETURN r.access_count AS cnt, r.actions AS actions + """, + params={"sid": "test-session", "name": "old-skill"}, + ) + assert rows[0]["cnt"] == 1 + assert rows[0]["actions"] == ["delete_skill"] def test_full_session_lifecycle(wired):