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.
Files changed (3) hide show
  1. package/lib/index.js +102 -4
  2. package/package.json +1 -1
  3. 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 still emits `tool/code-dispatch-start` / `tool/code-dispatch`, so bridged tool calls render as SUBTOOL rows in the existing trajectory UI with no client change.
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
- session?.append('tool/code-dispatch-start', trace)
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('tool/code-dispatch', { ...trace, isError: outcome.isError, content: outcome.content })
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('tool/code-dispatch', { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
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.2",
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
- self._send({"t": "call", "id": call_id, "shell": None if session is None else session.shell_id, "name": name, "args": args})
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
- return await bridge.call(name, {renames.get(key, key): value for key, value in kwargs.items()} if renames else kwargs)
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 [name for name in members if spellable(name) is not None]:
404
- members.setdefault(spellable(raw), members[raw])
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
- servers = mcp_servers({name: call for name, call in bound_tools().items() if servable(name) is not None})
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(bindings: dict) -> None:
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 mcp_servers(bindings):
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)) | {"ToolCallError", "mcp"}
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
- return {**flat, "mcp": MCP_ROOT} if mcp_servers(flat) else flat
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].decode("utf-8", "ignore"))
664
+ self.parts.append(chunk[:room])
627
665
  self.size += room
628
666
  self.truncated = True
629
667
  else:
630
- self.parts.append(s)
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
- self.bindings = build_bindings(bridge, specs)
709
- install_mcp_modules(self.bindings)
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
- while payload:
779
- try:
780
- written = self._out.write(payload)
781
- except BlockingIOError:
782
- written = None
783
- if written is None: # the buffer is full; wait for the host to drain it
784
- select.select([], [PROTOCOL_FD], [])
785
- continue
786
- payload = payload[written:]
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
- running = self._tasks.get(shell)
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
- running = self._tasks.get(shell)
836
- if running is not None and not running.done():
837
- running.cancel()
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
- self._session_for(shell, frame.get("tools") or [])
840
- self._send(
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
- line = await reader.readline()
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)