minimax-code-sdk 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.
- minimax_code/__init__.py +66 -0
- minimax_code/_args.py +90 -0
- minimax_code/_cli.py +205 -0
- minimax_code/_items.py +71 -0
- minimax_code/_protocol.py +56 -0
- minimax_code/client.py +76 -0
- minimax_code/errors.py +167 -0
- minimax_code/py.typed +0 -0
- minimax_code/sync.py +71 -0
- minimax_code/thread.py +480 -0
- minimax_code/types.py +192 -0
- minimax_code_sdk-0.1.0.dist-info/METADATA +174 -0
- minimax_code_sdk-0.1.0.dist-info/RECORD +15 -0
- minimax_code_sdk-0.1.0.dist-info/WHEEL +4 -0
- minimax_code_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
minimax_code/thread.py
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Threads and Turns. Mirrors ``packages/code-sdk/src/thread.ts`` and ``exec-process.ts``.
|
|
2
|
+
|
|
3
|
+
The SDK holds only the Session id: every Turn starts a new ``mcode exec`` process and continues
|
|
4
|
+
the Session with ``--session``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ._args import build_exec_args, validate_run_options
|
|
22
|
+
from ._cli import ResolvedCli
|
|
23
|
+
from ._items import ItemAccumulator
|
|
24
|
+
from ._protocol import parse_event_line
|
|
25
|
+
from .errors import (
|
|
26
|
+
MCodeError,
|
|
27
|
+
MCodeNotFoundError,
|
|
28
|
+
MCodeProtocolError,
|
|
29
|
+
ThreadBusyError,
|
|
30
|
+
error_for_exit,
|
|
31
|
+
error_for_result,
|
|
32
|
+
)
|
|
33
|
+
from .types import ExecResult, RunOptions, ThreadEvent, ThreadOptions, Turn
|
|
34
|
+
|
|
35
|
+
STDERR_TAIL_BYTES = 64 * 1024
|
|
36
|
+
#: CLI runtime shutdown budget. Cancellation waits at least this long before SIGKILL so a CLI that
|
|
37
|
+
#: is already shutting down can still persist the Turn and write ``exec.completed``.
|
|
38
|
+
CLI_SHUTDOWN_BUDGET_S = 60.0
|
|
39
|
+
#: Slack for work the CLI does (access, Session and model resolution) before it starts its own
|
|
40
|
+
#: ``timeout_ms`` timer; the SDK's timer starts at spawn.
|
|
41
|
+
CLI_STARTUP_SLACK_S = 30.0
|
|
42
|
+
#: Cancel signal → SIGKILL. Read at call time so tests can shorten it.
|
|
43
|
+
CANCEL_GRACE_S = CLI_SHUTDOWN_BUDGET_S
|
|
44
|
+
#: Extra time after ``timeout_ms`` before the SDK force-kills a CLI that did not stop itself.
|
|
45
|
+
BACKSTOP_EXTRA_S = CLI_SHUTDOWN_BUDGET_S + CLI_STARTUP_SLACK_S
|
|
46
|
+
#: After ``exec.completed``, how long a lingering CLI may take to exit before SIGKILL.
|
|
47
|
+
TERMINATE_GRACE_S = 5.0
|
|
48
|
+
#: After the CLI exits, how long to keep reading stdout. A grandchild process that inherited the
|
|
49
|
+
#: pipe can keep it open indefinitely.
|
|
50
|
+
EXIT_DRAIN_S = 2.0
|
|
51
|
+
_EXIT_POLL_S = 0.05
|
|
52
|
+
_STDOUT_LINE_LIMIT = 64 * 1024 * 1024
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ThreadHost:
|
|
56
|
+
"""What a Thread needs from its owning client."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
config_path: str | None,
|
|
62
|
+
child_env: Callable[[], dict[str, str]],
|
|
63
|
+
prepare_cli: Callable[[], Awaitable[ResolvedCli]],
|
|
64
|
+
) -> None:
|
|
65
|
+
self.config_path = config_path
|
|
66
|
+
self.child_env = child_env
|
|
67
|
+
self.prepare_cli = prepare_cli
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Strong references to background cleanups of abandoned Turns, so they are not garbage collected.
|
|
71
|
+
_BACKGROUND_CLOSES: set[asyncio.Task[None]] = set()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _TurnIterator:
|
|
75
|
+
"""What ``async for`` iterates. Python never calls ``aclose()`` on an iterator left by
|
|
76
|
+
``break`` or by an exception in the loop body (PEP 525), so the Turn is closed in the
|
|
77
|
+
background as soon as this iterator is dropped without being exhausted."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, turn: StreamedTurn) -> None:
|
|
80
|
+
self._turn = turn
|
|
81
|
+
self._done = False
|
|
82
|
+
|
|
83
|
+
def __aiter__(self) -> _TurnIterator:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __anext__(self) -> ThreadEvent:
|
|
87
|
+
if self._done:
|
|
88
|
+
raise StopAsyncIteration
|
|
89
|
+
try:
|
|
90
|
+
return await self._turn.events.__anext__()
|
|
91
|
+
except BaseException:
|
|
92
|
+
# End of stream or a failure: the event generator has already cleaned up.
|
|
93
|
+
self._done = True
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
def __del__(self) -> None:
|
|
97
|
+
if not self._done:
|
|
98
|
+
self._done = True
|
|
99
|
+
# Never raise from a finalizer, for example during interpreter shutdown.
|
|
100
|
+
with contextlib.suppress(Exception):
|
|
101
|
+
self._turn._close_in_background()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StreamedTurn:
|
|
105
|
+
"""Events of one running Turn. Iterate with ``async for``; leaving the loop early (``break``,
|
|
106
|
+
an exception, or task cancellation) cancels the Turn and stops the CLI (SIGINT, then SIGKILL
|
|
107
|
+
after a grace period). The next Turn on the same Thread waits for that shutdown to finish."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
events: AsyncIterator[ThreadEvent],
|
|
112
|
+
cancel: Callable[[], None],
|
|
113
|
+
finalize: Callable[[], Awaitable[None]],
|
|
114
|
+
) -> None:
|
|
115
|
+
self.events = events
|
|
116
|
+
self._cancel = cancel
|
|
117
|
+
self._finalize = finalize
|
|
118
|
+
self._loop = asyncio.get_running_loop()
|
|
119
|
+
self._closing: asyncio.Task[None] | None = None
|
|
120
|
+
|
|
121
|
+
def __aiter__(self) -> AsyncIterator[ThreadEvent]:
|
|
122
|
+
return _TurnIterator(self)
|
|
123
|
+
|
|
124
|
+
def _start_close(self) -> asyncio.Task[None]:
|
|
125
|
+
"""Starts closing the Turn once; every caller shares the same task. Must run on the loop."""
|
|
126
|
+
if self._closing is None:
|
|
127
|
+
task = self._loop.create_task(self._close())
|
|
128
|
+
self._closing = task
|
|
129
|
+
_BACKGROUND_CLOSES.add(task)
|
|
130
|
+
task.add_done_callback(_BACKGROUND_CLOSES.discard)
|
|
131
|
+
return self._closing
|
|
132
|
+
|
|
133
|
+
def _close_in_background(self) -> None:
|
|
134
|
+
"""Starts closing the Turn without waiting. Safe to call from ``__del__``."""
|
|
135
|
+
if self._closing is not None or self._loop.is_closed():
|
|
136
|
+
return
|
|
137
|
+
try:
|
|
138
|
+
running: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
|
|
139
|
+
except RuntimeError:
|
|
140
|
+
running = None
|
|
141
|
+
try:
|
|
142
|
+
if running is self._loop:
|
|
143
|
+
self._start_close()
|
|
144
|
+
else:
|
|
145
|
+
self._loop.call_soon_threadsafe(self._close_in_background)
|
|
146
|
+
except RuntimeError: # The loop closed meanwhile; nothing is left to clean up from here.
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
async def _wait_closing(self) -> None:
|
|
150
|
+
"""Waits for a close that is already in progress, if any."""
|
|
151
|
+
if self._closing is not None:
|
|
152
|
+
await asyncio.shield(self._closing)
|
|
153
|
+
|
|
154
|
+
def cancel(self) -> None:
|
|
155
|
+
"""Asks the CLI to stop (SIGINT). The stream then ends with the CLI's final events."""
|
|
156
|
+
self._cancel()
|
|
157
|
+
|
|
158
|
+
async def aclose(self) -> None:
|
|
159
|
+
"""Stops the Turn if it is still running and releases the Thread."""
|
|
160
|
+
await asyncio.shield(self._start_close())
|
|
161
|
+
|
|
162
|
+
async def _close(self) -> None:
|
|
163
|
+
aclose = getattr(self.events, "aclose", None)
|
|
164
|
+
if aclose is not None:
|
|
165
|
+
await aclose()
|
|
166
|
+
# Covers a stream that was never iterated, whose generator body never ran.
|
|
167
|
+
await self._finalize()
|
|
168
|
+
|
|
169
|
+
async def __aenter__(self) -> StreamedTurn:
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
async def __aexit__(
|
|
173
|
+
self,
|
|
174
|
+
exc_type: type[BaseException] | None,
|
|
175
|
+
exc: BaseException | None,
|
|
176
|
+
tb: TracebackType | None,
|
|
177
|
+
) -> None:
|
|
178
|
+
await self.aclose()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _Exec:
|
|
182
|
+
"""One ``mcode exec`` child process."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, proc: asyncio.subprocess.Process) -> None:
|
|
185
|
+
self.proc = proc
|
|
186
|
+
self.stderr_tail = ""
|
|
187
|
+
self._stderr_task = asyncio.ensure_future(self._drain_stderr())
|
|
188
|
+
self._exit_task = asyncio.ensure_future(self._watch_exit())
|
|
189
|
+
self._drain_deadline: float | None = None
|
|
190
|
+
self._interrupted = False
|
|
191
|
+
#: Set when the ``timeout_ms`` backstop had to kill the CLI.
|
|
192
|
+
self.backstop_fired = False
|
|
193
|
+
|
|
194
|
+
async def _watch_exit(self) -> None:
|
|
195
|
+
# ``Process.wait()`` resolves only after every pipe is closed, so a grandchild that
|
|
196
|
+
# inherited stdout or stderr would hold it. ``returncode`` is set once the CLI is reaped.
|
|
197
|
+
while self.proc.returncode is None:
|
|
198
|
+
await asyncio.sleep(_EXIT_POLL_S)
|
|
199
|
+
|
|
200
|
+
async def wait_exited(self) -> None:
|
|
201
|
+
await asyncio.shield(self._exit_task)
|
|
202
|
+
|
|
203
|
+
async def _drain_stderr(self) -> None:
|
|
204
|
+
assert self.proc.stderr is not None
|
|
205
|
+
while chunk := await self.proc.stderr.read(65536):
|
|
206
|
+
text = self.stderr_tail + chunk.decode("utf-8", "replace")
|
|
207
|
+
self.stderr_tail = text[-STDERR_TAIL_BYTES:]
|
|
208
|
+
|
|
209
|
+
def kill_for_timeout(self) -> None:
|
|
210
|
+
"""The ``timeout_ms`` backstop: the CLI did not stop on its own, so kill it."""
|
|
211
|
+
if self.proc.returncode is None:
|
|
212
|
+
self.backstop_fired = True
|
|
213
|
+
with contextlib.suppress(ProcessLookupError):
|
|
214
|
+
self.proc.kill()
|
|
215
|
+
|
|
216
|
+
async def read_line(self) -> bytes:
|
|
217
|
+
"""Next stdout line, ``b""`` at the end. Once the CLI has exited, the rest of its output is
|
|
218
|
+
read for at most ``EXIT_DRAIN_S``: a grandchild holding the pipe must not stall the Turn."""
|
|
219
|
+
assert self.proc.stdout is not None
|
|
220
|
+
loop = asyncio.get_running_loop()
|
|
221
|
+
read = asyncio.ensure_future(self.proc.stdout.readline())
|
|
222
|
+
try:
|
|
223
|
+
if self._drain_deadline is None:
|
|
224
|
+
await asyncio.wait({read, self._exit_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
225
|
+
if read.done():
|
|
226
|
+
return read.result()
|
|
227
|
+
self._drain_deadline = loop.time() + EXIT_DRAIN_S
|
|
228
|
+
remaining = max(0.0, self._drain_deadline - loop.time())
|
|
229
|
+
done, _ = await asyncio.wait({read}, timeout=remaining)
|
|
230
|
+
return read.result() if done else b""
|
|
231
|
+
finally:
|
|
232
|
+
if not read.done():
|
|
233
|
+
read.cancel()
|
|
234
|
+
|
|
235
|
+
def interrupt(self) -> None:
|
|
236
|
+
"""Asks the CLI to cancel. SIGTERM is the CLI's cancel signal, like SIGINT, but has its own
|
|
237
|
+
one-shot handler: after a terminal Ctrl+C already delivered SIGINT to the CLI's process
|
|
238
|
+
group, a second SIGINT would hit the default action and skip the graceful shutdown. On
|
|
239
|
+
Windows this is a hard kill."""
|
|
240
|
+
if self.proc.returncode is not None or self._interrupted:
|
|
241
|
+
return
|
|
242
|
+
self._interrupted = True
|
|
243
|
+
with contextlib.suppress(ProcessLookupError):
|
|
244
|
+
if sys.platform == "win32":
|
|
245
|
+
self.proc.terminate()
|
|
246
|
+
else:
|
|
247
|
+
self.proc.send_signal(signal.SIGTERM)
|
|
248
|
+
|
|
249
|
+
async def wait_exit(self, grace: float) -> None:
|
|
250
|
+
"""Waits up to ``grace`` seconds for the CLI to exit, then kills it."""
|
|
251
|
+
try:
|
|
252
|
+
await asyncio.wait_for(asyncio.shield(self._exit_task), timeout=grace)
|
|
253
|
+
except asyncio.TimeoutError:
|
|
254
|
+
with contextlib.suppress(ProcessLookupError):
|
|
255
|
+
self.proc.kill()
|
|
256
|
+
await asyncio.shield(self._exit_task)
|
|
257
|
+
|
|
258
|
+
async def shutdown(self, grace: float | None = None) -> None:
|
|
259
|
+
"""Interrupts a live CLI, then kills it after ``grace`` seconds (``CANCEL_GRACE_S``)."""
|
|
260
|
+
if self.proc.returncode is None:
|
|
261
|
+
self.interrupt()
|
|
262
|
+
await self.wait_exit(CANCEL_GRACE_S if grace is None else grace)
|
|
263
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
264
|
+
await asyncio.wait_for(asyncio.shield(self._stderr_task), timeout=EXIT_DRAIN_S)
|
|
265
|
+
# Close the pipes now, while the loop is alive; asyncio has no public API for this.
|
|
266
|
+
transport = getattr(self.proc, "_transport", None)
|
|
267
|
+
if transport is not None:
|
|
268
|
+
transport.close()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class Thread:
|
|
272
|
+
"""A conversation with the Agent."""
|
|
273
|
+
|
|
274
|
+
def __init__(self, host: ThreadHost, options: ThreadOptions, thread_id: str | None) -> None:
|
|
275
|
+
self._host = host
|
|
276
|
+
self._options = options
|
|
277
|
+
self._session_id = thread_id
|
|
278
|
+
self._busy = False
|
|
279
|
+
self._current: StreamedTurn | None = None
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def id(self) -> str | None:
|
|
283
|
+
"""Session id. Available after the first event of the first Turn, even if it fails."""
|
|
284
|
+
return self._session_id
|
|
285
|
+
|
|
286
|
+
async def run(self, input: str, options: RunOptions | None = None) -> Turn:
|
|
287
|
+
"""Runs one Turn. Returns only when it succeeds; otherwise raises an ``MCodeError``."""
|
|
288
|
+
state: dict[str, Any] = {}
|
|
289
|
+
result: ExecResult | None = None
|
|
290
|
+
streamed = await self._start(input, options or RunOptions(), state)
|
|
291
|
+
async with streamed:
|
|
292
|
+
async for event in streamed:
|
|
293
|
+
if event["type"] == "exec.completed" and "result" in event:
|
|
294
|
+
result = event["result"]
|
|
295
|
+
if result is None:
|
|
296
|
+
raise MCodeProtocolError("mcode finished without exec.completed.")
|
|
297
|
+
acc: ItemAccumulator = state["items"]
|
|
298
|
+
items = acc.items()
|
|
299
|
+
if result["status"] != "succeeded":
|
|
300
|
+
raise error_for_result(
|
|
301
|
+
result, items, exit_code=state.get("exit_code"), stderr=state.get("stderr", "")
|
|
302
|
+
)
|
|
303
|
+
duration_ms = result.get("durationMs")
|
|
304
|
+
if not isinstance(duration_ms, int) or isinstance(duration_ms, bool):
|
|
305
|
+
raise MCodeProtocolError(
|
|
306
|
+
"mcode exec.completed result has no valid durationMs.",
|
|
307
|
+
exit_code=state.get("exit_code"),
|
|
308
|
+
stderr=state.get("stderr", ""),
|
|
309
|
+
session_id=result["sessionId"],
|
|
310
|
+
result=result,
|
|
311
|
+
items=items,
|
|
312
|
+
)
|
|
313
|
+
output = result.get("output")
|
|
314
|
+
final = output if isinstance(output, str) else "" if output is None else json.dumps(output)
|
|
315
|
+
return Turn(
|
|
316
|
+
final_response=final,
|
|
317
|
+
items=items,
|
|
318
|
+
session_id=result["sessionId"],
|
|
319
|
+
turn_id=result["turnId"],
|
|
320
|
+
duration_ms=duration_ms,
|
|
321
|
+
result=result,
|
|
322
|
+
usage=result.get("usage"),
|
|
323
|
+
model=result.get("model"),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
async def run_streamed(self, input: str, options: RunOptions | None = None) -> StreamedTurn:
|
|
327
|
+
"""Runs one Turn and yields CLI events as they arrive. ``turn.failed`` is an ordinary
|
|
328
|
+
event; iteration raises only for process or protocol failures."""
|
|
329
|
+
return await self._start(input, options or RunOptions(), {})
|
|
330
|
+
|
|
331
|
+
async def _start(self, input: str, options: RunOptions, state: dict[str, Any]) -> StreamedTurn:
|
|
332
|
+
if self._busy and self._current is not None:
|
|
333
|
+
# The previous Turn's loop was left early; let its CLI finish shutting down first.
|
|
334
|
+
await self._current._wait_closing()
|
|
335
|
+
if self._busy:
|
|
336
|
+
raise ThreadBusyError(self._session_id)
|
|
337
|
+
if not isinstance(input, str):
|
|
338
|
+
raise TypeError("input must be a string.")
|
|
339
|
+
validate_run_options(options)
|
|
340
|
+
self._busy = True
|
|
341
|
+
temp_dir: str | None = None
|
|
342
|
+
try:
|
|
343
|
+
cli = await self._host.prepare_cli()
|
|
344
|
+
prompt_files: dict[str, str] = {}
|
|
345
|
+
if self._options.system_prompt is not None or (
|
|
346
|
+
self._options.append_system_prompt is not None
|
|
347
|
+
):
|
|
348
|
+
temp_dir = tempfile.mkdtemp(prefix="mcode-sdk-")
|
|
349
|
+
for key, text in (
|
|
350
|
+
("system_prompt_file", self._options.system_prompt),
|
|
351
|
+
("append_system_prompt_file", self._options.append_system_prompt),
|
|
352
|
+
):
|
|
353
|
+
if text is not None:
|
|
354
|
+
path = os.path.join(temp_dir, key.replace("_file", "") + ".txt")
|
|
355
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
356
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
357
|
+
fh.write(text)
|
|
358
|
+
prompt_files[key] = path
|
|
359
|
+
args = build_exec_args(
|
|
360
|
+
thread=self._options,
|
|
361
|
+
session_id=self._session_id,
|
|
362
|
+
run=options,
|
|
363
|
+
config_path=self._host.config_path,
|
|
364
|
+
**prompt_files,
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
proc = await asyncio.create_subprocess_exec(
|
|
368
|
+
*cli.command(args),
|
|
369
|
+
stdin=asyncio.subprocess.PIPE,
|
|
370
|
+
stdout=asyncio.subprocess.PIPE,
|
|
371
|
+
stderr=asyncio.subprocess.PIPE,
|
|
372
|
+
env=self._host.child_env(),
|
|
373
|
+
limit=_STDOUT_LINE_LIMIT,
|
|
374
|
+
)
|
|
375
|
+
except OSError as error:
|
|
376
|
+
raise MCodeNotFoundError(f"Could not start mcode at {cli.path}: {error}") from error
|
|
377
|
+
except BaseException:
|
|
378
|
+
self._release(temp_dir)
|
|
379
|
+
raise
|
|
380
|
+
child = _Exec(proc)
|
|
381
|
+
released = False
|
|
382
|
+
|
|
383
|
+
def release() -> None:
|
|
384
|
+
nonlocal released
|
|
385
|
+
if not released:
|
|
386
|
+
released = True
|
|
387
|
+
self._release(temp_dir)
|
|
388
|
+
|
|
389
|
+
events = self._events(child, input, options, state, release)
|
|
390
|
+
|
|
391
|
+
async def finalize() -> None:
|
|
392
|
+
await _quiet_shutdown(child)
|
|
393
|
+
release()
|
|
394
|
+
|
|
395
|
+
streamed = StreamedTurn(events, child.interrupt, finalize)
|
|
396
|
+
self._current = streamed
|
|
397
|
+
return streamed
|
|
398
|
+
|
|
399
|
+
async def _events(
|
|
400
|
+
self,
|
|
401
|
+
child: _Exec,
|
|
402
|
+
prompt: str,
|
|
403
|
+
options: RunOptions,
|
|
404
|
+
state: dict[str, Any],
|
|
405
|
+
release: Callable[[], None],
|
|
406
|
+
) -> AsyncIterator[ThreadEvent]:
|
|
407
|
+
items = ItemAccumulator()
|
|
408
|
+
state["items"] = items
|
|
409
|
+
completed = False
|
|
410
|
+
backstop: asyncio.TimerHandle | None = None
|
|
411
|
+
if options.timeout_ms is not None:
|
|
412
|
+
delay = options.timeout_ms / 1000 + BACKSTOP_EXTRA_S
|
|
413
|
+
backstop = asyncio.get_running_loop().call_later(delay, child.kill_for_timeout)
|
|
414
|
+
try:
|
|
415
|
+
assert child.proc.stdin is not None and child.proc.stdout is not None
|
|
416
|
+
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
|
417
|
+
child.proc.stdin.write(json.dumps({"prompt": prompt}).encode("utf-8"))
|
|
418
|
+
await child.proc.stdin.drain()
|
|
419
|
+
child.proc.stdin.close()
|
|
420
|
+
line_no = 0
|
|
421
|
+
while True:
|
|
422
|
+
try:
|
|
423
|
+
raw = await child.read_line()
|
|
424
|
+
except ValueError as error:
|
|
425
|
+
raise MCodeProtocolError("mcode stdout line exceeds the size limit.") from error
|
|
426
|
+
if not raw:
|
|
427
|
+
break
|
|
428
|
+
line_no += 1
|
|
429
|
+
try:
|
|
430
|
+
event = parse_event_line(raw.decode("utf-8", "replace"))
|
|
431
|
+
except MCodeError as error:
|
|
432
|
+
error.stderr = child.stderr_tail
|
|
433
|
+
raise
|
|
434
|
+
if event is None:
|
|
435
|
+
continue
|
|
436
|
+
if self._session_id is None:
|
|
437
|
+
self._session_id = event["sessionId"]
|
|
438
|
+
if event["type"] == "exec.completed":
|
|
439
|
+
completed = True
|
|
440
|
+
if items.apply(event):
|
|
441
|
+
yield event
|
|
442
|
+
if completed:
|
|
443
|
+
# exec.completed is terminal; anything the CLI prints afterwards is ignored.
|
|
444
|
+
break
|
|
445
|
+
if completed:
|
|
446
|
+
# A CLI that lingers after exec.completed is force-stopped.
|
|
447
|
+
await child.wait_exit(TERMINATE_GRACE_S)
|
|
448
|
+
else:
|
|
449
|
+
await child.wait_exited()
|
|
450
|
+
await child.shutdown()
|
|
451
|
+
state["exit_code"] = child.proc.returncode
|
|
452
|
+
state["stderr"] = child.stderr_tail
|
|
453
|
+
if not completed:
|
|
454
|
+
raise error_for_exit(
|
|
455
|
+
exit_code=child.proc.returncode,
|
|
456
|
+
stderr=child.stderr_tail,
|
|
457
|
+
items=items.items(),
|
|
458
|
+
session_id=self._session_id,
|
|
459
|
+
timed_out=child.backstop_fired,
|
|
460
|
+
)
|
|
461
|
+
except BaseException:
|
|
462
|
+
await _quiet_shutdown(child)
|
|
463
|
+
raise
|
|
464
|
+
finally:
|
|
465
|
+
if backstop is not None:
|
|
466
|
+
backstop.cancel()
|
|
467
|
+
release()
|
|
468
|
+
|
|
469
|
+
def _release(self, temp_dir: str | None) -> None:
|
|
470
|
+
self._busy = False
|
|
471
|
+
if temp_dir:
|
|
472
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def _quiet_shutdown(child: _Exec) -> None:
|
|
476
|
+
with contextlib.suppress(Exception):
|
|
477
|
+
await asyncio.shield(child.shutdown())
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
__all__ = ["StreamedTurn", "Thread", "ThreadHost"]
|
minimax_code/types.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Public types for the ``mcode exec --output-format stream-json`` contract (schemaVersion 1).
|
|
2
|
+
|
|
3
|
+
Mirrors ``packages/code-sdk/src/types.ts``. Events are passed through as plain dicts typed with
|
|
4
|
+
``TypedDict`` so new fields or event types added by newer CLIs never break parsing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Literal, TypedDict
|
|
11
|
+
|
|
12
|
+
from typing_extensions import NotRequired
|
|
13
|
+
|
|
14
|
+
ExecResultStatus = Literal["succeeded", "failed", "timeout", "cancelled", "limit_exceeded"]
|
|
15
|
+
ExecErrorCategory = Literal["config", "runtime", "internal"]
|
|
16
|
+
UsageSource = Literal["completed_responses", "analytics_fallback", "unavailable"]
|
|
17
|
+
ExecItemType = Literal["agent_message", "reasoning", "tool_call"]
|
|
18
|
+
PermissionPolicy = Literal["smart", "full", "off"]
|
|
19
|
+
ContextMode = Literal["standard", "lightweight"]
|
|
20
|
+
PromptMode = Literal["tui", "coding", "work"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExecError(TypedDict):
|
|
24
|
+
category: ExecErrorCategory
|
|
25
|
+
message: str
|
|
26
|
+
code: NotRequired[str]
|
|
27
|
+
retryable: NotRequired[bool]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelIdentity(TypedDict):
|
|
31
|
+
providerId: str
|
|
32
|
+
modelId: str
|
|
33
|
+
variant: NotRequired[str]
|
|
34
|
+
providerSource: NotRequired[str]
|
|
35
|
+
providerKind: NotRequired[str]
|
|
36
|
+
protocol: NotRequired[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Usage(TypedDict, total=False):
|
|
40
|
+
"""Token usage. ``totalTokens = inputTokens + outputTokens``; cache buckets are separate."""
|
|
41
|
+
|
|
42
|
+
totalTokens: int
|
|
43
|
+
inputTokens: int
|
|
44
|
+
outputTokens: int
|
|
45
|
+
reasoningTokens: int
|
|
46
|
+
cacheReadTokens: int
|
|
47
|
+
cacheWriteTokens: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExecResult(TypedDict):
|
|
51
|
+
"""Final result of one ``mcode exec`` run (``exec.completed.result``)."""
|
|
52
|
+
|
|
53
|
+
schemaVersion: Literal[1]
|
|
54
|
+
type: Literal["exec.result"]
|
|
55
|
+
runId: str
|
|
56
|
+
sessionId: str
|
|
57
|
+
turnId: str
|
|
58
|
+
status: ExecResultStatus
|
|
59
|
+
durationMs: int
|
|
60
|
+
output: NotRequired[Any]
|
|
61
|
+
error: NotRequired[ExecError]
|
|
62
|
+
model: NotRequired[ModelIdentity]
|
|
63
|
+
usage: NotRequired[Usage]
|
|
64
|
+
usageSource: NotRequired[UsageSource]
|
|
65
|
+
usageIncomplete: NotRequired[bool]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
RawToolCall = dict[str, Any]
|
|
69
|
+
"""Experimental raw tool call snapshot (``id``, ``name``, ``status``, ``input``, ``output``...)."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ExecItem(TypedDict):
|
|
73
|
+
id: str
|
|
74
|
+
type: ExecItemType
|
|
75
|
+
content: NotRequired[str]
|
|
76
|
+
contentDelta: NotRequired[str]
|
|
77
|
+
toolCall: NotRequired[RawToolCall]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ThreadEvent(TypedDict):
|
|
81
|
+
"""One CLI stream event. Check ``type`` and read the fields specific to it.
|
|
82
|
+
|
|
83
|
+
Known types: ``exec.started``, ``session.started``, ``session.resumed``, ``turn.started``,
|
|
84
|
+
``item.started``, ``item.updated``, ``item.completed`` (with ``item``), ``turn.completed``
|
|
85
|
+
(``model``/``usage``/``durationMs``), ``turn.failed`` (``status``/``error``/``durationMs``),
|
|
86
|
+
``exec.completed`` (``result``). Unknown types are passed through; keep a default branch.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
schemaVersion: Literal[1]
|
|
90
|
+
type: str
|
|
91
|
+
sequence: int
|
|
92
|
+
timestampMs: int
|
|
93
|
+
runId: str
|
|
94
|
+
sessionId: str
|
|
95
|
+
turnId: str
|
|
96
|
+
item: NotRequired[ExecItem]
|
|
97
|
+
result: NotRequired[ExecResult]
|
|
98
|
+
model: NotRequired[ModelIdentity]
|
|
99
|
+
usage: NotRequired[Usage]
|
|
100
|
+
usageSource: NotRequired[UsageSource]
|
|
101
|
+
usageIncomplete: NotRequired[bool]
|
|
102
|
+
status: NotRequired[str]
|
|
103
|
+
error: NotRequired[ExecError]
|
|
104
|
+
durationMs: NotRequired[int]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class AgentMessageItem:
|
|
109
|
+
id: str
|
|
110
|
+
text: str
|
|
111
|
+
incomplete: bool = False
|
|
112
|
+
type: Literal["agent_message"] = "agent_message"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class ReasoningItem:
|
|
117
|
+
id: str
|
|
118
|
+
text: str
|
|
119
|
+
incomplete: bool = False
|
|
120
|
+
type: Literal["reasoning"] = "reasoning"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class ToolCallItem:
|
|
125
|
+
id: str
|
|
126
|
+
tool_call: RawToolCall
|
|
127
|
+
incomplete: bool = False
|
|
128
|
+
type: Literal["tool_call"] = "tool_call"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
ThreadItem = AgentMessageItem | ReasoningItem | ToolCallItem
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Turn:
|
|
136
|
+
"""Result of a successful ``thread.run()``."""
|
|
137
|
+
|
|
138
|
+
final_response: str
|
|
139
|
+
items: list[ThreadItem]
|
|
140
|
+
session_id: str
|
|
141
|
+
turn_id: str
|
|
142
|
+
duration_ms: int
|
|
143
|
+
result: ExecResult
|
|
144
|
+
usage: Usage | None = None
|
|
145
|
+
model: ModelIdentity | None = None
|
|
146
|
+
status: Literal["succeeded"] = "succeeded"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class MCodeOptions:
|
|
151
|
+
"""Client options.
|
|
152
|
+
|
|
153
|
+
``bin``: explicit ``mcode`` executable (or JS entry). Otherwise ``$MCODE_BIN``, then ``PATH``.
|
|
154
|
+
``data_dir``: passed to the CLI as ``MINIMAX_DATA_DIR``.
|
|
155
|
+
``env``: child environment; replaces ``os.environ`` when provided. ``repr()`` shows only its
|
|
156
|
+
variable names, never the values, so options can be logged without leaking secrets.
|
|
157
|
+
``config_path``: runtime config file, passed as ``--config``.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
bin: str | None = None
|
|
161
|
+
data_dir: str | None = None
|
|
162
|
+
env: dict[str, str] | None = None
|
|
163
|
+
config_path: str | None = None
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
env = None if self.env is None else {key: "***" for key in self.env}
|
|
167
|
+
return (
|
|
168
|
+
f"MCodeOptions(bin={self.bin!r}, data_dir={self.data_dir!r}, env={env!r}, "
|
|
169
|
+
f"config_path={self.config_path!r})"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class ThreadOptions:
|
|
175
|
+
cwd: str | None = None
|
|
176
|
+
model: str | None = None
|
|
177
|
+
"""Model as ``provider/model``, for example ``minimax/MiniMax-M3``."""
|
|
178
|
+
effort: str | None = None
|
|
179
|
+
permission: PermissionPolicy | None = None
|
|
180
|
+
mode: ContextMode | None = None
|
|
181
|
+
"""Context mode for a new Thread. ``lightweight`` cannot be resumed."""
|
|
182
|
+
prompt_mode: PromptMode | None = None
|
|
183
|
+
system_prompt: str | None = None
|
|
184
|
+
append_system_prompt: str | None = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class RunOptions:
|
|
189
|
+
files: list[str] = field(default_factory=list)
|
|
190
|
+
"""Files attached to this Turn (``--file``). Relative paths resolve against ``cwd``."""
|
|
191
|
+
timeout_ms: int | None = None
|
|
192
|
+
max_steps: int | None = None
|