agent-context-graph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ """Agent Context Graph: Connect agent SDKs to context-graph components.
2
+
3
+ Agent Context Graph provides a generic adapter layer that bridges any agent SDK
4
+ (Claude Agent SDK, OpenAI Agents SDK, etc.) to any context-graph
5
+ component (actions-graph, skills-graph, etc.).
6
+
7
+ Architecture:
8
+ SDK Adapter (Claude, OpenAI, ...) → Event Protocol → Graph Connector(s)
9
+
10
+ Adapters live here. Connectors live in each graph library.
11
+
12
+ Quick Start::
13
+
14
+ from agent_context_graph import AgentLink
15
+ from agent_context_graph.adapters.claude import ClaudeAdapter
16
+ from skills_graph.connector import SkillGraphConnector
17
+
18
+ link = AgentLink()
19
+ link.add_connector(SkillGraphConnector(skill_graph))
20
+ adapter = ClaudeAdapter(link, session_id="s-1")
21
+ hooks = adapter.get_sdk_hooks()
22
+ """
23
+
24
+ from .events import (
25
+ AgentEndEvent,
26
+ AgentStartEvent,
27
+ ErrorOccurredEvent,
28
+ Event,
29
+ EventType,
30
+ HandoffEvent,
31
+ LLMEndEvent,
32
+ LLMStartEvent,
33
+ MessageEvent,
34
+ SessionEndEvent,
35
+ SessionStartEvent,
36
+ ToolEndEvent,
37
+ ToolStartEvent,
38
+ )
39
+ from .link import AgentLink
40
+ from .protocols import GraphConnector, SDKAdapter
41
+
42
+ __all__ = [
43
+ "AgentEndEvent",
44
+ "AgentLink",
45
+ "AgentStartEvent",
46
+ "ErrorOccurredEvent",
47
+ "Event",
48
+ "EventType",
49
+ "GraphConnector",
50
+ "HandoffEvent",
51
+ "LLMEndEvent",
52
+ "LLMStartEvent",
53
+ "MessageEvent",
54
+ "SDKAdapter",
55
+ "SessionEndEvent",
56
+ "SessionStartEvent",
57
+ "ToolEndEvent",
58
+ "ToolStartEvent",
59
+ ]
60
+
61
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,280 @@
1
+ """Claude Agent SDK adapter for agent-context-graph.
2
+
3
+ Translates Claude Agent SDK hook callbacks into the common Event protocol
4
+ and forwards them to the AgentLink hub.
5
+
6
+ Usage::
7
+
8
+ from agent_context_graph import AgentLink
9
+ from agent_context_graph.adapters.claude import ClaudeAdapter
10
+
11
+ link = AgentLink()
12
+ adapter = ClaudeAdapter(link, session_id="s-1")
13
+ hooks = adapter.get_sdk_hooks()
14
+
15
+ # Pass hooks to ClaudeAgentOptions
16
+ options = ClaudeAgentOptions(hooks=hooks)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from agent_context_graph.events import (
24
+ AgentEndEvent,
25
+ AgentStartEvent,
26
+ ErrorOccurredEvent,
27
+ MessageEvent,
28
+ SessionEndEvent,
29
+ SessionStartEvent,
30
+ ToolEndEvent,
31
+ ToolStartEvent,
32
+ )
33
+ from agent_context_graph.protocols import SDKAdapter
34
+
35
+ if TYPE_CHECKING:
36
+ from agent_context_graph.link import AgentLink
37
+
38
+ _SOURCE = "claude"
39
+
40
+
41
+ class ClaudeAdapter(SDKAdapter):
42
+ """Adapter that converts Claude Agent SDK hooks into agent-context-graph events.
43
+
44
+ Args:
45
+ link: The AgentLink hub to emit events to.
46
+ session_id: Session identifier for all events.
47
+ auto_session: If ``True``, emit SessionStartEvent on creation.
48
+ session_kwargs: Extra fields for SessionStartEvent (model, tags, …).
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ link: AgentLink,
54
+ session_id: str,
55
+ *,
56
+ auto_session: bool = True,
57
+ session_kwargs: dict[str, Any] | None = None,
58
+ ) -> None:
59
+ self._link = link
60
+ self._session_id = session_id
61
+ self._tool_use_to_action: dict[str, str] = {}
62
+
63
+ if auto_session:
64
+ kw = session_kwargs or {}
65
+ self._link.emit(
66
+ SessionStartEvent(
67
+ session_id=session_id,
68
+ source_sdk=_SOURCE,
69
+ model=kw.get("model"),
70
+ working_directory=kw.get("working_directory"),
71
+ tags=kw.get("tags", []),
72
+ metadata=kw.get("metadata", {}),
73
+ )
74
+ )
75
+
76
+ # ------------------------------------------------------------------
77
+ # SDK interface
78
+ # ------------------------------------------------------------------
79
+
80
+ def get_sdk_hooks(self) -> dict[str, list[Any]]:
81
+ """Return a hooks dict suitable for ``ClaudeAgentOptions(hooks=...)``."""
82
+ try:
83
+ from claude_agent_sdk import HookMatcher
84
+ except ImportError:
85
+
86
+ class HookMatcher: # type: ignore[no-redef]
87
+ def __init__(
88
+ self,
89
+ matcher: str | None = None,
90
+ hooks: list[Any] | None = None,
91
+ timeout: float | None = None,
92
+ ):
93
+ self.matcher = matcher
94
+ self.hooks = hooks or []
95
+ self.timeout = timeout
96
+
97
+ return {
98
+ "PreToolUse": [HookMatcher(hooks=[self._pre_tool_use])],
99
+ "PostToolUse": [HookMatcher(hooks=[self._post_tool_use])],
100
+ "PostToolUseFailure": [HookMatcher(hooks=[self._post_tool_use_failure])],
101
+ "UserPromptSubmit": [HookMatcher(hooks=[self._user_prompt_submit])],
102
+ "SubagentStart": [HookMatcher(hooks=[self._subagent_start])],
103
+ "SubagentStop": [HookMatcher(hooks=[self._subagent_stop])],
104
+ "Notification": [HookMatcher(hooks=[self._notification])],
105
+ "Stop": [HookMatcher(hooks=[self._stop])],
106
+ }
107
+
108
+ # ------------------------------------------------------------------
109
+ # Hook callbacks (async, matching Claude SDK signature)
110
+ # ------------------------------------------------------------------
111
+
112
+ async def _pre_tool_use(
113
+ self,
114
+ input_data: dict[str, Any],
115
+ tool_use_id: str | None,
116
+ _context: dict[str, Any],
117
+ ) -> dict[str, Any]:
118
+ tool_name = input_data.get("tool_name", "")
119
+ tool_input = input_data.get("tool_input", {})
120
+ actual_id = input_data.get("tool_use_id") or tool_use_id
121
+
122
+ self._link.emit(
123
+ ToolStartEvent(
124
+ session_id=self._session_id,
125
+ source_sdk=_SOURCE,
126
+ tool_name=tool_name,
127
+ tool_input=tool_input,
128
+ tool_use_id=actual_id,
129
+ agent_name=input_data.get("agent_id"),
130
+ metadata={k: input_data.get(k) for k in ("agent_type", "cwd") if input_data.get(k) is not None},
131
+ )
132
+ )
133
+ return {}
134
+
135
+ async def _post_tool_use(
136
+ self,
137
+ input_data: dict[str, Any],
138
+ tool_use_id: str | None,
139
+ _context: dict[str, Any],
140
+ ) -> dict[str, Any]:
141
+ tool_name = input_data.get("tool_name", "")
142
+ actual_id = input_data.get("tool_use_id") or tool_use_id
143
+ tool_response = input_data.get("tool_response")
144
+
145
+ is_error = False
146
+ error_message = None
147
+ content = tool_response
148
+ if isinstance(tool_response, dict):
149
+ is_error = tool_response.get("is_error", False)
150
+ error_message = tool_response.get("error")
151
+ content = tool_response.get("content", tool_response)
152
+
153
+ self._link.emit(
154
+ ToolEndEvent(
155
+ session_id=self._session_id,
156
+ source_sdk=_SOURCE,
157
+ tool_name=tool_name,
158
+ tool_use_id=actual_id,
159
+ result=content,
160
+ is_error=is_error,
161
+ error_message=error_message,
162
+ agent_name=input_data.get("agent_id"),
163
+ )
164
+ )
165
+ return {}
166
+
167
+ async def _post_tool_use_failure(
168
+ self,
169
+ input_data: dict[str, Any],
170
+ tool_use_id: str | None,
171
+ _context: dict[str, Any],
172
+ ) -> dict[str, Any]:
173
+ tool_name = input_data.get("tool_name", "")
174
+ actual_id = input_data.get("tool_use_id") or tool_use_id
175
+ error = input_data.get("error", "Unknown error")
176
+
177
+ self._link.emit(
178
+ ErrorOccurredEvent(
179
+ session_id=self._session_id,
180
+ source_sdk=_SOURCE,
181
+ error_type="tool_failure",
182
+ error_message=str(error),
183
+ error_details={
184
+ "tool_name": tool_name,
185
+ "tool_use_id": actual_id,
186
+ "tool_input": input_data.get("tool_input", {}),
187
+ "is_interrupt": input_data.get("is_interrupt", False),
188
+ },
189
+ recoverable=not input_data.get("is_interrupt", False),
190
+ )
191
+ )
192
+ return {}
193
+
194
+ async def _user_prompt_submit(
195
+ self,
196
+ input_data: dict[str, Any],
197
+ _tool_use_id: str | None,
198
+ _context: dict[str, Any],
199
+ ) -> dict[str, Any]:
200
+ self._link.emit(
201
+ MessageEvent(
202
+ session_id=self._session_id,
203
+ source_sdk=_SOURCE,
204
+ role="user",
205
+ content=input_data.get("prompt", ""),
206
+ metadata={k: input_data.get(k) for k in ("cwd", "permission_mode") if input_data.get(k) is not None},
207
+ )
208
+ )
209
+ return {}
210
+
211
+ async def _subagent_start(
212
+ self,
213
+ input_data: dict[str, Any],
214
+ _tool_use_id: str | None,
215
+ _context: dict[str, Any],
216
+ ) -> dict[str, Any]:
217
+ self._link.emit(
218
+ AgentStartEvent(
219
+ session_id=self._session_id,
220
+ source_sdk=_SOURCE,
221
+ agent_name=input_data.get("agent_id", ""),
222
+ agent_type=input_data.get("agent_type", ""),
223
+ )
224
+ )
225
+ return {}
226
+
227
+ async def _subagent_stop(
228
+ self,
229
+ input_data: dict[str, Any],
230
+ _tool_use_id: str | None,
231
+ _context: dict[str, Any],
232
+ ) -> dict[str, Any]:
233
+ self._link.emit(
234
+ AgentEndEvent(
235
+ session_id=self._session_id,
236
+ source_sdk=_SOURCE,
237
+ agent_name=input_data.get("agent_id", ""),
238
+ agent_type=input_data.get("agent_type", ""),
239
+ metadata={
240
+ "stop_hook_active": input_data.get("stop_hook_active"),
241
+ "agent_transcript_path": input_data.get("agent_transcript_path"),
242
+ },
243
+ )
244
+ )
245
+ return {}
246
+
247
+ async def _notification(
248
+ self,
249
+ input_data: dict[str, Any],
250
+ _tool_use_id: str | None,
251
+ _context: dict[str, Any],
252
+ ) -> dict[str, Any]:
253
+ self._link.emit(
254
+ MessageEvent(
255
+ session_id=self._session_id,
256
+ source_sdk=_SOURCE,
257
+ role="system",
258
+ content=input_data.get("message", ""),
259
+ metadata={
260
+ "title": input_data.get("title"),
261
+ "notification_type": input_data.get("notification_type"),
262
+ },
263
+ )
264
+ )
265
+ return {}
266
+
267
+ async def _stop(
268
+ self,
269
+ _input_data: dict[str, Any],
270
+ _tool_use_id: str | None,
271
+ _context: dict[str, Any],
272
+ ) -> dict[str, Any]:
273
+ self._link.emit(
274
+ SessionEndEvent(
275
+ session_id=self._session_id,
276
+ source_sdk=_SOURCE,
277
+ status="completed",
278
+ )
279
+ )
280
+ return {}
@@ -0,0 +1,319 @@
1
+ """OpenAI Codex hooks adapter for agent-context-graph.
2
+
3
+ Codex hooks are command-based: Codex invokes a configured command with the
4
+ hook payload on stdin. This adapter translates those JSON payloads into the
5
+ common Event protocol used by AgentLink.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from agent_context_graph.events import (
17
+ MessageEvent,
18
+ SessionEndEvent,
19
+ SessionStartEvent,
20
+ ToolEndEvent,
21
+ ToolStartEvent,
22
+ )
23
+ from agent_context_graph.link import AgentLink
24
+ from agent_context_graph.protocols import SDKAdapter
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Iterable, Sequence
28
+
29
+ from agent_context_graph.events import Event
30
+
31
+ _SOURCE = "codex"
32
+ _DEFAULT_COMMAND = "agent-context-graph hook run codex"
33
+ _SUPPORTED_HOOKS = (
34
+ "SessionStart",
35
+ "UserPromptSubmit",
36
+ "PreToolUse",
37
+ "PostToolUse",
38
+ "PermissionRequest",
39
+ "Stop",
40
+ )
41
+
42
+ # TODO: When adding Claude Code command hooks, extract the shared stdin
43
+ # loading, connector construction, and CLI runner into a command-hook helper
44
+ # module. Keep product-specific payload mapping and stdout response semantics
45
+ # in each runtime adapter.
46
+
47
+
48
+ class CodexHooksAdapter(SDKAdapter):
49
+ """Adapter that converts OpenAI Codex hook payloads into graph events.
50
+
51
+ Args:
52
+ link: The AgentLink hub to emit events to.
53
+ session_id: Optional override for all emitted event session ids.
54
+ """
55
+
56
+ def __init__(self, link: AgentLink, session_id: str | None = None) -> None:
57
+ self._link = link
58
+ self._session_id = session_id
59
+
60
+ def get_sdk_hooks(self) -> dict[str, list[dict[str, Any]]]:
61
+ """Return a hooks.json-compatible config skeleton.
62
+
63
+ Command paths are deployment-specific, so callers that need a custom
64
+ command should use :func:`build_hooks_config`.
65
+ """
66
+ return build_hooks_config(_DEFAULT_COMMAND)
67
+
68
+ def handle_payload(self, payload: dict[str, Any]) -> list[Event]:
69
+ """Translate and emit a Codex hook payload.
70
+
71
+ Returns the emitted events, which is mostly useful for tests and custom
72
+ command runners.
73
+ """
74
+ hook_event_name = payload.get("hook_event_name")
75
+ event = self._event_from_payload(hook_event_name, payload)
76
+ if event is None:
77
+ return []
78
+ self._link.emit(event)
79
+ return [event]
80
+
81
+ def _event_from_payload(self, hook_event_name: Any, payload: dict[str, Any]) -> Event | None:
82
+ session_id = self._session_id or str(payload.get("session_id") or "")
83
+ metadata = _metadata_from_payload(payload)
84
+
85
+ if hook_event_name == "SessionStart":
86
+ return SessionStartEvent(
87
+ session_id=session_id,
88
+ source_sdk=_SOURCE,
89
+ model=_string_or_none(payload.get("model")),
90
+ working_directory=_string_or_none(payload.get("cwd")),
91
+ metadata=metadata,
92
+ )
93
+
94
+ if hook_event_name == "UserPromptSubmit":
95
+ return MessageEvent(
96
+ session_id=session_id,
97
+ source_sdk=_SOURCE,
98
+ role="user",
99
+ content=payload.get("prompt", ""),
100
+ model=_string_or_none(payload.get("model")),
101
+ metadata=metadata,
102
+ )
103
+
104
+ if hook_event_name == "PreToolUse":
105
+ return ToolStartEvent(
106
+ session_id=session_id,
107
+ source_sdk=_SOURCE,
108
+ tool_name=str(payload.get("tool_name") or ""),
109
+ tool_input=payload.get("tool_input"),
110
+ tool_use_id=_string_or_none(payload.get("tool_use_id")),
111
+ metadata=metadata,
112
+ )
113
+
114
+ if hook_event_name == "PostToolUse":
115
+ tool_response = payload.get("tool_response")
116
+ result, is_error, error_message = _extract_tool_result(tool_response)
117
+ if "tool_input" in payload:
118
+ metadata["tool_input"] = payload.get("tool_input")
119
+ return ToolEndEvent(
120
+ session_id=session_id,
121
+ source_sdk=_SOURCE,
122
+ tool_name=str(payload.get("tool_name") or ""),
123
+ tool_use_id=_string_or_none(payload.get("tool_use_id")),
124
+ result=result,
125
+ is_error=is_error,
126
+ error_message=error_message,
127
+ metadata=metadata,
128
+ )
129
+
130
+ if hook_event_name == "PermissionRequest":
131
+ content = str(payload.get("tool_name") or "permission_request")
132
+ return MessageEvent(
133
+ session_id=session_id,
134
+ source_sdk=_SOURCE,
135
+ role="system",
136
+ content=content,
137
+ metadata=metadata,
138
+ )
139
+
140
+ if hook_event_name == "Stop":
141
+ return SessionEndEvent(
142
+ session_id=session_id,
143
+ source_sdk=_SOURCE,
144
+ status="completed",
145
+ metadata=metadata,
146
+ )
147
+
148
+ return None
149
+
150
+
151
+ CodexAdapter = CodexHooksAdapter
152
+
153
+
154
+ def build_hooks_config(command: str, *, timeout: int = 30) -> dict[str, list[dict[str, Any]]]:
155
+ """Build a Codex hooks config using *command* for every supported hook."""
156
+ config: dict[str, list[dict[str, Any]]] = {}
157
+ for hook_name in _SUPPORTED_HOOKS:
158
+ entry: dict[str, Any] = {
159
+ "hooks": [
160
+ {
161
+ "type": "command",
162
+ "command": command,
163
+ "timeout": timeout,
164
+ }
165
+ ]
166
+ }
167
+ if hook_name == "SessionStart":
168
+ entry["matcher"] = "startup|resume|clear"
169
+ elif hook_name in {"PreToolUse", "PostToolUse"}:
170
+ entry["matcher"] = "*"
171
+ config[hook_name] = [entry]
172
+ return config
173
+
174
+
175
+ def load_payload(stream: Any | None = None) -> dict[str, Any]:
176
+ """Read one Codex hook payload from a text stream."""
177
+ if stream is None:
178
+ stream = sys.stdin
179
+ raw = stream.read()
180
+ if not raw.strip():
181
+ return {}
182
+ payload = json.loads(raw)
183
+ if not isinstance(payload, dict):
184
+ msg = "Codex hook payload must be a JSON object"
185
+ raise TypeError(msg)
186
+ return payload
187
+
188
+
189
+ def create_link(connector_names: Iterable[str] = ()) -> AgentLink:
190
+ """Create an AgentLink with optional connectors named by CLI/config."""
191
+ link = AgentLink()
192
+ for connector_name in connector_names:
193
+ normalized = connector_name.strip().replace("-", "_")
194
+ if not normalized:
195
+ continue
196
+ if normalized == "skills_graph":
197
+ _add_skills_graph_connector(link)
198
+ else:
199
+ msg = f"Unsupported connector: {connector_name}"
200
+ raise ValueError(msg)
201
+ return link
202
+
203
+
204
+ def response_for_payload(payload: dict[str, Any]) -> dict[str, Any] | None:
205
+ """Return hook JSON response, when Codex expects one."""
206
+ if payload.get("hook_event_name") == "Stop":
207
+ return {"continue": True}
208
+ return None
209
+
210
+
211
+ def main(argv: Sequence[str] | None = None) -> int:
212
+ parser = argparse.ArgumentParser(description="Bridge OpenAI Codex hooks to agent-context-graph.")
213
+ parser.add_argument(
214
+ "--connector",
215
+ action="append",
216
+ default=None,
217
+ help="Graph connector to enable. Currently supported: skills-graph.",
218
+ )
219
+ parser.add_argument(
220
+ "--session-id",
221
+ default=None,
222
+ help="Override the session id from the Codex hook payload.",
223
+ )
224
+ parser.add_argument(
225
+ "--strict",
226
+ action="store_true",
227
+ help="Return a non-zero status if the hook payload cannot be recorded.",
228
+ )
229
+ args = parser.parse_args(argv)
230
+
231
+ connector_names = args.connector
232
+ if connector_names is None:
233
+ connector_names = _connectors_from_env()
234
+
235
+ payload: dict[str, Any] = {}
236
+ try:
237
+ payload = load_payload()
238
+ link = create_link(connector_names)
239
+ adapter = CodexHooksAdapter(link, session_id=args.session_id)
240
+ adapter.handle_payload(payload)
241
+ response = response_for_payload(payload)
242
+ if response is not None:
243
+ print(json.dumps(response))
244
+ except Exception as exc:
245
+ if args.strict or os.environ.get("AGENT_CONTEXT_GRAPH_CODEX_STRICT") == "1":
246
+ raise
247
+ response = response_for_payload(payload)
248
+ if response is not None:
249
+ print(json.dumps(response))
250
+ _debug_log(f"agent-context-graph Codex hook skipped: {exc}")
251
+ return 0
252
+
253
+
254
+ def _add_skills_graph_connector(link: AgentLink) -> None:
255
+ try:
256
+ from skills_graph import SkillGraph
257
+ from skills_graph.connector import SkillGraphConnector
258
+ except ImportError as exc:
259
+ msg = "skills-graph is required for the skills-graph Codex connector"
260
+ raise ImportError(msg) from exc
261
+
262
+ # Codex command hooks run in a fresh process for each hook invocation, so
263
+ # each call builds its own short-lived SkillGraph/Memgraph connection.
264
+ graph = SkillGraph()
265
+ link.add_connector(SkillGraphConnector(graph))
266
+
267
+
268
+ def _connectors_from_env() -> list[str]:
269
+ value = os.environ.get("AGENT_CONTEXT_GRAPH_CODEX_CONNECTORS", "")
270
+ return [part.strip() for part in value.split(",") if part.strip()]
271
+
272
+
273
+ def _metadata_from_payload(payload: dict[str, Any]) -> dict[str, Any]:
274
+ metadata: dict[str, Any] = {}
275
+ for key in (
276
+ "cwd",
277
+ "source",
278
+ "transcript_path",
279
+ "turn_id",
280
+ "permission_mode",
281
+ "tool_name",
282
+ "tool_input",
283
+ "tool_use_id",
284
+ "reason",
285
+ "decision",
286
+ "stop_hook_active",
287
+ ):
288
+ if key in payload and payload.get(key) is not None:
289
+ metadata[key] = payload.get(key)
290
+ return metadata
291
+
292
+
293
+ def _extract_tool_result(tool_response: Any) -> tuple[Any, bool, str | None]:
294
+ if not isinstance(tool_response, dict):
295
+ return tool_response, False, None
296
+
297
+ is_error = bool(
298
+ tool_response.get("is_error", False)
299
+ or tool_response.get("error")
300
+ or tool_response.get("exit_code") not in (None, 0)
301
+ )
302
+ error_message = tool_response.get("error") or tool_response.get("stderr")
303
+ result = tool_response.get("content", tool_response)
304
+ return result, is_error, _string_or_none(error_message)
305
+
306
+
307
+ def _string_or_none(value: Any) -> str | None:
308
+ if value is None:
309
+ return None
310
+ return str(value)
311
+
312
+
313
+ def _debug_log(message: str) -> None:
314
+ if os.environ.get("AGENT_CONTEXT_GRAPH_CODEX_DEBUG") == "1":
315
+ print(message, file=sys.stderr)
316
+
317
+
318
+ if __name__ == "__main__":
319
+ raise SystemExit(main())