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.
- opencode_agent_sdk/__init__.py +31 -0
- opencode_agent_sdk/_errors.py +9 -0
- opencode_agent_sdk/_internal/__init__.py +1 -0
- opencode_agent_sdk/_internal/acp.py +367 -0
- opencode_agent_sdk/_internal/http_transport.py +355 -0
- opencode_agent_sdk/_internal/transport.py +112 -0
- opencode_agent_sdk/client.py +221 -0
- opencode_agent_sdk/tools.py +82 -0
- opencode_agent_sdk/types.py +53 -0
- opencode_agent_sdk-0.2.0.dist-info/METADATA +385 -0
- opencode_agent_sdk-0.2.0.dist-info/RECORD +13 -0
- opencode_agent_sdk-0.2.0.dist-info/WHEEL +5 -0
- opencode_agent_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|