codebuddy-agent-sdk 0.1.5__py3-none-macosx_10_12_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.

Potentially problematic release.


This version of codebuddy-agent-sdk might be problematic. Click here for more details.

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