codebuddy-agent-sdk 0.1.15__py3-none-any.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,316 @@
1
+ """Query function for one-shot interactions with CodeBuddy."""
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import AsyncIterable, AsyncIterator
6
+ from dataclasses import asdict
7
+ from typing import Any
8
+
9
+ from ._message_parser import parse_message
10
+ from .transport import SubprocessTransport, Transport
11
+ from .types import (
12
+ AppendSystemPrompt,
13
+ CanUseToolOptions,
14
+ CodeBuddyAgentOptions,
15
+ HookMatcher,
16
+ Message,
17
+ ResultMessage,
18
+ )
19
+
20
+
21
+ async def query(
22
+ *,
23
+ prompt: str | AsyncIterable[dict[str, Any]],
24
+ options: CodeBuddyAgentOptions | None = None,
25
+ transport: Transport | None = None,
26
+ ) -> AsyncIterator[Message]:
27
+ """
28
+ Query CodeBuddy for one-shot or unidirectional streaming interactions.
29
+
30
+ This function is ideal for simple, stateless queries where you don't need
31
+ bidirectional communication or conversation management. For interactive,
32
+ stateful conversations, use CodeBuddySDKClient instead.
33
+
34
+ Args:
35
+ prompt: The prompt to send to CodeBuddy. Can be a string for single-shot
36
+ queries or an AsyncIterable[dict] for streaming mode.
37
+ options: Optional configuration (defaults to CodeBuddyAgentOptions() if None).
38
+ transport: Optional transport implementation. If provided, this will be used
39
+ instead of the default subprocess transport.
40
+
41
+ Yields:
42
+ Messages from the conversation.
43
+
44
+ Example:
45
+ ```python
46
+ async for message in query(prompt="What is 2+2?"):
47
+ print(message)
48
+ ```
49
+ """
50
+ if options is None:
51
+ options = CodeBuddyAgentOptions()
52
+
53
+ os.environ["CODEBUDDY_CODE_ENTRYPOINT"] = "sdk-py"
54
+
55
+ if transport is None:
56
+ transport = SubprocessTransport(options=options, prompt=prompt)
57
+
58
+ await transport.connect()
59
+
60
+ try:
61
+ await _send_initialize(transport, options)
62
+ await _send_prompt(transport, prompt)
63
+
64
+ async for line in transport.read():
65
+ if not line:
66
+ continue
67
+
68
+ try:
69
+ data = json.loads(line)
70
+
71
+ # Handle control requests (hooks)
72
+ if data.get("type") == "control_request":
73
+ await _handle_control_request(transport, data, options)
74
+ continue
75
+
76
+ message = parse_message(data)
77
+ if message:
78
+ yield message
79
+
80
+ if isinstance(message, ResultMessage):
81
+ break
82
+
83
+ except json.JSONDecodeError:
84
+ continue # Ignore non-JSON lines
85
+
86
+ finally:
87
+ await transport.close()
88
+
89
+
90
+ async def _send_initialize(transport: Transport, options: CodeBuddyAgentOptions) -> None:
91
+ """Send initialization control request."""
92
+ hooks_config = _build_hooks_config(options.hooks) if options.hooks else None
93
+ agents_config = (
94
+ {name: asdict(agent) for name, agent in options.agents.items()}
95
+ if options.agents
96
+ else None
97
+ )
98
+
99
+ # 解析 system_prompt 配置
100
+ system_prompt: str | None = None
101
+ append_system_prompt: str | None = None
102
+ if isinstance(options.system_prompt, str):
103
+ system_prompt = options.system_prompt
104
+ elif isinstance(options.system_prompt, AppendSystemPrompt):
105
+ append_system_prompt = options.system_prompt.append
106
+
107
+ request = {
108
+ "type": "control_request",
109
+ "request_id": f"init_{id(options)}",
110
+ "request": {
111
+ "subtype": "initialize",
112
+ "hooks": hooks_config,
113
+ "systemPrompt": system_prompt,
114
+ "appendSystemPrompt": append_system_prompt,
115
+ "agents": agents_config,
116
+ },
117
+ }
118
+ await transport.write(json.dumps(request))
119
+
120
+
121
+ async def _send_prompt(
122
+ transport: Transport, prompt: str | AsyncIterable[dict[str, Any]]
123
+ ) -> None:
124
+ """Send user prompt."""
125
+ if isinstance(prompt, str):
126
+ message = {
127
+ "type": "user",
128
+ "session_id": "",
129
+ "message": {"role": "user", "content": prompt},
130
+ "parent_tool_use_id": None,
131
+ }
132
+ await transport.write(json.dumps(message))
133
+ else:
134
+ async for msg in prompt:
135
+ await transport.write(json.dumps(msg))
136
+
137
+
138
+ async def _handle_control_request(
139
+ transport: Transport,
140
+ data: dict[str, Any],
141
+ options: CodeBuddyAgentOptions,
142
+ ) -> None:
143
+ """Handle control request from CLI."""
144
+ request_id = data.get("request_id", "")
145
+ request = data.get("request", {})
146
+ subtype = request.get("subtype", "")
147
+
148
+ if subtype == "hook_callback":
149
+ # Handle hook callback
150
+ callback_id = request.get("callback_id", "")
151
+ hook_input = request.get("input", {})
152
+ tool_use_id = request.get("tool_use_id")
153
+
154
+ # Find and execute the hook
155
+ response = await _execute_hook(callback_id, hook_input, tool_use_id, options)
156
+
157
+ # Send response
158
+ control_response = {
159
+ "type": "control_response",
160
+ "response": {
161
+ "subtype": "success",
162
+ "request_id": request_id,
163
+ "response": response,
164
+ },
165
+ }
166
+ await transport.write(json.dumps(control_response))
167
+
168
+ elif subtype == "can_use_tool":
169
+ await _handle_permission_request(transport, request_id, request, options)
170
+
171
+
172
+ async def _handle_permission_request(
173
+ transport: Transport,
174
+ request_id: str,
175
+ request: dict[str, Any],
176
+ options: CodeBuddyAgentOptions,
177
+ ) -> None:
178
+ """Handle permission request from CLI."""
179
+ tool_name = request.get("tool_name", "")
180
+ input_data = request.get("input", {})
181
+ tool_use_id = request.get("tool_use_id", "")
182
+ agent_id = request.get("agent_id")
183
+
184
+ can_use_tool = options.can_use_tool
185
+
186
+ # Default deny if no callback provided
187
+ if not can_use_tool:
188
+ response = {
189
+ "type": "control_response",
190
+ "response": {
191
+ "subtype": "success",
192
+ "request_id": request_id,
193
+ "response": {
194
+ "allowed": False,
195
+ "reason": "No permission handler provided",
196
+ "tool_use_id": tool_use_id,
197
+ },
198
+ },
199
+ }
200
+ await transport.write(json.dumps(response))
201
+ return
202
+
203
+ try:
204
+ callback_options = CanUseToolOptions(
205
+ tool_use_id=tool_use_id,
206
+ signal=None,
207
+ agent_id=agent_id,
208
+ suggestions=request.get("permission_suggestions"),
209
+ blocked_path=request.get("blocked_path"),
210
+ decision_reason=request.get("decision_reason"),
211
+ )
212
+
213
+ result = await can_use_tool(tool_name, input_data, callback_options)
214
+
215
+ if result.behavior == "allow":
216
+ response_data = {
217
+ "allowed": True,
218
+ "updatedInput": result.updated_input,
219
+ "tool_use_id": tool_use_id,
220
+ }
221
+ else:
222
+ response_data = {
223
+ "allowed": False,
224
+ "reason": result.message,
225
+ "interrupt": result.interrupt,
226
+ "tool_use_id": tool_use_id,
227
+ }
228
+
229
+ response = {
230
+ "type": "control_response",
231
+ "response": {
232
+ "subtype": "success",
233
+ "request_id": request_id,
234
+ "response": response_data,
235
+ },
236
+ }
237
+ await transport.write(json.dumps(response))
238
+
239
+ except Exception as e:
240
+ response = {
241
+ "type": "control_response",
242
+ "response": {
243
+ "subtype": "success",
244
+ "request_id": request_id,
245
+ "response": {
246
+ "allowed": False,
247
+ "reason": str(e),
248
+ "tool_use_id": tool_use_id,
249
+ },
250
+ },
251
+ }
252
+ await transport.write(json.dumps(response))
253
+
254
+
255
+ async def _execute_hook(
256
+ callback_id: str,
257
+ hook_input: dict[str, Any],
258
+ tool_use_id: str | None,
259
+ options: CodeBuddyAgentOptions,
260
+ ) -> dict[str, Any]:
261
+ """Execute a hook callback."""
262
+ if not options.hooks:
263
+ return {"continue_": True}
264
+
265
+ # Parse callback_id: hook_{event}_{matcherIndex}_{hookIndex}
266
+ parts = callback_id.split("_")
267
+ if len(parts) < 4:
268
+ return {"continue_": True}
269
+
270
+ event = parts[1]
271
+ try:
272
+ matcher_idx = int(parts[2])
273
+ hook_idx = int(parts[3])
274
+ except ValueError:
275
+ return {"continue_": True}
276
+
277
+ # Find the hook
278
+ matchers = options.hooks.get(event) # type: ignore[arg-type]
279
+ if not matchers or matcher_idx >= len(matchers):
280
+ return {"continue_": True}
281
+
282
+ matcher = matchers[matcher_idx]
283
+ if hook_idx >= len(matcher.hooks):
284
+ return {"continue_": True}
285
+
286
+ hook = matcher.hooks[hook_idx]
287
+
288
+ try:
289
+ result = await hook(hook_input, tool_use_id, {"signal": None})
290
+ return dict(result)
291
+ except Exception as e:
292
+ return {"continue_": False, "stopReason": str(e)}
293
+
294
+
295
+ def _build_hooks_config(
296
+ hooks: dict[Any, list[HookMatcher]] | None,
297
+ ) -> dict[str, list[dict[str, Any]]] | None:
298
+ """Build hooks configuration for CLI."""
299
+ if not hooks:
300
+ return None
301
+
302
+ config: dict[str, list[dict[str, Any]]] = {}
303
+
304
+ for event, matchers in hooks.items():
305
+ config[str(event)] = [
306
+ {
307
+ "matcher": m.matcher,
308
+ "hookCallbackIds": [
309
+ f"hook_{event}_{i}_{j}" for j, _ in enumerate(m.hooks)
310
+ ],
311
+ "timeout": m.timeout,
312
+ }
313
+ for i, m in enumerate(matchers)
314
+ ]
315
+
316
+ 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,24 @@
1
+ """Transport base class for CLI communication."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import AsyncIterator
5
+
6
+
7
+ class Transport(ABC):
8
+ """Abstract transport layer for CLI communication."""
9
+
10
+ @abstractmethod
11
+ async def connect(self) -> None:
12
+ """Establish connection to CLI."""
13
+
14
+ @abstractmethod
15
+ def read(self) -> AsyncIterator[str]:
16
+ """Read messages from CLI as an async iterator."""
17
+
18
+ @abstractmethod
19
+ async def write(self, data: str) -> None:
20
+ """Write data to CLI."""
21
+
22
+ @abstractmethod
23
+ async def close(self) -> None:
24
+ """Close the connection."""
@@ -0,0 +1,167 @@
1
+ """Subprocess transport for CLI communication."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from collections.abc import AsyncIterable, AsyncIterator
7
+ from typing import Any
8
+
9
+ from .._binary import get_cli_path
10
+ from ..types import AppendSystemPrompt, CodeBuddyAgentOptions
11
+ from .base import Transport
12
+
13
+
14
+ class SubprocessTransport(Transport):
15
+ """Transport that communicates with CLI via subprocess."""
16
+
17
+ def __init__(
18
+ self,
19
+ options: CodeBuddyAgentOptions,
20
+ prompt: str | AsyncIterable[dict[str, Any]] | None = None,
21
+ ):
22
+ self.options = options
23
+ self.prompt = prompt
24
+ self._process: asyncio.subprocess.Process | None = None
25
+ self._closed = False
26
+
27
+ def _get_cli_path(self) -> str:
28
+ """Get the path to CLI executable."""
29
+ # User-specified path takes highest precedence
30
+ if self.options.codebuddy_code_path:
31
+ return str(self.options.codebuddy_code_path)
32
+
33
+ # Use the binary resolver (env var -> package binary -> monorepo)
34
+ return get_cli_path()
35
+
36
+ def _build_args(self) -> list[str]:
37
+ """Build CLI arguments from options."""
38
+ args = [
39
+ "--input-format",
40
+ "stream-json",
41
+ "--verbose",
42
+ "--output-format",
43
+ "stream-json",
44
+ ]
45
+ opts = self.options
46
+
47
+ # Model options
48
+ if opts.model:
49
+ args.extend(["--model", opts.model])
50
+ if opts.fallback_model:
51
+ args.extend(["--fallback-model", opts.fallback_model])
52
+
53
+ # Permission options
54
+ if opts.permission_mode:
55
+ args.extend(["--permission-mode", opts.permission_mode])
56
+
57
+ # Turn limits
58
+ if opts.max_turns:
59
+ args.extend(["--max-turns", str(opts.max_turns)])
60
+
61
+ # Session options
62
+ if opts.continue_conversation:
63
+ args.append("--continue")
64
+ if opts.resume:
65
+ args.extend(["--resume", opts.resume])
66
+ if opts.fork_session:
67
+ args.append("--fork-session")
68
+
69
+ # Tool options
70
+ if opts.allowed_tools:
71
+ args.extend(["--allowedTools", ",".join(opts.allowed_tools)])
72
+ if opts.disallowed_tools:
73
+ args.extend(["--disallowedTools", ",".join(opts.disallowed_tools)])
74
+
75
+ # MCP options
76
+ if opts.mcp_servers and isinstance(opts.mcp_servers, dict):
77
+ args.extend(["--mcp-config", json.dumps({"mcpServers": opts.mcp_servers})])
78
+
79
+ # Settings
80
+ # SDK default: don't load any filesystem settings for clean environment isolation
81
+ # When setting_sources is explicitly provided (including empty list), use it
82
+ # When not provided (None), default to 'none' for SDK isolation
83
+ if opts.setting_sources is not None:
84
+ value = "none" if len(opts.setting_sources) == 0 else ",".join(opts.setting_sources)
85
+ args.extend(["--setting-sources", value])
86
+ else:
87
+ # SDK default behavior: no filesystem settings loaded
88
+ args.extend(["--setting-sources", "none"])
89
+
90
+ # Output options
91
+ if opts.include_partial_messages:
92
+ args.append("--include-partial-messages")
93
+
94
+ # System prompt options
95
+ if opts.system_prompt is not None:
96
+ if isinstance(opts.system_prompt, str):
97
+ args.extend(["--system-prompt", opts.system_prompt])
98
+ elif isinstance(opts.system_prompt, AppendSystemPrompt):
99
+ args.extend(["--append-system-prompt", opts.system_prompt.append])
100
+
101
+ # Extra args (custom flags)
102
+ for flag, value in opts.extra_args.items():
103
+ if value is None:
104
+ args.append(f"--{flag}")
105
+ else:
106
+ args.extend([f"--{flag}", value])
107
+
108
+ return args
109
+
110
+ async def connect(self) -> None:
111
+ """Start the subprocess."""
112
+ cli_path = self._get_cli_path()
113
+ args = self._build_args()
114
+ cwd = str(self.options.cwd) if self.options.cwd else os.getcwd()
115
+
116
+ env = {
117
+ **os.environ,
118
+ **self.options.env,
119
+ "CODEBUDDY_CODE_ENTRYPOINT": "sdk-py",
120
+ }
121
+
122
+ self._process = await asyncio.create_subprocess_exec(
123
+ cli_path,
124
+ *args,
125
+ stdin=asyncio.subprocess.PIPE,
126
+ stdout=asyncio.subprocess.PIPE,
127
+ stderr=asyncio.subprocess.PIPE,
128
+ cwd=cwd,
129
+ env=env,
130
+ )
131
+
132
+ # Start stderr reader if callback provided
133
+ if self.options.stderr and self._process.stderr:
134
+ asyncio.create_task(self._read_stderr())
135
+
136
+ async def _read_stderr(self) -> None:
137
+ """Read stderr and call callback."""
138
+ if self._process and self._process.stderr and self.options.stderr:
139
+ async for line in self._process.stderr:
140
+ self.options.stderr(line.decode())
141
+
142
+ async def read(self) -> AsyncIterator[str]:
143
+ """Read lines from stdout."""
144
+ if not self._process or not self._process.stdout:
145
+ return
146
+
147
+ async for line in self._process.stdout:
148
+ if self._closed:
149
+ break
150
+ yield line.decode().strip()
151
+
152
+ async def write(self, data: str) -> None:
153
+ """Write data to stdin."""
154
+ if self._process and self._process.stdin:
155
+ self._process.stdin.write((data + "\n").encode())
156
+ await self._process.stdin.drain()
157
+
158
+ async def close(self) -> None:
159
+ """Close the subprocess."""
160
+ if self._closed:
161
+ return
162
+
163
+ self._closed = True
164
+
165
+ if self._process:
166
+ self._process.kill()
167
+ await self._process.wait()