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,311 @@
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 CanUseToolOptions, CodeBuddyAgentOptions, ErrorMessage, Message, ResultMessage
15
+
16
+
17
+ class CodeBuddySDKClient:
18
+ """
19
+ Client for bidirectional, interactive conversations with CodeBuddy.
20
+
21
+ This client provides full control over the conversation flow with support
22
+ for streaming, interrupts, and dynamic message sending. For simple one-shot
23
+ queries, consider using the query() function instead.
24
+
25
+ Key features:
26
+ - Bidirectional: Send and receive messages at any time
27
+ - Stateful: Maintains conversation context across messages
28
+ - Interactive: Send follow-ups based on responses
29
+ - Control flow: Support for interrupts and session management
30
+
31
+ Example:
32
+ ```python
33
+ async with CodeBuddySDKClient() as client:
34
+ await client.query("Hello!")
35
+ async for msg in client.receive_response():
36
+ print(msg)
37
+ ```
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ options: CodeBuddyAgentOptions | None = None,
43
+ transport: Transport | None = None,
44
+ ):
45
+ """Initialize CodeBuddy SDK client."""
46
+ self.options = options or CodeBuddyAgentOptions()
47
+ self._custom_transport = transport
48
+ self._transport: Transport | None = None
49
+ self._connected = False
50
+
51
+ os.environ["CODEBUDDY_CODE_ENTRYPOINT"] = "sdk-py-client"
52
+
53
+ async def connect(self, prompt: str | AsyncIterable[dict[str, Any]] | None = None) -> None:
54
+ """Connect to CodeBuddy with an optional initial prompt."""
55
+ if self._custom_transport:
56
+ self._transport = self._custom_transport
57
+ else:
58
+ self._transport = SubprocessTransport(
59
+ options=self.options,
60
+ prompt=prompt,
61
+ )
62
+
63
+ await self._transport.connect()
64
+ self._connected = True
65
+ await self._send_initialize()
66
+
67
+ async def _send_initialize(self) -> None:
68
+ """Send initialization control request."""
69
+ if not self._transport:
70
+ return
71
+
72
+ request = {
73
+ "type": "control_request",
74
+ "request_id": f"init_{id(self)}",
75
+ "request": {"subtype": "initialize"},
76
+ }
77
+ await self._transport.write(json.dumps(request))
78
+
79
+ async def query(
80
+ self,
81
+ prompt: str | AsyncIterable[dict[str, Any]],
82
+ session_id: str = "default",
83
+ ) -> None:
84
+ """
85
+ Send a new request.
86
+
87
+ Args:
88
+ prompt: Either a string message or an async iterable of message dicts
89
+ session_id: Session identifier for the conversation
90
+ """
91
+ if not self._connected or not self._transport:
92
+ raise CLIConnectionError("Not connected. Call connect() first.")
93
+
94
+ if isinstance(prompt, str):
95
+ message = {
96
+ "type": "user",
97
+ "message": {"role": "user", "content": prompt},
98
+ "parent_tool_use_id": None,
99
+ "session_id": session_id,
100
+ }
101
+ await self._transport.write(json.dumps(message))
102
+ else:
103
+ async for msg in prompt:
104
+ if "session_id" not in msg:
105
+ msg["session_id"] = session_id
106
+ await self._transport.write(json.dumps(msg))
107
+
108
+ async def receive_messages(self) -> AsyncIterator[Message]:
109
+ """Receive all messages from CodeBuddy."""
110
+ if not self._transport:
111
+ raise CLIConnectionError("Not connected.")
112
+
113
+ async for line in self._transport.read():
114
+ if not line:
115
+ continue
116
+
117
+ try:
118
+ data = json.loads(line)
119
+
120
+ # Handle control requests (permissions, hooks)
121
+ if data.get("type") == "control_request":
122
+ await self._handle_control_request(data)
123
+ continue
124
+
125
+ message = parse_message(data)
126
+ if message:
127
+ yield message
128
+ except json.JSONDecodeError:
129
+ continue
130
+
131
+ async def _handle_control_request(self, data: dict[str, Any]) -> None:
132
+ """Handle control request from CLI."""
133
+ if not self._transport:
134
+ return
135
+
136
+ request_id = data.get("request_id", "")
137
+ request = data.get("request", {})
138
+ subtype = request.get("subtype", "")
139
+
140
+ if subtype == "can_use_tool":
141
+ await self._handle_permission_request(request_id, request)
142
+ elif subtype == "hook_callback":
143
+ # Default: continue
144
+ response = {
145
+ "type": "control_response",
146
+ "response": {
147
+ "subtype": "success",
148
+ "request_id": request_id,
149
+ "response": {"continue": True},
150
+ },
151
+ }
152
+ await self._transport.write(json.dumps(response))
153
+
154
+ async def _handle_permission_request(self, request_id: str, request: dict[str, Any]) -> None:
155
+ """Handle permission request from CLI."""
156
+ if not self._transport:
157
+ return
158
+
159
+ tool_name = request.get("tool_name", "")
160
+ input_data = request.get("input", {})
161
+ tool_use_id = request.get("tool_use_id", "")
162
+ agent_id = request.get("agent_id")
163
+
164
+ can_use_tool = self.options.can_use_tool
165
+
166
+ # Default deny if no callback provided
167
+ if not can_use_tool:
168
+ response = {
169
+ "type": "control_response",
170
+ "response": {
171
+ "subtype": "success",
172
+ "request_id": request_id,
173
+ "response": {
174
+ "allowed": False,
175
+ "reason": "No permission handler provided",
176
+ "tool_use_id": tool_use_id,
177
+ },
178
+ },
179
+ }
180
+ await self._transport.write(json.dumps(response))
181
+ return
182
+
183
+ try:
184
+ callback_options = CanUseToolOptions(
185
+ tool_use_id=tool_use_id,
186
+ signal=None,
187
+ agent_id=agent_id,
188
+ suggestions=request.get("permission_suggestions"),
189
+ blocked_path=request.get("blocked_path"),
190
+ decision_reason=request.get("decision_reason"),
191
+ )
192
+
193
+ result = await can_use_tool(tool_name, input_data, callback_options)
194
+
195
+ if result.behavior == "allow":
196
+ response_data = {
197
+ "allowed": True,
198
+ "updatedInput": result.updated_input,
199
+ "tool_use_id": tool_use_id,
200
+ }
201
+ else:
202
+ response_data = {
203
+ "allowed": False,
204
+ "reason": result.message,
205
+ "interrupt": result.interrupt,
206
+ "tool_use_id": tool_use_id,
207
+ }
208
+
209
+ response = {
210
+ "type": "control_response",
211
+ "response": {
212
+ "subtype": "success",
213
+ "request_id": request_id,
214
+ "response": response_data,
215
+ },
216
+ }
217
+ await self._transport.write(json.dumps(response))
218
+
219
+ except Exception as e:
220
+ response = {
221
+ "type": "control_response",
222
+ "response": {
223
+ "subtype": "success",
224
+ "request_id": request_id,
225
+ "response": {
226
+ "allowed": False,
227
+ "reason": str(e),
228
+ "tool_use_id": tool_use_id,
229
+ },
230
+ },
231
+ }
232
+ await self._transport.write(json.dumps(response))
233
+
234
+ async def receive_response(self) -> AsyncIterator[Message]:
235
+ """
236
+ Receive messages until and including a ResultMessage or ErrorMessage.
237
+
238
+ Yields each message as it's received and terminates after
239
+ yielding a ResultMessage or ErrorMessage.
240
+ Raises ExecutionError if ResultMessage indicates an error.
241
+ """
242
+ async for message in self.receive_messages():
243
+ # Check for execution error BEFORE yielding
244
+ if isinstance(message, ResultMessage):
245
+ if message.is_error and message.errors and len(message.errors) > 0:
246
+ raise ExecutionError(message.errors, message.subtype)
247
+ yield message
248
+ return
249
+
250
+ yield message
251
+
252
+ if isinstance(message, ErrorMessage):
253
+ return
254
+
255
+ async def interrupt(self) -> None:
256
+ """Send interrupt signal."""
257
+ if not self._transport:
258
+ raise CLIConnectionError("Not connected.")
259
+
260
+ request = {
261
+ "type": "control_request",
262
+ "request_id": f"interrupt_{id(self)}",
263
+ "request": {"subtype": "interrupt"},
264
+ }
265
+ await self._transport.write(json.dumps(request))
266
+
267
+ async def set_permission_mode(self, mode: str) -> None:
268
+ """Change permission mode during conversation."""
269
+ if not self._transport:
270
+ raise CLIConnectionError("Not connected.")
271
+
272
+ request = {
273
+ "type": "control_request",
274
+ "request_id": f"perm_{id(self)}",
275
+ "request": {"subtype": "set_permission_mode", "mode": mode},
276
+ }
277
+ await self._transport.write(json.dumps(request))
278
+
279
+ async def set_model(self, model: str | None = None) -> None:
280
+ """Change the AI model during conversation."""
281
+ if not self._transport:
282
+ raise CLIConnectionError("Not connected.")
283
+
284
+ request = {
285
+ "type": "control_request",
286
+ "request_id": f"model_{id(self)}",
287
+ "request": {"subtype": "set_model", "model": model},
288
+ }
289
+ await self._transport.write(json.dumps(request))
290
+
291
+ async def disconnect(self) -> None:
292
+ """Disconnect from CodeBuddy."""
293
+ if self._transport:
294
+ await self._transport.close()
295
+ self._transport = None
296
+ self._connected = False
297
+
298
+ async def __aenter__(self) -> CodeBuddySDKClient:
299
+ """Enter async context - automatically connects."""
300
+ await self.connect()
301
+ return self
302
+
303
+ async def __aexit__(
304
+ self,
305
+ exc_type: type[BaseException] | None,
306
+ exc_val: BaseException | None,
307
+ exc_tb: TracebackType | None,
308
+ ) -> bool:
309
+ """Exit async context - always disconnects."""
310
+ await self.disconnect()
311
+ 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)