amkernel 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.
- amkernel/__init__.py +7 -0
- amkernel/__main__.py +5 -0
- amkernel/artifact_store.py +1363 -0
- amkernel/cli.py +172 -0
- amkernel/distribution.py +597 -0
- amkernel/output.py +444 -0
- amkernel/protocol.py +1464 -0
- amkernel/py.typed +0 -0
- amkernel/runner.py +1222 -0
- amkernel/runtime.py +581 -0
- amkernel/server.py +774 -0
- amkernel/skill/SKILL.md +124 -0
- amkernel/skill/references/amkernel-appium.md +46 -0
- amkernel/skill/references/official-skills.json +232 -0
- amkernel/skill/references/official-sources.json +21 -0
- amkernel/skill/scripts/verify_official_skills.py +252 -0
- amkernel/structured_output.py +328 -0
- amkernel/supervisor.py +1453 -0
- amkernel/workspace.py +305 -0
- amkernel-0.1.0.dist-info/METADATA +105 -0
- amkernel-0.1.0.dist-info/RECORD +23 -0
- amkernel-0.1.0.dist-info/WHEEL +4 -0
- amkernel-0.1.0.dist-info/entry_points.txt +3 -0
amkernel/server.py
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""Official MCP SDK adapter for the single persistent Python Runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import FrameType
|
|
17
|
+
from typing import Annotated, Any, Literal, Protocol, TextIO, cast
|
|
18
|
+
|
|
19
|
+
import mcp.types as types
|
|
20
|
+
from mcp.server.context import ServerRequestContext
|
|
21
|
+
from mcp.server.lowlevel import Server
|
|
22
|
+
from mcp.server.stdio import stdio_server
|
|
23
|
+
from mcp.shared.exceptions import MCPError
|
|
24
|
+
from pydantic import (
|
|
25
|
+
AfterValidator,
|
|
26
|
+
BaseModel,
|
|
27
|
+
ConfigDict,
|
|
28
|
+
Field,
|
|
29
|
+
StrictBool,
|
|
30
|
+
StrictStr,
|
|
31
|
+
ValidationError,
|
|
32
|
+
model_validator,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from amkernel import __version__
|
|
36
|
+
from amkernel.artifact_store import (
|
|
37
|
+
ArtifactIntegrityError,
|
|
38
|
+
ArtifactNotFoundError,
|
|
39
|
+
ArtifactRecord,
|
|
40
|
+
ArtifactResource,
|
|
41
|
+
)
|
|
42
|
+
from amkernel.output import OutputObserver, OutputSnapshot
|
|
43
|
+
from amkernel.protocol import (
|
|
44
|
+
MAX_ARTIFACT_BYTES,
|
|
45
|
+
MAX_CODE_BYTES,
|
|
46
|
+
MAX_IMAGE_DIMENSION,
|
|
47
|
+
MAX_INLINE_IMAGE_BYTES,
|
|
48
|
+
MAX_INLINE_IMAGE_ITEM_BYTES,
|
|
49
|
+
MAX_LIVE_MESSAGE_BYTES,
|
|
50
|
+
MAX_LOGICAL_NAME_CODEPOINTS,
|
|
51
|
+
MAX_MIME_BYTES,
|
|
52
|
+
MAX_OUTPUT_SEQUENCE,
|
|
53
|
+
MAX_PACKAGE_COUNT,
|
|
54
|
+
MAX_PACKAGE_NAME_BYTES,
|
|
55
|
+
MAX_STORED_ITEMS,
|
|
56
|
+
MAX_STRUCTURED_RESULT_BYTES,
|
|
57
|
+
validate_package_names,
|
|
58
|
+
)
|
|
59
|
+
from amkernel.runtime import KernelRuntime
|
|
60
|
+
from amkernel.supervisor import ShutdownResult, SupervisedExecution
|
|
61
|
+
|
|
62
|
+
TOOL_NAME = "python_exec"
|
|
63
|
+
TOOL_TITLE = "Execute Python"
|
|
64
|
+
DEFAULT_TIMEOUT = 30.0
|
|
65
|
+
MAX_TITLE_CODEPOINTS = 200
|
|
66
|
+
LiveStatus = Literal["unavailable", "progress", "failed"]
|
|
67
|
+
|
|
68
|
+
TOOL_DESCRIPTION = (
|
|
69
|
+
"Execute one Python cell in a persistent local Runtime.\n\n"
|
|
70
|
+
"Calls serialize in FIFO order and share imports, variables, functions, mutations, and live "
|
|
71
|
+
"objects until reset. Mutations completed before a Python exception remain. Set reset=true to "
|
|
72
|
+
"destructively replace the kernel before this cell; reset and shutdown clean only AMKernel's "
|
|
73
|
+
"managed process group, not escaped processes, sockets, or external sessions. input() is "
|
|
74
|
+
"unsupported. Always inspect status and kernelState.\n\n"
|
|
75
|
+
"Artifact resource URIs are ephemeral: capacity pressure or server shutdown can expire them. "
|
|
76
|
+
"Read and copy every manifest part before making another large-output call. Artifact bytes are "
|
|
77
|
+
"untrusted; random identifiers are not same-user access control.\n\n"
|
|
78
|
+
"Code runs unrestricted with the permissions of the user who launched AMKernel. This is not a "
|
|
79
|
+
"sandbox and execution is not workspace-confined."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _validate_code_bytes(value: str) -> str:
|
|
84
|
+
if len(value.encode("utf-8")) > MAX_CODE_BYTES:
|
|
85
|
+
raise ValueError(f"code exceeds {MAX_CODE_BYTES} UTF-8 bytes")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
Code = Annotated[
|
|
90
|
+
StrictStr,
|
|
91
|
+
Field(max_length=MAX_CODE_BYTES, description="One Python cell; empty cells are valid."),
|
|
92
|
+
AfterValidator(_validate_code_bytes),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate_packages(value: list[str]) -> list[str]:
|
|
97
|
+
return list(validate_package_names(value))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
PackageName = Annotated[
|
|
101
|
+
StrictStr,
|
|
102
|
+
Field(
|
|
103
|
+
min_length=1,
|
|
104
|
+
max_length=MAX_PACKAGE_NAME_BYTES,
|
|
105
|
+
pattern=r"^[A-Za-z0-9]+(?:[._-]+[A-Za-z0-9]+)*$",
|
|
106
|
+
),
|
|
107
|
+
]
|
|
108
|
+
Packages = Annotated[
|
|
109
|
+
list[PackageName],
|
|
110
|
+
Field(max_length=MAX_PACKAGE_COUNT),
|
|
111
|
+
AfterValidator(_validate_packages),
|
|
112
|
+
]
|
|
113
|
+
Title = Annotated[
|
|
114
|
+
StrictStr,
|
|
115
|
+
Field(max_length=MAX_TITLE_CODEPOINTS, description="Presentation metadata only."),
|
|
116
|
+
]
|
|
117
|
+
Timeout = Annotated[
|
|
118
|
+
float,
|
|
119
|
+
Field(strict=True, gt=0, le=3_600, allow_inf_nan=False, description="Active-cell seconds."),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _StrictModel(BaseModel):
|
|
124
|
+
model_config = ConfigDict(extra="forbid", strict=True, populate_by_name=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PythonExecInput(_StrictModel):
|
|
128
|
+
code: Code
|
|
129
|
+
title: Title = ""
|
|
130
|
+
timeout: Timeout = DEFAULT_TIMEOUT
|
|
131
|
+
reset: StrictBool = False
|
|
132
|
+
packages: Packages = Field(default_factory=list)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ExecutionError(_StrictModel):
|
|
136
|
+
type: str
|
|
137
|
+
message: str
|
|
138
|
+
traceback: list[str]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ArtifactRef(_StrictModel):
|
|
142
|
+
uri: str
|
|
143
|
+
name: str = Field(min_length=1, max_length=MAX_LOGICAL_NAME_CODEPOINTS)
|
|
144
|
+
declared_mime_type: str = Field(
|
|
145
|
+
min_length=1, max_length=MAX_MIME_BYTES, alias="declaredMimeType"
|
|
146
|
+
)
|
|
147
|
+
mime_type: str = Field(min_length=1, max_length=MAX_MIME_BYTES, alias="mimeType")
|
|
148
|
+
representation: Literal["raw", "manifest"]
|
|
149
|
+
size: int = Field(ge=0, le=MAX_ARTIFACT_BYTES)
|
|
150
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PackageInfo(_StrictModel):
|
|
154
|
+
name: PackageName
|
|
155
|
+
version: str = cast(str, None)
|
|
156
|
+
error: Literal["not installed", "metadata unavailable", "invalid metadata"] = cast(
|
|
157
|
+
Literal["not installed", "metadata unavailable", "invalid metadata"], None
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@model_validator(mode="after")
|
|
161
|
+
def _require_one_result(self) -> PackageInfo:
|
|
162
|
+
if (self.version is None) == (self.error is None):
|
|
163
|
+
raise ValueError("package requires exactly one of version or error")
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RuntimeInfo(_StrictModel):
|
|
168
|
+
executable: str
|
|
169
|
+
python_version: str = Field(alias="pythonVersion")
|
|
170
|
+
working_directory: str = Field(alias="workingDirectory")
|
|
171
|
+
environment_kind: Literal["project_venv", "active_venv", "system"] = Field(
|
|
172
|
+
alias="environmentKind"
|
|
173
|
+
)
|
|
174
|
+
implementation: str
|
|
175
|
+
platform: str
|
|
176
|
+
packages: Annotated[list[PackageInfo], Field(max_length=MAX_PACKAGE_COUNT)]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class JsonDisplay(_StrictModel):
|
|
180
|
+
sequence: int = Field(ge=1, le=MAX_OUTPUT_SEQUENCE)
|
|
181
|
+
kind: Literal["json"]
|
|
182
|
+
value: Any
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ImageDisplay(_StrictModel):
|
|
186
|
+
sequence: int = Field(ge=1, le=MAX_OUTPUT_SEQUENCE)
|
|
187
|
+
kind: Literal["image"]
|
|
188
|
+
mime_type: Literal["image/png", "image/jpeg", "image/gif", "image/webp"] = Field(
|
|
189
|
+
alias="mimeType"
|
|
190
|
+
)
|
|
191
|
+
size: int = Field(ge=0, le=MAX_ARTIFACT_BYTES)
|
|
192
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
193
|
+
width: int = Field(ge=1, le=MAX_IMAGE_DIMENSION)
|
|
194
|
+
height: int = Field(ge=1, le=MAX_IMAGE_DIMENSION)
|
|
195
|
+
inline: bool
|
|
196
|
+
artifact: ArtifactRef
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ArtifactDisplay(_StrictModel):
|
|
200
|
+
sequence: int = Field(ge=1, le=MAX_OUTPUT_SEQUENCE)
|
|
201
|
+
kind: Literal["artifact"]
|
|
202
|
+
artifact: ArtifactRef
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
DisplayItem = Annotated[JsonDisplay | ImageDisplay | ArtifactDisplay, Field(discriminator="kind")]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class OutputInfo(_StrictModel):
|
|
209
|
+
total_bytes: int = Field(ge=0, alias="totalBytes")
|
|
210
|
+
total_lines: int = Field(ge=0, alias="totalLines")
|
|
211
|
+
discarded_bytes: int = Field(ge=0, alias="discardedBytes")
|
|
212
|
+
discarded_lines: int = Field(ge=0, alias="discardedLines")
|
|
213
|
+
truncated: bool
|
|
214
|
+
limit_bytes: int = Field(ge=1, alias="limitBytes")
|
|
215
|
+
limit_exceeded: bool = Field(alias="limitExceeded")
|
|
216
|
+
live: Literal["progress", "failed", "unavailable"]
|
|
217
|
+
stdout_artifact: ArtifactRef = Field(default=cast(ArtifactRef, None), alias="stdoutArtifact")
|
|
218
|
+
stderr_artifact: ArtifactRef = Field(default=cast(ArtifactRef, None), alias="stderrArtifact")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class PythonExecResult(_StrictModel):
|
|
222
|
+
status: Literal["completed", "failed", "timed_out", "kernel_killed"]
|
|
223
|
+
stdout: str
|
|
224
|
+
stderr: str
|
|
225
|
+
result: Any = None
|
|
226
|
+
error: ExecutionError = cast(ExecutionError, None)
|
|
227
|
+
execution_count: int = Field(ge=0, alias="executionCount")
|
|
228
|
+
duration_ms: int = Field(ge=0, alias="durationMs")
|
|
229
|
+
kernel_state: Literal["alive", "dead"] = Field(alias="kernelState")
|
|
230
|
+
runtime: RuntimeInfo
|
|
231
|
+
output: OutputInfo
|
|
232
|
+
displays: Annotated[list[DisplayItem], Field(max_length=MAX_OUTPUT_SEQUENCE)]
|
|
233
|
+
artifacts: Annotated[list[ArtifactRef], Field(max_length=MAX_STORED_ITEMS)]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
INPUT_SCHEMA: dict[str, Any] = PythonExecInput.model_json_schema(by_alias=True)
|
|
237
|
+
OUTPUT_SCHEMA: dict[str, Any] = PythonExecResult.model_json_schema(by_alias=True)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class RuntimeAdapter(Protocol):
|
|
241
|
+
@property
|
|
242
|
+
def diagnostic_tail(self) -> str: ...
|
|
243
|
+
|
|
244
|
+
async def execute(
|
|
245
|
+
self,
|
|
246
|
+
code: str,
|
|
247
|
+
*,
|
|
248
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
249
|
+
reset: bool = False,
|
|
250
|
+
packages: tuple[str, ...] = (),
|
|
251
|
+
observer: OutputObserver | None = None,
|
|
252
|
+
result_finalizer: Callable[[SupervisedExecution], Awaitable[None]] | None = None,
|
|
253
|
+
) -> SupervisedExecution: ...
|
|
254
|
+
|
|
255
|
+
async def read_resource(self, uri: str) -> ArtifactResource: ...
|
|
256
|
+
|
|
257
|
+
async def shutdown(self) -> ShutdownResult: ...
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class _DiagnosticEmitter:
|
|
261
|
+
def __init__(self, stream: TextIO) -> None:
|
|
262
|
+
self._stream = stream
|
|
263
|
+
self._previous = ""
|
|
264
|
+
|
|
265
|
+
def emit(self, current: str) -> None:
|
|
266
|
+
detail = current[len(self._previous) :] if current.startswith(self._previous) else current
|
|
267
|
+
self._previous = current
|
|
268
|
+
self.write(detail)
|
|
269
|
+
|
|
270
|
+
def write(self, detail: str) -> None:
|
|
271
|
+
if not detail:
|
|
272
|
+
return
|
|
273
|
+
try:
|
|
274
|
+
self._stream.write(detail)
|
|
275
|
+
if not detail.endswith("\n"):
|
|
276
|
+
self._stream.write("\n")
|
|
277
|
+
self._stream.flush()
|
|
278
|
+
except (OSError, ValueError):
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
def emit_cleanup_failure(self, result: ShutdownResult) -> None:
|
|
282
|
+
if result.cleaned:
|
|
283
|
+
return
|
|
284
|
+
detail = result.error or "managed kernel cleanup failed"
|
|
285
|
+
self.write(f"AMKernel shutdown cleanup failed: {detail}\n")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _structured_result(
|
|
289
|
+
outcome: SupervisedExecution,
|
|
290
|
+
*,
|
|
291
|
+
live: Literal["unavailable", "progress", "failed"] = "unavailable",
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
fields: dict[str, Any] = {
|
|
294
|
+
"status": outcome.status,
|
|
295
|
+
"stdout": outcome.stdout,
|
|
296
|
+
"stderr": outcome.stderr,
|
|
297
|
+
"execution_count": outcome.execution_count,
|
|
298
|
+
"duration_ms": outcome.duration_ms,
|
|
299
|
+
"kernel_state": outcome.kernel_state,
|
|
300
|
+
"runtime": RuntimeInfo(
|
|
301
|
+
executable=outcome.runtime.executable,
|
|
302
|
+
pythonVersion=outcome.runtime.python_version,
|
|
303
|
+
workingDirectory=outcome.runtime.working_directory,
|
|
304
|
+
environmentKind=outcome.runtime.environment_kind,
|
|
305
|
+
implementation=outcome.runtime.implementation,
|
|
306
|
+
platform=outcome.runtime.platform,
|
|
307
|
+
packages=[
|
|
308
|
+
PackageInfo(
|
|
309
|
+
name=package.name,
|
|
310
|
+
**(
|
|
311
|
+
{"version": package.version}
|
|
312
|
+
if package.version is not None
|
|
313
|
+
else {"error": package.error}
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
for package in outcome.runtime.packages
|
|
317
|
+
],
|
|
318
|
+
),
|
|
319
|
+
"output": OutputInfo(
|
|
320
|
+
totalBytes=outcome.output.total_bytes,
|
|
321
|
+
totalLines=outcome.output.total_lines,
|
|
322
|
+
discardedBytes=outcome.output.discarded_bytes,
|
|
323
|
+
discardedLines=outcome.output.discarded_lines,
|
|
324
|
+
truncated=outcome.output.truncated,
|
|
325
|
+
limitBytes=outcome.output.limit_bytes,
|
|
326
|
+
limitExceeded=outcome.output.limit_exceeded,
|
|
327
|
+
live=live,
|
|
328
|
+
**(
|
|
329
|
+
{"stdoutArtifact": _artifact_ref(outcome.output.stdout_artifact)}
|
|
330
|
+
if outcome.output.stdout_artifact is not None
|
|
331
|
+
else {}
|
|
332
|
+
),
|
|
333
|
+
**(
|
|
334
|
+
{"stderrArtifact": _artifact_ref(outcome.output.stderr_artifact)}
|
|
335
|
+
if outcome.output.stderr_artifact is not None
|
|
336
|
+
else {}
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
"displays": _display_items(outcome),
|
|
340
|
+
"artifacts": [_artifact_ref(artifact) for artifact in outcome.output.artifacts],
|
|
341
|
+
}
|
|
342
|
+
if outcome.has_result:
|
|
343
|
+
fields["result"] = outcome.result
|
|
344
|
+
if outcome.error is not None:
|
|
345
|
+
fields["error"] = ExecutionError(
|
|
346
|
+
type=outcome.error.type,
|
|
347
|
+
message=outcome.error.message,
|
|
348
|
+
traceback=list(outcome.error.traceback),
|
|
349
|
+
)
|
|
350
|
+
validated = PythonExecResult.model_validate(fields)
|
|
351
|
+
return validated.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _display_items(outcome: SupervisedExecution) -> list[dict[str, Any]]:
|
|
355
|
+
fields: list[dict[str, Any]] = []
|
|
356
|
+
inline_bytes = 0
|
|
357
|
+
for display in outcome.output.displays:
|
|
358
|
+
if display.kind == "json":
|
|
359
|
+
fields.append({"sequence": display.sequence, "kind": "json", "value": display.value})
|
|
360
|
+
continue
|
|
361
|
+
artifact = display.artifact
|
|
362
|
+
if artifact is None:
|
|
363
|
+
raise ValueError("stored display is missing artifact metadata")
|
|
364
|
+
if display.kind == "artifact":
|
|
365
|
+
fields.append(
|
|
366
|
+
{
|
|
367
|
+
"sequence": display.sequence,
|
|
368
|
+
"kind": "artifact",
|
|
369
|
+
"artifact": _artifact_ref(artifact),
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
continue
|
|
373
|
+
image = display.image
|
|
374
|
+
if image is None:
|
|
375
|
+
raise ValueError("stored image display is missing decoded metadata")
|
|
376
|
+
inline = (
|
|
377
|
+
artifact.size <= MAX_INLINE_IMAGE_ITEM_BYTES
|
|
378
|
+
and inline_bytes + artifact.size <= MAX_INLINE_IMAGE_BYTES
|
|
379
|
+
)
|
|
380
|
+
if inline:
|
|
381
|
+
inline_bytes += artifact.size
|
|
382
|
+
fields.append(
|
|
383
|
+
{
|
|
384
|
+
"sequence": display.sequence,
|
|
385
|
+
"kind": "image",
|
|
386
|
+
"mimeType": artifact.declared_mime_type,
|
|
387
|
+
"size": artifact.size,
|
|
388
|
+
"sha256": artifact.sha256,
|
|
389
|
+
"width": image.width,
|
|
390
|
+
"height": image.height,
|
|
391
|
+
"inline": inline,
|
|
392
|
+
"artifact": _artifact_ref(artifact),
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
return fields
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _artifact_ref(record: ArtifactRecord) -> ArtifactRef:
|
|
399
|
+
return ArtifactRef(
|
|
400
|
+
uri=record.uri,
|
|
401
|
+
name=record.name,
|
|
402
|
+
declaredMimeType=record.declared_mime_type,
|
|
403
|
+
mimeType=record.mime_type,
|
|
404
|
+
representation=record.representation,
|
|
405
|
+
size=record.size,
|
|
406
|
+
sha256=record.sha256,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class _ProgressReporter:
|
|
411
|
+
def __init__(
|
|
412
|
+
self,
|
|
413
|
+
context: ServerRequestContext[dict[str, Any]],
|
|
414
|
+
diagnostics: _DiagnosticEmitter,
|
|
415
|
+
) -> None:
|
|
416
|
+
self.observer = OutputObserver()
|
|
417
|
+
self.status: Literal["progress", "failed"] = "progress"
|
|
418
|
+
self._context = context
|
|
419
|
+
self._diagnostics = diagnostics
|
|
420
|
+
|
|
421
|
+
async def run(self) -> None:
|
|
422
|
+
last_send = 0.0
|
|
423
|
+
while True:
|
|
424
|
+
snapshot = await self.observer.next()
|
|
425
|
+
delay = 0.1 - (time.monotonic() - last_send)
|
|
426
|
+
if delay > 0:
|
|
427
|
+
await asyncio.sleep(delay)
|
|
428
|
+
snapshot = self.observer.drain_latest(snapshot)
|
|
429
|
+
try:
|
|
430
|
+
await self._context.session.report_progress(
|
|
431
|
+
float(snapshot.revision),
|
|
432
|
+
None,
|
|
433
|
+
_progress_message(snapshot),
|
|
434
|
+
)
|
|
435
|
+
except Exception as error:
|
|
436
|
+
self.status = "failed"
|
|
437
|
+
self._diagnostics.write(
|
|
438
|
+
f"python_exec progress reporter failed: {type(error).__name__}\n"
|
|
439
|
+
)
|
|
440
|
+
return
|
|
441
|
+
last_send = time.monotonic()
|
|
442
|
+
|
|
443
|
+
async def close(self, task: asyncio.Task[None]) -> None:
|
|
444
|
+
if not task.done():
|
|
445
|
+
task.cancel()
|
|
446
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _progress_message(snapshot: OutputSnapshot) -> str:
|
|
450
|
+
prefix = f"{snapshot.channel}: "
|
|
451
|
+
remaining = MAX_LIVE_MESSAGE_BYTES - len(prefix.encode("utf-8"))
|
|
452
|
+
payload = snapshot.text.encode("utf-8")[-remaining:]
|
|
453
|
+
while payload and payload[0] & 0xC0 == 0x80:
|
|
454
|
+
payload = payload[1:]
|
|
455
|
+
return prefix + payload.decode("utf-8")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _progress_reporter(
|
|
459
|
+
context: ServerRequestContext[dict[str, Any]],
|
|
460
|
+
diagnostics: _DiagnosticEmitter,
|
|
461
|
+
) -> _ProgressReporter | None:
|
|
462
|
+
meta = context.meta
|
|
463
|
+
if meta is None or (meta.get("progressToken") is None and meta.get("progress_token") is None):
|
|
464
|
+
return None
|
|
465
|
+
return _ProgressReporter(context, diagnostics)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def _tool_content(
|
|
469
|
+
runtime: RuntimeAdapter,
|
|
470
|
+
outcome: SupervisedExecution,
|
|
471
|
+
structured: dict[str, Any],
|
|
472
|
+
canonical_json: str,
|
|
473
|
+
) -> list[types.ContentBlock]:
|
|
474
|
+
content: list[types.ContentBlock] = [types.TextContent(type="text", text=canonical_json)]
|
|
475
|
+
displays = {
|
|
476
|
+
item["sequence"]: item for item in cast(list[dict[str, Any]], structured["displays"])
|
|
477
|
+
}
|
|
478
|
+
for display in outcome.output.displays:
|
|
479
|
+
if display.kind == "json":
|
|
480
|
+
continue
|
|
481
|
+
artifact = display.artifact
|
|
482
|
+
if artifact is None:
|
|
483
|
+
raise ValueError("stored display is missing artifact metadata")
|
|
484
|
+
if display.kind == "image" and displays[display.sequence]["inline"]:
|
|
485
|
+
resource = await runtime.read_resource(artifact.uri)
|
|
486
|
+
if resource.text:
|
|
487
|
+
raise ValueError("inline image resource is not binary")
|
|
488
|
+
content.append(
|
|
489
|
+
types.ImageContent(
|
|
490
|
+
type="image",
|
|
491
|
+
data=base64.b64encode(resource.data).decode("ascii"),
|
|
492
|
+
mime_type=artifact.mime_type,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
content.append(_resource_link(artifact))
|
|
496
|
+
|
|
497
|
+
for artifact in (outcome.output.stdout_artifact, outcome.output.stderr_artifact):
|
|
498
|
+
if artifact is not None:
|
|
499
|
+
content.append(_resource_link(artifact))
|
|
500
|
+
return content
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _resource_link(record: ArtifactRecord) -> types.ResourceLink:
|
|
504
|
+
artifact_id = record.uri.rsplit("/", 1)[-1]
|
|
505
|
+
return types.ResourceLink(
|
|
506
|
+
type="resource_link",
|
|
507
|
+
uri=record.uri,
|
|
508
|
+
name=f"artifact-{artifact_id}",
|
|
509
|
+
title=record.name,
|
|
510
|
+
mime_type=record.mime_type,
|
|
511
|
+
size=record.size,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
async def _map_tool_result(
|
|
516
|
+
runtime: RuntimeAdapter,
|
|
517
|
+
outcome: SupervisedExecution,
|
|
518
|
+
*,
|
|
519
|
+
live: LiveStatus,
|
|
520
|
+
) -> types.CallToolResult:
|
|
521
|
+
structured = _structured_result(outcome, live=live)
|
|
522
|
+
text = json.dumps(
|
|
523
|
+
structured,
|
|
524
|
+
ensure_ascii=False,
|
|
525
|
+
allow_nan=False,
|
|
526
|
+
separators=(",", ":"),
|
|
527
|
+
)
|
|
528
|
+
if len(text.encode("utf-8")) > MAX_STRUCTURED_RESULT_BYTES:
|
|
529
|
+
raise ValueError("structured result exceeds terminal byte bound")
|
|
530
|
+
content = await _tool_content(runtime, outcome, structured, text)
|
|
531
|
+
return types.CallToolResult(
|
|
532
|
+
content=content,
|
|
533
|
+
structured_content=structured,
|
|
534
|
+
is_error=False,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _minimal_tool_result(
|
|
539
|
+
outcome: SupervisedExecution,
|
|
540
|
+
*,
|
|
541
|
+
live: LiveStatus,
|
|
542
|
+
) -> types.CallToolResult:
|
|
543
|
+
structured = _structured_result(outcome, live=live)
|
|
544
|
+
text = json.dumps(
|
|
545
|
+
structured,
|
|
546
|
+
ensure_ascii=False,
|
|
547
|
+
allow_nan=False,
|
|
548
|
+
separators=(",", ":"),
|
|
549
|
+
)
|
|
550
|
+
return types.CallToolResult(
|
|
551
|
+
content=[types.TextContent(type="text", text=text)],
|
|
552
|
+
structured_content=structured,
|
|
553
|
+
is_error=False,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _tool_error(message: str) -> types.CallToolResult:
|
|
558
|
+
return types.CallToolResult(
|
|
559
|
+
content=[types.TextContent(type="text", text=message)],
|
|
560
|
+
is_error=True,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def create_server(
|
|
565
|
+
runtime: RuntimeAdapter,
|
|
566
|
+
*,
|
|
567
|
+
diagnostic_stream: TextIO | None = None,
|
|
568
|
+
) -> Server[dict[str, Any]]:
|
|
569
|
+
"""Bind one Runtime to the sole model-facing MCP tool."""
|
|
570
|
+
return _create_server(runtime, _DiagnosticEmitter(diagnostic_stream or sys.stderr))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _create_server(
|
|
574
|
+
runtime: RuntimeAdapter,
|
|
575
|
+
diagnostics: _DiagnosticEmitter,
|
|
576
|
+
) -> Server[dict[str, Any]]:
|
|
577
|
+
async def list_tools(
|
|
578
|
+
_context: ServerRequestContext[dict[str, Any]],
|
|
579
|
+
_params: types.PaginatedRequestParams | None,
|
|
580
|
+
) -> types.ListToolsResult:
|
|
581
|
+
return types.ListToolsResult(
|
|
582
|
+
tools=[
|
|
583
|
+
types.Tool(
|
|
584
|
+
name=TOOL_NAME,
|
|
585
|
+
title=TOOL_TITLE,
|
|
586
|
+
description=TOOL_DESCRIPTION,
|
|
587
|
+
input_schema=INPUT_SCHEMA,
|
|
588
|
+
output_schema=OUTPUT_SCHEMA,
|
|
589
|
+
)
|
|
590
|
+
]
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
async def list_resources(
|
|
594
|
+
_context: ServerRequestContext[dict[str, Any]],
|
|
595
|
+
_params: types.PaginatedRequestParams | None,
|
|
596
|
+
) -> types.ListResourcesResult:
|
|
597
|
+
return types.ListResourcesResult(
|
|
598
|
+
resources=[],
|
|
599
|
+
cache_scope="private",
|
|
600
|
+
result_type="complete",
|
|
601
|
+
ttl_ms=0,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
async def read_resource(
|
|
605
|
+
_context: ServerRequestContext[dict[str, Any]],
|
|
606
|
+
params: types.ReadResourceRequestParams,
|
|
607
|
+
) -> types.ReadResourceResult:
|
|
608
|
+
try:
|
|
609
|
+
resource = await runtime.read_resource(str(params.uri))
|
|
610
|
+
except ArtifactNotFoundError as error:
|
|
611
|
+
raise MCPError(types.INVALID_PARAMS, "resource not found") from error
|
|
612
|
+
except ArtifactIntegrityError as error:
|
|
613
|
+
diagnostics.emit(runtime.diagnostic_tail)
|
|
614
|
+
raise MCPError(types.INTERNAL_ERROR, "resource integrity failure") from error
|
|
615
|
+
if resource.text:
|
|
616
|
+
content: types.TextResourceContents | types.BlobResourceContents = (
|
|
617
|
+
types.TextResourceContents(
|
|
618
|
+
uri=resource.uri,
|
|
619
|
+
mime_type=resource.mime_type,
|
|
620
|
+
text=resource.data.decode("utf-8", errors="strict"),
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
content = types.BlobResourceContents(
|
|
625
|
+
uri=resource.uri,
|
|
626
|
+
mime_type=resource.mime_type,
|
|
627
|
+
blob=base64.b64encode(resource.data).decode("ascii"),
|
|
628
|
+
)
|
|
629
|
+
return types.ReadResourceResult(
|
|
630
|
+
contents=[content],
|
|
631
|
+
cache_scope="private",
|
|
632
|
+
result_type="complete",
|
|
633
|
+
ttl_ms=0,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
async def call_tool(
|
|
637
|
+
_context: ServerRequestContext[dict[str, Any]],
|
|
638
|
+
params: types.CallToolRequestParams,
|
|
639
|
+
) -> types.CallToolResult:
|
|
640
|
+
if params.name != TOOL_NAME:
|
|
641
|
+
return _tool_error(f"Unknown tool: {params.name}")
|
|
642
|
+
try:
|
|
643
|
+
request = PythonExecInput.model_validate(params.arguments or {})
|
|
644
|
+
except ValidationError:
|
|
645
|
+
diagnostics.write("python_exec rejected invalid arguments\n")
|
|
646
|
+
return _tool_error("Invalid python_exec arguments")
|
|
647
|
+
|
|
648
|
+
reporter = _progress_reporter(_context, diagnostics)
|
|
649
|
+
reporter_task = asyncio.create_task(reporter.run()) if reporter is not None else None
|
|
650
|
+
mapped: types.CallToolResult | None = None
|
|
651
|
+
|
|
652
|
+
async def finalize(outcome: SupervisedExecution) -> None:
|
|
653
|
+
nonlocal mapped
|
|
654
|
+
if reporter is not None and reporter_task is not None:
|
|
655
|
+
await reporter.close(reporter_task)
|
|
656
|
+
mapped = await _map_tool_result(
|
|
657
|
+
runtime,
|
|
658
|
+
outcome,
|
|
659
|
+
live=reporter.status if reporter is not None else "unavailable",
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
if reporter is None:
|
|
664
|
+
outcome = await runtime.execute(
|
|
665
|
+
request.code,
|
|
666
|
+
timeout=request.timeout,
|
|
667
|
+
reset=request.reset,
|
|
668
|
+
packages=tuple(request.packages),
|
|
669
|
+
result_finalizer=finalize,
|
|
670
|
+
)
|
|
671
|
+
else:
|
|
672
|
+
outcome = await runtime.execute(
|
|
673
|
+
request.code,
|
|
674
|
+
timeout=request.timeout,
|
|
675
|
+
reset=request.reset,
|
|
676
|
+
packages=tuple(request.packages),
|
|
677
|
+
observer=reporter.observer,
|
|
678
|
+
result_finalizer=finalize,
|
|
679
|
+
)
|
|
680
|
+
if mapped is None:
|
|
681
|
+
if reporter is not None and reporter_task is not None:
|
|
682
|
+
await reporter.close(reporter_task)
|
|
683
|
+
mapped = _minimal_tool_result(
|
|
684
|
+
outcome,
|
|
685
|
+
live=reporter.status if reporter is not None else "unavailable",
|
|
686
|
+
)
|
|
687
|
+
except Exception as error:
|
|
688
|
+
diagnostics.write(f"python_exec adapter failure: {type(error).__name__}\n")
|
|
689
|
+
return _tool_error("python_exec failed internally")
|
|
690
|
+
finally:
|
|
691
|
+
if reporter is not None and reporter_task is not None:
|
|
692
|
+
await reporter.close(reporter_task)
|
|
693
|
+
diagnostics.emit(runtime.diagnostic_tail)
|
|
694
|
+
|
|
695
|
+
return mapped
|
|
696
|
+
|
|
697
|
+
return Server(
|
|
698
|
+
"amkernel",
|
|
699
|
+
title="AMKernel",
|
|
700
|
+
description="Persistent Python execution for agentic automation.",
|
|
701
|
+
version=__version__,
|
|
702
|
+
get_tool_input_schema=lambda name: INPUT_SCHEMA if name == TOOL_NAME else None,
|
|
703
|
+
on_list_tools=list_tools,
|
|
704
|
+
on_call_tool=call_tool,
|
|
705
|
+
on_list_resources=list_resources,
|
|
706
|
+
on_read_resource=read_resource,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@contextmanager
|
|
711
|
+
def _defer_sigterm_until_cleanup() -> Iterator[Callable[[asyncio.Task[None]], None]]:
|
|
712
|
+
if os.name != "posix" or threading.current_thread() is not threading.main_thread():
|
|
713
|
+
yield lambda _task: None
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
task: asyncio.Task[None] | None = None
|
|
717
|
+
received = False
|
|
718
|
+
received_frame: FrameType | None = None
|
|
719
|
+
|
|
720
|
+
def handle_sigterm(_signal_number: int, frame: FrameType | None) -> None:
|
|
721
|
+
nonlocal received, received_frame
|
|
722
|
+
received = True
|
|
723
|
+
received_frame = frame
|
|
724
|
+
if task is not None and not task.done():
|
|
725
|
+
task.cancel()
|
|
726
|
+
|
|
727
|
+
def attach(current: asyncio.Task[None]) -> None:
|
|
728
|
+
nonlocal task
|
|
729
|
+
task = current
|
|
730
|
+
if received and not current.done():
|
|
731
|
+
current.cancel()
|
|
732
|
+
|
|
733
|
+
previous = signal.signal(signal.SIGTERM, handle_sigterm)
|
|
734
|
+
try:
|
|
735
|
+
yield attach
|
|
736
|
+
finally:
|
|
737
|
+
if signal.getsignal(signal.SIGTERM) is handle_sigterm:
|
|
738
|
+
signal.signal(signal.SIGTERM, previous)
|
|
739
|
+
if received:
|
|
740
|
+
if previous == signal.SIG_DFL:
|
|
741
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
742
|
+
elif previous != signal.SIG_IGN and callable(previous):
|
|
743
|
+
previous(signal.SIGTERM, received_frame)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
async def _run_server_stdio(server: Server[dict[str, Any]]) -> None:
|
|
747
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
748
|
+
await server.run(
|
|
749
|
+
read_stream,
|
|
750
|
+
write_stream,
|
|
751
|
+
server.create_initialization_options(),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
async def serve_stdio(
|
|
756
|
+
working_directory: Path | None = None,
|
|
757
|
+
*,
|
|
758
|
+
diagnostic_stream: TextIO | None = None,
|
|
759
|
+
runtime_factory: Callable[[Path | None], RuntimeAdapter] = KernelRuntime,
|
|
760
|
+
) -> int:
|
|
761
|
+
"""Serve stdio until transport close, then wait for managed cleanup."""
|
|
762
|
+
diagnostics = _DiagnosticEmitter(diagnostic_stream or sys.stderr)
|
|
763
|
+
runtime = runtime_factory(working_directory)
|
|
764
|
+
server = _create_server(runtime, diagnostics)
|
|
765
|
+
with _defer_sigterm_until_cleanup() as attach:
|
|
766
|
+
serving = asyncio.create_task(_run_server_stdio(server))
|
|
767
|
+
attach(serving)
|
|
768
|
+
try:
|
|
769
|
+
await serving
|
|
770
|
+
finally:
|
|
771
|
+
shutdown = await runtime.shutdown()
|
|
772
|
+
diagnostics.emit(runtime.diagnostic_tail)
|
|
773
|
+
diagnostics.emit_cleanup_failure(shutdown)
|
|
774
|
+
return 0 if shutdown.cleaned else 1
|