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.
- codebuddy_agent_sdk/__init__.py +133 -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 +394 -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 +340 -0
- codebuddy_agent_sdk/transport/__init__.py +6 -0
- codebuddy_agent_sdk/transport/base.py +31 -0
- codebuddy_agent_sdk/transport/subprocess.py +341 -0
- codebuddy_agent_sdk/types.py +395 -0
- codebuddy_agent_sdk-0.3.7.dist-info/METADATA +89 -0
- codebuddy_agent_sdk-0.3.7.dist-info/RECORD +20 -0
- codebuddy_agent_sdk-0.3.7.dist-info/WHEEL +4 -0
|
@@ -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()
|