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,341 @@
1
+ """Subprocess transport for CLI communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import re
9
+ from collections.abc import AsyncIterable, AsyncIterator, Callable
10
+ from typing import Any, TypeGuard, cast
11
+
12
+ from .._binary import get_cli_path
13
+ from ..mcp.sdk_control_server_transport import SdkControlServerTransport
14
+ from ..mcp.types import JSONRPCMessage, SdkMcpServer
15
+ from ..types import AppendSystemPrompt, CodeBuddyAgentOptions, McpSdkServerConfig, McpServerConfig
16
+ from .base import Transport
17
+
18
+
19
+ def _is_sdk_mcp_server(config: McpServerConfig) -> TypeGuard[McpSdkServerConfig]:
20
+ """Type guard to check if config is an SDK MCP server."""
21
+ return isinstance(config, dict) and config.get("type") == "sdk"
22
+
23
+
24
+ class SubprocessTransport(Transport):
25
+ """Transport that communicates with CLI via subprocess."""
26
+
27
+ def __init__(
28
+ self,
29
+ options: CodeBuddyAgentOptions,
30
+ prompt: str | AsyncIterable[dict[str, Any]] | None = None,
31
+ ):
32
+ self.prompt = prompt
33
+ self._process: asyncio.subprocess.Process | None = None
34
+ self._closed = False
35
+
36
+ # Validate session_id format
37
+ if options.session_id:
38
+ session_id_pattern = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9\-_:]*$")
39
+ if not session_id_pattern.match(options.session_id):
40
+ raise ValueError(
41
+ f'Invalid session ID format: "{options.session_id}". '
42
+ "Session IDs support numbers, letters, hyphens, underscores, and colons, "
43
+ "but must start with a letter or number."
44
+ )
45
+
46
+ # SDK MCP Server management
47
+ self._sdk_mcp_transports: dict[str, SdkControlServerTransport] = {}
48
+ self._sdk_mcp_servers: dict[str, SdkMcpServer] = {}
49
+ self._sdk_mcp_server_names: list[str] = []
50
+
51
+ # Extract SDK MCP servers from options
52
+ sdk_servers, regular_servers = self._extract_mcp_servers(options.mcp_servers)
53
+
54
+ # Store modified options with only regular MCP servers
55
+ self.options = CodeBuddyAgentOptions(
56
+ session_id=options.session_id,
57
+ allowed_tools=options.allowed_tools,
58
+ disallowed_tools=options.disallowed_tools,
59
+ system_prompt=options.system_prompt,
60
+ mcp_servers=regular_servers or {},
61
+ permission_mode=options.permission_mode,
62
+ continue_conversation=options.continue_conversation,
63
+ resume=options.resume,
64
+ max_turns=options.max_turns,
65
+ model=options.model,
66
+ fallback_model=options.fallback_model,
67
+ cwd=options.cwd,
68
+ codebuddy_code_path=options.codebuddy_code_path,
69
+ env=options.env,
70
+ extra_args=options.extra_args,
71
+ stderr=options.stderr,
72
+ hooks=options.hooks,
73
+ include_partial_messages=options.include_partial_messages,
74
+ fork_session=options.fork_session,
75
+ agents=options.agents,
76
+ setting_sources=options.setting_sources,
77
+ can_use_tool=options.can_use_tool,
78
+ )
79
+
80
+ # Connect SDK MCP servers
81
+ for name, server in sdk_servers.items():
82
+ self._connect_sdk_mcp_server(name, server)
83
+
84
+ def _extract_mcp_servers(
85
+ self,
86
+ mcp_servers: dict[str, McpServerConfig] | None,
87
+ ) -> tuple[dict[str, SdkMcpServer], dict[str, McpServerConfig] | None]:
88
+ """
89
+ Extract SDK MCP servers from the mcp_servers config.
90
+ SDK servers are identified by having type: 'sdk'.
91
+
92
+ Returns:
93
+ Tuple of (sdk_servers dict, regular_servers dict or None)
94
+ """
95
+ if not mcp_servers or not isinstance(mcp_servers, dict):
96
+ return {}, None
97
+
98
+ sdk_servers: dict[str, SdkMcpServer] = {}
99
+ regular_servers: dict[str, McpServerConfig] = {}
100
+
101
+ for name, config in mcp_servers.items():
102
+ if _is_sdk_mcp_server(config):
103
+ # SDK MCP server
104
+ sdk_servers[name] = config["server"]
105
+ self._sdk_mcp_server_names.append(name)
106
+ else:
107
+ # Regular MCP server (stdio)
108
+ regular_servers[name] = config
109
+
110
+ return (
111
+ sdk_servers,
112
+ regular_servers if regular_servers else None,
113
+ )
114
+
115
+ def _connect_sdk_mcp_server(self, name: str, server: SdkMcpServer) -> None:
116
+ """Connect an SDK MCP server."""
117
+
118
+ def _create_message_forwarder(server_name: str) -> Callable[[JSONRPCMessage], None]:
119
+ def forwarder(msg: JSONRPCMessage) -> None:
120
+ # This callback sends MCP messages from server to CLI
121
+ # For SDK servers, responses go through handle_mcp_message_request
122
+ pass
123
+
124
+ return forwarder
125
+
126
+ # Create custom transport that forwards to CLI
127
+ transport = SdkControlServerTransport(_create_message_forwarder(name))
128
+
129
+ # Store transport and server
130
+ self._sdk_mcp_transports[name] = transport
131
+ self._sdk_mcp_servers[name] = server
132
+
133
+ # Connect server to transport
134
+ server.connect(transport)
135
+
136
+ @property
137
+ def sdk_mcp_server_names(self) -> list[str]:
138
+ """Get the list of SDK MCP server names."""
139
+ return self._sdk_mcp_server_names
140
+
141
+ async def handle_mcp_message_request(
142
+ self,
143
+ request_id: str,
144
+ request: dict[str, Any],
145
+ ) -> None:
146
+ """Handle MCP message control request from CLI."""
147
+ server_name = request.get("server_name", "")
148
+ message = cast(JSONRPCMessage, request.get("message", {}))
149
+
150
+ server = self._sdk_mcp_servers.get(server_name)
151
+
152
+ if not server:
153
+ response = {
154
+ "type": "control_response",
155
+ "response": {
156
+ "subtype": "error",
157
+ "request_id": request_id,
158
+ "error": f"SDK MCP server not found: {server_name}",
159
+ },
160
+ }
161
+ await self.write(json.dumps(response))
162
+ return
163
+
164
+ try:
165
+ # Handle the message with the MCP server
166
+ mcp_response = await server.handle_message(message)
167
+
168
+ response = {
169
+ "type": "control_response",
170
+ "response": {
171
+ "subtype": "success",
172
+ "request_id": request_id,
173
+ "response": {
174
+ "mcp_response": mcp_response or {"jsonrpc": "2.0", "result": {}, "id": 0},
175
+ },
176
+ },
177
+ }
178
+ await self.write(json.dumps(response))
179
+
180
+ except Exception as e:
181
+ response = {
182
+ "type": "control_response",
183
+ "response": {
184
+ "subtype": "error",
185
+ "request_id": request_id,
186
+ "error": str(e),
187
+ },
188
+ }
189
+ await self.write(json.dumps(response))
190
+
191
+ def _get_cli_path(self) -> str:
192
+ """Get the path to CLI executable."""
193
+ # User-specified path takes highest precedence
194
+ if self.options.codebuddy_code_path:
195
+ return str(self.options.codebuddy_code_path)
196
+
197
+ # Use the binary resolver (env var -> package binary -> monorepo)
198
+ return get_cli_path()
199
+
200
+ def _build_args(self) -> list[str]:
201
+ """Build CLI arguments from options."""
202
+ args = [
203
+ "--input-format",
204
+ "stream-json",
205
+ "--verbose",
206
+ "--output-format",
207
+ "stream-json",
208
+ ]
209
+ opts = self.options
210
+
211
+ # Model options
212
+ if opts.model:
213
+ args.extend(["--model", opts.model])
214
+ if opts.fallback_model:
215
+ args.extend(["--fallback-model", opts.fallback_model])
216
+
217
+ # Permission options
218
+ if opts.permission_mode:
219
+ args.extend(["--permission-mode", opts.permission_mode])
220
+
221
+ # Turn limits
222
+ if opts.max_turns:
223
+ args.extend(["--max-turns", str(opts.max_turns)])
224
+
225
+ # Session options
226
+ if opts.session_id:
227
+ args.extend(["--session-id", opts.session_id])
228
+ if opts.continue_conversation:
229
+ args.append("--continue")
230
+ if opts.resume:
231
+ args.extend(["--resume", opts.resume])
232
+ if opts.fork_session:
233
+ args.append("--fork-session")
234
+
235
+ # Tool options
236
+ if opts.allowed_tools:
237
+ args.extend(["--allowedTools", ",".join(opts.allowed_tools)])
238
+ if opts.disallowed_tools:
239
+ args.extend(["--disallowedTools", ",".join(opts.disallowed_tools)])
240
+
241
+ # MCP options
242
+ if opts.mcp_servers and isinstance(opts.mcp_servers, dict):
243
+ args.extend(["--mcp-config", json.dumps({"mcpServers": opts.mcp_servers})])
244
+
245
+ # Settings
246
+ # SDK default: don't load any filesystem settings for clean environment isolation
247
+ # When setting_sources is explicitly provided (including empty list), use it
248
+ # When not provided (None), default to 'none' for SDK isolation
249
+ if opts.setting_sources is not None:
250
+ setting_value = (
251
+ "none" if len(opts.setting_sources) == 0 else ",".join(opts.setting_sources)
252
+ )
253
+ args.extend(["--setting-sources", setting_value])
254
+ else:
255
+ # SDK default behavior: no filesystem settings loaded
256
+ args.extend(["--setting-sources", "none"])
257
+
258
+ # Output options
259
+ if opts.include_partial_messages:
260
+ args.append("--include-partial-messages")
261
+
262
+ # System prompt options
263
+ if opts.system_prompt is not None:
264
+ if isinstance(opts.system_prompt, str):
265
+ args.extend(["--system-prompt", opts.system_prompt])
266
+ elif isinstance(opts.system_prompt, AppendSystemPrompt):
267
+ args.extend(["--append-system-prompt", opts.system_prompt.append])
268
+
269
+ # Extra args (custom flags)
270
+ for flag, value in opts.extra_args.items():
271
+ if value is None:
272
+ args.append(f"--{flag}")
273
+ else:
274
+ args.extend([f"--{flag}", value])
275
+
276
+ return args
277
+
278
+ async def connect(self) -> None:
279
+ """Start the subprocess."""
280
+ cli_path = self._get_cli_path()
281
+ args = self._build_args()
282
+ cwd = str(self.options.cwd) if self.options.cwd else os.getcwd()
283
+
284
+ env = {
285
+ **os.environ,
286
+ **self.options.env,
287
+ "CODEBUDDY_CODE_ENTRYPOINT": "sdk-py",
288
+ }
289
+
290
+ self._process = await asyncio.create_subprocess_exec(
291
+ cli_path,
292
+ *args,
293
+ stdin=asyncio.subprocess.PIPE,
294
+ stdout=asyncio.subprocess.PIPE,
295
+ stderr=asyncio.subprocess.PIPE,
296
+ cwd=cwd,
297
+ env=env,
298
+ )
299
+
300
+ # Start stderr reader if callback provided
301
+ if self.options.stderr and self._process.stderr:
302
+ asyncio.create_task(self._read_stderr())
303
+
304
+ async def _read_stderr(self) -> None:
305
+ """Read stderr and call callback."""
306
+ if self._process and self._process.stderr and self.options.stderr:
307
+ async for line in self._process.stderr:
308
+ self.options.stderr(line.decode())
309
+
310
+ async def read(self) -> AsyncIterator[str]:
311
+ """Read lines from stdout."""
312
+ if not self._process or not self._process.stdout:
313
+ return
314
+
315
+ async for line in self._process.stdout:
316
+ if self._closed:
317
+ break
318
+ yield line.decode().strip()
319
+
320
+ async def write(self, data: str) -> None:
321
+ """Write data to stdin."""
322
+ if self._process and self._process.stdin:
323
+ self._process.stdin.write((data + "\n").encode())
324
+ await self._process.stdin.drain()
325
+
326
+ async def close(self) -> None:
327
+ """Close the subprocess."""
328
+ if self._closed:
329
+ return
330
+
331
+ self._closed = True
332
+
333
+ # Clean up SDK MCP resources
334
+ for transport in self._sdk_mcp_transports.values():
335
+ await transport.close()
336
+ self._sdk_mcp_transports.clear()
337
+ self._sdk_mcp_servers.clear()
338
+
339
+ if self._process:
340
+ self._process.kill()
341
+ await self._process.wait()