monkeyscode 1.0.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.
- monkeyscode/__init__.py +178 -0
- monkeyscode/_http.py +83 -0
- monkeyscode/admin.py +141 -0
- monkeyscode/agent.py +772 -0
- monkeyscode/ci.py +288 -0
- monkeyscode/cli_process.py +730 -0
- monkeyscode/daemon.py +201 -0
- monkeyscode/events.py +323 -0
- monkeyscode/export.py +319 -0
- monkeyscode/hooks.py +190 -0
- monkeyscode/mcp.py +283 -0
- monkeyscode/orchestrator.py +170 -0
- monkeyscode/otel.py +181 -0
- monkeyscode/py.typed +1 -0
- monkeyscode/runs.py +138 -0
- monkeyscode/sandbox.py +125 -0
- monkeyscode/session.py +227 -0
- monkeyscode/subagent.py +195 -0
- monkeyscode/telemetry.py +204 -0
- monkeyscode/tools.py +133 -0
- monkeyscode/watcher.py +137 -0
- monkeyscode-1.0.0.dist-info/METADATA +152 -0
- monkeyscode-1.0.0.dist-info/RECORD +25 -0
- monkeyscode-1.0.0.dist-info/WHEEL +4 -0
- monkeyscode-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Drive an installed ``mc`` over stdio (the local agent, not the cloud API).
|
|
3
|
+
|
|
4
|
+
Spawns ``mc -p --input-format stream-json --output-format stream-json
|
|
5
|
+
--permission-prompt stdio`` and speaks the same NDJSON the CLI prints
|
|
6
|
+
(``schema_version`` 1). Hooks, MCP servers, settings and permission rules
|
|
7
|
+
all apply, because the real CLI runs them.
|
|
8
|
+
|
|
9
|
+
One-shot::
|
|
10
|
+
|
|
11
|
+
from monkeyscode.cli_process import query
|
|
12
|
+
|
|
13
|
+
async for event in query("explain src/main.ts"):
|
|
14
|
+
if isinstance(event, ResultMessage):
|
|
15
|
+
print(event.result)
|
|
16
|
+
|
|
17
|
+
Multi-turn with approvals::
|
|
18
|
+
|
|
19
|
+
async def can_use_tool(tool, tool_input, ctx):
|
|
20
|
+
if tool == "create_file" and tool_input.get("path", "").startswith("docs/"):
|
|
21
|
+
return PermissionAllow()
|
|
22
|
+
return PermissionDeny(message="only docs/ may be written")
|
|
23
|
+
|
|
24
|
+
async with MonkeysCodeClient(CliOptions(can_use_tool=can_use_tool)) as client:
|
|
25
|
+
await client.send("write docs/intro.md")
|
|
26
|
+
async for event in client.receive_turn():
|
|
27
|
+
...
|
|
28
|
+
await client.send("now write src/x.ts")
|
|
29
|
+
result = await client.receive_result()
|
|
30
|
+
|
|
31
|
+
Standard library only. The TypeScript (``@monkeyscode/sdk/subprocess``) and
|
|
32
|
+
Go (``monkeyscode.Query``) clients implement the same protocol.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import contextlib
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import shutil
|
|
42
|
+
import tempfile
|
|
43
|
+
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from typing import Any, Literal
|
|
46
|
+
|
|
47
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
48
|
+
|
|
49
|
+
# ── Events ───────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TextBlock:
|
|
54
|
+
text: str
|
|
55
|
+
type: Literal["text"] = "text"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ToolUseBlock:
|
|
60
|
+
id: str
|
|
61
|
+
name: str
|
|
62
|
+
input: dict[str, Any]
|
|
63
|
+
type: Literal["tool_use"] = "tool_use"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ToolResultBlock:
|
|
68
|
+
tool_use_id: str
|
|
69
|
+
content: str
|
|
70
|
+
is_error: bool
|
|
71
|
+
type: Literal["tool_result"] = "tool_result"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class PermissionDenial:
|
|
76
|
+
tool_name: str
|
|
77
|
+
tool_input: dict[str, Any]
|
|
78
|
+
reason: str
|
|
79
|
+
tool_use_id: str | None = None
|
|
80
|
+
suggested_rule: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Usage:
|
|
85
|
+
input_tokens: int = 0
|
|
86
|
+
output_tokens: int = 0
|
|
87
|
+
cached_tokens: int = 0
|
|
88
|
+
thinking_tokens: int = 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class InitMessage:
|
|
93
|
+
session_id: str
|
|
94
|
+
schema_version: int
|
|
95
|
+
cwd: str
|
|
96
|
+
model: str
|
|
97
|
+
permission_mode: str
|
|
98
|
+
tools: list[str]
|
|
99
|
+
mcp_servers: list[dict[str, str]]
|
|
100
|
+
cli_version: str
|
|
101
|
+
resumed_from: str | None = None
|
|
102
|
+
forked_from: str | None = None
|
|
103
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
104
|
+
type: Literal["system"] = "system"
|
|
105
|
+
subtype: Literal["init"] = "init"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class AssistantMessage:
|
|
110
|
+
session_id: str
|
|
111
|
+
content: list[TextBlock | ToolUseBlock]
|
|
112
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
113
|
+
type: Literal["assistant"] = "assistant"
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def text(self) -> str:
|
|
117
|
+
return "".join(b.text for b in self.content if isinstance(b, TextBlock))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class UserMessage:
|
|
122
|
+
"""A prompt (``content`` is a str) or tool output (a list of ToolResultBlock)."""
|
|
123
|
+
|
|
124
|
+
session_id: str
|
|
125
|
+
content: str | list[ToolResultBlock]
|
|
126
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
127
|
+
type: Literal["user"] = "user"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class ResultMessage:
|
|
132
|
+
subtype: str
|
|
133
|
+
is_error: bool
|
|
134
|
+
result: str
|
|
135
|
+
session_id: str
|
|
136
|
+
num_turns: int
|
|
137
|
+
duration_ms: int
|
|
138
|
+
usage: Usage
|
|
139
|
+
cost_usd: float
|
|
140
|
+
is_estimated: bool
|
|
141
|
+
permission_denials: list[PermissionDenial]
|
|
142
|
+
exit_code: int
|
|
143
|
+
structured: Any = None
|
|
144
|
+
error: str | None = None
|
|
145
|
+
schema_errors: list[str] | None = None
|
|
146
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
147
|
+
type: Literal["result"] = "result"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class SystemMessage:
|
|
152
|
+
"""Informational ``system`` events (retrying, compacted, hook, warning, …)."""
|
|
153
|
+
|
|
154
|
+
subtype: str
|
|
155
|
+
session_id: str
|
|
156
|
+
data: dict[str, Any]
|
|
157
|
+
type: Literal["system"] = "system"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class ErrorMessage:
|
|
162
|
+
error: str
|
|
163
|
+
session_id: str | None = None
|
|
164
|
+
type: Literal["error"] = "error"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
Event = (
|
|
168
|
+
InitMessage | AssistantMessage | UserMessage | ResultMessage | SystemMessage | ErrorMessage
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_event(e: Mapping[str, Any]) -> Event:
|
|
173
|
+
"""Typed view of one stream-json line. Unknown fields are kept in ``raw``."""
|
|
174
|
+
t = e.get("type")
|
|
175
|
+
sid = str(e.get("session_id", ""))
|
|
176
|
+
if t == "system" and e.get("subtype") == "init":
|
|
177
|
+
return InitMessage(
|
|
178
|
+
session_id=sid,
|
|
179
|
+
schema_version=int(e.get("schema_version", 0)),
|
|
180
|
+
cwd=str(e.get("cwd", "")),
|
|
181
|
+
model=str(e.get("model", "")),
|
|
182
|
+
permission_mode=str(e.get("permission_mode", "")),
|
|
183
|
+
tools=list(e.get("tools", [])),
|
|
184
|
+
mcp_servers=list(e.get("mcp_servers", [])),
|
|
185
|
+
cli_version=str(e.get("cli_version", "")),
|
|
186
|
+
resumed_from=e.get("resumed_from"),
|
|
187
|
+
forked_from=e.get("forked_from"),
|
|
188
|
+
raw=dict(e),
|
|
189
|
+
)
|
|
190
|
+
if t == "assistant":
|
|
191
|
+
blocks: list[TextBlock | ToolUseBlock] = []
|
|
192
|
+
for b in e.get("message", {}).get("content", []):
|
|
193
|
+
if b.get("type") == "text":
|
|
194
|
+
blocks.append(TextBlock(text=b.get("text", "")))
|
|
195
|
+
elif b.get("type") == "tool_use":
|
|
196
|
+
blocks.append(
|
|
197
|
+
ToolUseBlock(id=b["id"], name=b["name"], input=dict(b.get("input", {}))),
|
|
198
|
+
)
|
|
199
|
+
return AssistantMessage(session_id=sid, content=blocks, raw=dict(e))
|
|
200
|
+
if t == "user":
|
|
201
|
+
c = e.get("message", {}).get("content", "")
|
|
202
|
+
if isinstance(c, list):
|
|
203
|
+
results = [
|
|
204
|
+
ToolResultBlock(
|
|
205
|
+
tool_use_id=b["tool_use_id"],
|
|
206
|
+
content=str(b.get("content", "")),
|
|
207
|
+
is_error=bool(b.get("is_error")),
|
|
208
|
+
)
|
|
209
|
+
for b in c
|
|
210
|
+
if b.get("type") == "tool_result"
|
|
211
|
+
]
|
|
212
|
+
return UserMessage(session_id=sid, content=results, raw=dict(e))
|
|
213
|
+
return UserMessage(session_id=sid, content=str(c), raw=dict(e))
|
|
214
|
+
if t == "result":
|
|
215
|
+
u = e.get("usage", {}) or {}
|
|
216
|
+
return ResultMessage(
|
|
217
|
+
subtype=str(e.get("subtype", "")),
|
|
218
|
+
is_error=bool(e.get("is_error")),
|
|
219
|
+
result=str(e.get("result", "")),
|
|
220
|
+
session_id=sid,
|
|
221
|
+
num_turns=int(e.get("num_turns", 0)),
|
|
222
|
+
duration_ms=int(e.get("duration_ms", 0)),
|
|
223
|
+
usage=Usage(
|
|
224
|
+
input_tokens=int(u.get("input_tokens", 0)),
|
|
225
|
+
output_tokens=int(u.get("output_tokens", 0)),
|
|
226
|
+
cached_tokens=int(u.get("cached_tokens", 0)),
|
|
227
|
+
thinking_tokens=int(u.get("thinking_tokens", 0)),
|
|
228
|
+
),
|
|
229
|
+
cost_usd=float(e.get("cost_usd", 0) or 0),
|
|
230
|
+
is_estimated=bool(e.get("is_estimated")),
|
|
231
|
+
permission_denials=[
|
|
232
|
+
PermissionDenial(
|
|
233
|
+
tool_name=d.get("tool_name", ""),
|
|
234
|
+
tool_input=dict(d.get("tool_input", {})),
|
|
235
|
+
reason=d.get("reason", ""),
|
|
236
|
+
tool_use_id=d.get("tool_use_id"),
|
|
237
|
+
suggested_rule=d.get("suggested_rule"),
|
|
238
|
+
)
|
|
239
|
+
for d in e.get("permission_denials", [])
|
|
240
|
+
],
|
|
241
|
+
exit_code=int(e.get("exit_code", 0)),
|
|
242
|
+
structured=e.get("structured"),
|
|
243
|
+
error=e.get("error"),
|
|
244
|
+
schema_errors=e.get("schema_errors"),
|
|
245
|
+
raw=dict(e),
|
|
246
|
+
)
|
|
247
|
+
if t == "error":
|
|
248
|
+
return ErrorMessage(error=str(e.get("error", "")), session_id=e.get("session_id"))
|
|
249
|
+
data = {k: v for k, v in e.items() if k not in ("type", "subtype", "session_id")}
|
|
250
|
+
return SystemMessage(subtype=str(e.get("subtype", t or "")), session_id=sid, data=data)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ── Permissions ──────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass
|
|
257
|
+
class PermissionAllow:
|
|
258
|
+
updated_input: dict[str, Any] | None = None
|
|
259
|
+
behavior: Literal["allow"] = "allow"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class PermissionDeny:
|
|
264
|
+
message: str | None = None
|
|
265
|
+
behavior: Literal["deny"] = "deny"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
PermissionResult = PermissionAllow | PermissionDeny
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass
|
|
272
|
+
class ToolPermissionContext:
|
|
273
|
+
description: str
|
|
274
|
+
suggestions: list[str]
|
|
275
|
+
tool_use_id: str | None = None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
CanUseTool = Callable[[str, dict[str, Any], ToolPermissionContext], Awaitable[PermissionResult]]
|
|
279
|
+
|
|
280
|
+
# ── Options ──────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass
|
|
284
|
+
class CliOptions:
|
|
285
|
+
"""Options for the CLI subprocess. ``None`` means "use the CLI's default"."""
|
|
286
|
+
|
|
287
|
+
#: ``mc`` executable, or argv for a wrapper (``["node", "cli.cjs"]``).
|
|
288
|
+
#: Default: ``$MC_PATH``, else ``mc`` on PATH.
|
|
289
|
+
path_to_mc: str | list[str] | None = None
|
|
290
|
+
cwd: str | None = None
|
|
291
|
+
model: str | None = None
|
|
292
|
+
permission_mode: Literal["default", "acceptEdits", "plan", "bypass"] | None = None
|
|
293
|
+
effort: Literal["low", "medium", "high"] | None = None
|
|
294
|
+
allowed_tools: list[str] | None = None
|
|
295
|
+
disallowed_tools: list[str] | None = None
|
|
296
|
+
add_dirs: list[str] | None = None
|
|
297
|
+
#: Approve every ordinary ask when no ``can_use_tool`` is given (deny rules still apply).
|
|
298
|
+
auto_approve: bool = False
|
|
299
|
+
can_use_tool: CanUseTool | None = None
|
|
300
|
+
permission_timeout_sec: float | None = None
|
|
301
|
+
mcp_config: list[str] | None = None
|
|
302
|
+
strict_mcp_config: bool = False
|
|
303
|
+
#: Ignore the repository's .monkeyscode settings, hooks and MCP servers (untrusted code).
|
|
304
|
+
ignore_repo_settings: bool = False
|
|
305
|
+
#: Extra settings layer: a dict (sent inline as JSON) or a file path.
|
|
306
|
+
settings: dict[str, Any] | str | None = None
|
|
307
|
+
append_system_prompt: str | None = None
|
|
308
|
+
system_prompt_file: str | None = None
|
|
309
|
+
max_cost_usd: float | None = None
|
|
310
|
+
max_tokens: int | None = None
|
|
311
|
+
#: JSON Schema for the final answer: a dict, or a path.
|
|
312
|
+
json_schema: dict[str, Any] | str | None = None
|
|
313
|
+
resume: str | None = None
|
|
314
|
+
continue_session: bool = False
|
|
315
|
+
fork_session: bool = False
|
|
316
|
+
no_hooks: bool = False
|
|
317
|
+
#: Wall-clock limit in ms (default: none).
|
|
318
|
+
timeout_ms: int | None = None
|
|
319
|
+
env: dict[str, str | None] | None = None
|
|
320
|
+
extra_args: list[str] | None = None
|
|
321
|
+
#: Called with each stderr line of the CLI.
|
|
322
|
+
stderr: Callable[[str], None] | None = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class McError(Exception):
|
|
326
|
+
"""Base class for subprocess-client errors."""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class McSchemaError(McError):
|
|
330
|
+
def __init__(self, schema_version: int, cli_version: str) -> None:
|
|
331
|
+
super().__init__(
|
|
332
|
+
f"mc {cli_version} speaks stream-json schema_version {schema_version}; this client "
|
|
333
|
+
f"supports {SUPPORTED_SCHEMA_VERSION}. "
|
|
334
|
+
"Upgrade the monkeyscode package (or install a matching mc).",
|
|
335
|
+
)
|
|
336
|
+
self.schema_version = schema_version
|
|
337
|
+
self.cli_version = cli_version
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class McProcessError(McError):
|
|
341
|
+
def __init__(self, message: str, exit_code: int | None, stderr_tail: str = "") -> None:
|
|
342
|
+
super().__init__(f"{message}\n{stderr_tail}" if stderr_tail else message)
|
|
343
|
+
self.exit_code = exit_code
|
|
344
|
+
self.stderr_tail = stderr_tail
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def build_args(o: CliOptions, write_json: Callable[[Any], str] | None = None) -> list[str]:
|
|
348
|
+
"""CLI argv (no executable). Always an argv list, never a shell string."""
|
|
349
|
+
a = [
|
|
350
|
+
"-p",
|
|
351
|
+
"--input-format",
|
|
352
|
+
"stream-json",
|
|
353
|
+
"--output-format",
|
|
354
|
+
"stream-json",
|
|
355
|
+
"--permission-prompt",
|
|
356
|
+
"stdio",
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
def push(flag: str, v: object) -> None:
|
|
360
|
+
if v is not None and v != "":
|
|
361
|
+
a.extend([flag, str(v)])
|
|
362
|
+
|
|
363
|
+
push("--workspace", o.cwd)
|
|
364
|
+
push("--model", o.model)
|
|
365
|
+
push("--permission-mode", o.permission_mode)
|
|
366
|
+
push("--effort", o.effort)
|
|
367
|
+
for flag, values in (
|
|
368
|
+
("--allowed-tools", o.allowed_tools),
|
|
369
|
+
("--disallowed-tools", o.disallowed_tools),
|
|
370
|
+
("--add-dir", o.add_dirs),
|
|
371
|
+
("--mcp-config", o.mcp_config),
|
|
372
|
+
):
|
|
373
|
+
if values:
|
|
374
|
+
a.extend([flag, *values])
|
|
375
|
+
if o.strict_mcp_config:
|
|
376
|
+
a.append("--strict-mcp-config")
|
|
377
|
+
if o.ignore_repo_settings:
|
|
378
|
+
a.append("--ignore-repo-settings")
|
|
379
|
+
if o.settings is not None:
|
|
380
|
+
push("--settings", o.settings if isinstance(o.settings, str) else json.dumps(o.settings))
|
|
381
|
+
push("--append-system-prompt", o.append_system_prompt)
|
|
382
|
+
push("--system-prompt-file", o.system_prompt_file)
|
|
383
|
+
push("--max-cost", o.max_cost_usd)
|
|
384
|
+
push("--max-tokens", o.max_tokens)
|
|
385
|
+
if o.json_schema is not None:
|
|
386
|
+
path = (
|
|
387
|
+
o.json_schema
|
|
388
|
+
if isinstance(o.json_schema, str)
|
|
389
|
+
else (write_json or _write_temp_json)(o.json_schema)
|
|
390
|
+
)
|
|
391
|
+
push("--json-schema", path)
|
|
392
|
+
push("--resume", o.resume)
|
|
393
|
+
if o.continue_session:
|
|
394
|
+
a.append("--continue")
|
|
395
|
+
if o.fork_session:
|
|
396
|
+
a.append("--fork-session")
|
|
397
|
+
if o.no_hooks:
|
|
398
|
+
a.append("--no-hooks")
|
|
399
|
+
push("--permission-timeout", o.permission_timeout_sec)
|
|
400
|
+
a.extend(["--timeout", str(o.timeout_ms or 0)])
|
|
401
|
+
if o.extra_args:
|
|
402
|
+
a.extend(o.extra_args)
|
|
403
|
+
return a
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
_temp_dirs: list[str] = []
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _write_temp_json(v: Any) -> str:
|
|
410
|
+
d = tempfile.mkdtemp(prefix="mc-sdk-")
|
|
411
|
+
_temp_dirs.append(d)
|
|
412
|
+
p = os.path.join(d, "schema.json")
|
|
413
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
414
|
+
json.dump(v, f)
|
|
415
|
+
return p
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _resolve_command(o: CliOptions) -> list[str]:
|
|
419
|
+
c = o.path_to_mc or os.environ.get("MC_PATH") or "mc"
|
|
420
|
+
if isinstance(c, list):
|
|
421
|
+
if not c:
|
|
422
|
+
raise ValueError("path_to_mc must not be empty")
|
|
423
|
+
return list(c)
|
|
424
|
+
found = shutil.which(c) if os.sep not in c else c
|
|
425
|
+
return [found or c]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ── Client ───────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
_END = object()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class MonkeysCodeClient:
|
|
434
|
+
"""
|
|
435
|
+
A long-lived ``mc`` session. Use as an async context manager.
|
|
436
|
+
|
|
437
|
+
``send()`` queues a user message; ``receive()`` yields every event;
|
|
438
|
+
``receive_turn()`` yields until (and including) the next result;
|
|
439
|
+
``interrupt()`` stops the current turn; ``close()`` ends stdin and waits.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def __init__(self, options: CliOptions | None = None) -> None:
|
|
443
|
+
self.options = options or CliOptions()
|
|
444
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
445
|
+
self._events: asyncio.Queue[object] = asyncio.Queue()
|
|
446
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
447
|
+
self._stderr_tail = ""
|
|
448
|
+
self._interrupts = 0
|
|
449
|
+
self._sent = 0
|
|
450
|
+
self._results = 0
|
|
451
|
+
self._ending = False
|
|
452
|
+
self._stdin_closed = False
|
|
453
|
+
self._write_lock = asyncio.Lock()
|
|
454
|
+
self.session_id: str | None = None
|
|
455
|
+
self.last_result: ResultMessage | None = None
|
|
456
|
+
self.exit_code: int | None = None
|
|
457
|
+
self._error: BaseException | None = None
|
|
458
|
+
|
|
459
|
+
async def __aenter__(self) -> MonkeysCodeClient:
|
|
460
|
+
await self.connect()
|
|
461
|
+
return self
|
|
462
|
+
|
|
463
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
464
|
+
await self.close()
|
|
465
|
+
|
|
466
|
+
async def connect(self) -> None:
|
|
467
|
+
cmd = _resolve_command(self.options)
|
|
468
|
+
argv = [*cmd, *build_args(self.options)]
|
|
469
|
+
env = dict(os.environ)
|
|
470
|
+
for k, v in (self.options.env or {}).items():
|
|
471
|
+
if v is None:
|
|
472
|
+
env.pop(k, None)
|
|
473
|
+
else:
|
|
474
|
+
env[k] = v
|
|
475
|
+
try:
|
|
476
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
477
|
+
*argv,
|
|
478
|
+
cwd=self.options.cwd,
|
|
479
|
+
env=env,
|
|
480
|
+
stdin=asyncio.subprocess.PIPE,
|
|
481
|
+
stdout=asyncio.subprocess.PIPE,
|
|
482
|
+
stderr=asyncio.subprocess.PIPE,
|
|
483
|
+
limit=16 * 1024 * 1024,
|
|
484
|
+
)
|
|
485
|
+
except (FileNotFoundError, PermissionError) as err:
|
|
486
|
+
raise McProcessError(
|
|
487
|
+
f"could not start {cmd[0]}: {err}. Install the CLI "
|
|
488
|
+
"(npm i -g monkeyscode-cli) or set path_to_mc / MC_PATH.",
|
|
489
|
+
None,
|
|
490
|
+
) from err
|
|
491
|
+
self._tasks = [
|
|
492
|
+
asyncio.create_task(self._read_stdout()),
|
|
493
|
+
asyncio.create_task(self._read_stderr()),
|
|
494
|
+
]
|
|
495
|
+
|
|
496
|
+
# ── writing ────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
async def _write(self, obj: Mapping[str, Any]) -> None:
|
|
499
|
+
proc = self._proc
|
|
500
|
+
if proc is None or proc.stdin is None or self._stdin_closed:
|
|
501
|
+
return
|
|
502
|
+
async with self._write_lock:
|
|
503
|
+
try:
|
|
504
|
+
proc.stdin.write((json.dumps(obj) + "\n").encode())
|
|
505
|
+
await proc.stdin.drain()
|
|
506
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
507
|
+
self._stdin_closed = True
|
|
508
|
+
|
|
509
|
+
async def send(self, message: str) -> None:
|
|
510
|
+
"""Queue a user message."""
|
|
511
|
+
if self._ending:
|
|
512
|
+
raise McError("send() after end()")
|
|
513
|
+
self._sent += 1
|
|
514
|
+
await self._write({"type": "user", "message": {"role": "user", "content": message}})
|
|
515
|
+
|
|
516
|
+
async def interrupt(self) -> None:
|
|
517
|
+
"""Stop the current turn (its result has subtype ``error_interrupted``)."""
|
|
518
|
+
self._interrupts += 1
|
|
519
|
+
await self._write(
|
|
520
|
+
{
|
|
521
|
+
"type": "control_request",
|
|
522
|
+
"request_id": f"int_{self._interrupts}",
|
|
523
|
+
"request": {"subtype": "interrupt"},
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
async def end(self) -> None:
|
|
528
|
+
"""No more messages. stdin closes once every sent message has its result."""
|
|
529
|
+
self._ending = True
|
|
530
|
+
await self._maybe_close_stdin()
|
|
531
|
+
|
|
532
|
+
async def _maybe_close_stdin(self) -> None:
|
|
533
|
+
proc = self._proc
|
|
534
|
+
if (
|
|
535
|
+
self._ending
|
|
536
|
+
and not self._stdin_closed
|
|
537
|
+
and self._results >= self._sent
|
|
538
|
+
and proc
|
|
539
|
+
and proc.stdin
|
|
540
|
+
):
|
|
541
|
+
self._stdin_closed = True
|
|
542
|
+
with contextlib.suppress(Exception):
|
|
543
|
+
proc.stdin.close()
|
|
544
|
+
|
|
545
|
+
# ── reading ────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
async def _answer(self, e: Mapping[str, Any]) -> None:
|
|
548
|
+
req = e.get("request") or {}
|
|
549
|
+
if req.get("subtype") != "can_use_tool":
|
|
550
|
+
return
|
|
551
|
+
suggestions = list(req.get("suggestions") or [])
|
|
552
|
+
cb = self.options.can_use_tool
|
|
553
|
+
r: PermissionResult
|
|
554
|
+
try:
|
|
555
|
+
if cb is not None:
|
|
556
|
+
ctx = ToolPermissionContext(
|
|
557
|
+
description=str(req.get("description", "")),
|
|
558
|
+
suggestions=suggestions,
|
|
559
|
+
tool_use_id=req.get("tool_use_id"),
|
|
560
|
+
)
|
|
561
|
+
r = await cb(str(req.get("tool_name", "")), dict(req.get("input") or {}), ctx)
|
|
562
|
+
elif self.options.auto_approve:
|
|
563
|
+
r = PermissionAllow()
|
|
564
|
+
else:
|
|
565
|
+
hint = (
|
|
566
|
+
f' To allow it, add allowed_tools=["{suggestions[0]}"].' if suggestions else ""
|
|
567
|
+
)
|
|
568
|
+
r = PermissionDeny(
|
|
569
|
+
message=(
|
|
570
|
+
f"Not approved (headless): requires approval.{hint} "
|
|
571
|
+
"Continue without this action if possible."
|
|
572
|
+
),
|
|
573
|
+
)
|
|
574
|
+
except Exception as err:
|
|
575
|
+
r = PermissionDeny(message=f"Permission callback failed: {err}")
|
|
576
|
+
if isinstance(r, PermissionAllow):
|
|
577
|
+
resp: dict[str, Any] = {"behavior": "allow"}
|
|
578
|
+
if r.updated_input is not None:
|
|
579
|
+
resp["updated_input"] = r.updated_input
|
|
580
|
+
else:
|
|
581
|
+
resp = {"behavior": "deny"}
|
|
582
|
+
if r.message:
|
|
583
|
+
resp["message"] = r.message
|
|
584
|
+
await self._write(
|
|
585
|
+
{"type": "control_response", "request_id": e.get("request_id"), "response": resp},
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
async def _read_stdout(self) -> None:
|
|
589
|
+
assert self._proc and self._proc.stdout
|
|
590
|
+
try:
|
|
591
|
+
while True:
|
|
592
|
+
line = await self._proc.stdout.readline()
|
|
593
|
+
if not line:
|
|
594
|
+
break
|
|
595
|
+
text = line.decode("utf-8", "replace").strip()
|
|
596
|
+
if not text:
|
|
597
|
+
continue
|
|
598
|
+
try:
|
|
599
|
+
raw = json.loads(text)
|
|
600
|
+
except json.JSONDecodeError:
|
|
601
|
+
if self.options.stderr:
|
|
602
|
+
self.options.stderr(f"[stdout] {text}")
|
|
603
|
+
continue
|
|
604
|
+
t = raw.get("type")
|
|
605
|
+
if t == "control_request":
|
|
606
|
+
# Answer concurrently: a slow callback must not stall the stream.
|
|
607
|
+
self._tasks.append(asyncio.create_task(self._answer(raw)))
|
|
608
|
+
continue
|
|
609
|
+
if t == "control_response":
|
|
610
|
+
continue
|
|
611
|
+
ev = parse_event(raw)
|
|
612
|
+
if isinstance(ev, InitMessage):
|
|
613
|
+
if ev.schema_version != SUPPORTED_SCHEMA_VERSION:
|
|
614
|
+
self._error = McSchemaError(ev.schema_version, ev.cli_version)
|
|
615
|
+
with contextlib.suppress(ProcessLookupError):
|
|
616
|
+
self._proc.kill()
|
|
617
|
+
break
|
|
618
|
+
self.session_id = ev.session_id
|
|
619
|
+
if isinstance(ev, ResultMessage):
|
|
620
|
+
self.last_result = ev
|
|
621
|
+
self._results += 1
|
|
622
|
+
await self._events.put(ev)
|
|
623
|
+
if isinstance(ev, ResultMessage):
|
|
624
|
+
await self._maybe_close_stdin()
|
|
625
|
+
finally:
|
|
626
|
+
self.exit_code = await self._proc.wait()
|
|
627
|
+
for d in _temp_dirs:
|
|
628
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
629
|
+
_temp_dirs.clear()
|
|
630
|
+
if self._error is None and self.session_id is None:
|
|
631
|
+
self._error = McProcessError(
|
|
632
|
+
f"mc exited ({self.exit_code}) before system/init",
|
|
633
|
+
self.exit_code,
|
|
634
|
+
self._stderr_tail.strip(),
|
|
635
|
+
)
|
|
636
|
+
await self._events.put(_END)
|
|
637
|
+
|
|
638
|
+
async def _read_stderr(self) -> None:
|
|
639
|
+
assert self._proc and self._proc.stderr
|
|
640
|
+
while True:
|
|
641
|
+
line = await self._proc.stderr.readline()
|
|
642
|
+
if not line:
|
|
643
|
+
return
|
|
644
|
+
s = line.decode("utf-8", "replace").rstrip("\n")
|
|
645
|
+
self._stderr_tail = (self._stderr_tail + s + "\n")[-4000:]
|
|
646
|
+
if self.options.stderr:
|
|
647
|
+
self.options.stderr(s)
|
|
648
|
+
|
|
649
|
+
async def receive(self) -> AsyncIterator[Event]:
|
|
650
|
+
"""Every event until the CLI exits."""
|
|
651
|
+
while True:
|
|
652
|
+
ev = await self._events.get()
|
|
653
|
+
if ev is _END:
|
|
654
|
+
self._events.put_nowait(_END) # later receive() calls end too
|
|
655
|
+
if self._error is not None:
|
|
656
|
+
raise self._error
|
|
657
|
+
return
|
|
658
|
+
yield ev # type: ignore[misc,unused-ignore]
|
|
659
|
+
|
|
660
|
+
async def receive_turn(self) -> AsyncIterator[Event]:
|
|
661
|
+
"""Events up to and including the next result."""
|
|
662
|
+
async for ev in self.receive():
|
|
663
|
+
yield ev
|
|
664
|
+
if isinstance(ev, ResultMessage):
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
async def receive_result(self) -> ResultMessage:
|
|
668
|
+
"""Skip to the next result."""
|
|
669
|
+
async for ev in self.receive_turn():
|
|
670
|
+
if isinstance(ev, ResultMessage):
|
|
671
|
+
return ev
|
|
672
|
+
raise McProcessError(
|
|
673
|
+
f"mc exited ({self.exit_code}) without a result",
|
|
674
|
+
self.exit_code,
|
|
675
|
+
self._stderr_tail.strip(),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
async def close(self) -> None:
|
|
679
|
+
"""End input and wait for the CLI to exit (killed after 10 s)."""
|
|
680
|
+
self._ending = True
|
|
681
|
+
proc = self._proc
|
|
682
|
+
if proc is None:
|
|
683
|
+
return
|
|
684
|
+
if not self._stdin_closed and proc.stdin:
|
|
685
|
+
self._stdin_closed = True
|
|
686
|
+
with contextlib.suppress(Exception):
|
|
687
|
+
proc.stdin.close()
|
|
688
|
+
try:
|
|
689
|
+
await asyncio.wait_for(proc.wait(), timeout=10)
|
|
690
|
+
except asyncio.TimeoutError:
|
|
691
|
+
with contextlib.suppress(ProcessLookupError):
|
|
692
|
+
proc.kill()
|
|
693
|
+
await proc.wait()
|
|
694
|
+
for t in self._tasks:
|
|
695
|
+
if not t.done():
|
|
696
|
+
with contextlib.suppress(asyncio.TimeoutError, asyncio.CancelledError):
|
|
697
|
+
await asyncio.wait_for(t, timeout=2)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
async def query(
|
|
701
|
+
prompt: str | AsyncIterable[str],
|
|
702
|
+
options: CliOptions | None = None,
|
|
703
|
+
) -> AsyncIterator[Event]:
|
|
704
|
+
"""
|
|
705
|
+
Run a prompt (or a stream of prompts) and yield every event.
|
|
706
|
+
|
|
707
|
+
With a string, the session ends after its result. With an async iterable,
|
|
708
|
+
each item is a turn; the session ends when the iterable is exhausted.
|
|
709
|
+
"""
|
|
710
|
+
client = MonkeysCodeClient(options)
|
|
711
|
+
await client.connect()
|
|
712
|
+
feeder: asyncio.Task[None] | None = None
|
|
713
|
+
try:
|
|
714
|
+
if isinstance(prompt, str):
|
|
715
|
+
await client.send(prompt)
|
|
716
|
+
await client.end()
|
|
717
|
+
else:
|
|
718
|
+
|
|
719
|
+
async def feed() -> None:
|
|
720
|
+
async for p in prompt:
|
|
721
|
+
await client.send(p)
|
|
722
|
+
await client.end()
|
|
723
|
+
|
|
724
|
+
feeder = asyncio.create_task(feed())
|
|
725
|
+
async for ev in client.receive():
|
|
726
|
+
yield ev
|
|
727
|
+
finally:
|
|
728
|
+
if feeder and not feeder.done():
|
|
729
|
+
feeder.cancel()
|
|
730
|
+
await client.close()
|