dsh-py-codeact 0.0.0 → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/cordis.patch.yml +10 -0
- package/example/agent.cordis.yml +33 -0
- package/lib/client.js +129 -0
- package/lib/index.js +410 -0
- package/lib/kernel.js +351 -0
- package/package.json +74 -9
- package/py/kernel.py +614 -0
- package/index.js +0 -1
package/py/kernel.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.12"
|
|
4
|
+
# dependencies = [
|
|
5
|
+
# "ipython~=9.16.0",
|
|
6
|
+
# "objprint~=0.3.0",
|
|
7
|
+
# ]
|
|
8
|
+
# # Checked with `TY_UV=scripts ty check py/kernel.py` — that prefix is what hands ty this script's venv. A `ty.toml` would be found and then silently ignored for PEP 723 scripts (astral-sh/ty#4083), so any ty config has to live here.
|
|
9
|
+
# ///
|
|
10
|
+
|
|
11
|
+
"""Persistent IPython kernel for the dsh CodeAct REPL tool.
|
|
12
|
+
|
|
13
|
+
One long-lived process per conversation tree, holding one `InteractiveShell` per agent. A namespace survives between cells — that is the whole point, and the reason this cannot be a `CodeRuntime` backend (that seam mandates "no state survives between runs").
|
|
14
|
+
|
|
15
|
+
Cells run through `InteractiveShell.run_cell_async`, which carries magics, top-level `await`, `store_history` (`_`, `Out[n]`), and IPython's own traceback formatter. Structure and the LLM-specific touches (redundant-import hints, objprint reprs) follow `CNSeniorious000/temporary-mcp-servers:ipython-mcp.py`.
|
|
16
|
+
|
|
17
|
+
Wire protocol: JSON-lines on fd 3. stdout/stderr stay free for native writes.
|
|
18
|
+
|
|
19
|
+
Every frame but `result` and `shutdown` carries `shell` — the agent whose shell it addresses, defaulting to "main". One process holds one shell per agent, so a fan-out of subagents shares this interpreter, its event loop and its packages while keeping separate globals.
|
|
20
|
+
|
|
21
|
+
host -> child {"t":"init","shell":S,"tools":[{"name","doc","params":[...]}]}
|
|
22
|
+
{"t":"exec","id":N,"shell":S,"code":"...","tools":[...]}
|
|
23
|
+
{"t":"result","id":N,"ok":true,"value":<json>}
|
|
24
|
+
{"t":"result","id":N,"ok":false,"tool":"read","message":"..."}
|
|
25
|
+
{"t":"interrupt","shell":S} | {"t":"dispose","shell":S} | {"t":"shutdown"}
|
|
26
|
+
child -> host {"t":"ready","shell":S,"env":{"executable","version","prefix","venv","cwd"}}
|
|
27
|
+
{"t":"call","id":N,"name":"read","args":{...}}
|
|
28
|
+
{"t":"done","id":N,"shell":S,"ok":bool,"stdout","stderr","repr","error","note"}
|
|
29
|
+
|
|
30
|
+
`id` namespaces are per frame type: an exec id and a call id never collide because each is only matched against frames of the same kind."""
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
|
|
34
|
+
# No TTY here, so `get_terminal_size()` falls back to a cramped 80x24 and wraps tracebacks and pretty output far narrower than the harness renders at.
|
|
35
|
+
os.environ.setdefault("COLUMNS", "456")
|
|
36
|
+
os.environ.setdefault("LINES", "123")
|
|
37
|
+
|
|
38
|
+
# We run in a throwaway venv so `!uv pip install` cannot leak into the shared PEP 723 environment (or the user's project venv). That venv has none of our dependencies, so borrow the base environment's packages — BEFORE the IPython import below, and APPENDED, so anything installed into the throwaway venv shadows the inherited copy rather than the other way round.
|
|
39
|
+
if _inherited := os.environ.get("DSH_CODEACT_INHERIT_SITE"):
|
|
40
|
+
import site
|
|
41
|
+
|
|
42
|
+
for _directory in _inherited.split(os.pathsep):
|
|
43
|
+
site.addsitedir(_directory)
|
|
44
|
+
|
|
45
|
+
# These sit below the two blocks above on purpose: COLUMNS/LINES must be set before IPython reads the terminal size, and the inherited site-packages must be on the path before IPython is imported at all.
|
|
46
|
+
import asyncio
|
|
47
|
+
import contextlib
|
|
48
|
+
import inspect
|
|
49
|
+
import io
|
|
50
|
+
import json
|
|
51
|
+
import select
|
|
52
|
+
import sys
|
|
53
|
+
import traceback
|
|
54
|
+
import types
|
|
55
|
+
from contextvars import ContextVar
|
|
56
|
+
from dis import get_instructions
|
|
57
|
+
from functools import lru_cache, wraps
|
|
58
|
+
from inspect import isclass
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
from IPython.core.interactiveshell import InteractiveShell
|
|
62
|
+
from IPython.lib.pretty import pretty
|
|
63
|
+
from objprint import ObjPrint
|
|
64
|
+
from traitlets.config import Config
|
|
65
|
+
|
|
66
|
+
PROTOCOL_FD = 3
|
|
67
|
+
|
|
68
|
+
# Wire default for the `shell` field. The JS host holds its own copy of this literal — the seam is the one place the two languages must agree by hand.
|
|
69
|
+
DEFAULT_SHELL = "main"
|
|
70
|
+
|
|
71
|
+
# Bound BEFORE any `redirect_stderr` can swap `sys.stderr`. IPython writes its traceback out itself; sending that copy here (the process's real stderr, which the host keeps only for crash diagnostics) keeps ANSI escapes and a duplicate traceback out of the cell's captured stderr. The model-facing copy is re-rendered without color by `Session.format_exc`.
|
|
72
|
+
REAL_STDERR = sys.stderr
|
|
73
|
+
|
|
74
|
+
# `uv pip install <pkg>` inside a cell has to know WHICH environment to install into. The harness spawns us with an allowlisted environment that deliberately drops VIRTUAL_ENV, and without it uv refuses outright — while IPython's `!cmd` does not fail on a non-zero exit, so the cell reports success and the import fails a cell later, with nothing connecting the two.
|
|
75
|
+
if sys.prefix != sys.base_prefix:
|
|
76
|
+
os.environ.setdefault("VIRTUAL_ENV", sys.prefix)
|
|
77
|
+
|
|
78
|
+
# Cap one cell's captured output. The host caps the whole result again; this only stops a runaway loop from exhausting memory before the host sees it.
|
|
79
|
+
MAX_STREAM_BYTES = 1 << 20
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ToolCallError(Exception):
|
|
83
|
+
"""Raised inside cell code when a bridged tool call fails.
|
|
84
|
+
|
|
85
|
+
Carries only `tool_name` and the message: harness-internal error codes and Native content deliberately stay outside the program-visible contract."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, tool_name: str, message: str) -> None:
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
self.tool_name: str = tool_name
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── model-facing repr ────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CustomObjectPrinter(ObjPrint):
|
|
96
|
+
"""Readable structure for objects whose own repr says nothing."""
|
|
97
|
+
|
|
98
|
+
def _objstr(self, obj, memo, indent_level, cfg):
|
|
99
|
+
cfg.attr_pattern = "(?!^__.*__$).*"
|
|
100
|
+
if isclass(obj):
|
|
101
|
+
return repr(obj)
|
|
102
|
+
if type(obj).__repr__ is object.__repr__:
|
|
103
|
+
# objprint does its cycle/depth bookkeeping in `_objstr`, immediately before dispatching. Jumping straight to `_get_custom_object_str` skips it, and `_get_custom_object_str` calls back into `_objstr` per attribute — so any self-referential object with a default repr (a tree node with `.parent`, a doubly-linked list) recurses until the stack blows. Mirror the guard rather than lose the unpacking.
|
|
104
|
+
if (memo is not None and id(obj) in memo) or (cfg.depth is not None and indent_level >= cfg.depth):
|
|
105
|
+
return self._get_ellipsis(obj, cfg)
|
|
106
|
+
if memo is not None:
|
|
107
|
+
memo = memo.copy()
|
|
108
|
+
memo.add(id(obj))
|
|
109
|
+
return self._get_custom_object_str(obj, memo, indent_level, cfg)
|
|
110
|
+
if callable(obj):
|
|
111
|
+
return pretty(obj, verbose=True, max_width=320)
|
|
112
|
+
return super()._objstr(obj, memo, indent_level, cfg)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_objstr = CustomObjectPrinter().objstr
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def safe_render(value) -> str:
|
|
119
|
+
# A raising `__repr__` is ordinary in data work — a half-initialised ORM row, a mock, a lazy proxy whose fetch failed. Unguarded it escapes into `_exec`'s blanket handler, which answers with hardcoded empty `stdout`/`stderr`: the cell's real output is thrown away and a run that actually succeeded is reported as a kernel fault. Tell the model whose repr broke instead; everything else about the cell is intact.
|
|
120
|
+
try:
|
|
121
|
+
return render_value(value)
|
|
122
|
+
except Exception as error: # noqa: BLE001
|
|
123
|
+
return f"<{type(value).__name__} object: its __repr__ raised {type(error).__name__}: {error}>"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_value(value) -> str:
|
|
127
|
+
# No cap here. dsh's own `spill-policy` bounds the finished tool result: it saves the full text to the session's spill store and leaves the model a head/tail preview plus the path to `read` or `grep`. A cap here would run first and destroy those bytes before anything could store them — and it never saved memory either, since `_objstr` materializes the whole string before any cap could apply.
|
|
128
|
+
return _objstr(value)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── redundant-import hints ───────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
_redundant_imports: ContextVar = ContextVar("dsh_codeact_redundant_imports", default=None)
|
|
134
|
+
|
|
135
|
+
# Which Session the currently running cell belongs to.
|
|
136
|
+
#
|
|
137
|
+
# Several shells share this process — one per agent, so a fan-out of subagents costs no extra interpreters and shares `sys.modules`. But `sys.stdout`, `sys.displayhook` and `sys.modules["__dsh__.tools"]` are all process-wide, so concurrent cells would steal each other's output, values and tool catalogue. Routing each of them through this ContextVar is what keeps them apart: `run_cell_async` runs in its own task, and contextvars follow the task.
|
|
138
|
+
_current_session: ContextVar = ContextVar("dsh_codeact_session", default=None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class HintingNamespace(dict):
|
|
142
|
+
"""Records names an `import` rebinds to the *same* object.
|
|
143
|
+
|
|
144
|
+
A model driving a persistent REPL routinely forgets what it already imported; telling it so is cheaper than letting it re-import every cell. cpython#121306: top-level `STORE_NAME` routes through `__setitem__` for dict subclasses (a function body's `STORE_GLOBAL` does not — function-local imports never pollute the session namespace anyway)."""
|
|
145
|
+
|
|
146
|
+
def __setitem__(self, key, value):
|
|
147
|
+
if key in self and self[key] is value and (redundant := _redundant_imports.get()) is not None:
|
|
148
|
+
# `PyObject_SetItem` is C, so there is no Python frame between us and the cell: `_getframe(1)` IS the cell. Match IMPORT_NAME/IMPORT_FROM -> STORE_NAME at f_lasti to skip coincidental rebinds (`x = x`).
|
|
149
|
+
caller = sys._getframe(1) # noqa: SLF001
|
|
150
|
+
if caller.f_lasti in _import_stores(caller.f_code):
|
|
151
|
+
redundant.append(key)
|
|
152
|
+
super().__setitem__(key, value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@lru_cache(maxsize=128)
|
|
156
|
+
def _import_stores(code) -> frozenset:
|
|
157
|
+
"""Offsets in `code` where a STORE_NAME follows an import — i.e. the only places the hint above can fire.
|
|
158
|
+
|
|
159
|
+
Cached per code object because the alternative re-disassembled the WHOLE cell on every top-level rebind of a name to the same object. `True`, `None` and small ints are interned, so an ordinary `for x in [None] * 20000: y = x` at top level hits that path every iteration: measured 771 ms against 0.1 ms for byte-identical work inside a function body (where `STORE_FAST` never reaches `__setitem__`), and it produced no hint at all — the entire cost was waste. Cells are compiled fresh, so this is keyed on an object that is never reused with different bytecode."""
|
|
160
|
+
offsets = set()
|
|
161
|
+
previous = None
|
|
162
|
+
for instruction in get_instructions(code):
|
|
163
|
+
if instruction.opname == "STORE_NAME" and previous is not None and previous.opname in ("IMPORT_NAME", "IMPORT_FROM"):
|
|
164
|
+
offsets.add(instruction.offset)
|
|
165
|
+
previous = instruction
|
|
166
|
+
return frozenset(offsets)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def format_import_hint(keys):
|
|
170
|
+
if not (keys := list(dict.fromkeys(keys))): # dedupe, preserve order
|
|
171
|
+
return None
|
|
172
|
+
quoted = [f"`{key}`" for key in keys]
|
|
173
|
+
if len(quoted) == 1:
|
|
174
|
+
return f"{quoted[0]} is already imported in this session — no need to re-import it."
|
|
175
|
+
return f"{', '.join(quoted[:-1])} and {quoted[-1]} are already imported in this session — no need to re-import them."
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── tool bridge ──────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Bridge:
|
|
182
|
+
"""Outbound tool dispatch: one pending future per in-flight call."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, send) -> None:
|
|
185
|
+
self._send = send
|
|
186
|
+
self._pending: dict = {}
|
|
187
|
+
self._next = 0
|
|
188
|
+
|
|
189
|
+
def call(self, name: str, args):
|
|
190
|
+
self._next += 1
|
|
191
|
+
call_id = self._next
|
|
192
|
+
future = asyncio.get_running_loop().create_future()
|
|
193
|
+
self._pending[call_id] = future
|
|
194
|
+
# There is ONE Bridge for the whole process, so the frame has to say which shell is calling or the host cannot tell a parent's in-flight call from a subagent's. The ContextVar already routes stdout, the displayhook and `__dsh__.tools` the same way, and `create_task` snapshots it — so a task the model detached keeps naming the shell that created it.
|
|
195
|
+
session = _current_session.get()
|
|
196
|
+
self._send({"t": "call", "id": call_id, "shell": None if session is None else session.shell_id, "name": name, "args": args})
|
|
197
|
+
return future
|
|
198
|
+
|
|
199
|
+
def settle(self, call_id, ok, value, tool, message) -> None:
|
|
200
|
+
future = self._pending.pop(call_id, None)
|
|
201
|
+
if future is None or future.done():
|
|
202
|
+
return # late or duplicate reply — drop, never throw
|
|
203
|
+
if ok:
|
|
204
|
+
future.set_result(value)
|
|
205
|
+
else:
|
|
206
|
+
future.set_exception(ToolCallError(tool or "?", message or "tool call failed"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _make_binding(bridge: Bridge, spec):
|
|
210
|
+
"""One tool as a real `async def`: dsh's description becomes its docstring and its parameters become a keyword-only signature, so `read?`, `help(read)`, and tab-completion all work inside the REPL. The annotations arrive pre-rendered from the host, which projects them with dsh's own `jsonSchemaToPy` — no second mapper to drift out of sync."""
|
|
211
|
+
name = spec["name"]
|
|
212
|
+
|
|
213
|
+
async def call(**kwargs):
|
|
214
|
+
return await bridge.call(name, kwargs)
|
|
215
|
+
|
|
216
|
+
call.__name__ = name if name.isidentifier() else "call"
|
|
217
|
+
call.__qualname__ = f"__dsh__.tools.{name}"
|
|
218
|
+
call.__doc__ = spec.get("doc") or None
|
|
219
|
+
# Per-parameter, not one suppress around the whole thing: a single exotic name (`class`, `file-path` — hyphens are routine for MCP tools) used to discard the ENTIRE signature, so `read?` showed `(**kwargs)` while the prompt showed the full parameter list, with no error either way. Anything unrenderable is folded into `**kwargs` so the picture stays honest.
|
|
220
|
+
params, dropped = [], False
|
|
221
|
+
for p in spec.get("params") or []:
|
|
222
|
+
try:
|
|
223
|
+
params.append(
|
|
224
|
+
inspect.Parameter(
|
|
225
|
+
p["name"],
|
|
226
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
227
|
+
annotation=p.get("type") or "Any",
|
|
228
|
+
default=inspect.Parameter.empty if p.get("required") else ...,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
except (ValueError, TypeError):
|
|
232
|
+
dropped = True
|
|
233
|
+
if dropped:
|
|
234
|
+
params.append(inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD))
|
|
235
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
236
|
+
call.__signature__ = inspect.Signature(params) # type: ignore
|
|
237
|
+
return call
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ToolsModule(types.ModuleType):
|
|
241
|
+
"""`__dsh__.tools` — the bridged tool surface of the CALLING shell.
|
|
242
|
+
|
|
243
|
+
`sys.modules` is process-global, so there is exactly ONE of these no matter how many shells are live — yet each agent sees a different catalogue (a subagent's `toolFilter` narrows it, and restrictions move tools in and out between cells). So the bindings live on the Session and every lookup routes through the ContextVar; putting them in `__dict__` would hand every shell the last writer's catalogue."""
|
|
244
|
+
|
|
245
|
+
def __init__(self) -> None:
|
|
246
|
+
super().__init__("__dsh__.tools", "Harness tools, bridged into this session as awaitables.")
|
|
247
|
+
self.ToolCallError = ToolCallError
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _bindings():
|
|
251
|
+
session = _current_session.get()
|
|
252
|
+
return {} if session is None else session.bindings
|
|
253
|
+
|
|
254
|
+
def __getattr__(self, name): # only reached when the attribute is absent
|
|
255
|
+
if name.startswith("__"):
|
|
256
|
+
raise AttributeError(name) # import/introspection probing — never answer with a tool
|
|
257
|
+
bindings = ToolsModule._bindings()
|
|
258
|
+
if name in bindings:
|
|
259
|
+
return bindings[name]
|
|
260
|
+
available = ", ".join(sorted(bindings)) or "(none)"
|
|
261
|
+
raise AttributeError(f"no such tool: {name!r}. Available: {available}")
|
|
262
|
+
|
|
263
|
+
def __dir__(self):
|
|
264
|
+
return sorted(ToolsModule._bindings())
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def __all__(self):
|
|
268
|
+
return sorted(ToolsModule._bindings())
|
|
269
|
+
|
|
270
|
+
def __repr__(self) -> str:
|
|
271
|
+
return f"<module '__dsh__.tools': {', '.join(sorted(ToolsModule._bindings())) or 'no tools bound'}>"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def build_bindings(bridge: Bridge, specs) -> dict:
|
|
275
|
+
"""Project one agent's visible tools into awaitables for its shell."""
|
|
276
|
+
# A tool named `ToolCallError` or `_bindings` would be shadowed by the module's own attributes, and one named `_rebind` used to overwrite a bound method outright. dsh's own SDK renderer refuses `_`-leading tool names for exactly this collision class.
|
|
277
|
+
reserved = set(vars(ToolsModule)) | set(vars(types.ModuleType)) | {"ToolCallError"}
|
|
278
|
+
return {spec["name"]: _make_binding(bridge, spec) for spec in specs if not spec["name"].startswith("_") and spec["name"] not in reserved}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def install_bridge_modules() -> ToolsModule:
|
|
282
|
+
"""Install `__dsh__` — the seam between this interpreter and the JS host.
|
|
283
|
+
|
|
284
|
+
A real package in `sys.modules`, so every import form the model might reach for resolves: `from __dsh__.tools import glob, grep`, `import __dsh__.tools`, `from __dsh__ import tools`. Deliberately NOT a bare `tools` global: a dunder-named package reads as harness-owned, survives `%reset`, and leaves the obvious name free for the model's own variables."""
|
|
285
|
+
if isinstance(existing := sys.modules.get("__dsh__.tools"), ToolsModule):
|
|
286
|
+
return existing # one seam for the whole process; the catalogue is per shell
|
|
287
|
+
package = types.ModuleType("__dsh__", "The seam between this interpreter and the dsh harness.")
|
|
288
|
+
package.__path__ = [] # marks it a package so `__dsh__.tools` resolves
|
|
289
|
+
package.ToolCallError = ToolCallError # type: ignore
|
|
290
|
+
tools = ToolsModule()
|
|
291
|
+
package.tools = tools # type: ignore
|
|
292
|
+
package.shared = types.ModuleType("__dsh__.shared", SHARED_DOC) # type: ignore
|
|
293
|
+
sys.modules["__dsh__.shared"] = package.shared
|
|
294
|
+
sys.modules["__dsh__"] = package
|
|
295
|
+
sys.modules["__dsh__.tools"] = tools
|
|
296
|
+
return tools
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ── the session ──────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class StreamProxy(io.TextIOBase):
|
|
303
|
+
"""One stable `sys.stdout`/`sys.stderr` for the whole PROCESS.
|
|
304
|
+
|
|
305
|
+
Two forces pin this shape. The namespace persists, so `logging.basicConfig()` resolves `sys.stderr` ONCE at handler construction and must keep reaching whatever the live cell is capturing. And shells share the process, so the sink cannot be an attribute here — two concurrent cells would overwrite each other's. Looking it up per task through the ContextVar satisfies both."""
|
|
306
|
+
|
|
307
|
+
encoding = "utf-8"
|
|
308
|
+
|
|
309
|
+
def __init__(self, which: int) -> None:
|
|
310
|
+
self._which = which # 0 = stdout, 1 = stderr
|
|
311
|
+
|
|
312
|
+
def write(self, s: str) -> int:
|
|
313
|
+
session = _current_session.get()
|
|
314
|
+
target = None if session is None else session.sinks[self._which]
|
|
315
|
+
# Outside any cell — a background task the model detached, or a thread — there is no cell to attribute the write to, so it goes to the process's real stderr where the host keeps it for diagnostics.
|
|
316
|
+
return target.write(s) if target is not None else REAL_STDERR.write(s)
|
|
317
|
+
|
|
318
|
+
def writable(self) -> bool:
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
def flush(self) -> None:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class RoutedDisplayHook:
|
|
326
|
+
"""The single process-wide `sys.displayhook`, dispatched per task.
|
|
327
|
+
|
|
328
|
+
Each shell owns a separate displayhook object, but a cell's trailing expression reaches the GLOBAL entry point — so with two shells live, one cell's value is filled into the other's `ExecutionResult`. Measured: the two results come back swapped, silently. `InteractiveShell.__init__` also points `sys.displayhook` at its own hook, so every new Session reinstalls this."""
|
|
329
|
+
|
|
330
|
+
def __call__(self, value):
|
|
331
|
+
session = _current_session.get()
|
|
332
|
+
if session is not None:
|
|
333
|
+
session.shell.displayhook(value)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
ROUTED_DISPLAYHOOK = RoutedDisplayHook()
|
|
337
|
+
PROXY_STDOUT, PROXY_STDERR = StreamProxy(0), StreamProxy(1)
|
|
338
|
+
|
|
339
|
+
SHARED_DOC = """`__dsh__.shared` — a namespace every agent in this process can reach.
|
|
340
|
+
|
|
341
|
+
Shells are isolated: a subagent cannot see the parent's variables, and two subagents cannot see each other's. This module is the deliberate exception — one module object in the process-global `sys.modules`, so anything set on it is visible to all of them as a live Python object, with no serialization and no token cost. Set an attribute to publish, read one to consume."""
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class Capped(io.TextIOBase):
|
|
345
|
+
"""Byte-capped StringIO stand-in for one cell's stdout or stderr."""
|
|
346
|
+
|
|
347
|
+
def __init__(self, limit: int) -> None:
|
|
348
|
+
self.parts: list = []
|
|
349
|
+
self.size = 0
|
|
350
|
+
self.limit = limit
|
|
351
|
+
self.truncated = False
|
|
352
|
+
|
|
353
|
+
def write(self, s: str) -> int:
|
|
354
|
+
if not self.truncated:
|
|
355
|
+
chunk = s.encode("utf-8", "replace")
|
|
356
|
+
if self.size + len(chunk) > self.limit:
|
|
357
|
+
room = self.limit - self.size
|
|
358
|
+
if room > 0:
|
|
359
|
+
# "ignore", not "replace": slicing encoded bytes at the cap can land mid-character, and "replace" would hand the model a U+FFFD. Dropping the partial character is honest.
|
|
360
|
+
self.parts.append(chunk[:room].decode("utf-8", "ignore"))
|
|
361
|
+
self.size += room
|
|
362
|
+
self.truncated = True
|
|
363
|
+
else:
|
|
364
|
+
self.parts.append(s)
|
|
365
|
+
self.size += len(chunk)
|
|
366
|
+
return len(s)
|
|
367
|
+
|
|
368
|
+
def writable(self) -> bool:
|
|
369
|
+
return True
|
|
370
|
+
|
|
371
|
+
def text(self) -> str:
|
|
372
|
+
body = "".join(self.parts)
|
|
373
|
+
return f"{body}\n[dsh-py-codeact] output truncated at {self.limit} bytes" if self.truncated else body
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class Session:
|
|
377
|
+
"""One IPython shell whose namespace persists across cells.
|
|
378
|
+
|
|
379
|
+
One of these per agent. They share the process — and therefore `sys.modules`, the event loop and the installed packages — but not their globals."""
|
|
380
|
+
|
|
381
|
+
def __init__(self, bridge: Bridge, specs, shell_id: str) -> None:
|
|
382
|
+
self.shell_id = shell_id
|
|
383
|
+
namespace = HintingNamespace()
|
|
384
|
+
# `:memory:` because a bare `InteractiveShell` writes every cell into the USER's own `~/.ipython/profile_default/history.sqlite` — measured at 87 MB / 47k cells here, shared with their interactive ipython, contended by every kernel process at once, and growing with nothing to prune it. Model cell sources have no business in that file; the session log is the audit record. `_ih`, `_` and `Out[n]` all still work, which is what `repr` is built from.
|
|
385
|
+
history = Config()
|
|
386
|
+
history.HistoryAccessor.hist_file = ":memory:"
|
|
387
|
+
self.shell = InteractiveShell(user_ns=namespace, config=history)
|
|
388
|
+
self.bindings = build_bindings(bridge, specs)
|
|
389
|
+
self.sinks: list = [None, None] # the Capped buffers of the cell in flight
|
|
390
|
+
install_bridge_modules()
|
|
391
|
+
|
|
392
|
+
# `InteractiveShell.__init__` points `sys.displayhook` at its OWN hook, so the newest shell would otherwise capture every shell's values.
|
|
393
|
+
sys.stdout, sys.stderr = PROXY_STDOUT, PROXY_STDERR
|
|
394
|
+
sys.displayhook = ROUTED_DISPLAYHOOK
|
|
395
|
+
# Installing the routed hook is not enough on its own: IPython wraps every cell in `display_trap`, which swaps `sys.displayhook` for THIS shell's own hook for the duration. Two cells in flight interleave those swaps, and a trailing expression evaluated while a sibling's trap is the active one is filled into the SIBLING's `ExecutionResult` — the value is then overwritten by that sibling's own, and the first cell reports no return value at all. Measured: with a bridged `await` in both cells, the parent's repr came back empty every time while the child's was fine, and a cell run alone was unaffected.
|
|
396
|
+
#
|
|
397
|
+
# Pointing both ends of the trap at the routed hook makes it a no-op — `set` short-circuits on `is not self.hook`, `unset` restores what was already there — so `sys.displayhook` stays routed and the ContextVar decides, which is the whole point of `RoutedDisplayHook`.
|
|
398
|
+
self.shell.display_trap.hook = ROUTED_DISPLAYHOOK
|
|
399
|
+
self.shell.display_trap.old_hook = ROUTED_DISPLAYHOOK
|
|
400
|
+
|
|
401
|
+
# IPython writes tracebacks itself, in color. Send that copy to the real stderr so the cell's captured streams stay clean; the model gets the nocolor re-render from `format_exc` instead.
|
|
402
|
+
#
|
|
403
|
+
# Hook `_showtraceback`, not `showtraceback`: compile-time failures (SyntaxError, IndentationError) never reach `showtraceback` — IPython routes them through `showsyntaxerror`/`showindentationerror`, which print with a bare `print()`, i.e. into the cell's captured STDOUT. All three funnel through `_showtraceback`, so that is the choke point.
|
|
404
|
+
original = self.shell._showtraceback # noqa: SLF001
|
|
405
|
+
self._rendering_for_model = False
|
|
406
|
+
|
|
407
|
+
@wraps(original)
|
|
408
|
+
def wrapper(*args, **kwargs):
|
|
409
|
+
if self._rendering_for_model:
|
|
410
|
+
return original(*args, **kwargs)
|
|
411
|
+
with contextlib.redirect_stdout(REAL_STDERR), contextlib.redirect_stderr(REAL_STDERR):
|
|
412
|
+
return original(*args, **kwargs)
|
|
413
|
+
|
|
414
|
+
self.shell._showtraceback = wrapper # noqa: SLF001 # ty: ignore[invalid-assignment]
|
|
415
|
+
|
|
416
|
+
# Keep the display hook's bookkeeping — it binds `_`/`Out[n]` and fills `result.result`, which is where `repr` comes from — but drop its `Out[1]: …` echo. That echo goes to stdout, so the model would see every returned value twice, once in <stdout> and once in <return>.
|
|
417
|
+
self.shell.displayhook.write_output_prompt = lambda: None # ty: ignore[invalid-assignment]
|
|
418
|
+
self.shell.displayhook.write_format_data = lambda *_args, **_kwargs: None # ty: ignore[invalid-assignment]
|
|
419
|
+
# The instructions tell the model to end every cell on the value worth seeing, so `Out[n]` pins one live object per cell — at the default 1000 that is the last 1000 dataframes. Still reachable as `_`/`Out[n]`, just bounded.
|
|
420
|
+
self.shell.displayhook.cache_size = 100
|
|
421
|
+
|
|
422
|
+
@contextlib.contextmanager
|
|
423
|
+
def _capture(self):
|
|
424
|
+
out, err = Capped(MAX_STREAM_BYTES), Capped(MAX_STREAM_BYTES)
|
|
425
|
+
captured: list = []
|
|
426
|
+
# Save and restore rather than clear: `format_exc` captures while the cell's own capture is still on the stack.
|
|
427
|
+
previous = list(self.sinks)
|
|
428
|
+
self.sinks[:] = [out, err]
|
|
429
|
+
try:
|
|
430
|
+
yield captured
|
|
431
|
+
finally:
|
|
432
|
+
captured[:] = [out.text(), err.text()]
|
|
433
|
+
self.sinks[:] = previous
|
|
434
|
+
|
|
435
|
+
def rebind(self, bridge: Bridge, specs) -> None:
|
|
436
|
+
self.bindings = build_bindings(bridge, specs)
|
|
437
|
+
|
|
438
|
+
def format_exc(self) -> str:
|
|
439
|
+
"""IPython's own traceback, rendered without ANSI colors."""
|
|
440
|
+
with self._capture() as captured:
|
|
441
|
+
colors = self.shell.colors
|
|
442
|
+
self._rendering_for_model = True
|
|
443
|
+
try:
|
|
444
|
+
self.shell.colors = "nocolor"
|
|
445
|
+
self.shell.showtraceback()
|
|
446
|
+
finally:
|
|
447
|
+
self.shell.colors = colors
|
|
448
|
+
self._rendering_for_model = False
|
|
449
|
+
return "\n".join(part for part in captured if part).strip()
|
|
450
|
+
|
|
451
|
+
async def run_cell(self, code: str) -> dict:
|
|
452
|
+
redundant: list = []
|
|
453
|
+
token = _redundant_imports.set(redundant)
|
|
454
|
+
# Everything routed — stdout, the display hook, the tool catalogue — reads this. `run_cell_async` runs in its own task, so the binding follows the cell and concurrent cells in other shells never see it.
|
|
455
|
+
session_token = _current_session.set(self)
|
|
456
|
+
try: # NOTE: this `finally` must outlive `format_exc()` below — it renders through the same routed streams and needs the binding still set.
|
|
457
|
+
with self._capture() as captured:
|
|
458
|
+
# Mirror IPython's own `run_cell`: a transform failure is the MODEL's syntax error (inconsistent indentation is the common one), so it has to travel as `preprocessing_exc_tuple` and come back as a clean `error_before_exec`. Calling `transform_cell` bare let it raise past `run_cell_async` and be reported as a harness crash, complete with a traceback into this file.
|
|
459
|
+
try:
|
|
460
|
+
transformed, preprocessing_exc = self.shell.transform_cell(code), None
|
|
461
|
+
except Exception: # noqa: BLE001 — any transform failure is the model's own syntax error, to be reported as one
|
|
462
|
+
transformed, preprocessing_exc = code, sys.exc_info()
|
|
463
|
+
result = await self.shell.run_cell_async(
|
|
464
|
+
code,
|
|
465
|
+
transformed_cell=transformed,
|
|
466
|
+
preprocessing_exc_tuple=preprocessing_exc,
|
|
467
|
+
store_history=True,
|
|
468
|
+
)
|
|
469
|
+
stdout, stderr = captured
|
|
470
|
+
failed = result.error_before_exec or result.error_in_exec
|
|
471
|
+
# `run_cell_async` catches the cancellation and reports it as a failed cell, so the `except CancelledError` around this never sees one. Say plainly what happened instead of handing the model a CancelledError traceback it might read as a bug in its own code. In-flight tool calls are deliberately NOT failed here. The cell's own awaits already unwind on the CancelledError; anything still pending belongs to a task the model detached with `create_task`, which is the whole point of a persistent kernel — interrupting cell 5 must not kill a subagent launched in cell 2. Abandoned futures settle when the host replies, and `settle` drops late or duplicate replies safely.
|
|
472
|
+
if isinstance(result.error_in_exec, asyncio.CancelledError):
|
|
473
|
+
error = "InterruptedError: the harness cancelled this cell. State is intact; the cell did not finish."
|
|
474
|
+
else:
|
|
475
|
+
error = self.format_exc() if failed else None
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
"ok": bool(result.success),
|
|
479
|
+
"stdout": stdout,
|
|
480
|
+
"stderr": stderr,
|
|
481
|
+
"error": error,
|
|
482
|
+
"repr": None if result.result is None else safe_render(result.result),
|
|
483
|
+
"note": format_import_hint(redundant),
|
|
484
|
+
}
|
|
485
|
+
finally:
|
|
486
|
+
_redundant_imports.reset(token)
|
|
487
|
+
_current_session.reset(session_token)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# ── kernel loop ──────────────────────────────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
class Kernel:
|
|
494
|
+
def __init__(self) -> None:
|
|
495
|
+
self._out = os.fdopen(PROTOCOL_FD, "wb", buffering=0)
|
|
496
|
+
self._bridge = Bridge(self._send)
|
|
497
|
+
# One Session per agent, one in-flight task per Session. Keyed by the shell id the host assigns; a fan-out of subagents lands here as N entries sharing this process, its event loop and its packages.
|
|
498
|
+
self._sessions: dict = {}
|
|
499
|
+
self._tasks: dict = {}
|
|
500
|
+
|
|
501
|
+
def _send(self, frame: dict) -> None:
|
|
502
|
+
# fd 3 is NON-BLOCKING: `asyncio.connect_read_pipe` sets O_NONBLOCK on it, and Node's `stdio[3]: 'pipe'` is one duplex socketpair, so the read and write ends are the same descriptor. A raw `write()` therefore stops at the socket send buffer (8 KiB on macOS) and reports how far it got — ignoring that return value truncated the frame mid-JSON, silently, and the next frame was concatenated onto the stump. The host then dropped one unparsable blob and the session wedged for good. `default=str` so the natural CodeAct idiom just works: the model globs with `pathlib` and passes the `Path` straight into a tool. Coercing here beats making every call site write `str(p)` — and beats a `TypeError` raised mid-frame, which is what used to happen. Same for `datetime`, `Decimal`, `UUID`, numpy scalars.
|
|
503
|
+
# `errors="replace"` is load-bearing, not tidiness: a lone surrogate — routine from `surrogateescape` decoding, or any `Path` on a non-UTF-8 filename — passes `json.dumps` and then raises `UnicodeEncodeError` here. This send is what answers an exec, so a raise means no `done` frame ever arrives and the turn hangs forever. A U+FFFD in one string beats a wedged session.
|
|
504
|
+
payload = memoryview((json.dumps(frame, ensure_ascii=False, default=str) + "\n").encode("utf-8", errors="replace"))
|
|
505
|
+
while payload:
|
|
506
|
+
try:
|
|
507
|
+
written = self._out.write(payload)
|
|
508
|
+
except BlockingIOError:
|
|
509
|
+
written = None
|
|
510
|
+
if written is None: # the buffer is full; wait for the host to drain it
|
|
511
|
+
select.select([], [PROTOCOL_FD], [])
|
|
512
|
+
continue
|
|
513
|
+
payload = payload[written:]
|
|
514
|
+
|
|
515
|
+
def _session_for(self, shell, specs=None) -> Session:
|
|
516
|
+
session = self._sessions.get(shell)
|
|
517
|
+
if session is None:
|
|
518
|
+
# An exec before its init still gets a usable shell, just no bindings.
|
|
519
|
+
session = self._sessions[shell] = Session(self._bridge, specs or [], shell)
|
|
520
|
+
elif specs is not None:
|
|
521
|
+
session.rebind(self._bridge, specs)
|
|
522
|
+
return session
|
|
523
|
+
|
|
524
|
+
async def _exec(self, exec_id, shell, code: str) -> None:
|
|
525
|
+
session = self._session_for(shell)
|
|
526
|
+
try:
|
|
527
|
+
frame = {"t": "done", "id": exec_id, "shell": shell, **await session.run_cell(code)}
|
|
528
|
+
except asyncio.CancelledError:
|
|
529
|
+
frame = {"t": "done", "id": exec_id, "shell": shell, "ok": False, "stdout": "", "stderr": "", "error": "KeyboardInterrupt: cell interrupted by the harness", "repr": None, "note": None}
|
|
530
|
+
except Exception: # noqa: BLE001 — the exec MUST be answered whatever went wrong, or the host hangs forever
|
|
531
|
+
# `run_cell` guards the cell's own code, but not `transform_cell` or `render_value` around it. A leak here used to kill this task silently: no `done` frame is ever sent, the host's promise never settles, and the whole turn hangs until the user aborts. Always answer the exec, even when the answer is "the kernel broke".
|
|
532
|
+
frame = {
|
|
533
|
+
"t": "done",
|
|
534
|
+
"id": exec_id,
|
|
535
|
+
"shell": shell,
|
|
536
|
+
"ok": False,
|
|
537
|
+
"stdout": "",
|
|
538
|
+
"stderr": "",
|
|
539
|
+
"error": f"KernelError: the kernel failed while running this cell.\n{traceback.format_exc()}",
|
|
540
|
+
"repr": None,
|
|
541
|
+
"note": None,
|
|
542
|
+
}
|
|
543
|
+
self._send(frame)
|
|
544
|
+
|
|
545
|
+
def _handle(self, frame: dict) -> None:
|
|
546
|
+
kind = frame.get("t")
|
|
547
|
+
shell = frame.get("shell") or DEFAULT_SHELL
|
|
548
|
+
if kind == "exec":
|
|
549
|
+
running = self._tasks.get(shell)
|
|
550
|
+
if running is not None and not running.done():
|
|
551
|
+
# Answer BEFORE rebinding: a rejected exec must not swap the tool table under the cell that is still running, or a binding it captured (`from __dsh__.tools import read`) can change identity — or vanish — between its start and its next await. The check is per shell: another agent's cell running is not a conflict.
|
|
552
|
+
self._send(
|
|
553
|
+
{"t": "done", "id": frame.get("id"), "shell": shell, "ok": False, "stdout": "", "stderr": "", "error": "kernel busy: a previous cell is still running", "repr": None, "note": None}
|
|
554
|
+
)
|
|
555
|
+
return
|
|
556
|
+
specs = frame.get("tools")
|
|
557
|
+
self._session_for(shell, specs if isinstance(specs, list) else None)
|
|
558
|
+
self._tasks[shell] = asyncio.ensure_future(self._exec(frame.get("id"), shell, frame.get("code") or ""))
|
|
559
|
+
elif kind == "result":
|
|
560
|
+
self._bridge.settle(frame.get("id"), bool(frame.get("ok")), frame.get("value"), frame.get("tool"), frame.get("message"))
|
|
561
|
+
elif kind == "interrupt":
|
|
562
|
+
running = self._tasks.get(shell)
|
|
563
|
+
if running is not None and not running.done():
|
|
564
|
+
running.cancel()
|
|
565
|
+
elif kind == "init":
|
|
566
|
+
self._session_for(shell, frame.get("tools") or [])
|
|
567
|
+
self._send(
|
|
568
|
+
{
|
|
569
|
+
"t": "ready",
|
|
570
|
+
"shell": shell,
|
|
571
|
+
"env": {
|
|
572
|
+
"executable": sys.executable,
|
|
573
|
+
"version": ".".join(str(part) for part in sys.version_info[:3]),
|
|
574
|
+
"prefix": sys.prefix,
|
|
575
|
+
"venv": sys.prefix != sys.base_prefix,
|
|
576
|
+
"cwd": str(Path.cwd()),
|
|
577
|
+
},
|
|
578
|
+
}
|
|
579
|
+
)
|
|
580
|
+
elif kind == "dispose":
|
|
581
|
+
# The agent is gone; drop its shell so its globals can be collected.
|
|
582
|
+
self._tasks.pop(shell, None)
|
|
583
|
+
self._sessions.pop(shell, None)
|
|
584
|
+
|
|
585
|
+
async def serve(self) -> None:
|
|
586
|
+
loop = asyncio.get_running_loop()
|
|
587
|
+
# The default 64 KiB line limit is far below a real frame: a catalogue of ~40 tools serialises past 80 KB, and a cell carrying a file body is unbounded by nature. Overrunning it raises out of `readline` and takes every shell's namespace with it.
|
|
588
|
+
reader = asyncio.StreamReader(limit=1 << 24)
|
|
589
|
+
await loop.connect_read_pipe(
|
|
590
|
+
lambda: asyncio.StreamReaderProtocol(reader),
|
|
591
|
+
os.fdopen(PROTOCOL_FD, "rb", buffering=0),
|
|
592
|
+
)
|
|
593
|
+
while True:
|
|
594
|
+
line = await reader.readline()
|
|
595
|
+
if not line:
|
|
596
|
+
return # host closed fd 3
|
|
597
|
+
try:
|
|
598
|
+
frame = json.loads(line)
|
|
599
|
+
except ValueError:
|
|
600
|
+
continue # the host is not model-controlled, but never crash on junk
|
|
601
|
+
if not isinstance(frame, dict):
|
|
602
|
+
continue
|
|
603
|
+
if frame.get("t") == "shutdown":
|
|
604
|
+
return
|
|
605
|
+
try:
|
|
606
|
+
self._handle(frame)
|
|
607
|
+
except Exception: # noqa: BLE001 — one malformed frame must not take the interpreter down
|
|
608
|
+
# `_handle` runs directly in this loop: an exception here unwinds out of `asyncio.run` and takes the interpreter — and the whole session's state — with it. One malformed frame is not worth that; a `result` whose `id` is unhashable used to do exactly it.
|
|
609
|
+
print(f"[dsh-py-codeact] dropped a frame that raised: {traceback.format_exc()}", file=REAL_STDERR)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
if __name__ == "__main__":
|
|
613
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
614
|
+
asyncio.run(Kernel().serve())
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// Placeholder
|