opencode-agent-sdk 0.2.0__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.
@@ -0,0 +1,31 @@
1
+ from .types import (
2
+ AssistantMessage,
3
+ HookContext,
4
+ HookInput,
5
+ HookJSONOutput,
6
+ HookMatcher,
7
+ ResultMessage,
8
+ SystemMessage,
9
+ TextBlock,
10
+ ToolUseBlock,
11
+ )
12
+ from .client import AgentOptions, SDKClient
13
+ from .tools import create_sdk_mcp_server, tool
14
+
15
+ __all__ = [
16
+ "AgentOptions",
17
+ "AssistantMessage",
18
+ "HookContext",
19
+ "HookInput",
20
+ "HookJSONOutput",
21
+ "HookMatcher",
22
+ "ResultMessage",
23
+ "SDKClient",
24
+ "SystemMessage",
25
+ "TextBlock",
26
+ "ToolUseBlock",
27
+ "create_sdk_mcp_server",
28
+ "tool",
29
+ ]
30
+
31
+ __version__ = "0.2.0"
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class ProcessError(Exception):
5
+ """Raised when the opencode subprocess exits with an error."""
6
+
7
+ def __init__(self, message: str, exit_code: int | None = None) -> None:
8
+ super().__init__(message)
9
+ self.exit_code = exit_code
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,367 @@
1
+ """ACP (Agent Client Protocol) JSON-RPC handler.
2
+
3
+ Maps JSON-RPC 2.0 messages to/from opencode_agent_sdk types.
4
+ Handles requestPermission (PreToolUse hooks) and sessionUpdate notifications.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ import uuid
12
+ from typing import Any, AsyncIterator
13
+
14
+ from ..types import (
15
+ AssistantMessage,
16
+ HookMatcher,
17
+ ResultMessage,
18
+ SystemMessage,
19
+ TextBlock,
20
+ ToolUseBlock,
21
+ )
22
+ from .transport import SubprocessTransport
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _PROTOCOL_VERSION = "2025-01-01"
27
+
28
+
29
+ class ACPSession:
30
+ """Manages ACP JSON-RPC protocol over a SubprocessTransport."""
31
+
32
+ def __init__(
33
+ self,
34
+ transport: SubprocessTransport,
35
+ hooks: dict[str, list[HookMatcher]] | None = None,
36
+ ) -> None:
37
+ self._transport = transport
38
+ self._hooks = hooks or {}
39
+ self._session_id: str = ""
40
+ self._request_id: int = 0
41
+
42
+ # Message queues for routing
43
+ self._response_futures: dict[int, asyncio.Future[dict[str, Any]]] = {}
44
+ self._update_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
45
+ self._reader_task: asyncio.Task[None] | None = None
46
+
47
+ # State for accumulating streamed content
48
+ self._text_buffer: str = ""
49
+ self._tool_calls: dict[str, dict[str, Any]] = {}
50
+ self._prompt_done: bool = False
51
+ self._usage: dict[str, Any] = {}
52
+ self._cost: dict[str, Any] = {}
53
+
54
+ @property
55
+ def session_id(self) -> str:
56
+ return self._session_id
57
+
58
+ def _next_id(self) -> int:
59
+ self._request_id += 1
60
+ return self._request_id
61
+
62
+ async def _send_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
63
+ """Send a JSON-RPC request and wait for the response."""
64
+ req_id = self._next_id()
65
+ msg = {
66
+ "jsonrpc": "2.0",
67
+ "id": req_id,
68
+ "method": method,
69
+ "params": params,
70
+ }
71
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
72
+ self._response_futures[req_id] = future
73
+ await self._transport.write(msg)
74
+ return await future
75
+
76
+ async def _send_response(self, req_id: Any, result: dict[str, Any]) -> None:
77
+ """Send a JSON-RPC response (for server->client requests like requestPermission)."""
78
+ msg = {
79
+ "jsonrpc": "2.0",
80
+ "id": req_id,
81
+ "result": result,
82
+ }
83
+ await self._transport.write(msg)
84
+
85
+ async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
86
+ """Send a JSON-RPC notification (no id, no response expected)."""
87
+ msg = {
88
+ "jsonrpc": "2.0",
89
+ "method": method,
90
+ "params": params,
91
+ }
92
+ await self._transport.write(msg)
93
+
94
+ async def start_reader(self) -> None:
95
+ """Start the background reader task that routes incoming messages."""
96
+ self._reader_task = asyncio.create_task(self._read_loop())
97
+
98
+ async def _read_loop(self) -> None:
99
+ """Read messages from transport and route them."""
100
+ try:
101
+ async for msg in self._transport.read_messages():
102
+ await self._handle_message(msg)
103
+ except Exception as exc:
104
+ logger.debug("Reader loop ended: %s", exc)
105
+ finally:
106
+ # Signal end of stream
107
+ await self._update_queue.put({"_eof": True})
108
+
109
+ async def _handle_message(self, msg: dict[str, Any]) -> None:
110
+ """Route an incoming JSON-RPC message."""
111
+ # Response to one of our requests
112
+ if "id" in msg and ("result" in msg or "error" in msg):
113
+ req_id = msg["id"]
114
+ future = self._response_futures.pop(req_id, None)
115
+ if future and not future.done():
116
+ if "error" in msg:
117
+ future.set_exception(
118
+ RuntimeError(f"ACP error: {msg['error']}")
119
+ )
120
+ else:
121
+ future.set_result(msg.get("result", {}))
122
+ return
123
+
124
+ method = msg.get("method", "")
125
+
126
+ # Server->Client request: requestPermission
127
+ if method == "requestPermission" and "id" in msg:
128
+ await self._handle_permission_request(msg)
129
+ return
130
+
131
+ # Notification: sessionUpdate
132
+ if method == "sessionUpdate":
133
+ params = msg.get("params", {})
134
+ await self._update_queue.put(params)
135
+ return
136
+
137
+ logger.debug("Unhandled message: %s", msg)
138
+
139
+ async def _handle_permission_request(self, msg: dict[str, Any]) -> None:
140
+ """Handle requestPermission from the server, run PreToolUse hooks."""
141
+ req_id = msg["id"]
142
+ params = msg.get("params", {})
143
+ tool_call = params.get("toolCall", {})
144
+ tool_name = tool_call.get("title", "")
145
+ tool_input = tool_call.get("rawInput", {})
146
+ tool_call_id = tool_call.get("toolCallId", str(uuid.uuid4()))
147
+ options = params.get("options", [])
148
+
149
+ # Default: allow once
150
+ decision_option_id = "once"
151
+ for opt in options:
152
+ if opt.get("kind") == "allow_once":
153
+ decision_option_id = opt.get("optionId", "once")
154
+ break
155
+
156
+ # Run PreToolUse hooks if any
157
+ pre_tool_hooks = self._hooks.get("PreToolUse", [])
158
+ for hook_matcher in pre_tool_hooks:
159
+ # Check if matcher matches tool name (None matches all)
160
+ if hook_matcher.matcher is not None and hook_matcher.matcher != tool_name:
161
+ continue
162
+
163
+ hook_input = {
164
+ "hook_event_name": "PreToolUse",
165
+ "tool_name": tool_name,
166
+ "tool_input": tool_input,
167
+ "session_id": self._session_id,
168
+ "cwd": "",
169
+ }
170
+ hook_context = {
171
+ "session_id": self._session_id,
172
+ "tool_call_id": tool_call_id,
173
+ }
174
+
175
+ for hook_fn in hook_matcher.hooks:
176
+ try:
177
+ result = hook_fn(hook_input, tool_call_id, hook_context)
178
+ if asyncio.iscoroutine(result):
179
+ result = await result
180
+
181
+ if isinstance(result, dict):
182
+ decision = result.get("permissionDecision", "")
183
+ if decision == "deny":
184
+ # Find reject option
185
+ for opt in options:
186
+ if opt.get("kind") == "reject_once":
187
+ decision_option_id = opt.get("optionId", "reject")
188
+ break
189
+ else:
190
+ decision_option_id = "reject"
191
+ break
192
+ except Exception:
193
+ logger.exception("Hook error for tool %s", tool_name)
194
+
195
+ await self._send_response(req_id, {
196
+ "outcome": {
197
+ "outcome": "selected",
198
+ "optionId": decision_option_id,
199
+ },
200
+ })
201
+
202
+ async def initialize(self) -> dict[str, Any]:
203
+ """Send the initialize request."""
204
+ result = await self._send_request("initialize", {
205
+ "protocolVersion": _PROTOCOL_VERSION,
206
+ "clientCapabilities": {},
207
+ })
208
+ logger.debug("Initialized: %s", result)
209
+ return result
210
+
211
+ async def new_session(
212
+ self,
213
+ cwd: str,
214
+ mcp_servers: list[dict[str, Any]] | None = None,
215
+ model: str | None = None,
216
+ ) -> str:
217
+ """Create a new ACP session. Returns the session ID."""
218
+ params: dict[str, Any] = {
219
+ "cwd": cwd,
220
+ "mcpServers": mcp_servers or [],
221
+ }
222
+ result = await self._send_request("newSession", params)
223
+ self._session_id = result.get("sessionId", "")
224
+ logger.debug("New session: %s", self._session_id)
225
+ return self._session_id
226
+
227
+ async def load_session(self, session_id: str, cwd: str) -> str:
228
+ """Resume an existing ACP session."""
229
+ params: dict[str, Any] = {
230
+ "sessionId": session_id,
231
+ "cwd": cwd,
232
+ "mcpServers": [],
233
+ }
234
+ result = await self._send_request("loadSession", params)
235
+ self._session_id = result.get("sessionId", session_id)
236
+ return self._session_id
237
+
238
+ async def prompt(self, parts: list[dict[str, Any]]) -> None:
239
+ """Send a prompt to the session. Response comes via sessionUpdate notifications."""
240
+ self._text_buffer = ""
241
+ self._tool_calls.clear()
242
+ self._prompt_done = False
243
+ self._usage = {}
244
+ self._cost = {}
245
+
246
+ result = await self._send_request("prompt", {
247
+ "sessionId": self._session_id,
248
+ "prompt": parts,
249
+ })
250
+
251
+ # prompt response indicates the turn is done
252
+ self._prompt_done = True
253
+ if "usage" in result:
254
+ self._usage = result["usage"]
255
+ if "stopReason" in result:
256
+ self._usage["stop_reason"] = result["stopReason"]
257
+
258
+ # Signal that prompt is done
259
+ await self._update_queue.put({"_prompt_done": True, "_result": result})
260
+
261
+ async def cancel(self) -> None:
262
+ """Cancel the current operation."""
263
+ await self._send_notification("cancel", {
264
+ "sessionId": self._session_id,
265
+ })
266
+
267
+ async def receive_messages(self) -> AsyncIterator[SystemMessage | AssistantMessage | ResultMessage]:
268
+ """Async generator yielding translated messages from sessionUpdate notifications."""
269
+ while True:
270
+ update = await self._update_queue.get()
271
+
272
+ if update.get("_eof"):
273
+ return
274
+
275
+ if update.get("_prompt_done"):
276
+ # Flush any accumulated text
277
+ if self._text_buffer:
278
+ yield AssistantMessage(
279
+ content=[TextBlock(text=self._text_buffer)]
280
+ )
281
+ self._text_buffer = ""
282
+
283
+ # Yield final result message
284
+ result_data = update.get("_result", {})
285
+ usage = result_data.get("usage", {})
286
+ cost_amount = self._cost.get("amount", 0.0)
287
+
288
+ yield ResultMessage(
289
+ usage=usage,
290
+ total_cost_usd=cost_amount,
291
+ session_id=self._session_id,
292
+ duration_ms=0.0,
293
+ num_turns=1,
294
+ is_error=False,
295
+ )
296
+ return
297
+
298
+ session_update = update.get("update", update)
299
+ update_type = session_update.get("sessionUpdate", "")
300
+
301
+ if update_type == "agent_message_chunk":
302
+ content = session_update.get("content", {})
303
+ text = content.get("text", "")
304
+ if text:
305
+ self._text_buffer += text
306
+
307
+ elif update_type == "tool_call":
308
+ tool_call_id = session_update.get("toolCallId", "")
309
+ self._tool_calls[tool_call_id] = {
310
+ "id": tool_call_id,
311
+ "name": session_update.get("title", ""),
312
+ "input": session_update.get("rawInput", {}),
313
+ "status": session_update.get("status", "pending"),
314
+ }
315
+
316
+ elif update_type == "tool_call_update":
317
+ tool_call_id = session_update.get("toolCallId", "")
318
+ status = session_update.get("status", "")
319
+
320
+ if tool_call_id in self._tool_calls:
321
+ self._tool_calls[tool_call_id]["status"] = status
322
+ self._tool_calls[tool_call_id]["input"] = session_update.get(
323
+ "rawInput", self._tool_calls[tool_call_id].get("input", {})
324
+ )
325
+
326
+ if status in ("completed", "failed"):
327
+ # Flush text buffer before yielding tool use
328
+ if self._text_buffer:
329
+ yield AssistantMessage(
330
+ content=[TextBlock(text=self._text_buffer)]
331
+ )
332
+ self._text_buffer = ""
333
+
334
+ tc = self._tool_calls.get(tool_call_id, {})
335
+ yield AssistantMessage(
336
+ content=[
337
+ ToolUseBlock(
338
+ id=tool_call_id,
339
+ name=tc.get("name", session_update.get("title", "")),
340
+ input=tc.get("input", {}),
341
+ )
342
+ ]
343
+ )
344
+
345
+ elif update_type == "usage_update":
346
+ self._usage = {
347
+ "used": session_update.get("used", 0),
348
+ "size": session_update.get("size", 0),
349
+ }
350
+ cost = session_update.get("cost", {})
351
+ if cost:
352
+ self._cost = cost
353
+
354
+ elif update_type == "plan":
355
+ yield SystemMessage(
356
+ subtype="plan",
357
+ data={"entries": session_update.get("entries", [])},
358
+ )
359
+
360
+ elif update_type == "agent_thought_chunk":
361
+ content = session_update.get("content", {})
362
+ text = content.get("text", "")
363
+ if text:
364
+ yield SystemMessage(
365
+ subtype="thought",
366
+ data={"text": text},
367
+ )