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/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Python SDK for driving the local MiniMax Code (``mcode``) agent."""
|
|
2
|
+
|
|
3
|
+
from .client import MCode
|
|
4
|
+
from .errors import (
|
|
5
|
+
MCODE_EXEC_EXIT_CODES,
|
|
6
|
+
MCodeAbortError,
|
|
7
|
+
MCodeError,
|
|
8
|
+
MCodeInvocationError,
|
|
9
|
+
MCodeNotFoundError,
|
|
10
|
+
MCodeProtocolError,
|
|
11
|
+
MCodeRunError,
|
|
12
|
+
MCodeVersionError,
|
|
13
|
+
ThreadBusyError,
|
|
14
|
+
)
|
|
15
|
+
from .sync import SyncMCode, SyncThread
|
|
16
|
+
from .thread import StreamedTurn, Thread
|
|
17
|
+
from .types import (
|
|
18
|
+
AgentMessageItem,
|
|
19
|
+
ExecError,
|
|
20
|
+
ExecItem,
|
|
21
|
+
ExecResult,
|
|
22
|
+
MCodeOptions,
|
|
23
|
+
ModelIdentity,
|
|
24
|
+
ReasoningItem,
|
|
25
|
+
RunOptions,
|
|
26
|
+
ThreadEvent,
|
|
27
|
+
ThreadItem,
|
|
28
|
+
ThreadOptions,
|
|
29
|
+
ToolCallItem,
|
|
30
|
+
Turn,
|
|
31
|
+
Usage,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"MCODE_EXEC_EXIT_CODES",
|
|
38
|
+
"AgentMessageItem",
|
|
39
|
+
"ExecError",
|
|
40
|
+
"ExecItem",
|
|
41
|
+
"ExecResult",
|
|
42
|
+
"MCode",
|
|
43
|
+
"MCodeAbortError",
|
|
44
|
+
"MCodeError",
|
|
45
|
+
"MCodeInvocationError",
|
|
46
|
+
"MCodeNotFoundError",
|
|
47
|
+
"MCodeOptions",
|
|
48
|
+
"MCodeProtocolError",
|
|
49
|
+
"MCodeRunError",
|
|
50
|
+
"MCodeVersionError",
|
|
51
|
+
"ModelIdentity",
|
|
52
|
+
"ReasoningItem",
|
|
53
|
+
"RunOptions",
|
|
54
|
+
"StreamedTurn",
|
|
55
|
+
"SyncMCode",
|
|
56
|
+
"SyncThread",
|
|
57
|
+
"Thread",
|
|
58
|
+
"ThreadBusyError",
|
|
59
|
+
"ThreadEvent",
|
|
60
|
+
"ThreadItem",
|
|
61
|
+
"ThreadOptions",
|
|
62
|
+
"ToolCallItem",
|
|
63
|
+
"Turn",
|
|
64
|
+
"Usage",
|
|
65
|
+
"__version__",
|
|
66
|
+
]
|
minimax_code/_args.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Builds ``mcode exec`` argv. Mirrors ``packages/code-sdk/src/args.ts``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .errors import MCodeInvocationError
|
|
8
|
+
from .types import RunOptions, ThreadOptions
|
|
9
|
+
|
|
10
|
+
_PERMISSIONS = {"smart", "full", "off"}
|
|
11
|
+
_MODES = {"standard", "lightweight"}
|
|
12
|
+
_PROMPT_MODES = {"tui", "coding", "work"}
|
|
13
|
+
MAX_TIMEOUT_MS = 2**31 - 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_thread_options(options: ThreadOptions, resuming: bool) -> None:
|
|
17
|
+
if options.permission is not None and options.permission not in _PERMISSIONS:
|
|
18
|
+
raise MCodeInvocationError(
|
|
19
|
+
f"permission must be smart, full or off; received {options.permission!r}."
|
|
20
|
+
)
|
|
21
|
+
if options.mode is not None and options.mode not in _MODES:
|
|
22
|
+
raise MCodeInvocationError(
|
|
23
|
+
f"mode must be standard or lightweight; received {options.mode!r}."
|
|
24
|
+
)
|
|
25
|
+
if resuming and options.mode == "lightweight":
|
|
26
|
+
raise MCodeInvocationError(
|
|
27
|
+
'mode "lightweight" requires a new Thread; it cannot be resumed.'
|
|
28
|
+
)
|
|
29
|
+
if options.prompt_mode is not None and options.prompt_mode not in _PROMPT_MODES:
|
|
30
|
+
raise MCodeInvocationError(
|
|
31
|
+
f"prompt_mode must be tui, coding or work; received {options.prompt_mode!r}."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _positive_int(value: object) -> bool:
|
|
36
|
+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_run_options(options: RunOptions) -> None:
|
|
40
|
+
if options.timeout_ms is not None and (
|
|
41
|
+
not _positive_int(options.timeout_ms) or options.timeout_ms > MAX_TIMEOUT_MS
|
|
42
|
+
):
|
|
43
|
+
raise MCodeInvocationError(
|
|
44
|
+
f"timeout_ms must be a positive integer no greater than {MAX_TIMEOUT_MS}."
|
|
45
|
+
)
|
|
46
|
+
if options.max_steps is not None and not _positive_int(options.max_steps):
|
|
47
|
+
raise MCodeInvocationError("max_steps must be a positive integer.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_exec_args(
|
|
51
|
+
*,
|
|
52
|
+
thread: ThreadOptions,
|
|
53
|
+
session_id: str | None,
|
|
54
|
+
run: RunOptions,
|
|
55
|
+
config_path: str | None = None,
|
|
56
|
+
system_prompt_file: str | None = None,
|
|
57
|
+
append_system_prompt_file: str | None = None,
|
|
58
|
+
) -> list[str]:
|
|
59
|
+
"""The prompt travels over stdin and system prompts through temp files; values use the
|
|
60
|
+
``--flag=value`` form so a value starting with ``-`` is never parsed as a flag."""
|
|
61
|
+
args = [
|
|
62
|
+
"exec",
|
|
63
|
+
"--output-format=stream-json",
|
|
64
|
+
"--input=-",
|
|
65
|
+
"--input-format=json",
|
|
66
|
+
f"--cwd={os.path.abspath(thread.cwd or os.getcwd())}",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def push(flag: str, value: str | None) -> None:
|
|
70
|
+
if value is not None:
|
|
71
|
+
args.append(f"{flag}={value}")
|
|
72
|
+
|
|
73
|
+
push("--model", thread.model)
|
|
74
|
+
push("--effort", thread.effort)
|
|
75
|
+
push("--permission", thread.permission)
|
|
76
|
+
push("--prompt-mode", thread.prompt_mode)
|
|
77
|
+
push("--config", config_path)
|
|
78
|
+
if session_id is None:
|
|
79
|
+
push("--mode", thread.mode)
|
|
80
|
+
else:
|
|
81
|
+
push("--session", session_id)
|
|
82
|
+
push("--system-prompt-file", system_prompt_file)
|
|
83
|
+
push("--append-system-prompt-file", append_system_prompt_file)
|
|
84
|
+
for file in run.files:
|
|
85
|
+
push("--file", file)
|
|
86
|
+
if run.timeout_ms is not None:
|
|
87
|
+
push("--timeout", f"{run.timeout_ms}ms")
|
|
88
|
+
if run.max_steps is not None:
|
|
89
|
+
push("--max-steps", str(run.max_steps))
|
|
90
|
+
return args
|
minimax_code/_cli.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Locates and version-checks the ``mcode`` CLI. Mirrors ``packages/code-sdk/src/cli-resolver.ts``
|
|
2
|
+
and the Windows launcher handling of ``project-bin.ts``: the SDK never starts the CLI through a
|
|
3
|
+
shell, so Windows ``.cmd`` launchers are followed to the JS entry and Node runtime they run.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .errors import MCODE_INSTALL_HINT, MCodeNotFoundError, MCodeVersionError
|
|
17
|
+
|
|
18
|
+
MIN_CLI_VERSION = (0, 6, 3)
|
|
19
|
+
_JS_SUFFIXES = (".js", ".mjs", ".cjs")
|
|
20
|
+
#: Windows files that CreateProcess runs directly, without cmd.exe.
|
|
21
|
+
_WINDOWS_NATIVE = (".exe", ".com")
|
|
22
|
+
#: Lines of the Windows root launcher written by the official installer and CLI self-update.
|
|
23
|
+
_VERSIONED_POINTER_LINE = 'SET /P MCODE_RELEASE=<"%~dp0current"'
|
|
24
|
+
_VERSIONED_CALL_LINE = 'CALL "%~dp0releases\\%MCODE_RELEASE%\\.mcode-launcher.cmd"'
|
|
25
|
+
#: Same release-key rule the root launcher enforces before following the pointer.
|
|
26
|
+
_RELEASE_KEY = re.compile(r"[0-9A-Za-z][0-9A-Za-z._-]*")
|
|
27
|
+
#: JS entry in an npm / pnpm ``.cmd`` shim or an installer release launcher.
|
|
28
|
+
_CMD_SHIM_ENTRY = re.compile(r'"%~?dp0%?\\?([^"%]+\.(?:c|m)?js)"', re.IGNORECASE)
|
|
29
|
+
#: JS entry in an npm / pnpm POSIX shell or PowerShell shim.
|
|
30
|
+
_SHELL_SHIM_ENTRY = re.compile(r'"\$basedir[\\/]([^"$]+\.(?:c|m)?js)"')
|
|
31
|
+
#: The pinned runtime of an installer release launcher: ``"<node>" "%~dp0<entry>" %*``.
|
|
32
|
+
_CMD_RUNTIME = re.compile(r'^"([^"%]+)"[ \t]+"%~dp0', re.MULTILINE)
|
|
33
|
+
_SHIM_HEAD_BYTES = 4096
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ResolvedCli:
|
|
38
|
+
"""How to start the CLI."""
|
|
39
|
+
|
|
40
|
+
path: str
|
|
41
|
+
"""The executable, or the JS entry that runs with Node."""
|
|
42
|
+
node: str | None = None
|
|
43
|
+
"""Node runtime for a JS entry; ``None`` uses ``node`` from ``PATH``."""
|
|
44
|
+
launcher: str | None = None
|
|
45
|
+
"""Windows installer root launcher. Its release pointer is followed again before every run, so
|
|
46
|
+
a CLI self-update between Turns is picked up."""
|
|
47
|
+
|
|
48
|
+
def command(self, args: list[str]) -> list[str]:
|
|
49
|
+
if self.launcher is not None:
|
|
50
|
+
current = _follow_versioned_launcher(self.launcher)
|
|
51
|
+
if current is None:
|
|
52
|
+
raise MCodeNotFoundError(
|
|
53
|
+
f"{self.launcher} no longer points to a usable release. "
|
|
54
|
+
"Pass the CLI's JS entry as MCodeOptions(bin=...)."
|
|
55
|
+
)
|
|
56
|
+
return current.command(args)
|
|
57
|
+
if self.path.lower().endswith(_JS_SUFFIXES):
|
|
58
|
+
return [self.node or shutil.which("node") or "node", self.path, *args]
|
|
59
|
+
return [self.path, *args]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_cli(
|
|
63
|
+
bin: str | None, env: Mapping[str, str], platform: str = sys.platform
|
|
64
|
+
) -> ResolvedCli:
|
|
65
|
+
"""Explicit ``bin`` → ``$MCODE_BIN`` → ``mcode`` on ``PATH``."""
|
|
66
|
+
explicit = bin or _env(env, "MCODE_BIN", platform) or None
|
|
67
|
+
if explicit:
|
|
68
|
+
path = os.path.abspath(os.path.expanduser(explicit))
|
|
69
|
+
if not os.path.isfile(path):
|
|
70
|
+
source = "bin" if bin else "MCODE_BIN"
|
|
71
|
+
raise MCodeNotFoundError(f"mcode CLI from {source} does not exist: {path}")
|
|
72
|
+
return _from_executable(path, platform)
|
|
73
|
+
found = shutil.which("mcode", path=_env(env, "PATH", platform) or os.defpath)
|
|
74
|
+
if not found:
|
|
75
|
+
raise MCodeNotFoundError(
|
|
76
|
+
"Could not find the mcode CLI on PATH. Install it with "
|
|
77
|
+
f"`{MCODE_INSTALL_HINT}`, set MCODE_BIN, or pass MCodeOptions(bin=...)."
|
|
78
|
+
)
|
|
79
|
+
return _from_executable(found, platform)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _from_executable(path: str, platform: str) -> ResolvedCli:
|
|
83
|
+
extension = os.path.splitext(path)[1].lower()
|
|
84
|
+
if extension in _JS_SUFFIXES or platform != "win32" or extension in _WINDOWS_NATIVE:
|
|
85
|
+
return ResolvedCli(path)
|
|
86
|
+
# A batch file or shell shim would need cmd.exe and its quoting rules, and cancelling it would
|
|
87
|
+
# stop only cmd.exe and leave the CLI running. Run the JS entry behind it instead.
|
|
88
|
+
try:
|
|
89
|
+
head = _read_head(path)
|
|
90
|
+
except OSError:
|
|
91
|
+
head = ""
|
|
92
|
+
if extension in (".cmd", ".bat"):
|
|
93
|
+
resolved = resolve_cmd_launcher(head, path)
|
|
94
|
+
else:
|
|
95
|
+
entry = _parse_shim(_SHELL_SHIM_ENTRY, head, path)
|
|
96
|
+
resolved = ResolvedCli(entry, node=_adjacent_node(path)) if entry else None
|
|
97
|
+
if resolved is None:
|
|
98
|
+
raise MCodeNotFoundError(
|
|
99
|
+
f"Found {path}, but could not determine the JS entry it runs. "
|
|
100
|
+
"Pass the CLI's JS entry as MCodeOptions(bin=...) or MCODE_BIN."
|
|
101
|
+
)
|
|
102
|
+
return resolved
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_cmd_launcher(content: str, launcher: str) -> ResolvedCli | None:
|
|
106
|
+
"""Resolves a Windows ``.cmd`` launcher: an npm / pnpm shim, or the installer's root
|
|
107
|
+
launcher, whose release is looked up again before every run."""
|
|
108
|
+
entry = _parse_shim(_CMD_SHIM_ENTRY, content, launcher)
|
|
109
|
+
if entry:
|
|
110
|
+
return ResolvedCli(entry, node=_adjacent_node(launcher))
|
|
111
|
+
versioned = _VERSIONED_POINTER_LINE in content and _VERSIONED_CALL_LINE in content
|
|
112
|
+
if versioned and _follow_versioned_launcher(launcher) is not None:
|
|
113
|
+
return ResolvedCli(launcher, launcher=launcher)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _follow_versioned_launcher(launcher: str) -> ResolvedCli | None:
|
|
118
|
+
"""``<prefix>\\mcode.cmd`` reads the release key from ``<prefix>\\current`` and calls
|
|
119
|
+
``<prefix>\\releases\\<release>\\.mcode-launcher.cmd``, which runs a pinned Node runtime."""
|
|
120
|
+
prefix = os.path.dirname(launcher)
|
|
121
|
+
try:
|
|
122
|
+
with open(os.path.join(prefix, "current"), encoding="utf-8") as handle:
|
|
123
|
+
release = handle.read().strip()
|
|
124
|
+
if not _RELEASE_KEY.fullmatch(release):
|
|
125
|
+
return None
|
|
126
|
+
release_launcher = os.path.join(prefix, "releases", release, ".mcode-launcher.cmd")
|
|
127
|
+
content = _read_head(release_launcher)
|
|
128
|
+
except OSError:
|
|
129
|
+
return None
|
|
130
|
+
entry = _parse_shim(_CMD_SHIM_ENTRY, content, release_launcher)
|
|
131
|
+
if entry is None:
|
|
132
|
+
return None
|
|
133
|
+
runtime = _CMD_RUNTIME.search(content)
|
|
134
|
+
node = runtime.group(1) if runtime and os.path.isfile(runtime.group(1)) else None
|
|
135
|
+
return ResolvedCli(entry, node=node)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_shim(pattern: re.Pattern[str], content: str, shim: str) -> str | None:
|
|
139
|
+
match = pattern.search(content)
|
|
140
|
+
if not match:
|
|
141
|
+
return None
|
|
142
|
+
parts = [part for part in re.split(r"[\\/]", match.group(1)) if part]
|
|
143
|
+
entry = os.path.normpath(os.path.join(os.path.dirname(shim), *parts))
|
|
144
|
+
return entry if os.path.isfile(entry) else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _adjacent_node(shim: str) -> str | None:
|
|
148
|
+
"""npm shims prefer a ``node.exe`` next to them over ``PATH``."""
|
|
149
|
+
candidate = os.path.join(os.path.dirname(shim), "node.exe")
|
|
150
|
+
return candidate if os.path.isfile(candidate) else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read_head(path: str) -> str:
|
|
154
|
+
with open(path, "rb") as handle:
|
|
155
|
+
return handle.read(_SHIM_HEAD_BYTES).decode("utf-8", "replace")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _env(env: Mapping[str, str], key: str, platform: str) -> str | None:
|
|
159
|
+
"""Environment names are case-insensitive on Windows (``Path``)."""
|
|
160
|
+
if platform != "win32":
|
|
161
|
+
return env.get(key)
|
|
162
|
+
return next((value for name, value in env.items() if name.upper() == key), None)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_version(text: str) -> tuple[int, int, int] | None:
|
|
166
|
+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", text)
|
|
167
|
+
if not match:
|
|
168
|
+
return None
|
|
169
|
+
major, minor, patch = (int(part) for part in match.groups())
|
|
170
|
+
return (major, minor, patch)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def check_cli_version(cli: ResolvedCli, env: Mapping[str, str]) -> str:
|
|
174
|
+
try:
|
|
175
|
+
proc = await asyncio.create_subprocess_exec(
|
|
176
|
+
*cli.command(["--version"]),
|
|
177
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
178
|
+
stdout=asyncio.subprocess.PIPE,
|
|
179
|
+
stderr=asyncio.subprocess.PIPE,
|
|
180
|
+
env=dict(env),
|
|
181
|
+
)
|
|
182
|
+
out, err = await asyncio.wait_for(proc.communicate(), timeout=30)
|
|
183
|
+
except (OSError, asyncio.TimeoutError) as error:
|
|
184
|
+
raise MCodeNotFoundError(f"Could not run `{cli.path} --version`: {error}") from error
|
|
185
|
+
if proc.returncode != 0:
|
|
186
|
+
raise MCodeVersionError(
|
|
187
|
+
f"`mcode --version` failed ({proc.returncode}); cannot verify CLI compatibility.",
|
|
188
|
+
exit_code=proc.returncode,
|
|
189
|
+
stderr=err.decode("utf-8", "replace"),
|
|
190
|
+
)
|
|
191
|
+
text = out.decode("utf-8", "replace")
|
|
192
|
+
version = parse_version(text)
|
|
193
|
+
if version is None:
|
|
194
|
+
raise MCodeVersionError(f"Could not read the mcode version from: {text.strip()!r}.")
|
|
195
|
+
if version < MIN_CLI_VERSION:
|
|
196
|
+
minimum = ".".join(map(str, MIN_CLI_VERSION))
|
|
197
|
+
found = ".".join(map(str, version))
|
|
198
|
+
raise MCodeVersionError(
|
|
199
|
+
f"mcode {found} is older than the minimum {minimum} required by minimax-code-sdk. "
|
|
200
|
+
f"Upgrade with `{MCODE_INSTALL_HINT}`."
|
|
201
|
+
)
|
|
202
|
+
return ".".join(map(str, version))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
__all__ = ["ResolvedCli", "check_cli_version", "resolve_cli", "resolve_cmd_launcher"]
|
minimax_code/_items.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Folds ``item.*`` events into SDK items. Mirrors ``packages/code-sdk/src/items.ts``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .types import AgentMessageItem, ReasoningItem, ThreadEvent, ThreadItem, ToolCallItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class _State:
|
|
14
|
+
type: str
|
|
15
|
+
text: str = ""
|
|
16
|
+
tool_call: dict[str, Any] | None = None
|
|
17
|
+
completed: bool = False
|
|
18
|
+
snapshot: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ItemAccumulator:
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._states: dict[str, _State] = {}
|
|
24
|
+
|
|
25
|
+
def apply(self, event: ThreadEvent) -> bool:
|
|
26
|
+
"""Applies one event. Returns False for a duplicate snapshot ``item.updated``."""
|
|
27
|
+
if event["type"] not in ("item.started", "item.updated", "item.completed"):
|
|
28
|
+
return True
|
|
29
|
+
item = event.get("item")
|
|
30
|
+
if item is None:
|
|
31
|
+
return True
|
|
32
|
+
delta = item.get("contentDelta")
|
|
33
|
+
snapshot = json.dumps(item, sort_keys=True) if delta is None else ""
|
|
34
|
+
state = self._states.get(item["id"])
|
|
35
|
+
if (
|
|
36
|
+
state is not None
|
|
37
|
+
and event["type"] == "item.updated"
|
|
38
|
+
and snapshot
|
|
39
|
+
and state.snapshot == snapshot
|
|
40
|
+
and not state.completed
|
|
41
|
+
):
|
|
42
|
+
return False
|
|
43
|
+
if state is None:
|
|
44
|
+
state = _State(type=item["type"])
|
|
45
|
+
self._states[item["id"]] = state
|
|
46
|
+
state.snapshot = snapshot
|
|
47
|
+
if delta is not None:
|
|
48
|
+
state.text += delta
|
|
49
|
+
if "content" in item:
|
|
50
|
+
state.text = item["content"]
|
|
51
|
+
if "toolCall" in item:
|
|
52
|
+
state.tool_call = item["toolCall"]
|
|
53
|
+
if event["type"] == "item.completed":
|
|
54
|
+
state.completed = True
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def items(self) -> list[ThreadItem]:
|
|
58
|
+
out: list[ThreadItem] = []
|
|
59
|
+
for item_id, s in self._states.items():
|
|
60
|
+
incomplete = not s.completed
|
|
61
|
+
if s.type == "agent_message":
|
|
62
|
+
out.append(AgentMessageItem(id=item_id, text=s.text, incomplete=incomplete))
|
|
63
|
+
elif s.type == "reasoning":
|
|
64
|
+
out.append(ReasoningItem(id=item_id, text=s.text, incomplete=incomplete))
|
|
65
|
+
elif s.type == "tool_call":
|
|
66
|
+
out.append(
|
|
67
|
+
ToolCallItem(
|
|
68
|
+
id=item_id, tool_call=s.tool_call or {"id": item_id}, incomplete=incomplete
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return out
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Parses stdout lines. Mirrors ``packages/code-sdk/src/protocol.ts``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from .errors import MCodeProtocolError, MCodeVersionError
|
|
9
|
+
from .types import ThreadEvent
|
|
10
|
+
|
|
11
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _truncate(text: str) -> str:
|
|
15
|
+
return text if len(text) <= 200 else text[:200] + "…"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_event_line(line: str) -> ThreadEvent | None:
|
|
19
|
+
text = line.strip()
|
|
20
|
+
if not text:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
value: Any = json.loads(text)
|
|
24
|
+
except json.JSONDecodeError as error:
|
|
25
|
+
raise MCodeProtocolError(f"mcode stdout is not JSON: {_truncate(text)}") from error
|
|
26
|
+
if not isinstance(value, dict):
|
|
27
|
+
raise MCodeProtocolError(f"mcode stdout line is not a JSON object: {_truncate(text)}")
|
|
28
|
+
if value.get("schemaVersion") != SUPPORTED_SCHEMA_VERSION:
|
|
29
|
+
raise MCodeVersionError(
|
|
30
|
+
f"Unsupported mcode event schemaVersion {value.get('schemaVersion')!r}; "
|
|
31
|
+
f"this SDK supports {SUPPORTED_SCHEMA_VERSION}."
|
|
32
|
+
)
|
|
33
|
+
if not isinstance(value.get("type"), str) or not isinstance(value.get("sessionId"), str):
|
|
34
|
+
raise MCodeProtocolError(f"mcode event is missing type or sessionId: {_truncate(text)}")
|
|
35
|
+
kind: str = value["type"]
|
|
36
|
+
if kind.startswith("item."):
|
|
37
|
+
item = value.get("item")
|
|
38
|
+
if not (
|
|
39
|
+
isinstance(item, dict)
|
|
40
|
+
and isinstance(item.get("id"), str)
|
|
41
|
+
and isinstance(item.get("type"), str)
|
|
42
|
+
):
|
|
43
|
+
raise MCodeProtocolError(f"mcode {kind} event has an invalid item: {_truncate(text)}")
|
|
44
|
+
if kind == "exec.completed":
|
|
45
|
+
result = value.get("result")
|
|
46
|
+
if not (
|
|
47
|
+
isinstance(result, dict)
|
|
48
|
+
and result.get("schemaVersion") == SUPPORTED_SCHEMA_VERSION
|
|
49
|
+
and isinstance(result.get("status"), str)
|
|
50
|
+
and isinstance(result.get("sessionId"), str)
|
|
51
|
+
and isinstance(result.get("turnId"), str)
|
|
52
|
+
):
|
|
53
|
+
raise MCodeProtocolError(
|
|
54
|
+
f"mcode exec.completed has an invalid result: {_truncate(text)}"
|
|
55
|
+
)
|
|
56
|
+
return cast(ThreadEvent, value)
|
minimax_code/client.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Entry point. Mirrors ``packages/code-sdk/src/mcode.ts``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from ._args import validate_thread_options
|
|
10
|
+
from ._cli import ResolvedCli, check_cli_version, resolve_cli
|
|
11
|
+
from .errors import MCodeInvocationError
|
|
12
|
+
from .thread import Thread, ThreadHost
|
|
13
|
+
from .types import MCodeOptions, ThreadOptions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MCode:
|
|
17
|
+
"""One client resolves and version-checks the CLI once, then starts Threads."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, options: MCodeOptions | None = None) -> None:
|
|
20
|
+
self._options = options or MCodeOptions()
|
|
21
|
+
self._cli: ResolvedCli | None = None
|
|
22
|
+
self._lock: asyncio.Lock | None = None
|
|
23
|
+
config = self._options.config_path
|
|
24
|
+
self._host = ThreadHost(
|
|
25
|
+
config_path=os.path.abspath(config) if config else None,
|
|
26
|
+
child_env=self._child_env,
|
|
27
|
+
prepare_cli=self._prepare_cli,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def start_thread(self, options: ThreadOptions | None = None) -> Thread:
|
|
31
|
+
"""Starts a new Thread. The Session is created by its first Turn.
|
|
32
|
+
|
|
33
|
+
``ThreadOptions.cwd`` defaults to the current working directory and is resolved now, so a
|
|
34
|
+
later ``os.chdir()`` does not move the Thread to another workspace.
|
|
35
|
+
"""
|
|
36
|
+
options = options or ThreadOptions()
|
|
37
|
+
validate_thread_options(options, resuming=False)
|
|
38
|
+
return Thread(self._host, _with_absolute_cwd(options), None)
|
|
39
|
+
|
|
40
|
+
def resume_thread(self, thread_id: str, options: ThreadOptions | None = None) -> Thread:
|
|
41
|
+
"""Continues an existing Session, for example one persisted from ``thread.id``.
|
|
42
|
+
|
|
43
|
+
Pass the same ``ThreadOptions.cwd`` the Session was created with. When ``cwd`` is omitted
|
|
44
|
+
the current working directory at this call is used, and the CLI rejects a Session whose
|
|
45
|
+
workspace differs (``MCodeRunError`` with "belongs to workspace ...").
|
|
46
|
+
"""
|
|
47
|
+
if not isinstance(thread_id, str) or not thread_id.strip():
|
|
48
|
+
raise MCodeInvocationError("resume_thread requires a non-empty Session id.")
|
|
49
|
+
options = options or ThreadOptions()
|
|
50
|
+
validate_thread_options(options, resuming=True)
|
|
51
|
+
return Thread(self._host, _with_absolute_cwd(options), thread_id.strip())
|
|
52
|
+
|
|
53
|
+
def _child_env(self) -> dict[str, str]:
|
|
54
|
+
env = dict(self._options.env if self._options.env is not None else os.environ)
|
|
55
|
+
if self._options.data_dir is not None:
|
|
56
|
+
env["MINIMAX_DATA_DIR"] = os.path.abspath(self._options.data_dir)
|
|
57
|
+
return env
|
|
58
|
+
|
|
59
|
+
async def _prepare_cli(self) -> ResolvedCli:
|
|
60
|
+
if self._cli is not None:
|
|
61
|
+
return self._cli
|
|
62
|
+
if self._lock is None:
|
|
63
|
+
self._lock = asyncio.Lock()
|
|
64
|
+
async with self._lock:
|
|
65
|
+
if self._cli is None:
|
|
66
|
+
env = self._child_env()
|
|
67
|
+
cli = resolve_cli(self._options.bin, env)
|
|
68
|
+
await check_cli_version(cli, env)
|
|
69
|
+
# Only cached on success, so a later run works after installing the CLI.
|
|
70
|
+
self._cli = cli
|
|
71
|
+
return self._cli
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _with_absolute_cwd(options: ThreadOptions) -> ThreadOptions:
|
|
75
|
+
"""Pins the workspace when the Thread is created; every Turn of a Session must use it."""
|
|
76
|
+
return dataclasses.replace(options, cwd=os.path.abspath(options.cwd or os.getcwd()))
|