dsh-py-codeact 0.3.2 → 0.3.4
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/lib/index.js +102 -4
- package/package.json +1 -1
- package/py/kernel.py +137 -43
package/lib/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* So this is NOT a `CodeRuntime` backend — it could not conform. It is an ordinary tool plugin that owns its own kernel: one IPython process per conversation tree with a shell per agent, globals surviving between calls, the model's action space being Python code and its observation that cell's output.
|
|
7
7
|
*
|
|
8
|
-
* It
|
|
8
|
+
* It emits the session format's PTC dispatch events, so bridged tool calls render as SUBTOOL rows in the existing trajectory UI with no client change.
|
|
9
9
|
*
|
|
10
10
|
* @module dsh-py-codeact
|
|
11
11
|
*/
|
|
@@ -23,6 +23,9 @@ const TOOLS_MODULE = '__dsh__.tools'
|
|
|
23
23
|
|
|
24
24
|
const SDK_SECTION_ORDER = 150
|
|
25
25
|
|
|
26
|
+
/** Behind the tool listing, because the listing is what says whether a delegation tool is even there to orchestrate. */
|
|
27
|
+
const ORCHESTRATION_SECTION_ORDER = 160
|
|
28
|
+
|
|
26
29
|
/**
|
|
27
30
|
* Ahead of the tool-guidance band, for the reason Code Mode orders its own code-only rule there: the model should read WHICH tools it may call before it reads what each one is for. Behind it sit thousands of characters of per-tool guidance ("use the read tool, not cat") that still apply — but by a different route than the one they imply.
|
|
28
31
|
*/
|
|
@@ -67,6 +70,72 @@ Your action space is Python. Each call runs one cell in a **persistent IPython s
|
|
|
67
70
|
|
|
68
71
|
The available tools:`
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Orchestration is the one thing this loop can express that a tool-calling loop cannot: a fan-out whose shape is decided in code rather than by the model emitting N calls and reading N results back into its context. Worth its own section for the reason Code Mode's own guidance is separate from its tool listing — the listing says what exists, this says what to build out of it.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately names no tool: the delegation tool's name is configurable (`dsh-tool-subagent` defaults to `subagent`) and it may not be mounted at all, so the text points at the listing above instead of asserting a binding that might not be there.
|
|
77
|
+
*/
|
|
78
|
+
const ORCHESTRATION = `## Orchestrating delegations
|
|
79
|
+
|
|
80
|
+
The delegation tool is an awaitable — Python IS the orchestration. \`asyncio.gather\` fans out, \`while\` decides how many rounds, \`if\` decides what survives.
|
|
81
|
+
|
|
82
|
+
Its result is a union keyed by \`kind\`, and by default it settles in the background and hands back an id (\`continuable: true\`). Pass \`run_in_background=False\` when this cell needs the answer; the text sits in \`"output"\`, a list of content blocks:
|
|
83
|
+
|
|
84
|
+
\`\`\`python
|
|
85
|
+
from __dsh__.tools import <delegation-tool> as delegate
|
|
86
|
+
import asyncio, json
|
|
87
|
+
|
|
88
|
+
LIMIT = asyncio.Semaphore(4) # a dozen children racing for one upstream finish later than a few at a time
|
|
89
|
+
|
|
90
|
+
async def ask(desc, prompt, timeout=600):
|
|
91
|
+
async with LIMIT: # a gather waits out its slowest member forever, so give every child a deadline
|
|
92
|
+
r = await asyncio.wait_for(delegate(description=desc, prompt=prompt, run_in_background=False), timeout)
|
|
93
|
+
return "".join(b.get("text", "") for b in r["output"] if isinstance(b, dict))
|
|
94
|
+
|
|
95
|
+
def as_json(text): # the child tends to wrap its JSON in a sentence
|
|
96
|
+
return json.loads(text[text.index("{"):text.rindex("}") + 1])
|
|
97
|
+
\`\`\`
|
|
98
|
+
|
|
99
|
+
**Carry each item through its own chain instead of stage by stage.** Gather the chains, not the stages, so one item is being verified while another is still being found:
|
|
100
|
+
|
|
101
|
+
\`\`\`python
|
|
102
|
+
SCHEMA = '{"refuted": true, "reason": "why"}' # example VALUES: a child handed {"refuted": bool} copies that literally, and the parse dies rather than the work
|
|
103
|
+
|
|
104
|
+
async def survives(claim):
|
|
105
|
+
verdict = await ask("refute", f"Refute this, and default to refuted when unsure: {claim}. Reply as JSON: {SCHEMA}")
|
|
106
|
+
return not as_json(verdict)["refuted"]
|
|
107
|
+
|
|
108
|
+
async def chain(dim):
|
|
109
|
+
claims = as_json(await ask(f"review {dim}", PROMPTS[dim]))["findings"]
|
|
110
|
+
votes = await asyncio.gather(*(survives(c) for c in claims))
|
|
111
|
+
return [c for c, ok in zip(claims, votes) if ok]
|
|
112
|
+
|
|
113
|
+
rows = await asyncio.gather(*(chain(d) for d in DIMENSIONS), return_exceptions=True)
|
|
114
|
+
kept = [c for row in rows if not isinstance(row, BaseException) for c in row]
|
|
115
|
+
\`\`\`
|
|
116
|
+
|
|
117
|
+
Wall-clock is then the slowest single chain rather than the sum of the slowest per stage. Reach for a barrier only where the next step needs the whole previous one at once — dedup, an early exit, ranking candidates against each other.
|
|
118
|
+
|
|
119
|
+
Isolate every child's failure, the way \`return_exceptions=True\` does above: one that dies on its token limit or answers unparseably otherwise takes the whole batch with it, and the siblings that did finish are worth more than the round.
|
|
120
|
+
|
|
121
|
+
Keep each prompt to what only that child could be told — point it at a path and let it read the file, since it shares this filesystem. Pasting a file into a prompt is what walks a child into that limit.
|
|
122
|
+
|
|
123
|
+
The shape need not be settled before the first call either: keep running rounds while one still turns up something new, deduping against everything SEEN rather than against what survived — dedup against the survivors and every rejected claim comes back next round.
|
|
124
|
+
`
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether any visible tool delegates to a subagent, detected by the SHAPE of its declared output rather than by name: `toolName` is configurable (`dsh-tool-subagent` defaults to `subagent`), so a name test would miss a renamed mount, and the three-branch union below is one no other tool in a stock catalogue declares.
|
|
128
|
+
*
|
|
129
|
+
* The branches are a static literal in `dsh-tool-subagent` — `background` and `continuable` stay in the schema even where config cannot reach them — so this holds regardless of how that plugin is configured.
|
|
130
|
+
*/
|
|
131
|
+
function hasDelegationTool(schemas) {
|
|
132
|
+
return schemas.some((schema) => {
|
|
133
|
+
const branches = schema.output?.oneOf
|
|
134
|
+
if (!Array.isArray(branches)) return false
|
|
135
|
+
return ['foreground', 'background', 'continuable'].every((expected) => branches.some((b) => b?.properties?.kind?.const === expected))
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
70
139
|
/**
|
|
71
140
|
* The wrapper dsh's MCP client puts around every result: `{ content, structuredContent? }`, with
|
|
72
141
|
* `content` the protocol's block array and `structuredContent` the server's own payload. Detected
|
|
@@ -564,6 +633,21 @@ function renderCell(result) {
|
|
|
564
633
|
return sections.join('\n') || '[[ execution successful, no output ]]'
|
|
565
634
|
}
|
|
566
635
|
|
|
636
|
+
/**
|
|
637
|
+
* The hand-written prose that reaches `ctx.systemPrompt.section({text})` verbatim.
|
|
638
|
+
*
|
|
639
|
+
* dsh-system-prompt reads `{{` in section text as a variable reference, and a malformed one throws during ASSEMBLY — which takes down every session on the preset rather than degrading one prompt. The per-tool prose inside the sdk section is escaped by `escapePromptGroups` on its way through the renderer; these literals have no such pass, so they are asserted brace-free at mount instead, keeping the text the one thing a reader of the source can trust.
|
|
640
|
+
*
|
|
641
|
+
* `ORCHESTRATION` and `INSTRUCTIONS` are the ones carrying Python that shows JSON shapes, where an f-string's doubled literal brace is the easy mistake. `RESTART_NOTICE` is deliberately absent: it is injected as a transcript message, which never passes through `interpolate`.
|
|
642
|
+
*
|
|
643
|
+
* Exported so the suite can put each one through the real renderer, rather than restating the check.
|
|
644
|
+
*/
|
|
645
|
+
export const HAND_WRITTEN_PROSE = [
|
|
646
|
+
{ label: 'code-only', text: EXCLUSIVE_RULE },
|
|
647
|
+
{ label: 'sdk', text: INSTRUCTIONS },
|
|
648
|
+
{ label: 'orchestration', text: ORCHESTRATION },
|
|
649
|
+
]
|
|
650
|
+
|
|
567
651
|
export const name = PLUGIN_NAME
|
|
568
652
|
export const inject = ['tools', 'systemPrompt']
|
|
569
653
|
|
|
@@ -615,6 +699,18 @@ export function apply(ctx, config = {}) {
|
|
|
615
699
|
text: (assembly) => renderToolsSection(visibleSchemas(assembly.scope)),
|
|
616
700
|
})
|
|
617
701
|
|
|
702
|
+
for (const { label, text } of HAND_WRITTEN_PROSE) {
|
|
703
|
+
const run = /\{{2,}/.exec(text)
|
|
704
|
+
if (run !== null) throw new Error(`dsh-py-codeact: the ${label} section contains "${run[0]}", which dsh-system-prompt parses as a variable reference`)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Empty string where no delegation tool is mounted, the way `dsh-tool-subagent` blanks its own `tool:subagent` section: a section describing a fan-out the model has no way to perform is several hundred characters of standing misdirection.
|
|
708
|
+
ctx.systemPrompt.section({
|
|
709
|
+
name: 'py-codeact:orchestration',
|
|
710
|
+
order: ORCHESTRATION_SECTION_ORDER,
|
|
711
|
+
text: (assembly) => (hasDelegationTool(visibleSchemas(assembly.scope)) ? ORCHESTRATION : ''),
|
|
712
|
+
})
|
|
713
|
+
|
|
618
714
|
// Injected at the seam rather than carried as a prompt section, because the position IS the information: everything above the notice ran in an interpreter that no longer exists, everything below runs in the new one. A section states the fact but cannot say where the boundary fell — and the model reads the transcript in order.
|
|
619
715
|
ctx.on('agent/session-start', ({ agent }) => {
|
|
620
716
|
// `session-start` also fires for `compact` and `clear`, where the interpreter is very much alive — and a resume inside the SAME process finds its kernel still running. Only an actually-absent interpreter means the bindings died.
|
|
@@ -660,7 +756,9 @@ export function apply(ctx, config = {}) {
|
|
|
660
756
|
console.warn(`[dsh-py-codeact] no agent on execution ${exec.callId}: sub-calls will run but not appear as SUBTOOL rows`)
|
|
661
757
|
}
|
|
662
758
|
const trace = { rootCallId: exec.rootCallId, parentCallId: exec.callId, subCallId, name, arguments: args }
|
|
663
|
-
|
|
759
|
+
// V3 rejects the old Code Mode tags even though their payload is unchanged. Older hosts in our peer range still require them.
|
|
760
|
+
const dispatchEvent = session?.header?.version >= 3 ? 'tool/ptc-dispatch' : 'tool/code-dispatch'
|
|
761
|
+
session?.append(`${dispatchEvent}-start`, trace)
|
|
664
762
|
|
|
665
763
|
// Every outcome has to emit the terminal event, throws included: if `ctx.tools.execute` rejects rather than returning `{isError:true}` (an abort, a policy wrapper throwing), the Python side is still answered, but the SUBTOOL row would sit in the trajectory as "running" forever.
|
|
666
764
|
try {
|
|
@@ -674,7 +772,7 @@ export function apply(ctx, config = {}) {
|
|
|
674
772
|
signal: exec.signal,
|
|
675
773
|
})
|
|
676
774
|
|
|
677
|
-
session?.append(
|
|
775
|
+
session?.append(dispatchEvent, { ...trace, isError: outcome.isError, content: outcome.content })
|
|
678
776
|
// Pixels cannot travel through the bridge: `value` is JSON, so a `read_image` result reaches the cell as width/height/attachmentId and NOTHING to look at. Re-attach the blocks the way Code Mode does, and the image lands in the conversation right after this cell — the model sees it on its next step. Without this the model believes the call succeeded, gets metadata, and cannot tell why it still cannot see anything.
|
|
679
777
|
if (!outcome.isError && contentHasImage(outcome.content ?? [])) {
|
|
680
778
|
exec.deferContext(createUserMessage({ content: outcome.content, source: { kind: 'plugin', plugin: 'dsh-py-codeact' } }))
|
|
@@ -684,7 +782,7 @@ export function apply(ctx, config = {}) {
|
|
|
684
782
|
// falling back to the wrapper there would hand the cell the one shape it was promised not to see.
|
|
685
783
|
return toolCallReply(outcome, entry.envelopes.get(from)?.has(name))
|
|
686
784
|
} catch (error) {
|
|
687
|
-
session?.append(
|
|
785
|
+
session?.append(dispatchEvent, { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
|
|
688
786
|
throw error
|
|
689
787
|
}
|
|
690
788
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-py-codeact",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "CodeAct agent loop for the DeepSeek Harness: a persistent IPython session as the model's action space, with harness tools bridged in as a virtual `__dsh__.tools` module.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/py/kernel.py
CHANGED
|
@@ -49,7 +49,6 @@ import inspect
|
|
|
49
49
|
import io
|
|
50
50
|
import json
|
|
51
51
|
import keyword
|
|
52
|
-
import select
|
|
53
52
|
import sys
|
|
54
53
|
import traceback
|
|
55
54
|
import types
|
|
@@ -186,6 +185,7 @@ class Bridge:
|
|
|
186
185
|
self._send = send
|
|
187
186
|
self._pending: dict = {}
|
|
188
187
|
self._next = 0
|
|
188
|
+
self._sends: set = set() # hold fire-and-forget send tasks so the GC cannot drop a `call` frame mid-flight
|
|
189
189
|
|
|
190
190
|
def call(self, name: str, args):
|
|
191
191
|
self._next += 1
|
|
@@ -194,7 +194,21 @@ class Bridge:
|
|
|
194
194
|
self._pending[call_id] = future
|
|
195
195
|
# 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.
|
|
196
196
|
session = _current_session.get()
|
|
197
|
-
|
|
197
|
+
|
|
198
|
+
# `_send` is async (it yields to the loop while the fd-3 buffer drains), but `call` stays sync so the binding can `return await bridge.call(...)` and hand the cell the Future itself to await — `await` on a coroutine that returned a Future would hand the cell a pending Future instead of its resolved value. A `call` frame is tiny, so fire it as a task: it runs immediately and only yields if the buffer is full, never blocking the caller. If `_send` itself raises (e.g. json.dumps hitting a raising __str__), fail the call instead of hanging the awaiter — the future's done callback then pops `_pending`.
|
|
199
|
+
async def _send_call():
|
|
200
|
+
try:
|
|
201
|
+
await self._send({"t": "call", "id": call_id, "shell": None if session is None else session.shell_id, "name": name, "args": args})
|
|
202
|
+
except Exception as exc: # noqa: BLE001 - _send is host-provided; any failure must not leak the future
|
|
203
|
+
if not future.done():
|
|
204
|
+
future.set_exception(exc)
|
|
205
|
+
|
|
206
|
+
send = asyncio.create_task(_send_call())
|
|
207
|
+
send.add_done_callback(self._sends.discard)
|
|
208
|
+
self._sends.add(send)
|
|
209
|
+
|
|
210
|
+
# One done callback for both jobs. Popping `_pending` is the leak safety net: if the host never replies, or `_send` raised and we failed the future ourselves, or a `wait_for` timeout cancelled it, the entry still leaves. Cancelling `send` is the side-effect one: a cancelled or timed-out call whose frame has not gone out yet must not reach the host at all, or the tool runs and its side effects land after the cell already reported the call as cancelled. A frame mid-write is past that point — `_send` holds the lock and shields the drain wait, so it finishes the line rather than splicing the next frame onto a stump.
|
|
211
|
+
future.add_done_callback(lambda _: (self._pending.pop(call_id, None), send.cancel()))
|
|
198
212
|
return future
|
|
199
213
|
|
|
200
214
|
def settle(self, call_id, ok, value, tool, message) -> None:
|
|
@@ -215,7 +229,8 @@ def _make_binding(bridge: Bridge, spec):
|
|
|
215
229
|
renames = {p["name"]: p["raw"] for p in spec.get("params") or [] if p.get("raw")}
|
|
216
230
|
|
|
217
231
|
async def call(**kwargs):
|
|
218
|
-
|
|
232
|
+
# A per-call timeout is the orphan-future safety net: if the host never sends a result for this `call_id`, the await raises `TimeoutError` instead of hanging forever, and the done callback registered in `Bridge.call` drops the entry from `_pending`. It must sit ABOVE the 600s delegation budget the prompt teaches (`ask(desc, prompt, timeout=600)`), or the kernel kills a legitimate in-flight subagent while the host is still running it — the side effects land, the late result is dropped, and the model retries and duplicates them.
|
|
233
|
+
return await asyncio.wait_for(bridge.call(name, {renames.get(key, key): value for key, value in kwargs.items()} if renames else kwargs), 660)
|
|
219
234
|
|
|
220
235
|
call.__name__ = name if name.isidentifier() else "call"
|
|
221
236
|
call.__qualname__ = f"__dsh__.tools.{name}"
|
|
@@ -260,10 +275,12 @@ class ToolsModule(types.ModuleType):
|
|
|
260
275
|
|
|
261
276
|
`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."""
|
|
262
277
|
|
|
278
|
+
# A class attribute, not an instance one: normal lookup finds it before `__getattr__` ever runs, and the `__setattr__` guard below cannot touch the class body. `ToolCallError` is a harness-provided exception type the model is told to catch, not a tool binding.
|
|
279
|
+
ToolCallError = ToolCallError
|
|
280
|
+
|
|
263
281
|
def __init__(self) -> None:
|
|
264
282
|
super().__init__("__dsh__.tools", "Harness tools, bridged into this session as awaitables.")
|
|
265
283
|
self.__path__ = [] # a package, so `__dsh__.tools.mcp` resolves under it
|
|
266
|
-
self.ToolCallError = ToolCallError
|
|
267
284
|
|
|
268
285
|
def __getattr__(self, name): # only reached when the attribute is absent
|
|
269
286
|
if name.startswith("__"):
|
|
@@ -276,6 +293,14 @@ class ToolsModule(types.ModuleType):
|
|
|
276
293
|
available = ", ".join(sorted(listed_tools())) or "(none)"
|
|
277
294
|
raise AttributeError(f"no such tool: {name!r}. Available: {available}")
|
|
278
295
|
|
|
296
|
+
def __setattr__(self, name, value):
|
|
297
|
+
# One ToolsModule per process, shared by every agent — a write here would shadow that name
|
|
298
|
+
# for all of them, permanently and invisibly to `dir()`, which keeps reporting the tool it
|
|
299
|
+
# no longer reaches. The old per-call `Namespace` made this a local mistake.
|
|
300
|
+
if not (name.startswith("__") and name.endswith("__")):
|
|
301
|
+
raise AttributeError("__dsh__.tools belongs to the harness and is shared by every agent in this process — bind your own name instead of writing to it")
|
|
302
|
+
super().__setattr__(name, value)
|
|
303
|
+
|
|
279
304
|
def __dir__(self):
|
|
280
305
|
return sorted(listed_tools())
|
|
281
306
|
|
|
@@ -400,8 +425,9 @@ def alias(members: dict) -> None:
|
|
|
400
425
|
`setdefault`, so a name that already exists keeps its own value: a server exposing `a-b` beside
|
|
401
426
|
`a_b` gets no alias for `a-b`, and both stay reachable under their own spellings.
|
|
402
427
|
"""
|
|
403
|
-
for raw in
|
|
404
|
-
|
|
428
|
+
for raw in list(members): # a snapshot: `setdefault` below adds the folds mid-loop
|
|
429
|
+
if (fold := spellable(raw)) is not None:
|
|
430
|
+
members.setdefault(fold, members[raw])
|
|
405
431
|
|
|
406
432
|
|
|
407
433
|
def canonical(server: str, servers: dict) -> str:
|
|
@@ -438,8 +464,12 @@ def mcp_members(module_name: str) -> dict:
|
|
|
438
464
|
|
|
439
465
|
Deliberately not a method: a non-dunder attribute on the class would shadow a tool or a server
|
|
440
466
|
of that name, and a raw MCP name is the server's to choose — `_private` is a legal one.
|
|
467
|
+
|
|
468
|
+
Reads the `mcp_servers` tree cached on the Session by `rebind`, so an attribute access does not
|
|
469
|
+
rebuild the whole tree (O(n)) when nothing has changed.
|
|
441
470
|
"""
|
|
442
|
-
|
|
471
|
+
session = _current_session.get()
|
|
472
|
+
servers = session.mcp_tree if session is not None else {} # no session → no bound tools → no tree
|
|
443
473
|
if module_name == MCP_MODULE:
|
|
444
474
|
# Keyed by the FOLDED name so a hyphenated server and its fold resolve to one module object,
|
|
445
475
|
# not two: `listed` tells an alias from a real neighbour by identity, and two objects would
|
|
@@ -519,7 +549,7 @@ def mcp_server_module(server: str) -> McpModule:
|
|
|
519
549
|
return module
|
|
520
550
|
|
|
521
551
|
|
|
522
|
-
def install_mcp_modules(
|
|
552
|
+
def install_mcp_modules(servers: dict[str, dict]) -> None:
|
|
523
553
|
"""Register a module per visible MCP server.
|
|
524
554
|
|
|
525
555
|
Eager, because the deep import form never reaches `mcp_members`: `from __dsh__.tools.mcp.x
|
|
@@ -529,19 +559,27 @@ def install_mcp_modules(bindings: dict) -> None:
|
|
|
529
559
|
`sys.modules` only ever gains entries: a server another shell can see costs this one an unused
|
|
530
560
|
module, while removing it would break an import that shell is mid-conversation with. What a
|
|
531
561
|
shell can actually reach is decided by `mcp_members`, not by what is registered.
|
|
562
|
+
|
|
563
|
+
Takes the grouped tree from `build_bindings` so `rebind` does not rebuild it a second time.
|
|
532
564
|
"""
|
|
533
|
-
for server in
|
|
565
|
+
for server in servers:
|
|
534
566
|
mcp_server_module(server)
|
|
535
567
|
|
|
536
568
|
|
|
537
|
-
def build_bindings(bridge: Bridge, specs) -> dict:
|
|
538
|
-
"""Project one agent's visible tools into awaitables for its shell.
|
|
569
|
+
def build_bindings(bridge: Bridge, specs) -> tuple[dict, dict[str, dict]]:
|
|
570
|
+
"""Project one agent's visible tools into awaitables for its shell.
|
|
571
|
+
|
|
572
|
+
Returns `(bindings, mcp_servers)` so `rebind` can hand the grouped tree to
|
|
573
|
+
`install_mcp_modules` and cache it for `mcp_members` without rebuilding it.
|
|
574
|
+
"""
|
|
539
575
|
# A tool named `ToolCallError` 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.
|
|
540
576
|
# `mcp` joins them, and unconditionally: it used to be bound and then overwritten by the namespace, so the tool was uncallable anyway — but only when an MCP server happened to be mounted. A name that means the namespace in one catalogue and a tool in the next is worse than one that always means the same thing.
|
|
541
|
-
reserved = set(vars(ToolsModule)) | set(vars(types.ModuleType)) | {"
|
|
577
|
+
reserved = set(vars(ToolsModule)) | set(vars(types.ModuleType)) | {"mcp"}
|
|
542
578
|
flat = {spec["name"]: _make_binding(bridge, spec) for spec in specs if not spec["name"].startswith("_") and spec["name"] not in reserved}
|
|
543
579
|
# `mcp` only when something is under it: an empty namespace in `dir()` reads as a broken mount.
|
|
544
|
-
|
|
580
|
+
servers = mcp_servers(flat)
|
|
581
|
+
bindings = {**flat, "mcp": MCP_ROOT} if servers else flat
|
|
582
|
+
return bindings, servers
|
|
545
583
|
|
|
546
584
|
|
|
547
585
|
def install_bridge_modules() -> ToolsModule:
|
|
@@ -611,7 +649,7 @@ class Capped(io.TextIOBase):
|
|
|
611
649
|
"""Byte-capped StringIO stand-in for one cell's stdout or stderr."""
|
|
612
650
|
|
|
613
651
|
def __init__(self, limit: int) -> None:
|
|
614
|
-
self.parts: list = []
|
|
652
|
+
self.parts: list[bytes] = []
|
|
615
653
|
self.size = 0
|
|
616
654
|
self.limit = limit
|
|
617
655
|
self.truncated = False
|
|
@@ -623,11 +661,11 @@ class Capped(io.TextIOBase):
|
|
|
623
661
|
room = self.limit - self.size
|
|
624
662
|
if room > 0:
|
|
625
663
|
# "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.
|
|
626
|
-
self.parts.append(chunk[:room]
|
|
664
|
+
self.parts.append(chunk[:room])
|
|
627
665
|
self.size += room
|
|
628
666
|
self.truncated = True
|
|
629
667
|
else:
|
|
630
|
-
self.parts.append(
|
|
668
|
+
self.parts.append(chunk)
|
|
631
669
|
self.size += len(chunk)
|
|
632
670
|
return len(s)
|
|
633
671
|
|
|
@@ -635,7 +673,7 @@ class Capped(io.TextIOBase):
|
|
|
635
673
|
return True
|
|
636
674
|
|
|
637
675
|
def text(self) -> str:
|
|
638
|
-
body = "".join(self.parts)
|
|
676
|
+
body = b"".join(self.parts).decode("utf-8", "ignore")
|
|
639
677
|
return f"{body}\n[dsh-py-codeact] output truncated at {self.limit} bytes" if self.truncated else body
|
|
640
678
|
|
|
641
679
|
|
|
@@ -651,6 +689,14 @@ class Session:
|
|
|
651
689
|
history = Config()
|
|
652
690
|
history.HistoryAccessor.hist_file = ":memory:"
|
|
653
691
|
self.shell = InteractiveShell(user_ns=namespace, config=history)
|
|
692
|
+
# The specs last bound, so an identical catalogue (the common case — a restriction that
|
|
693
|
+
# did not change anything, or the same tools re-sent) skips `rebind` and its per-tool
|
|
694
|
+
# callable + signature rebuild. `None` means "nothing bound yet"; `==` on the parsed
|
|
695
|
+
# structures still catches a catalogue the host re-serialised, which identity would miss.
|
|
696
|
+
self._last_specs: list | None = None
|
|
697
|
+
# The grouped `mcp_servers` tree from the last `rebind`, cached so `mcp_members` reads it
|
|
698
|
+
# (O(1) lookup) instead of rebuilding it on every attribute access.
|
|
699
|
+
self.mcp_tree: dict[str, dict] = {}
|
|
654
700
|
self.rebind(bridge, specs)
|
|
655
701
|
self.sinks: list = [None, None] # the Capped buffers of the cell in flight
|
|
656
702
|
install_bridge_modules()
|
|
@@ -704,9 +750,23 @@ class Session:
|
|
|
704
750
|
The one place the `sys.modules` registration lives, so `build_bindings` stays the pure
|
|
705
751
|
projection its name promises and no caller can produce bindings the import machinery
|
|
706
752
|
cannot follow.
|
|
753
|
+
|
|
754
|
+
Skipped when the specs equal the last bound set: `build_bindings` rebuilds
|
|
755
|
+
every tool callable + `inspect.Signature` per exec, and the common case is a catalogue that
|
|
756
|
+
never changed between cells. `==` on the parsed structures catches a catalogue the host
|
|
757
|
+
re-serialised, which object identity would miss.
|
|
707
758
|
"""
|
|
708
|
-
|
|
709
|
-
|
|
759
|
+
if specs == self._last_specs:
|
|
760
|
+
return
|
|
761
|
+
self.bindings, servers = build_bindings(bridge, specs)
|
|
762
|
+
install_mcp_modules(servers)
|
|
763
|
+
# `mcp_members` reads a servable-filtered tree: a true-dunder raw name (e.g. `__odd__`) is
|
|
764
|
+
# reachable via the flat name and the deep import, never via `mcp.<server>.<tool>`, so the
|
|
765
|
+
# server it sits under is absent from `dir(mcp)`. `install_mcp_modules` takes the unfiltered
|
|
766
|
+
# tree so the server module is still registered for the deep import form.
|
|
767
|
+
self.mcp_tree = mcp_servers({name: call for name, call in self.bindings.items() if servable(name) is not None})
|
|
768
|
+
# Recorded only once the build above has succeeded: a memo written before a raising spec would make a retry with the same specs early-return and silently serve the stale catalogue.
|
|
769
|
+
self._last_specs = specs
|
|
710
770
|
|
|
711
771
|
def format_exc(self) -> str:
|
|
712
772
|
"""IPython's own traceback, rendered without ANSI colors."""
|
|
@@ -767,23 +827,48 @@ class Kernel:
|
|
|
767
827
|
def __init__(self) -> None:
|
|
768
828
|
self._out = os.fdopen(PROTOCOL_FD, "wb", buffering=0)
|
|
769
829
|
self._bridge = Bridge(self._send)
|
|
830
|
+
# One frame at a time: `call` frames fire as tasks beside the `done`/`ready` frames this loop awaits, and a sender that yields mid-frame (buffer full) must not let another write into the gap — the host's `readline` would see two spliced frames and drop both.
|
|
831
|
+
self._send_lock = asyncio.Lock()
|
|
770
832
|
# 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.
|
|
771
833
|
self._sessions: dict = {}
|
|
772
834
|
self._tasks: dict = {}
|
|
773
835
|
|
|
774
|
-
def _send(self, frame: dict) -> None:
|
|
836
|
+
async def _send(self, frame: dict) -> None:
|
|
775
837
|
# 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.
|
|
776
838
|
# `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.
|
|
777
839
|
payload = memoryview((json.dumps(frame, ensure_ascii=False, default=str) + "\n").encode("utf-8", errors="replace"))
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
840
|
+
loop = asyncio.get_running_loop()
|
|
841
|
+
async with self._send_lock:
|
|
842
|
+
cancelled = False
|
|
843
|
+
while payload:
|
|
844
|
+
try:
|
|
845
|
+
written = self._out.write(payload)
|
|
846
|
+
except BlockingIOError:
|
|
847
|
+
written = None
|
|
848
|
+
if written is None: # the buffer is full; wait for the host to drain it — a bare `sleep(0)` would hot-spin a core until it does
|
|
849
|
+
drained = loop.create_future()
|
|
850
|
+
loop.add_writer(PROTOCOL_FD, self._writable, drained)
|
|
851
|
+
try:
|
|
852
|
+
await asyncio.shield(drained)
|
|
853
|
+
except asyncio.CancelledError:
|
|
854
|
+
# A cancel must not abandon a half-written frame: the next send splices onto the stump, the host drops both lines, and the aborted exec's `done` never arrives — the 5s `hardInterruptMs` backstop then SIGKILLs the whole kernel. Finish this frame first, then let the cancel through; the host answers a `done` once, so the replacement frame `_exec` sends next is ignored, not fatal.
|
|
855
|
+
cancelled = True
|
|
856
|
+
finally:
|
|
857
|
+
loop.remove_writer(PROTOCOL_FD) # a no-op when the callback already removed it; cancels a queued-but-unrun one
|
|
858
|
+
continue
|
|
859
|
+
payload = payload[written:]
|
|
860
|
+
if cancelled:
|
|
861
|
+
raise asyncio.CancelledError
|
|
862
|
+
|
|
863
|
+
@staticmethod
|
|
864
|
+
def _writable(drained) -> None:
|
|
865
|
+
# Remove INSIDE the callback: a level-triggered selector re-fires a still-registered writer on every poll, and the future's done callbacks are `call_soon`-scheduled — the sender's `finally` above runs two loop batches later, by which time a re-fire would `set_result` on the already-done future.
|
|
866
|
+
asyncio.get_running_loop().remove_writer(PROTOCOL_FD)
|
|
867
|
+
drained.set_result(None)
|
|
868
|
+
|
|
869
|
+
def _busy(self, shell) -> bool:
|
|
870
|
+
running = self._tasks.get(shell)
|
|
871
|
+
return running is not None and not running.done()
|
|
787
872
|
|
|
788
873
|
def _session_for(self, shell, specs=None) -> Session:
|
|
789
874
|
session = self._sessions.get(shell)
|
|
@@ -813,16 +898,15 @@ class Kernel:
|
|
|
813
898
|
"repr": None,
|
|
814
899
|
"note": None,
|
|
815
900
|
}
|
|
816
|
-
self._send(frame)
|
|
901
|
+
await self._send(frame)
|
|
817
902
|
|
|
818
|
-
def _handle(self, frame: dict) -> None:
|
|
903
|
+
async def _handle(self, frame: dict) -> None:
|
|
819
904
|
kind = frame.get("t")
|
|
820
905
|
shell = frame.get("shell") or DEFAULT_SHELL
|
|
821
906
|
if kind == "exec":
|
|
822
|
-
|
|
823
|
-
if running is not None and not running.done():
|
|
907
|
+
if self._busy(shell):
|
|
824
908
|
# 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.
|
|
825
|
-
self._send(
|
|
909
|
+
await self._send(
|
|
826
910
|
{"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}
|
|
827
911
|
)
|
|
828
912
|
return
|
|
@@ -832,12 +916,15 @@ class Kernel:
|
|
|
832
916
|
elif kind == "result":
|
|
833
917
|
self._bridge.settle(frame.get("id"), bool(frame.get("ok")), frame.get("value"), frame.get("tool"), frame.get("message"))
|
|
834
918
|
elif kind == "interrupt":
|
|
835
|
-
|
|
836
|
-
if
|
|
837
|
-
|
|
919
|
+
# A synchronous CPU-bound cell (`while True: pass`) blocks the event loop inside `exec`, so this interrupt frame sits unread and `task.cancel()` can't fire until the next `await` yields back to the loop.
|
|
920
|
+
if self._busy(shell):
|
|
921
|
+
self._tasks[shell].cancel()
|
|
838
922
|
elif kind == "init":
|
|
839
|
-
|
|
840
|
-
|
|
923
|
+
# Mirror the exec branch's two guards: a non-list `tools` would raise inside `build_bindings` (caught only by `serve`'s broad `except`, which swallows the `ready` frame and wedges the host), and a re-init while a cell is running must not rebind the tool table under it. `ready` is always sent — init is idempotent, so the existing bindings stay valid when we skip.
|
|
924
|
+
specs = frame.get("tools")
|
|
925
|
+
if isinstance(specs, list) and not self._busy(shell):
|
|
926
|
+
self._session_for(shell, specs)
|
|
927
|
+
await self._send(
|
|
841
928
|
{
|
|
842
929
|
"t": "ready",
|
|
843
930
|
"shell": shell,
|
|
@@ -851,8 +938,10 @@ class Kernel:
|
|
|
851
938
|
}
|
|
852
939
|
)
|
|
853
940
|
elif kind == "dispose":
|
|
854
|
-
# The agent is gone; drop its shell so its globals can be collected.
|
|
855
|
-
self._tasks.pop(shell, None)
|
|
941
|
+
# The agent is gone; cancel any in-flight cell, then drop its shell so its globals can be collected.
|
|
942
|
+
running = self._tasks.pop(shell, None)
|
|
943
|
+
if running is not None and not running.done():
|
|
944
|
+
running.cancel()
|
|
856
945
|
self._sessions.pop(shell, None)
|
|
857
946
|
|
|
858
947
|
async def serve(self) -> None:
|
|
@@ -864,7 +953,12 @@ class Kernel:
|
|
|
864
953
|
os.fdopen(PROTOCOL_FD, "rb", buffering=0),
|
|
865
954
|
)
|
|
866
955
|
while True:
|
|
867
|
-
|
|
956
|
+
try:
|
|
957
|
+
line = await reader.readline()
|
|
958
|
+
except ValueError:
|
|
959
|
+
# a line over the 16 MiB limit overflows the buffer; it is already drained, so skipping is safe — but say so: the host's exec never gets its `done` and the turn hangs, and a silent drop hides why
|
|
960
|
+
print("[dsh-py-codeact] dropped a frame over the 16 MiB line limit", file=REAL_STDERR)
|
|
961
|
+
continue
|
|
868
962
|
if not line:
|
|
869
963
|
return # host closed fd 3
|
|
870
964
|
try:
|
|
@@ -876,7 +970,7 @@ class Kernel:
|
|
|
876
970
|
if frame.get("t") == "shutdown":
|
|
877
971
|
return
|
|
878
972
|
try:
|
|
879
|
-
self._handle(frame)
|
|
973
|
+
await self._handle(frame)
|
|
880
974
|
except Exception: # noqa: BLE001 — one malformed frame must not take the interpreter down
|
|
881
975
|
# `_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.
|
|
882
976
|
print(f"[dsh-py-codeact] dropped a frame that raised: {traceback.format_exc()}", file=REAL_STDERR)
|