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/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