codebuddy-agent-sdk 0.3.7__py3-none-manylinux_2_17_x86_64.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,394 @@
1
+ """CodeBuddy SDK Client for interactive conversations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import AsyncIterable, AsyncIterator
8
+ from types import TracebackType
9
+ from typing import Any
10
+
11
+ from ._errors import CLIConnectionError, ExecutionError
12
+ from ._message_parser import parse_message
13
+ from .transport import SubprocessTransport, Transport
14
+ from .types import (
15
+ CanUseToolOptions,
16
+ CodeBuddyAgentOptions,
17
+ ErrorMessage,
18
+ Message,
19
+ ResultMessage,
20
+ )
21
+
22
+
23
+ class CodeBuddySDKClient:
24
+ """
25
+ Client for bidirectional, interactive conversations with CodeBuddy.
26
+
27
+ This client provides full control over the conversation flow with support
28
+ for streaming, interrupts, and dynamic message sending. For simple one-shot
29
+ queries, consider using the query() function instead.
30
+
31
+ Key features:
32
+ - Bidirectional: Send and receive messages at any time
33
+ - Stateful: Maintains conversation context across messages
34
+ - Interactive: Send follow-ups based on responses
35
+ - Control flow: Support for interrupts and session management
36
+
37
+ Example:
38
+ ```python
39
+ async with CodeBuddySDKClient() as client:
40
+ await client.query("Hello!")
41
+ async for msg in client.receive_response():
42
+ print(msg)
43
+ ```
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ options: CodeBuddyAgentOptions | None = None,
49
+ transport: Transport | None = None,
50
+ ):
51
+ """Initialize CodeBuddy SDK client."""
52
+ self.options = options or CodeBuddyAgentOptions()
53
+ self._custom_transport = transport
54
+ self._transport: Transport | None = None
55
+ self._connected = False
56
+ # Hook callback registry: callback_id -> hook function
57
+ self._hook_callbacks: dict[str, Any] = {}
58
+
59
+ os.environ["CODEBUDDY_CODE_ENTRYPOINT"] = "sdk-py-client"
60
+
61
+ async def connect(self, prompt: str | AsyncIterable[dict[str, Any]] | None = None) -> None:
62
+ """Connect to CodeBuddy with an optional initial prompt."""
63
+ if self._custom_transport:
64
+ self._transport = self._custom_transport
65
+ else:
66
+ self._transport = SubprocessTransport(
67
+ options=self.options,
68
+ prompt=prompt,
69
+ )
70
+
71
+ await self._transport.connect()
72
+ self._connected = True
73
+ await self._send_initialize()
74
+
75
+ async def _send_initialize(self) -> None:
76
+ """Send initialization control request."""
77
+ if not self._transport:
78
+ return
79
+
80
+ hooks_config, self._hook_callbacks = self._build_hooks_config()
81
+
82
+ # Get SDK MCP server names from transport
83
+ sdk_mcp_server_names = self._transport.sdk_mcp_server_names
84
+
85
+ request = {
86
+ "type": "control_request",
87
+ "request_id": f"init_{id(self)}",
88
+ "request": {
89
+ "subtype": "initialize",
90
+ "hooks": hooks_config,
91
+ # Include SDK MCP server names from transport
92
+ "sdkMcpServers": sdk_mcp_server_names if sdk_mcp_server_names else None,
93
+ },
94
+ }
95
+ await self._transport.write(json.dumps(request))
96
+
97
+ def _build_hooks_config(self) -> tuple[dict[str, list[dict[str, Any]]] | None, dict[str, Any]]:
98
+ """Build hooks configuration for CLI and callback registry.
99
+
100
+ Returns:
101
+ Tuple of (config for CLI, callback_id -> hook function mapping)
102
+ """
103
+ callbacks: dict[str, Any] = {}
104
+
105
+ if not self.options.hooks:
106
+ return None, callbacks
107
+
108
+ config: dict[str, list[dict[str, Any]]] = {}
109
+
110
+ for event, matchers in self.options.hooks.items():
111
+ event_str = str(event)
112
+ matcher_configs = []
113
+
114
+ for i, m in enumerate(matchers):
115
+ callback_ids = []
116
+ for j, hook in enumerate(m.hooks):
117
+ callback_id = f"hook_{event_str}_{i}_{j}"
118
+ callback_ids.append(callback_id)
119
+ callbacks[callback_id] = hook
120
+
121
+ matcher_configs.append(
122
+ {
123
+ "matcher": m.matcher,
124
+ "hookCallbackIds": callback_ids,
125
+ "timeout": m.timeout,
126
+ }
127
+ )
128
+
129
+ config[event_str] = matcher_configs
130
+
131
+ return (config if config else None), callbacks
132
+
133
+ async def query(
134
+ self,
135
+ prompt: str | AsyncIterable[dict[str, Any]],
136
+ session_id: str = "default",
137
+ ) -> None:
138
+ """
139
+ Send a new request.
140
+
141
+ Args:
142
+ prompt: Either a string message or an async iterable of message dicts
143
+ session_id: Session identifier for the conversation
144
+ """
145
+ if not self._connected or not self._transport:
146
+ raise CLIConnectionError("Not connected. Call connect() first.")
147
+
148
+ if isinstance(prompt, str):
149
+ message = {
150
+ "type": "user",
151
+ "message": {"role": "user", "content": prompt},
152
+ "parent_tool_use_id": None,
153
+ "session_id": session_id,
154
+ }
155
+ await self._transport.write(json.dumps(message))
156
+ else:
157
+ async for msg in prompt:
158
+ if "session_id" not in msg:
159
+ msg["session_id"] = session_id
160
+ await self._transport.write(json.dumps(msg))
161
+
162
+ async def receive_messages(self) -> AsyncIterator[Message]:
163
+ """Receive all messages from CodeBuddy."""
164
+ if not self._transport:
165
+ raise CLIConnectionError("Not connected.")
166
+
167
+ async for line in self._transport.read():
168
+ if not line:
169
+ continue
170
+
171
+ try:
172
+ data = json.loads(line)
173
+
174
+ # Handle control requests (permissions, hooks)
175
+ if data.get("type") == "control_request":
176
+ await self._handle_control_request(data)
177
+ continue
178
+
179
+ message = parse_message(data)
180
+ if message:
181
+ yield message
182
+ except json.JSONDecodeError:
183
+ continue
184
+
185
+ async def _handle_control_request(self, data: dict[str, Any]) -> None:
186
+ """Handle control request from CLI."""
187
+ if not self._transport:
188
+ return
189
+
190
+ request_id = data.get("request_id", "")
191
+ request = data.get("request", {})
192
+ subtype = request.get("subtype", "")
193
+
194
+ if subtype == "can_use_tool":
195
+ await self._handle_permission_request(request_id, request)
196
+ elif subtype == "hook_callback":
197
+ callback_id = request.get("callback_id", "")
198
+ hook_input = request.get("input", {})
199
+ tool_use_id = request.get("tool_use_id")
200
+
201
+ # Execute the hook
202
+ hook_response = await self._execute_hook(callback_id, hook_input, tool_use_id)
203
+
204
+ response = {
205
+ "type": "control_response",
206
+ "response": {
207
+ "subtype": "success",
208
+ "request_id": request_id,
209
+ "response": hook_response,
210
+ },
211
+ }
212
+ await self._transport.write(json.dumps(response))
213
+ elif subtype == "mcp_message":
214
+ # MCP messages are handled at the transport level
215
+ from .transport import SubprocessTransport
216
+
217
+ if isinstance(self._transport, SubprocessTransport):
218
+ await self._transport.handle_mcp_message_request(request_id, request)
219
+
220
+ async def _handle_permission_request(self, request_id: str, request: dict[str, Any]) -> None:
221
+ """Handle permission request from CLI."""
222
+ if not self._transport:
223
+ return
224
+
225
+ tool_name = request.get("tool_name", "")
226
+ input_data = request.get("input", {})
227
+ tool_use_id = request.get("tool_use_id", "")
228
+ agent_id = request.get("agent_id")
229
+
230
+ can_use_tool = self.options.can_use_tool
231
+
232
+ # Default deny if no callback provided
233
+ if not can_use_tool:
234
+ response = {
235
+ "type": "control_response",
236
+ "response": {
237
+ "subtype": "success",
238
+ "request_id": request_id,
239
+ "response": {
240
+ "allowed": False,
241
+ "reason": "No permission handler provided",
242
+ "tool_use_id": tool_use_id,
243
+ },
244
+ },
245
+ }
246
+ await self._transport.write(json.dumps(response))
247
+ return
248
+
249
+ try:
250
+ callback_options = CanUseToolOptions(
251
+ tool_use_id=tool_use_id,
252
+ signal=None,
253
+ agent_id=agent_id,
254
+ suggestions=request.get("permission_suggestions"),
255
+ blocked_path=request.get("blocked_path"),
256
+ decision_reason=request.get("decision_reason"),
257
+ )
258
+
259
+ result = await can_use_tool(tool_name, input_data, callback_options)
260
+
261
+ if result.behavior == "allow":
262
+ response_data = {
263
+ "allowed": True,
264
+ "updatedInput": result.updated_input,
265
+ "tool_use_id": tool_use_id,
266
+ }
267
+ else:
268
+ response_data = {
269
+ "allowed": False,
270
+ "reason": result.message,
271
+ "interrupt": result.interrupt,
272
+ "tool_use_id": tool_use_id,
273
+ }
274
+
275
+ response = {
276
+ "type": "control_response",
277
+ "response": {
278
+ "subtype": "success",
279
+ "request_id": request_id,
280
+ "response": response_data,
281
+ },
282
+ }
283
+ await self._transport.write(json.dumps(response))
284
+
285
+ except Exception as e:
286
+ response = {
287
+ "type": "control_response",
288
+ "response": {
289
+ "subtype": "success",
290
+ "request_id": request_id,
291
+ "response": {
292
+ "allowed": False,
293
+ "reason": str(e),
294
+ "tool_use_id": tool_use_id,
295
+ },
296
+ },
297
+ }
298
+ await self._transport.write(json.dumps(response))
299
+
300
+ async def _execute_hook(
301
+ self,
302
+ callback_id: str,
303
+ hook_input: dict[str, Any],
304
+ tool_use_id: str | None,
305
+ ) -> dict[str, Any]:
306
+ """Execute a hook callback by looking up in the callback registry."""
307
+ hook = self._hook_callbacks.get(callback_id)
308
+ if not hook:
309
+ return {"continue": True}
310
+
311
+ try:
312
+ result = await hook(hook_input, tool_use_id, {"signal": None})
313
+ return dict(result)
314
+ except Exception as e:
315
+ return {"continue": False, "stopReason": str(e)}
316
+
317
+ async def receive_response(self) -> AsyncIterator[Message]:
318
+ """
319
+ Receive messages until and including a ResultMessage or ErrorMessage.
320
+
321
+ Yields each message as it's received and terminates after
322
+ yielding a ResultMessage or ErrorMessage.
323
+ Raises ExecutionError if ResultMessage indicates an error.
324
+ """
325
+ async for message in self.receive_messages():
326
+ # Check for execution error BEFORE yielding
327
+ if isinstance(message, ResultMessage):
328
+ if message.is_error and message.errors and len(message.errors) > 0:
329
+ raise ExecutionError(message.errors, message.subtype)
330
+ yield message
331
+ return
332
+
333
+ yield message
334
+
335
+ if isinstance(message, ErrorMessage):
336
+ return
337
+
338
+ async def interrupt(self) -> None:
339
+ """Send interrupt signal."""
340
+ if not self._transport:
341
+ raise CLIConnectionError("Not connected.")
342
+
343
+ request = {
344
+ "type": "control_request",
345
+ "request_id": f"interrupt_{id(self)}",
346
+ "request": {"subtype": "interrupt"},
347
+ }
348
+ await self._transport.write(json.dumps(request))
349
+
350
+ async def set_permission_mode(self, mode: str) -> None:
351
+ """Change permission mode during conversation."""
352
+ if not self._transport:
353
+ raise CLIConnectionError("Not connected.")
354
+
355
+ request = {
356
+ "type": "control_request",
357
+ "request_id": f"perm_{id(self)}",
358
+ "request": {"subtype": "set_permission_mode", "mode": mode},
359
+ }
360
+ await self._transport.write(json.dumps(request))
361
+
362
+ async def set_model(self, model: str | None = None) -> None:
363
+ """Change the AI model during conversation."""
364
+ if not self._transport:
365
+ raise CLIConnectionError("Not connected.")
366
+
367
+ request = {
368
+ "type": "control_request",
369
+ "request_id": f"model_{id(self)}",
370
+ "request": {"subtype": "set_model", "model": model},
371
+ }
372
+ await self._transport.write(json.dumps(request))
373
+
374
+ async def disconnect(self) -> None:
375
+ """Disconnect from CodeBuddy."""
376
+ if self._transport:
377
+ await self._transport.close()
378
+ self._transport = None
379
+ self._connected = False
380
+
381
+ async def __aenter__(self) -> CodeBuddySDKClient:
382
+ """Enter async context - automatically connects."""
383
+ await self.connect()
384
+ return self
385
+
386
+ async def __aexit__(
387
+ self,
388
+ exc_type: type[BaseException] | None,
389
+ exc_val: BaseException | None,
390
+ exc_tb: TracebackType | None,
391
+ ) -> bool:
392
+ """Exit async context - always disconnects."""
393
+ await self.disconnect()
394
+ return False
@@ -0,0 +1,35 @@
1
+ """
2
+ MCP (Model Context Protocol) Integration
3
+
4
+ This module provides utilities for creating and managing SDK MCP servers
5
+ that can be integrated with the CLI via the control protocol.
6
+ """
7
+
8
+ from .create_sdk_mcp_server import (
9
+ create_sdk_mcp_server,
10
+ tool,
11
+ )
12
+ from .sdk_control_server_transport import SdkControlServerTransport
13
+ from .types import (
14
+ CallToolResult,
15
+ SdkMcpServerOptions,
16
+ SdkMcpServerResult,
17
+ SdkMcpToolDefinition,
18
+ TextContent,
19
+ ToolHandler,
20
+ )
21
+
22
+ __all__ = [
23
+ # Factory functions
24
+ "create_sdk_mcp_server",
25
+ "tool",
26
+ # Transport
27
+ "SdkControlServerTransport",
28
+ # Types
29
+ "SdkMcpServerOptions",
30
+ "SdkMcpServerResult",
31
+ "SdkMcpToolDefinition",
32
+ "ToolHandler",
33
+ "CallToolResult",
34
+ "TextContent",
35
+ ]
@@ -0,0 +1,154 @@
1
+ """
2
+ create_sdk_mcp_server - Create an SDK MCP Server
3
+
4
+ This function creates an MCP server that can be integrated into the SDK
5
+ and used with the CLI via the control protocol.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any, TypeVar
12
+
13
+ from .types import (
14
+ CallToolResult,
15
+ SdkMcpServer,
16
+ SdkMcpServerOptions,
17
+ SdkMcpServerResult,
18
+ SdkMcpToolDefinition,
19
+ ToolInputSchema,
20
+ )
21
+
22
+ # Tool handler type
23
+ ToolHandler = Callable[[dict[str, Any]], CallToolResult | Awaitable[CallToolResult]]
24
+
25
+ # Type variable for the decorated function
26
+ F = TypeVar("F", bound=ToolHandler)
27
+
28
+
29
+ def _python_type_to_json_schema(py_type: type) -> dict[str, Any]:
30
+ """Convert Python type to JSON Schema type."""
31
+ type_mapping = {
32
+ str: {"type": "string"},
33
+ int: {"type": "integer"},
34
+ float: {"type": "number"},
35
+ bool: {"type": "boolean"},
36
+ list: {"type": "array"},
37
+ dict: {"type": "object"},
38
+ }
39
+ return type_mapping.get(py_type, {"type": "string"})
40
+
41
+
42
+ def _convert_schema(input_schema: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str]]:
43
+ """
44
+ Convert input schema to JSON Schema format.
45
+
46
+ Supports multiple formats:
47
+ 1. Simple type mapping: {"latitude": float, "longitude": float}
48
+ 2. JSON Schema format: {"properties": {...}, "required": [...]}
49
+
50
+ Returns:
51
+ Tuple of (properties dict, required list)
52
+ """
53
+ # Check if it's already in JSON Schema format
54
+ if "properties" in input_schema:
55
+ return input_schema.get("properties", {}), input_schema.get("required", [])
56
+
57
+ if "type" in input_schema and input_schema.get("type") == "object":
58
+ return input_schema.get("properties", {}), input_schema.get("required", [])
59
+
60
+ # Simple type mapping format: {"param_name": type}
61
+ properties = {}
62
+ required = []
63
+
64
+ for param_name, param_type in input_schema.items():
65
+ if isinstance(param_type, type):
66
+ properties[param_name] = _python_type_to_json_schema(param_type)
67
+ required.append(param_name)
68
+ elif isinstance(param_type, dict):
69
+ properties[param_name] = param_type
70
+ if "default" not in param_type:
71
+ required.append(param_name)
72
+ else:
73
+ properties[param_name] = {"type": "string"}
74
+ required.append(param_name)
75
+
76
+ return properties, required
77
+
78
+
79
+ def tool(name: str, description: str, input_schema: dict[str, Any]) -> Callable[[F], F]:
80
+ """
81
+ Decorator to define an MCP tool.
82
+
83
+ Example:
84
+ ```python
85
+ @tool("get_weather", "Get current weather", {"latitude": float, "longitude": float})
86
+ async def get_weather(args: dict[str, Any]) -> dict[str, Any]:
87
+ return {"content": [{"type": "text", "text": f"Weather: sunny"}]}
88
+ ```
89
+
90
+ Args:
91
+ name: Tool name (unique within the server)
92
+ description: Tool description
93
+ input_schema: Input parameters schema. Supports:
94
+ - Simple types: {"latitude": float, "longitude": float}
95
+ - JSON Schema: {"properties": {...}, "required": [...]}
96
+
97
+ Returns:
98
+ Decorated function with tool metadata attached
99
+ """
100
+ properties, required = _convert_schema(input_schema)
101
+ tool_schema = ToolInputSchema(type="object", properties=properties, required=required)
102
+
103
+ def decorator(func: F) -> F:
104
+ func._tool_definition = SdkMcpToolDefinition( # type: ignore[attr-defined]
105
+ name=name,
106
+ description=description,
107
+ input_schema=tool_schema,
108
+ handler=func,
109
+ )
110
+ return func
111
+
112
+ return decorator
113
+
114
+
115
+ def create_sdk_mcp_server(
116
+ name: str,
117
+ version: str = "1.0.0",
118
+ tools: list[Callable[..., Any]] | None = None,
119
+ ) -> SdkMcpServerResult:
120
+ """
121
+ Create an SDK MCP Server.
122
+
123
+ Args:
124
+ name: Server name (unique within the session)
125
+ version: Server version (defaults to "1.0.0")
126
+ tools: List of functions decorated with @tool
127
+
128
+ Returns:
129
+ SDK MCP server result for use with query()
130
+
131
+ Example:
132
+ ```python
133
+ from codebuddy_agent_sdk import create_sdk_mcp_server, tool, query
134
+
135
+ @tool("get_weather", "Get weather", {"location": str})
136
+ async def get_weather(args: dict) -> dict:
137
+ return {"content": [{"type": "text", "text": f"Weather: sunny"}]}
138
+
139
+ server = create_sdk_mcp_server(
140
+ name="my-server",
141
+ tools=[get_weather]
142
+ )
143
+ ```
144
+ """
145
+ tool_definitions: list[SdkMcpToolDefinition] = []
146
+ if tools:
147
+ for func in tools:
148
+ if not hasattr(func, "_tool_definition"):
149
+ raise ValueError(f"Function {func.__name__} is not decorated with @tool")
150
+ tool_definitions.append(func._tool_definition)
151
+
152
+ server = SdkMcpServer(SdkMcpServerOptions(name=name, version=version, tools=tool_definitions))
153
+
154
+ return SdkMcpServerResult(type="sdk", name=name, server=server)
@@ -0,0 +1,95 @@
1
+ """
2
+ SDK Control Server Transport
3
+
4
+ Custom transport implementation that bridges SDK MCP servers to CLI process.
5
+ This transport forwards MCP messages through the control protocol.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from .types import JSONRPCMessage
13
+
14
+ # Callback function type for sending MCP messages to CLI
15
+ SendMcpMessageCallback = Callable[[JSONRPCMessage], None]
16
+
17
+
18
+ class SdkControlServerTransport:
19
+ """
20
+ SdkControlServerTransport - bridges MCP servers to CLI via control messages.
21
+
22
+ This transport implements a simple interface for forwarding MCP messages
23
+ between the SDK MCP server and the CLI via the control protocol.
24
+ """
25
+
26
+ def __init__(self, send_mcp_message: SendMcpMessageCallback):
27
+ """
28
+ Create a new SDK Control Server Transport.
29
+
30
+ Args:
31
+ send_mcp_message: Callback function to forward MCP messages to CLI
32
+ """
33
+ self._send_mcp_message = send_mcp_message
34
+ self._is_closed = False
35
+ self._on_message: Callable[[JSONRPCMessage], None] | None = None
36
+ self._on_close: Callable[[], None] | None = None
37
+ self._on_error: Callable[[Exception], None] | None = None
38
+
39
+ @property
40
+ def closed(self) -> bool:
41
+ """Check if the transport is closed."""
42
+ return self._is_closed
43
+
44
+ def set_on_message(self, callback: Callable[[JSONRPCMessage], None] | None) -> None:
45
+ """Set the message callback."""
46
+ self._on_message = callback
47
+
48
+ def set_on_close(self, callback: Callable[[], None] | None) -> None:
49
+ """Set the close callback."""
50
+ self._on_close = callback
51
+
52
+ def set_on_error(self, callback: Callable[[Exception], None] | None) -> None:
53
+ """Set the error callback."""
54
+ self._on_error = callback
55
+
56
+ async def start(self) -> None:
57
+ """
58
+ Start the transport.
59
+ No-op since connection is already established via stdio.
60
+ """
61
+ pass
62
+
63
+ async def send(self, message: JSONRPCMessage) -> None:
64
+ """
65
+ Send a message to the CLI via control_request.
66
+
67
+ Args:
68
+ message: The JSON-RPC message to send
69
+ """
70
+ if self._is_closed:
71
+ raise RuntimeError("Transport is closed")
72
+ # Forward message to CLI via control_request
73
+ self._send_mcp_message(message)
74
+
75
+ async def close(self) -> None:
76
+ """Close the transport."""
77
+ if self._is_closed:
78
+ return
79
+
80
+ self._is_closed = True
81
+ if self._on_close:
82
+ self._on_close()
83
+
84
+ def handle_incoming_message(self, message: JSONRPCMessage) -> None:
85
+ """
86
+ Handle incoming message from CLI.
87
+ This method should be called when the CLI sends a message to this server.
88
+
89
+ Args:
90
+ message: The JSON-RPC message from CLI
91
+ """
92
+ if self._is_closed:
93
+ return
94
+ if self._on_message:
95
+ self._on_message(message)