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.

@@ -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,6 @@
1
+ """Transport layer for CLI communication."""
2
+
3
+ from .base import Transport
4
+ from .subprocess import SubprocessTransport
5
+
6
+ __all__ = ["Transport", "SubprocessTransport"]
@@ -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."""