opencode-py 0.1.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/__init__.py +45 -0
- opencode/__main__.py +20 -0
- opencode/_async_client.py +538 -0
- opencode/_async_opencode.py +255 -0
- opencode/_async_session.py +155 -0
- opencode/_binary.py +135 -0
- opencode/_client.py +532 -0
- opencode/_errors.py +20 -0
- opencode/_models.py +110 -0
- opencode/_opencode.py +290 -0
- opencode/_process.py +24 -0
- opencode/_server.py +97 -0
- opencode/_session.py +160 -0
- opencode/_tools.py +156 -0
- opencode_py-0.1.0.dist-info/METADATA +201 -0
- opencode_py-0.1.0.dist-info/RECORD +17 -0
- opencode_py-0.1.0.dist-info/WHEEL +4 -0
opencode/_opencode.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
6
|
+
|
|
7
|
+
from opencode._client import OpencodeClient
|
|
8
|
+
from opencode._models import SessionMessage
|
|
9
|
+
from opencode._server import OpencodeServer, create_opencode_server
|
|
10
|
+
from opencode._session import Session
|
|
11
|
+
|
|
12
|
+
_opencode_state: Dict[str, Any] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_model(
|
|
16
|
+
model: Optional[str] = None,
|
|
17
|
+
config: Optional[Dict[str, Any]] = None,
|
|
18
|
+
) -> Optional[Dict[str, str]]:
|
|
19
|
+
model_str = model
|
|
20
|
+
if not model_str and config:
|
|
21
|
+
model_str = config.get("model")
|
|
22
|
+
if not model_str:
|
|
23
|
+
return None
|
|
24
|
+
if "/" in model_str:
|
|
25
|
+
provider, model_id = model_str.split("/", 1)
|
|
26
|
+
return {"providerID": provider, "modelID": model_id}
|
|
27
|
+
return {"providerID": "opencode", "modelID": model_str}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Opencode:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
model: Optional[str] = None,
|
|
35
|
+
hostname: str = "127.0.0.1",
|
|
36
|
+
port: int = 4096,
|
|
37
|
+
directory: Optional[str] = None,
|
|
38
|
+
workspace: Optional[str] = None,
|
|
39
|
+
server_timeout: float = 30.0,
|
|
40
|
+
client_timeout: float = 300.0,
|
|
41
|
+
config: Optional[Dict[str, Any]] = None,
|
|
42
|
+
opencode_binary: Optional[str] = None,
|
|
43
|
+
):
|
|
44
|
+
self._model = model
|
|
45
|
+
self._hostname = hostname
|
|
46
|
+
self._port = port
|
|
47
|
+
self._directory = directory
|
|
48
|
+
self._workspace = workspace
|
|
49
|
+
self._server_timeout = server_timeout
|
|
50
|
+
self._client_timeout = client_timeout
|
|
51
|
+
self._config = config
|
|
52
|
+
self._opencode_binary = opencode_binary
|
|
53
|
+
|
|
54
|
+
self._server: Optional[OpencodeServer] = None
|
|
55
|
+
self._client: Optional[OpencodeClient] = None
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Context manager
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> Opencode:
|
|
62
|
+
self.start()
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *args: Any) -> None:
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Server / Client lifecycle
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def start(self) -> None:
|
|
73
|
+
if self._client is not None:
|
|
74
|
+
return
|
|
75
|
+
server = create_opencode_server(
|
|
76
|
+
hostname=self._hostname,
|
|
77
|
+
port=self._port,
|
|
78
|
+
timeout=self._server_timeout,
|
|
79
|
+
config=self._config,
|
|
80
|
+
opencode_binary=self._opencode_binary,
|
|
81
|
+
)
|
|
82
|
+
client = OpencodeClient(
|
|
83
|
+
base_url=server.url,
|
|
84
|
+
directory=self._directory,
|
|
85
|
+
workspace=self._workspace,
|
|
86
|
+
timeout=self._client_timeout,
|
|
87
|
+
)
|
|
88
|
+
self._server = server
|
|
89
|
+
self._client = client
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
if self._client:
|
|
93
|
+
self._client.close()
|
|
94
|
+
self._client = None
|
|
95
|
+
if self._server:
|
|
96
|
+
self._server.close()
|
|
97
|
+
self._server = None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def client(self) -> OpencodeClient:
|
|
101
|
+
self.start()
|
|
102
|
+
assert self._client is not None
|
|
103
|
+
return self._client
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def server(self) -> OpencodeServer:
|
|
107
|
+
self.start()
|
|
108
|
+
assert self._server is not None
|
|
109
|
+
return self._server
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# High-level API
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def create_session(self, agent: Optional[str] = None, **kwargs) -> Session:
|
|
116
|
+
if agent:
|
|
117
|
+
kwargs["agent"] = agent
|
|
118
|
+
raw = self.client.session_create(**kwargs)
|
|
119
|
+
sid = raw["id"]
|
|
120
|
+
return Session(self.client, sid)
|
|
121
|
+
|
|
122
|
+
def _resolve_model(self) -> Optional[Dict[str, str]]:
|
|
123
|
+
return _resolve_model(model=self._model, config=self._config)
|
|
124
|
+
|
|
125
|
+
def ask(
|
|
126
|
+
self,
|
|
127
|
+
prompt: str,
|
|
128
|
+
*,
|
|
129
|
+
files: Optional[List[Dict[str, Any]]] = None,
|
|
130
|
+
auto_tools: bool = False,
|
|
131
|
+
agent: Optional[str] = None,
|
|
132
|
+
format: Optional[Dict[str, Any]] = None,
|
|
133
|
+
wait: bool = True,
|
|
134
|
+
poll_interval: float = 0.5,
|
|
135
|
+
poll_timeout: float = 600.0,
|
|
136
|
+
) -> str:
|
|
137
|
+
session = self.create_session(agent=agent)
|
|
138
|
+
if auto_tools:
|
|
139
|
+
from opencode._tools import ToolExecutor
|
|
140
|
+
|
|
141
|
+
msg = session.ask(
|
|
142
|
+
prompt,
|
|
143
|
+
files=files,
|
|
144
|
+
model=self._resolve_model(),
|
|
145
|
+
format=format,
|
|
146
|
+
tool_executor=ToolExecutor(),
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
msg = session.prompt(
|
|
150
|
+
prompt,
|
|
151
|
+
files=files,
|
|
152
|
+
wait=wait,
|
|
153
|
+
model=self._resolve_model(),
|
|
154
|
+
format=format,
|
|
155
|
+
poll_interval=poll_interval,
|
|
156
|
+
poll_timeout=poll_timeout,
|
|
157
|
+
)
|
|
158
|
+
return _extract_text(msg)
|
|
159
|
+
|
|
160
|
+
def ask_stream(
|
|
161
|
+
self,
|
|
162
|
+
prompt: str,
|
|
163
|
+
*,
|
|
164
|
+
files: Optional[List[Dict[str, Any]]] = None,
|
|
165
|
+
session: Optional[Session] = None,
|
|
166
|
+
) -> Iterator[str]:
|
|
167
|
+
import json
|
|
168
|
+
|
|
169
|
+
import httpx
|
|
170
|
+
|
|
171
|
+
if session is None:
|
|
172
|
+
session = self.create_session()
|
|
173
|
+
# Use V1 synchronous prompt — the response events arrive via /event
|
|
174
|
+
body: Dict[str, Any] = {"parts": [{"type": "text", "text": prompt}]}
|
|
175
|
+
resolved = self._resolve_model()
|
|
176
|
+
if resolved:
|
|
177
|
+
body["model"] = resolved
|
|
178
|
+
|
|
179
|
+
# Subscribe before sending to catch all events
|
|
180
|
+
response = self.client.event_subscribe()
|
|
181
|
+
assert isinstance(response, httpx.Response)
|
|
182
|
+
|
|
183
|
+
# Send prompt (V1 sync)
|
|
184
|
+
self.client.session_send(session.id, body)
|
|
185
|
+
|
|
186
|
+
seen_parts: set[str] = set()
|
|
187
|
+
|
|
188
|
+
for line in response.iter_lines():
|
|
189
|
+
if not line.startswith("data: "):
|
|
190
|
+
continue
|
|
191
|
+
payload = json.loads(line[6:])
|
|
192
|
+
event_type = payload.get("type")
|
|
193
|
+
props = payload.get("properties", {})
|
|
194
|
+
|
|
195
|
+
# Skip events for other sessions
|
|
196
|
+
sid = props.get("sessionID")
|
|
197
|
+
if sid is not None and sid != session.id:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if event_type == "message.part.delta":
|
|
201
|
+
if props.get("field") == "text":
|
|
202
|
+
part_id = props.get("partID", "")
|
|
203
|
+
delta = props.get("delta", "")
|
|
204
|
+
if delta:
|
|
205
|
+
seen_parts.add(part_id)
|
|
206
|
+
yield delta
|
|
207
|
+
|
|
208
|
+
elif event_type == "message.part.updated":
|
|
209
|
+
part = props.get("part", {})
|
|
210
|
+
part_id = part.get("id", "")
|
|
211
|
+
if part_id not in seen_parts and part.get("type") == "text":
|
|
212
|
+
text = part.get("text", "")
|
|
213
|
+
if text:
|
|
214
|
+
seen_parts.add(part_id)
|
|
215
|
+
yield text
|
|
216
|
+
|
|
217
|
+
elif event_type == "session.status":
|
|
218
|
+
status = props.get("status", {})
|
|
219
|
+
if isinstance(status, dict) and status.get("type") == "idle":
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
elif event_type == "session.idle":
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _extract_text(msg: SessionMessage) -> str:
|
|
227
|
+
if isinstance(msg, dict) and msg.get("type") == "assistant":
|
|
228
|
+
structured = msg.get("structured")
|
|
229
|
+
if structured is not None:
|
|
230
|
+
return json.dumps(structured, ensure_ascii=False, default=str)
|
|
231
|
+
parts = msg.get("content", [])
|
|
232
|
+
texts: List[str] = []
|
|
233
|
+
for part in parts:
|
|
234
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
235
|
+
texts.append(part.get("text", ""))
|
|
236
|
+
return "\n".join(texts)
|
|
237
|
+
if isinstance(msg, dict) and msg.get("type") == "user":
|
|
238
|
+
return msg.get("text", "")
|
|
239
|
+
return str(msg)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def opencode(
|
|
243
|
+
prompt: str,
|
|
244
|
+
*,
|
|
245
|
+
keep: bool = False,
|
|
246
|
+
auto_tools: bool = False,
|
|
247
|
+
agent: Optional[str] = None,
|
|
248
|
+
model: Optional[str] = None,
|
|
249
|
+
format: Optional[Dict[str, Any]] = None,
|
|
250
|
+
port: int = 4096,
|
|
251
|
+
directory: Optional[str] = None,
|
|
252
|
+
config: Optional[Dict[str, Any]] = None,
|
|
253
|
+
) -> str:
|
|
254
|
+
global _opencode_state
|
|
255
|
+
state = _opencode_state
|
|
256
|
+
|
|
257
|
+
if not state:
|
|
258
|
+
cfg = dict(config or {})
|
|
259
|
+
resolved_agent = agent or ("build" if auto_tools else None)
|
|
260
|
+
ai = Opencode(port=port, directory=directory, config=cfg or None, model=model)
|
|
261
|
+
ai.start()
|
|
262
|
+
session = ai.create_session(agent=resolved_agent)
|
|
263
|
+
state["ai"] = ai
|
|
264
|
+
state["session"] = session
|
|
265
|
+
state["config"] = cfg
|
|
266
|
+
state["model"] = model
|
|
267
|
+
else:
|
|
268
|
+
ai = state["ai"]
|
|
269
|
+
session = state["session"]
|
|
270
|
+
if model is not None or config is not None:
|
|
271
|
+
new_cfg = dict(config or {})
|
|
272
|
+
if model is not None and model != state.get("model"):
|
|
273
|
+
warnings.warn("Ignoring new model — using existing session")
|
|
274
|
+
elif new_cfg != state.get("config", {}):
|
|
275
|
+
warnings.warn("Ignoring new config — using existing session")
|
|
276
|
+
|
|
277
|
+
resolved = _resolve_model(model=state.get("model"), config=state.get("config", {}))
|
|
278
|
+
if auto_tools:
|
|
279
|
+
from opencode._tools import ToolExecutor
|
|
280
|
+
|
|
281
|
+
msg = session.ask(prompt, model=resolved, format=format, tool_executor=ToolExecutor())
|
|
282
|
+
else:
|
|
283
|
+
msg = session.prompt(prompt, model=resolved, format=format)
|
|
284
|
+
result = _extract_text(msg)
|
|
285
|
+
|
|
286
|
+
if not keep:
|
|
287
|
+
ai.close()
|
|
288
|
+
state.clear()
|
|
289
|
+
|
|
290
|
+
return result
|
opencode/_process.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def stop(proc: subprocess.Popen) -> None:
|
|
9
|
+
if proc.poll() is not None:
|
|
10
|
+
return
|
|
11
|
+
if sys.platform == "win32" and proc.pid:
|
|
12
|
+
try:
|
|
13
|
+
subprocess.run(
|
|
14
|
+
["taskkill", "/pid", str(proc.pid), "/T", "/F"],
|
|
15
|
+
capture_output=True,
|
|
16
|
+
timeout=5,
|
|
17
|
+
)
|
|
18
|
+
return
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
try:
|
|
22
|
+
proc.send_signal(signal.SIGTERM)
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
opencode/_server.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from opencode._binary import ensure_opencode
|
|
12
|
+
from opencode._errors import ServerStartupTimeout
|
|
13
|
+
from opencode._process import stop
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OpencodeServer:
|
|
17
|
+
def __init__(self, proc: subprocess.Popen, url: str, binary: str):
|
|
18
|
+
self._proc = proc
|
|
19
|
+
self.url = url
|
|
20
|
+
self.binary = binary
|
|
21
|
+
|
|
22
|
+
def close(self) -> None:
|
|
23
|
+
stop(self._proc)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def pid(self) -> Optional[int]:
|
|
27
|
+
return self._proc.pid
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def running(self) -> bool:
|
|
31
|
+
return self._proc.poll() is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_opencode_server(
|
|
35
|
+
*,
|
|
36
|
+
hostname: str = "127.0.0.1",
|
|
37
|
+
port: int = 4096,
|
|
38
|
+
timeout: float = 30.0,
|
|
39
|
+
config: Optional[Dict[str, Any]] = None,
|
|
40
|
+
opencode_binary: Optional[str] = None,
|
|
41
|
+
) -> OpencodeServer:
|
|
42
|
+
binary = opencode_binary or ensure_opencode()
|
|
43
|
+
|
|
44
|
+
args = [binary, "serve", f"--hostname={hostname}", f"--port={port}"]
|
|
45
|
+
env = os.environ.copy()
|
|
46
|
+
if config:
|
|
47
|
+
env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config)
|
|
48
|
+
|
|
49
|
+
proc = subprocess.Popen(
|
|
50
|
+
args,
|
|
51
|
+
stdout=subprocess.PIPE,
|
|
52
|
+
stderr=subprocess.PIPE,
|
|
53
|
+
env=env,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
start_time = time.monotonic()
|
|
57
|
+
output = ""
|
|
58
|
+
url: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
while time.monotonic() - start_time < timeout:
|
|
61
|
+
line = proc.stdout.readline() if proc.stdout else b""
|
|
62
|
+
if not line:
|
|
63
|
+
if proc.poll() is not None:
|
|
64
|
+
stderr_output = ""
|
|
65
|
+
if proc.stderr:
|
|
66
|
+
stderr_output = proc.stderr.read().decode("utf-8", errors="replace")
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
f"opencode exited with code {proc.returncode}"
|
|
69
|
+
f"\nstdout: {output}"
|
|
70
|
+
f"\nstderr: {stderr_output}"
|
|
71
|
+
)
|
|
72
|
+
time.sleep(0.05)
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
decoded = line.decode("utf-8", errors="replace").strip()
|
|
76
|
+
output += decoded + "\n"
|
|
77
|
+
|
|
78
|
+
m = re.search(r"on\s+(https?://[^\s]+)", decoded)
|
|
79
|
+
if m:
|
|
80
|
+
url = m.group(1)
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
if not url:
|
|
84
|
+
stop(proc)
|
|
85
|
+
stderr_output = ""
|
|
86
|
+
if proc.stderr:
|
|
87
|
+
try:
|
|
88
|
+
stderr_output = proc.stderr.read().decode("utf-8", errors="replace")
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
raise ServerStartupTimeout(
|
|
92
|
+
f"Timeout waiting for opencode server after {timeout}s"
|
|
93
|
+
f"\nstdout: {output}"
|
|
94
|
+
f"\nstderr: {stderr_output}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return OpencodeServer(proc, url, binary)
|
opencode/_session.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from opencode._client import OpencodeClient
|
|
7
|
+
from opencode._models import SessionMessage
|
|
8
|
+
from opencode._tools import ToolExecutor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Session:
|
|
12
|
+
def __init__(self, client: OpencodeClient, session_id: str):
|
|
13
|
+
self._client = client
|
|
14
|
+
self.id = session_id
|
|
15
|
+
self._auto_confirmed: bool = False
|
|
16
|
+
|
|
17
|
+
def prompt(
|
|
18
|
+
self,
|
|
19
|
+
text: str,
|
|
20
|
+
*,
|
|
21
|
+
files: Optional[List[Dict[str, Any]]] = None,
|
|
22
|
+
agents: Optional[List[Dict[str, Any]]] = None,
|
|
23
|
+
references: Optional[List[Dict[str, Any]]] = None,
|
|
24
|
+
model: Optional[Dict[str, str]] = None,
|
|
25
|
+
format: Optional[Dict[str, Any]] = None,
|
|
26
|
+
wait: bool = True,
|
|
27
|
+
poll_interval: float = 0.5,
|
|
28
|
+
poll_timeout: float = 600.0,
|
|
29
|
+
) -> SessionMessage:
|
|
30
|
+
parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
|
|
31
|
+
body: Dict[str, Any] = {"parts": parts}
|
|
32
|
+
if model:
|
|
33
|
+
body["model"] = model
|
|
34
|
+
if format:
|
|
35
|
+
body["format"] = format
|
|
36
|
+
|
|
37
|
+
# Use V1 sync prompt (POST /session/:id/message)
|
|
38
|
+
result = self._client.session_send(self.id, body)
|
|
39
|
+
|
|
40
|
+
if isinstance(result, dict):
|
|
41
|
+
parts_list = result.get("parts", [])
|
|
42
|
+
info = result.get("info", {})
|
|
43
|
+
structured = result.get("structured") or info.get("structured")
|
|
44
|
+
# Convert parts to V2-like SessionMessage format
|
|
45
|
+
text_parts: List[Dict[str, Any]] = []
|
|
46
|
+
for p in parts_list:
|
|
47
|
+
ptype = p.get("type", "")
|
|
48
|
+
if ptype in ("text", "reasoning", "tool"):
|
|
49
|
+
text_parts.append({"type": ptype, "text": p.get("text", "")})
|
|
50
|
+
msg: Dict[str, Any] = {
|
|
51
|
+
"id": info.get("id", ""),
|
|
52
|
+
"type": "assistant",
|
|
53
|
+
"content": text_parts,
|
|
54
|
+
"model": info.get("model"),
|
|
55
|
+
"time": info.get("time", {}),
|
|
56
|
+
}
|
|
57
|
+
if structured is not None:
|
|
58
|
+
msg["structured"] = structured
|
|
59
|
+
return msg
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
def ask(
|
|
64
|
+
self,
|
|
65
|
+
text: str,
|
|
66
|
+
*,
|
|
67
|
+
files: Optional[List[Dict[str, Any]]] = None,
|
|
68
|
+
model: Optional[Dict[str, str]] = None,
|
|
69
|
+
format: Optional[Dict[str, Any]] = None,
|
|
70
|
+
max_tool_rounds: int = 25,
|
|
71
|
+
tool_executor: Optional[ToolExecutor] = None,
|
|
72
|
+
quiet: bool = False,
|
|
73
|
+
) -> SessionMessage:
|
|
74
|
+
if tool_executor is None:
|
|
75
|
+
tool_executor = ToolExecutor()
|
|
76
|
+
parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
|
|
77
|
+
if files:
|
|
78
|
+
parts.extend(files)
|
|
79
|
+
|
|
80
|
+
for _round in range(max_tool_rounds):
|
|
81
|
+
body: Dict[str, Any] = {"parts": parts}
|
|
82
|
+
if model:
|
|
83
|
+
body["model"] = model
|
|
84
|
+
if format:
|
|
85
|
+
body["format"] = format
|
|
86
|
+
|
|
87
|
+
result = self._client.session_send(self.id, body)
|
|
88
|
+
if not isinstance(result, dict):
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
parts_list = result.get("parts", [])
|
|
92
|
+
info = result.get("info", {})
|
|
93
|
+
|
|
94
|
+
tool_uses = [p for p in parts_list if p.get("type") == "tool-use"]
|
|
95
|
+
if tool_uses:
|
|
96
|
+
self._auto_confirmed = True
|
|
97
|
+
results: List[Dict[str, Any]] = []
|
|
98
|
+
for tu in tool_uses:
|
|
99
|
+
tool_name = tu.get("tool", {}).get("name", "")
|
|
100
|
+
tool_input = tu.get("tool", {}).get("input", {})
|
|
101
|
+
tool_id = tu.get("toolUseID", "")
|
|
102
|
+
if not quiet:
|
|
103
|
+
print(f"\033[33m[Tool] {tool_name}\033[0m")
|
|
104
|
+
output = tool_executor.execute(tool_name, tool_input)
|
|
105
|
+
results.append({
|
|
106
|
+
"type": "tool-result",
|
|
107
|
+
"toolUseID": tool_id,
|
|
108
|
+
"tool": {"name": tool_name},
|
|
109
|
+
"output": output,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
parts = results
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# No tool-use parts — model may be planning or asking.
|
|
116
|
+
if not self._auto_confirmed:
|
|
117
|
+
self._auto_confirmed = True
|
|
118
|
+
parts = [{"type": "text", "text": "Exit plan mode and proceed with execution now. Use tools as needed."}]
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Final response (no tool calls, already confirmed)
|
|
122
|
+
text_parts: List[Dict[str, Any]] = []
|
|
123
|
+
for p in parts_list:
|
|
124
|
+
ptype = p.get("type", "")
|
|
125
|
+
if ptype in ("text", "reasoning", "tool"):
|
|
126
|
+
text_parts.append({"type": ptype, "text": p.get("text", "")})
|
|
127
|
+
msg: Dict[str, Any] = {
|
|
128
|
+
"id": info.get("id", ""),
|
|
129
|
+
"type": "assistant",
|
|
130
|
+
"content": text_parts,
|
|
131
|
+
"model": info.get("model"),
|
|
132
|
+
"time": info.get("time", {}),
|
|
133
|
+
}
|
|
134
|
+
structured = result.get("structured") or info.get("structured")
|
|
135
|
+
if structured is not None:
|
|
136
|
+
msg["structured"] = structured
|
|
137
|
+
return msg
|
|
138
|
+
|
|
139
|
+
raise RuntimeError(f"Tool loop exceeded {max_tool_rounds} rounds")
|
|
140
|
+
|
|
141
|
+
def messages(self, **kwargs) -> Any:
|
|
142
|
+
return self._client.v2_session_messages(self.id, **kwargs)
|
|
143
|
+
|
|
144
|
+
def context(self, **kwargs) -> List[SessionMessage]:
|
|
145
|
+
return self._client.v2_session_context(self.id, **kwargs)
|
|
146
|
+
|
|
147
|
+
def compact(self) -> Any:
|
|
148
|
+
return self._client.v2_session_compact(self.id)
|
|
149
|
+
|
|
150
|
+
def abort(self) -> Any:
|
|
151
|
+
return self._client.session_abort(self.id)
|
|
152
|
+
|
|
153
|
+
def fork(self, **kwargs) -> Any:
|
|
154
|
+
return self._client.session_fork(self.id, **kwargs)
|
|
155
|
+
|
|
156
|
+
def diff(self) -> Any:
|
|
157
|
+
return self._client.session_diff(self.id)
|
|
158
|
+
|
|
159
|
+
def todo(self) -> Any:
|
|
160
|
+
return self._client.session_todo(self.id)
|