codebuddy-agent-sdk 0.1.27__py3-none-macosx_11_0_arm64.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.
Potentially problematic release.
This version of codebuddy-agent-sdk might be problematic. Click here for more details.
- codebuddy_agent_sdk/__init__.py +126 -0
- codebuddy_agent_sdk/_binary.py +150 -0
- codebuddy_agent_sdk/_errors.py +54 -0
- codebuddy_agent_sdk/_message_parser.py +122 -0
- codebuddy_agent_sdk/_version.py +3 -0
- codebuddy_agent_sdk/bin/codebuddy +0 -0
- codebuddy_agent_sdk/client.py +311 -0
- codebuddy_agent_sdk/mcp/__init__.py +35 -0
- codebuddy_agent_sdk/mcp/create_sdk_mcp_server.py +154 -0
- codebuddy_agent_sdk/mcp/sdk_control_server_transport.py +95 -0
- codebuddy_agent_sdk/mcp/types.py +300 -0
- codebuddy_agent_sdk/py.typed +0 -0
- codebuddy_agent_sdk/query.py +535 -0
- codebuddy_agent_sdk/transport/__init__.py +6 -0
- codebuddy_agent_sdk/transport/base.py +26 -0
- codebuddy_agent_sdk/transport/subprocess.py +171 -0
- codebuddy_agent_sdk/types.py +330 -0
- codebuddy_agent_sdk-0.1.27.dist-info/METADATA +89 -0
- codebuddy_agent_sdk-0.1.27.dist-info/RECORD +20 -0
- codebuddy_agent_sdk-0.1.27.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Query function for one-shot interactions with CodeBuddy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import AsyncIterable, AsyncIterator, Callable
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from typing import Any, TypeGuard
|
|
11
|
+
|
|
12
|
+
from ._errors import ExecutionError
|
|
13
|
+
from ._message_parser import parse_message
|
|
14
|
+
from .mcp.sdk_control_server_transport import SdkControlServerTransport
|
|
15
|
+
from .mcp.types import JSONRPCMessage, SdkMcpServer
|
|
16
|
+
from .transport import SubprocessTransport, Transport
|
|
17
|
+
from .types import (
|
|
18
|
+
AppendSystemPrompt,
|
|
19
|
+
CanUseToolOptions,
|
|
20
|
+
CodeBuddyAgentOptions,
|
|
21
|
+
ErrorMessage,
|
|
22
|
+
HookEvent,
|
|
23
|
+
HookMatcher,
|
|
24
|
+
McpSdkServerConfig,
|
|
25
|
+
McpServerConfig,
|
|
26
|
+
Message,
|
|
27
|
+
ResultMessage,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_sdk_mcp_server(config: McpServerConfig) -> TypeGuard[McpSdkServerConfig]:
|
|
32
|
+
"""Type guard to check if config is an SDK MCP server."""
|
|
33
|
+
return isinstance(config, dict) and config.get("type") == "sdk"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_valid_hook_event(event: str) -> TypeGuard[HookEvent]:
|
|
37
|
+
"""Type guard to check if event is a valid HookEvent."""
|
|
38
|
+
return event in {
|
|
39
|
+
"PreToolUse",
|
|
40
|
+
"PostToolUse",
|
|
41
|
+
"UserPromptSubmit",
|
|
42
|
+
"Stop",
|
|
43
|
+
"SubagentStop",
|
|
44
|
+
"PreCompact",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class QueryContext:
|
|
49
|
+
"""Context for managing query state including SDK MCP servers."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, options: CodeBuddyAgentOptions):
|
|
52
|
+
self.options = options
|
|
53
|
+
# SDK MCP Server management
|
|
54
|
+
self.sdk_mcp_transports: dict[str, SdkControlServerTransport] = {}
|
|
55
|
+
self.sdk_mcp_servers: dict[str, SdkMcpServer] = {}
|
|
56
|
+
self.pending_mcp_responses: dict[str, asyncio.Future[JSONRPCMessage]] = {}
|
|
57
|
+
self.sdk_mcp_server_names: list[str] = []
|
|
58
|
+
|
|
59
|
+
def extract_mcp_servers(
|
|
60
|
+
self,
|
|
61
|
+
) -> tuple[dict[str, SdkMcpServer], dict[str, McpServerConfig] | None]:
|
|
62
|
+
"""
|
|
63
|
+
Extract SDK MCP servers from the mcp_servers config.
|
|
64
|
+
SDK servers are identified by having type: 'sdk'.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Tuple of (sdk_servers dict, regular_servers dict or None)
|
|
68
|
+
"""
|
|
69
|
+
mcp_servers = self.options.mcp_servers
|
|
70
|
+
|
|
71
|
+
if not mcp_servers or not isinstance(mcp_servers, dict):
|
|
72
|
+
return {}, None
|
|
73
|
+
|
|
74
|
+
sdk_servers: dict[str, SdkMcpServer] = {}
|
|
75
|
+
regular_servers: dict[str, McpServerConfig] = {}
|
|
76
|
+
|
|
77
|
+
for name, config in mcp_servers.items():
|
|
78
|
+
if _is_sdk_mcp_server(config):
|
|
79
|
+
# SDK MCP server
|
|
80
|
+
sdk_servers[name] = config["server"]
|
|
81
|
+
self.sdk_mcp_server_names.append(name)
|
|
82
|
+
else:
|
|
83
|
+
# Regular MCP server (stdio)
|
|
84
|
+
regular_servers[name] = config
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
sdk_servers,
|
|
88
|
+
regular_servers if regular_servers else None,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def connect_sdk_mcp_server(
|
|
92
|
+
self,
|
|
93
|
+
name: str,
|
|
94
|
+
server: SdkMcpServer,
|
|
95
|
+
send_callback: Callable[[str, JSONRPCMessage], None],
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Connect an SDK MCP server."""
|
|
98
|
+
|
|
99
|
+
def _create_message_forwarder(
|
|
100
|
+
server_name: str,
|
|
101
|
+
) -> Callable[[JSONRPCMessage], None]:
|
|
102
|
+
def forwarder(msg: JSONRPCMessage) -> None:
|
|
103
|
+
send_callback(server_name, msg)
|
|
104
|
+
|
|
105
|
+
return forwarder
|
|
106
|
+
|
|
107
|
+
# Create custom transport that forwards to CLI
|
|
108
|
+
transport = SdkControlServerTransport(_create_message_forwarder(name))
|
|
109
|
+
|
|
110
|
+
# Store transport and server
|
|
111
|
+
self.sdk_mcp_transports[name] = transport
|
|
112
|
+
self.sdk_mcp_servers[name] = server
|
|
113
|
+
|
|
114
|
+
# Connect server to transport
|
|
115
|
+
server.connect(transport)
|
|
116
|
+
|
|
117
|
+
async def handle_mcp_message_request(
|
|
118
|
+
self,
|
|
119
|
+
transport: Transport,
|
|
120
|
+
request_id: str,
|
|
121
|
+
request: dict[str, Any],
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Handle MCP message control request from CLI."""
|
|
124
|
+
server_name = request.get("server_name", "")
|
|
125
|
+
message: JSONRPCMessage = request.get("message", {})
|
|
126
|
+
|
|
127
|
+
server = self.sdk_mcp_servers.get(server_name)
|
|
128
|
+
|
|
129
|
+
if not server:
|
|
130
|
+
response = {
|
|
131
|
+
"type": "control_response",
|
|
132
|
+
"response": {
|
|
133
|
+
"subtype": "error",
|
|
134
|
+
"request_id": request_id,
|
|
135
|
+
"error": f"SDK MCP server not found: {server_name}",
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
await transport.write(json.dumps(response))
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# Handle the message with the MCP server
|
|
143
|
+
mcp_response = await server.handle_message(message)
|
|
144
|
+
|
|
145
|
+
response = {
|
|
146
|
+
"type": "control_response",
|
|
147
|
+
"response": {
|
|
148
|
+
"subtype": "success",
|
|
149
|
+
"request_id": request_id,
|
|
150
|
+
"response": {
|
|
151
|
+
"mcp_response": mcp_response or {"jsonrpc": "2.0", "result": {}, "id": 0},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
await transport.write(json.dumps(response))
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
response = {
|
|
159
|
+
"type": "control_response",
|
|
160
|
+
"response": {
|
|
161
|
+
"subtype": "error",
|
|
162
|
+
"request_id": request_id,
|
|
163
|
+
"error": str(e),
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
await transport.write(json.dumps(response))
|
|
167
|
+
|
|
168
|
+
async def cleanup(self) -> None:
|
|
169
|
+
"""Cleanup all resources."""
|
|
170
|
+
# Cancel pending MCP responses
|
|
171
|
+
for future in self.pending_mcp_responses.values():
|
|
172
|
+
if not future.done():
|
|
173
|
+
future.cancel()
|
|
174
|
+
self.pending_mcp_responses.clear()
|
|
175
|
+
|
|
176
|
+
# Close all SDK MCP transports
|
|
177
|
+
for transport in self.sdk_mcp_transports.values():
|
|
178
|
+
await transport.close()
|
|
179
|
+
self.sdk_mcp_transports.clear()
|
|
180
|
+
self.sdk_mcp_servers.clear()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def query(
|
|
184
|
+
*,
|
|
185
|
+
prompt: str | AsyncIterable[dict[str, Any]],
|
|
186
|
+
options: CodeBuddyAgentOptions | None = None,
|
|
187
|
+
transport: Transport | None = None,
|
|
188
|
+
) -> AsyncIterator[Message]:
|
|
189
|
+
"""
|
|
190
|
+
Query CodeBuddy for one-shot or unidirectional streaming interactions.
|
|
191
|
+
|
|
192
|
+
This function is ideal for simple, stateless queries where you don't need
|
|
193
|
+
bidirectional communication or conversation management. For interactive,
|
|
194
|
+
stateful conversations, use CodeBuddySDKClient instead.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
prompt: The prompt to send to CodeBuddy. Can be a string for single-shot
|
|
198
|
+
queries or an AsyncIterable[dict] for streaming mode.
|
|
199
|
+
options: Optional configuration (defaults to CodeBuddyAgentOptions() if None).
|
|
200
|
+
transport: Optional transport implementation. If provided, this will be used
|
|
201
|
+
instead of the default subprocess transport.
|
|
202
|
+
|
|
203
|
+
Yields:
|
|
204
|
+
Messages from the conversation.
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
```python
|
|
208
|
+
async for message in query(prompt="What is 2+2?"):
|
|
209
|
+
print(message)
|
|
210
|
+
```
|
|
211
|
+
"""
|
|
212
|
+
if options is None:
|
|
213
|
+
options = CodeBuddyAgentOptions()
|
|
214
|
+
|
|
215
|
+
os.environ["CODEBUDDY_CODE_ENTRYPOINT"] = "sdk-py"
|
|
216
|
+
|
|
217
|
+
# Create query context for managing SDK MCP servers
|
|
218
|
+
ctx = QueryContext(options)
|
|
219
|
+
|
|
220
|
+
# Extract SDK MCP servers and regular MCP servers
|
|
221
|
+
sdk_servers, regular_servers = ctx.extract_mcp_servers()
|
|
222
|
+
|
|
223
|
+
# Create modified options with only regular MCP servers for subprocess
|
|
224
|
+
modified_options = CodeBuddyAgentOptions(
|
|
225
|
+
allowed_tools=options.allowed_tools,
|
|
226
|
+
disallowed_tools=options.disallowed_tools,
|
|
227
|
+
system_prompt=options.system_prompt,
|
|
228
|
+
mcp_servers=regular_servers or {},
|
|
229
|
+
permission_mode=options.permission_mode,
|
|
230
|
+
continue_conversation=options.continue_conversation,
|
|
231
|
+
resume=options.resume,
|
|
232
|
+
max_turns=options.max_turns,
|
|
233
|
+
model=options.model,
|
|
234
|
+
fallback_model=options.fallback_model,
|
|
235
|
+
cwd=options.cwd,
|
|
236
|
+
codebuddy_code_path=options.codebuddy_code_path,
|
|
237
|
+
env=options.env,
|
|
238
|
+
extra_args=options.extra_args,
|
|
239
|
+
stderr=options.stderr,
|
|
240
|
+
hooks=options.hooks,
|
|
241
|
+
include_partial_messages=options.include_partial_messages,
|
|
242
|
+
fork_session=options.fork_session,
|
|
243
|
+
agents=options.agents,
|
|
244
|
+
setting_sources=options.setting_sources,
|
|
245
|
+
can_use_tool=options.can_use_tool,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if transport is None:
|
|
249
|
+
transport = SubprocessTransport(options=modified_options, prompt=prompt)
|
|
250
|
+
|
|
251
|
+
await transport.connect()
|
|
252
|
+
|
|
253
|
+
# Connect SDK MCP servers
|
|
254
|
+
def send_mcp_message(_server_name: str, _message: JSONRPCMessage) -> None:
|
|
255
|
+
# This callback sends MCP messages from server to CLI
|
|
256
|
+
# For now, SDK servers only respond to CLI requests, so this is rarely used
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
for name, server in sdk_servers.items():
|
|
260
|
+
ctx.connect_sdk_mcp_server(name, server, send_mcp_message)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
await _send_initialize(transport, options, ctx.sdk_mcp_server_names)
|
|
264
|
+
await _send_prompt(transport, prompt)
|
|
265
|
+
|
|
266
|
+
async for line in transport.read():
|
|
267
|
+
if not line:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
data = json.loads(line)
|
|
272
|
+
|
|
273
|
+
# Handle control requests (hooks, permissions, MCP messages)
|
|
274
|
+
if data.get("type") == "control_request":
|
|
275
|
+
await _handle_control_request(transport, data, options, ctx)
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
message = parse_message(data)
|
|
279
|
+
if message:
|
|
280
|
+
# Check for execution error BEFORE yielding
|
|
281
|
+
if isinstance(message, ResultMessage):
|
|
282
|
+
if message.is_error and message.errors and len(message.errors) > 0:
|
|
283
|
+
raise ExecutionError(message.errors, message.subtype)
|
|
284
|
+
yield message
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
yield message
|
|
288
|
+
|
|
289
|
+
if isinstance(message, ErrorMessage):
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
except json.JSONDecodeError:
|
|
293
|
+
continue # Ignore non-JSON lines
|
|
294
|
+
|
|
295
|
+
finally:
|
|
296
|
+
await ctx.cleanup()
|
|
297
|
+
await transport.close()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def _send_initialize(
|
|
301
|
+
transport: Transport,
|
|
302
|
+
options: CodeBuddyAgentOptions,
|
|
303
|
+
sdk_mcp_server_names: list[str],
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Send initialization control request."""
|
|
306
|
+
hooks_config = _build_hooks_config(options.hooks) if options.hooks else None
|
|
307
|
+
agents_config = (
|
|
308
|
+
{name: asdict(agent) for name, agent in options.agents.items()} if options.agents else None
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Parse system_prompt config
|
|
312
|
+
system_prompt: str | None = None
|
|
313
|
+
append_system_prompt: str | None = None
|
|
314
|
+
if isinstance(options.system_prompt, str):
|
|
315
|
+
system_prompt = options.system_prompt
|
|
316
|
+
elif isinstance(options.system_prompt, AppendSystemPrompt):
|
|
317
|
+
append_system_prompt = options.system_prompt.append
|
|
318
|
+
|
|
319
|
+
request = {
|
|
320
|
+
"type": "control_request",
|
|
321
|
+
"request_id": f"init_{id(options)}",
|
|
322
|
+
"request": {
|
|
323
|
+
"subtype": "initialize",
|
|
324
|
+
"hooks": hooks_config,
|
|
325
|
+
"systemPrompt": system_prompt,
|
|
326
|
+
"appendSystemPrompt": append_system_prompt,
|
|
327
|
+
"agents": agents_config,
|
|
328
|
+
# Include SDK MCP server names
|
|
329
|
+
"sdkMcpServers": sdk_mcp_server_names if sdk_mcp_server_names else None,
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
await transport.write(json.dumps(request))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def _send_prompt(transport: Transport, prompt: str | AsyncIterable[dict[str, Any]]) -> None:
|
|
336
|
+
"""Send user prompt."""
|
|
337
|
+
if isinstance(prompt, str):
|
|
338
|
+
message = {
|
|
339
|
+
"type": "user",
|
|
340
|
+
"session_id": "",
|
|
341
|
+
"message": {"role": "user", "content": prompt},
|
|
342
|
+
"parent_tool_use_id": None,
|
|
343
|
+
}
|
|
344
|
+
await transport.write(json.dumps(message))
|
|
345
|
+
else:
|
|
346
|
+
async for msg in prompt:
|
|
347
|
+
await transport.write(json.dumps(msg))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def _handle_control_request(
|
|
351
|
+
transport: Transport,
|
|
352
|
+
data: dict[str, Any],
|
|
353
|
+
options: CodeBuddyAgentOptions,
|
|
354
|
+
ctx: QueryContext,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Handle control request from CLI."""
|
|
357
|
+
request_id = data.get("request_id", "")
|
|
358
|
+
request = data.get("request", {})
|
|
359
|
+
subtype = request.get("subtype", "")
|
|
360
|
+
|
|
361
|
+
if subtype == "hook_callback":
|
|
362
|
+
# Handle hook callback
|
|
363
|
+
callback_id = request.get("callback_id", "")
|
|
364
|
+
hook_input = request.get("input", {})
|
|
365
|
+
tool_use_id = request.get("tool_use_id")
|
|
366
|
+
|
|
367
|
+
# Find and execute the hook
|
|
368
|
+
response = await _execute_hook(callback_id, hook_input, tool_use_id, options)
|
|
369
|
+
|
|
370
|
+
# Send response
|
|
371
|
+
control_response = {
|
|
372
|
+
"type": "control_response",
|
|
373
|
+
"response": {
|
|
374
|
+
"subtype": "success",
|
|
375
|
+
"request_id": request_id,
|
|
376
|
+
"response": response,
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
await transport.write(json.dumps(control_response))
|
|
380
|
+
|
|
381
|
+
elif subtype == "can_use_tool":
|
|
382
|
+
await _handle_permission_request(transport, request_id, request, options)
|
|
383
|
+
|
|
384
|
+
elif subtype == "mcp_message":
|
|
385
|
+
await ctx.handle_mcp_message_request(transport, request_id, request)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _handle_permission_request(
|
|
389
|
+
transport: Transport,
|
|
390
|
+
request_id: str,
|
|
391
|
+
request: dict[str, Any],
|
|
392
|
+
options: CodeBuddyAgentOptions,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Handle permission request from CLI."""
|
|
395
|
+
tool_name = request.get("tool_name", "")
|
|
396
|
+
input_data = request.get("input", {})
|
|
397
|
+
tool_use_id = request.get("tool_use_id", "")
|
|
398
|
+
agent_id = request.get("agent_id")
|
|
399
|
+
|
|
400
|
+
can_use_tool = options.can_use_tool
|
|
401
|
+
|
|
402
|
+
# Default deny if no callback provided
|
|
403
|
+
if not can_use_tool:
|
|
404
|
+
response = {
|
|
405
|
+
"type": "control_response",
|
|
406
|
+
"response": {
|
|
407
|
+
"subtype": "success",
|
|
408
|
+
"request_id": request_id,
|
|
409
|
+
"response": {
|
|
410
|
+
"allowed": False,
|
|
411
|
+
"reason": "No permission handler provided",
|
|
412
|
+
"tool_use_id": tool_use_id,
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
await transport.write(json.dumps(response))
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
callback_options = CanUseToolOptions(
|
|
421
|
+
tool_use_id=tool_use_id,
|
|
422
|
+
signal=None,
|
|
423
|
+
agent_id=agent_id,
|
|
424
|
+
suggestions=request.get("permission_suggestions"),
|
|
425
|
+
blocked_path=request.get("blocked_path"),
|
|
426
|
+
decision_reason=request.get("decision_reason"),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
result = await can_use_tool(tool_name, input_data, callback_options)
|
|
430
|
+
|
|
431
|
+
if result.behavior == "allow":
|
|
432
|
+
response_data = {
|
|
433
|
+
"allowed": True,
|
|
434
|
+
"updatedInput": result.updated_input,
|
|
435
|
+
"tool_use_id": tool_use_id,
|
|
436
|
+
}
|
|
437
|
+
else:
|
|
438
|
+
response_data = {
|
|
439
|
+
"allowed": False,
|
|
440
|
+
"reason": result.message,
|
|
441
|
+
"interrupt": result.interrupt,
|
|
442
|
+
"tool_use_id": tool_use_id,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
response = {
|
|
446
|
+
"type": "control_response",
|
|
447
|
+
"response": {
|
|
448
|
+
"subtype": "success",
|
|
449
|
+
"request_id": request_id,
|
|
450
|
+
"response": response_data,
|
|
451
|
+
},
|
|
452
|
+
}
|
|
453
|
+
await transport.write(json.dumps(response))
|
|
454
|
+
|
|
455
|
+
except Exception as e:
|
|
456
|
+
response = {
|
|
457
|
+
"type": "control_response",
|
|
458
|
+
"response": {
|
|
459
|
+
"subtype": "success",
|
|
460
|
+
"request_id": request_id,
|
|
461
|
+
"response": {
|
|
462
|
+
"allowed": False,
|
|
463
|
+
"reason": str(e),
|
|
464
|
+
"tool_use_id": tool_use_id,
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
await transport.write(json.dumps(response))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
async def _execute_hook(
|
|
472
|
+
callback_id: str,
|
|
473
|
+
hook_input: dict[str, Any],
|
|
474
|
+
tool_use_id: str | None,
|
|
475
|
+
options: CodeBuddyAgentOptions,
|
|
476
|
+
) -> dict[str, Any]:
|
|
477
|
+
"""Execute a hook callback."""
|
|
478
|
+
if not options.hooks:
|
|
479
|
+
return {"continue_": True}
|
|
480
|
+
|
|
481
|
+
# Parse callback_id: hook_{event}_{matcherIndex}_{hookIndex}
|
|
482
|
+
parts = callback_id.split("_")
|
|
483
|
+
if len(parts) < 4:
|
|
484
|
+
return {"continue_": True}
|
|
485
|
+
|
|
486
|
+
event = parts[1]
|
|
487
|
+
|
|
488
|
+
# Validate event is a known HookEvent using TypeGuard
|
|
489
|
+
if not _is_valid_hook_event(event):
|
|
490
|
+
return {"continue_": True}
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
matcher_idx = int(parts[2])
|
|
494
|
+
hook_idx = int(parts[3])
|
|
495
|
+
except ValueError:
|
|
496
|
+
return {"continue_": True}
|
|
497
|
+
|
|
498
|
+
# Find the hook - event is now narrowed to HookEvent by TypeGuard
|
|
499
|
+
matchers = options.hooks.get(event)
|
|
500
|
+
if not matchers or matcher_idx >= len(matchers):
|
|
501
|
+
return {"continue_": True}
|
|
502
|
+
|
|
503
|
+
matcher = matchers[matcher_idx]
|
|
504
|
+
if hook_idx >= len(matcher.hooks):
|
|
505
|
+
return {"continue_": True}
|
|
506
|
+
|
|
507
|
+
hook = matcher.hooks[hook_idx]
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
result = await hook(hook_input, tool_use_id, {"signal": None})
|
|
511
|
+
return dict(result)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
return {"continue_": False, "stopReason": str(e)}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _build_hooks_config(
|
|
517
|
+
hooks: dict[Any, list[HookMatcher]] | None,
|
|
518
|
+
) -> dict[str, list[dict[str, Any]]] | None:
|
|
519
|
+
"""Build hooks configuration for CLI."""
|
|
520
|
+
if not hooks:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
config: dict[str, list[dict[str, Any]]] = {}
|
|
524
|
+
|
|
525
|
+
for event, matchers in hooks.items():
|
|
526
|
+
config[str(event)] = [
|
|
527
|
+
{
|
|
528
|
+
"matcher": m.matcher,
|
|
529
|
+
"hookCallbackIds": [f"hook_{event}_{i}_{j}" for j, _ in enumerate(m.hooks)],
|
|
530
|
+
"timeout": m.timeout,
|
|
531
|
+
}
|
|
532
|
+
for i, m in enumerate(matchers)
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
return config if config else None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Transport base class for CLI communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Transport(ABC):
|
|
10
|
+
"""Abstract transport layer for CLI communication."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def connect(self) -> None:
|
|
14
|
+
"""Establish connection to CLI."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def read(self) -> AsyncIterator[str]:
|
|
18
|
+
"""Read messages from CLI as an async iterator."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def write(self, data: str) -> None:
|
|
22
|
+
"""Write data to CLI."""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def close(self) -> None:
|
|
26
|
+
"""Close the connection."""
|