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.
- agent_context_graph/__init__.py +61 -0
- agent_context_graph/adapters/__init__.py +0 -0
- agent_context_graph/adapters/claude.py +280 -0
- agent_context_graph/adapters/codex.py +319 -0
- agent_context_graph/adapters/openai.py +242 -0
- agent_context_graph/cli.py +40 -0
- agent_context_graph/events.py +195 -0
- agent_context_graph/hooks/__init__.py +1 -0
- agent_context_graph/hooks/cli.py +160 -0
- agent_context_graph/link.py +50 -0
- agent_context_graph/protocols.py +55 -0
- agent_context_graph-0.1.0.dist-info/METADATA +379 -0
- agent_context_graph-0.1.0.dist-info/RECORD +16 -0
- agent_context_graph-0.1.0.dist-info/WHEEL +4 -0
- agent_context_graph-0.1.0.dist-info/entry_points.txt +2 -0
- agent_context_graph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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())
|