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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muspi Merol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# dsh-py-codeact
|
|
2
|
+
|
|
3
|
+
A CodeAct agent loop for the DeepSeek Harness: the model's action space is a **persistent IPython session**, and harness tools are bridged into it as awaitables.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
# one cell
|
|
7
|
+
from __dsh__.tools import read, glob
|
|
8
|
+
import pandas as pd, io
|
|
9
|
+
|
|
10
|
+
paths = await glob(pattern="data/*.csv")
|
|
11
|
+
frames = [pd.read_csv(io.StringIO(await read(file_path=p))) for p in paths]
|
|
12
|
+
df = pd.concat(frames)
|
|
13
|
+
|
|
14
|
+
# a later cell — `df` is still bound, and so are `read`, `_`, `Out[1]`, everything
|
|
15
|
+
df.groupby("region").revenue.sum().nlargest(3)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`python` is the only tool the model can call directly. Everything else lives in `__dsh__.tools`, so the action space really is one tool — the CodeAct shape.
|
|
19
|
+
|
|
20
|
+
Cells run through `InteractiveShell.run_cell_async`, so magics, `transform_cell`, top-level `await`, execution history, and IPython's traceback formatter all come for free. The shell setup and the LLM-specific touches below follow [`CNSeniorious000/temporary-mcp-servers:ipython-mcp.py`](https://github.com/CNSeniorious000/temporary-mcp-servers/blob/main/ipython-mcp.py).
|
|
21
|
+
|
|
22
|
+
## How it differs from dsh's built-in Code Mode
|
|
23
|
+
|
|
24
|
+
Same idea, opposite state model.
|
|
25
|
+
|
|
26
|
+
| | Code Mode (`run_code`) | this plugin (`python`) |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Language | TypeScript (or Python via a backend) | Python, in IPython |
|
|
29
|
+
| State between calls | none — one fresh worker per run | **persists** for the session |
|
|
30
|
+
| Layer | a `CodeRuntime` seam provider | an ordinary tool plugin |
|
|
31
|
+
| Tool surface | a generated `tools` global | the `__dsh__.tools` module |
|
|
32
|
+
| Exclusivity | `tools.mode: code`, enforced by the registry | `mode: code`, via prompt assembly + a guard |
|
|
33
|
+
| Result | program logs + return value | tagged stdout / return / traceback / note |
|
|
34
|
+
|
|
35
|
+
**It is deliberately not a `CodeRuntime` backend.** That seam's contract states *"no state survives between runs"*, and Code Mode's own Agent Note records a persistent REPL kernel as rejected-for-MVP, because cross-call state would be invisible to the session log. A persistent kernel cannot conform — so it owns its own process instead of implementing that interface.
|
|
36
|
+
|
|
37
|
+
That tradeoff is real and inherited: the interpreter's live state cannot be reconstructed from a session replay. What IS in the log is every cell's source and every bridged tool call, which is enough to audit what happened.
|
|
38
|
+
|
|
39
|
+
## `__dsh__` — the seam
|
|
40
|
+
|
|
41
|
+
`__dsh__` is a real package in `sys.modules`, so every import form resolves:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from __dsh__.tools import glob, grep # the usual one
|
|
45
|
+
import __dsh__.tools as T # T.read(...)
|
|
46
|
+
from __dsh__ import tools # tools.read(...)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A dunder-named package reads as harness-owned, survives `%reset`, and leaves the obvious name `tools` free for the model's own variables.
|
|
50
|
+
|
|
51
|
+
**Each tool is a real `async def`.** dsh's description becomes its `__doc__` and its parameters become a keyword-only signature, so the REPL's own introspection does the explaining:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
In [1]: read?
|
|
55
|
+
Signature: read(*, file_path: 'str', offset: 'int' = Ellipsis, limit: 'int' = Ellipsis)
|
|
56
|
+
Docstring: Read a file from the workspace. Results include line numbers…
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That is why the prompt block carries signatures only — the descriptions are one `?` away instead of resident in every request. Annotations are rendered host-side with dsh's own exported `jsonSchemaToPy`, so there is no second JSON-Schema mapper to drift. The binding set is resent with every cell, because restrictions and mid-conversation tool changes can move a tool in or out between calls.
|
|
60
|
+
|
|
61
|
+
### MCP tools work, with nothing special
|
|
62
|
+
|
|
63
|
+
MCP servers register into the same `ctx.tools` registry as everything else, so they arrive in `__dsh__.tools` like any other binding and dispatch through the same pipeline:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from __dsh__.tools import mcp__gh__github_graphql as gh
|
|
67
|
+
|
|
68
|
+
data = await gh(query="{ viewer { login } }")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
dsh's MCP client is explicitly aware of this route — its canonical value "retains the complete JSON MCP blocks and optional structured content for programmatic and Code Mode callers" — and the sub-call logs a `SUBTOOL` row like any other.
|
|
72
|
+
|
|
73
|
+
Worth contrasting: Anthropic's server-side [programmatic tool calling](https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling) is *not* compatible with MCP tools. Owning the bridge host-side is what buys this.
|
|
74
|
+
|
|
75
|
+
(A tool whose name is not a valid Python identifier cannot be `import`ed, but is still reachable as `getattr(__dsh__.tools, "odd-name")`. dsh's MCP naming — `mcp__<server>__<tool>` — always is one.)
|
|
76
|
+
|
|
77
|
+
## Exclusive mode
|
|
78
|
+
|
|
79
|
+
`mode: code` (the default) makes `python` the only directly callable tool:
|
|
80
|
+
|
|
81
|
+
- a `system-prompt/assemble` waterfall listener filters `assembly.tools` down to `python`, so the other schemas never enter the request;
|
|
82
|
+
- a `ctx.tools.guard()` denies a model-direct call to anything else and names the route back (`from __dsh__.tools import <name>`) — presentation alone is not enforcement, and a bare rejection reads as a broken deployment. Sub-dispatches carry a `parent` token and are exempt.
|
|
83
|
+
|
|
84
|
+
`ctx.tools.restrict()` cannot do the first job: it masks *global* tools only, and in a preset composition the tools are scope-local — "scoped registrations remain visible". The assemble waterfall is the layer that owns the model-facing list.
|
|
85
|
+
|
|
86
|
+
The rule ships as its own prompt section at order 99, ahead of the 100–199 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. (Measured: moving it out of the band took it from character 4129 to 170.)
|
|
87
|
+
|
|
88
|
+
Set `mode: both` to keep the native schemas alongside — useful while debugging a composition, not the CodeAct shape.
|
|
89
|
+
|
|
90
|
+
## The `CodeAct` card
|
|
91
|
+
|
|
92
|
+
The browser half registers `tool.call.toolview` under `key: 'python'`, giving the call its own card:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
CodeAct · count the markdown files at the root
|
|
96
|
+
┌ python copy ┐
|
|
97
|
+
│ from __dsh__.tools import glob │
|
|
98
|
+
│ print(len(await glob(pattern="*.md"))) │
|
|
99
|
+
└───────────────────────────────────────────────┘
|
|
100
|
+
Glob · *.md ← native SUBTOOL row, untouched
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The code body is the shipped `CodeBlock` primitive with `lang: "python"`, so the highlighting, the language label, and the copy button are dsh's own.
|
|
104
|
+
|
|
105
|
+
**A client half is required, because nothing host-side can affect this row.** `toolRowModel` ignores the `presentCall` view entirely and derives everything from the tool NAME plus raw args:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
title = TOOL_TITLES[toolName] ?? VARIANT_TITLES[classifyTool(toolName)] // unknown → "Tool call"
|
|
109
|
+
summary = variant === 'others' ? `${toolName} · ${base}` : base
|
|
110
|
+
body = deriveBody(variant, argsRaw) // unknown → raw args JSON
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
and `classifyTool` reads a hardcoded map in which `run_code: 'code'` is the only entry that reaches the syntax-highlighted branch (with `lang: "typescript"` also hardcoded). `run_code` is a reserved name a plugin may not take. Upstream fix proposed in [discussion #4724](https://github.com/deepseek-ai/deepseek-harness/discussions/4724): let the call view carry `kind: 'execute'` plus a language.
|
|
114
|
+
|
|
115
|
+
**SUBTOOL nesting survives by construction.** `ToolCallBranch` renders `block.subCalls` as *siblings* of the slot occupant:
|
|
116
|
+
|
|
117
|
+
```js
|
|
118
|
+
children: [renderSlot('tool.call.toolview', owner, { entryKey: toolName, fallback }), children]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
so a registered toolview replaces the card body only. The slot contract says the same: "registering is additive for your own tool".
|
|
122
|
+
|
|
123
|
+
The card is hand-written in the client module system's factory form (a classic script registering a CJS factory) rather than bundled — this package has no build step, and the card needs only React plus one shipped primitive.
|
|
124
|
+
|
|
125
|
+
The key is the literal name `python`; a custom `toolName` falls back to the generic row.
|
|
126
|
+
|
|
127
|
+
## SUBTOOL rows come free
|
|
128
|
+
|
|
129
|
+
Each bridged call appends the same two session events Code Mode uses:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
session.append('tool/code-dispatch-start', { rootCallId, parentCallId, subCallId, name, arguments })
|
|
133
|
+
session.append('tool/code-dispatch', { ...same, isError, content })
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
They pair by `subCallId` (`<parent>:py:<n>`), and the settle event carries `tool/result`'s own vocabulary (`content` + `isError`) — so the trajectory UI renders them through the exact code path it uses for native calls, as `SUBTOOL` rows under the `python` call. No client change.
|
|
137
|
+
|
|
138
|
+
Both types are in `KNOWN_SESSION_EVENT_TYPES`, so reusing them is supported. Note that an out-of-repo plugin cannot invent a *new* event type: the persistence read path refuses a log containing a type outside that set unless the event is marked `ignorable`, and a registration surface for downstream events is explicitly deferred upstream.
|
|
139
|
+
|
|
140
|
+
Sub-dispatches carry the outer execution's `parent` token, so they re-enter the complete `pre-execute → guards → execute → post-execute → result` pipeline. The sandbox and approval stack still gate every tool call made from inside a cell.
|
|
141
|
+
|
|
142
|
+
## What the model gets
|
|
143
|
+
|
|
144
|
+
- **Magics.** `%whos` to recall what it has bound, `%timeit`, `%run script.py`, `%%writefile`, `obj?` / `obj??`, `%cd`. Cheap self-orientation in a session it has partly forgotten.
|
|
145
|
+
- **History.** `store_history=True`, so `_`, `__`, `_i3`, `Out[n]` all work.
|
|
146
|
+
- **Redundant-import hints.** When an `import` rebinds a name to the object it already held, the result carries `` `json` is already imported in this session — no need to re-import it. `` A model driving a persistent REPL re-imports constantly; telling it is cheaper than letting it burn a line every cell. (Implemented with a `dict` subclass that watches top-level `STORE_NAME` and checks the preceding opcode was `IMPORT_NAME`/`IMPORT_FROM`, so `x = x` does not trip it.)
|
|
147
|
+
- **Readable reprs.** `objprint` + IPython's `pretty` for objects whose own `__repr__` is `object.__repr__` — an agent reading values needs structure, not `<Foo object at 0x…>`.
|
|
148
|
+
- **Tagged observations.** `<stdout>`, `<stderr>`, `<return>`, `<traceback>`, `<note>` — with four things possibly present at once, the model needs to know which is which. A plain successful value stays bare.
|
|
149
|
+
- **Introspection over prompt text.** `read?` for one tool's full description, `dir(__dsh__.tools)` for the whole list, `%whos` for its own bindings.
|
|
150
|
+
|
|
151
|
+
## Cancellation
|
|
152
|
+
|
|
153
|
+
An aborted turn sends an in-band `interrupt` frame, cancelling the running cell at any `await` point. `run_cell_async` catches the `CancelledError` itself, so the model is told plainly (`InterruptedError: the harness cancelled this cell. State is intact; the cell did not finish.`) rather than handed a raw traceback it might read as a bug in its own code. The session and every binding survive.
|
|
154
|
+
|
|
155
|
+
A pure CPU loop (`while True: pass`) never reaches an await point. After `hardInterruptMs` (default 5s) the interpreter is SIGKILLed and the next call respawns it; that observation is prefixed with `[the interpreter was restarted; every earlier binding is gone]` so the model does not keep referring to variables that no longer exist.
|
|
156
|
+
|
|
157
|
+
## Known limitations
|
|
158
|
+
|
|
159
|
+
- **Native writes are not captured.** stdout/stderr are captured at the Python level, so `print` is captured but a subprocess writing to fd 1 is not. Use `subprocess.run(..., capture_output=True)`, or `%run`. (Anything that does reach fd 1/2 — including IPython's own colored traceback, deliberately routed there — is retained only for the crash message.)
|
|
160
|
+
- **Scope is not optional.** The visible tool set comes from `ctx.tools.schemas(scope)` — the scope being the agent. Omitting it yields the *global* view, which in a preset composition holds only host-registered tools; the preset's own `read`/`bash`/`edit` live in the agent scope and vanish. The prompt section reads `assembly.scope`, and the kernel's name list is resent with every cell (restrictions and mid-conversation tool changes can move a tool in or out between calls).
|
|
161
|
+
- **Pick a `toolName` nothing else answers to.** With an MCP IPython server also mounted, a model told to "use the python tool" reaches for `mcp__py__ipython_execute_code` — which has no `tools` binding — and then reports that your tool does not exist.
|
|
162
|
+
- **Return types render as `Any`.** The prompt section is generated from `ctx.tools.schemas()`, which whitelists name/description/parameters; the canonical *output* schemas sit behind the registry's private `sdkSchemas`. Argument annotations are complete; return annotations are not. A public accessor upstream would fix this — `renderToolsSdkPy` is already exported and takes exactly the `ToolSchema + output` shape it would provide.
|
|
163
|
+
- **Cold start.** The PEP 723 environment is resolved on first use (`uv python find --script`). Warm afterwards; pass `python` to skip it.
|
|
164
|
+
|
|
165
|
+
The interpreter is then spawned **directly**, never behind `uv run --script`. A wrapper stays in the process tree as the interpreter's parent: when it exits first, the interpreter is reparented to init, the handle the host holds reports an exit, and a perfectly live kernel looks dead — so the next cell respawns and the session's state vanishes with an `[the interpreter was restarted]` notice nothing actually caused. `alive` is likewise tracked from the exit event rather than read off `proc.killed`, which Node sets on any `kill()` call, including a signal the process survived.
|
|
166
|
+
- **Containment, not a security boundary.** A separate process with a curated environment, but model code can `import os`. Treat a session on this preset as shell access.
|
|
167
|
+
- **One interpreter per conversation tree, one shell per agent.** A subagent reuses the parent's process — sharing its event loop, `sys.modules`, installed packages and `__dsh__.shared` — but gets its own globals and its own tool catalogue. Costs an `init` frame, not another interpreter.
|
|
168
|
+
- **`__dsh__.shared`** is the one deliberate crack in that isolation: a module every agent in the process can read and write, for handing live objects across a fan-out with no serialization and no tokens.
|
|
169
|
+
- Shells close with their session; the process goes when the last agent pointing at it does.
|
|
170
|
+
|
|
171
|
+
## Environment
|
|
172
|
+
|
|
173
|
+
By default the kernel gets an allowlist — `PATH`, `HOME`, `TMPDIR`, `LANG`, `LC_ALL`, `TERM` — not the empty environment the worker-thread runtime uses. CPython running IPython needs a profile dir and a `PATH` for shell escapes; the point is excluding ambient credentials, which an allowlist does just as well. `inheritEnv: true` passes the whole harness environment.
|
|
174
|
+
|
|
175
|
+
`COLUMNS`/`LINES` are pinned inside the kernel: with no TTY, `get_terminal_size()` falls back to 80×24 and wraps tracebacks and pretty output far narrower than the harness renders at.
|
|
176
|
+
|
|
177
|
+
## Install
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
dsh plugin --profile <name> add dsh-py-codeact
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Then add the row from `example/agent.cordis.yml` to a preset you own.
|
|
184
|
+
|
|
185
|
+
The kernel provisions its own Python from the PEP 723 header in `py/kernel.py`, so `uv` is the only prerequisite beyond dsh itself; the first cell pays for that resolve and later ones are warm.
|
|
186
|
+
|
|
187
|
+
**For the card, also list the package in the profile's `dsh.profile.bundles`** (`~/.dsh/profiles/<name>/package.json`). The browser bundle is served only for packages named by an *enabled Loader entry*, and a preset's rows are per-session — they are not in the profile's entry list at boot, so a preset-only mount never gets its card. This package's own `cordis.patch.yml` therefore inserts one profile-level row carrying `uiOnly: true`, which makes the host half a no-op there: mounting it at profile level would register `python` globally and apply exclusive mode's guard to every preset. Never edit a shipped preset — copy it first (`ctx.agentPresets.copy('standard', 'py-codeact')`) and mount-validate the result with `standingKeyFor('py-codeact')`.
|
|
188
|
+
|
|
189
|
+
## Verified end to end
|
|
190
|
+
|
|
191
|
+
Against `Macaron V1 Venti` on a real dsh session. Given a plain task — "read this CSV and tell me which region earned most", with no mention of Python or the module — the model imported from `__dsh__.tools`, ran the cell, and answered with a table. The request carried exactly one tool schema (`python`); all 33 others were reachable only from inside the cell, and the bridged `read` appears as a `SUBTOOL` row under the `python` call in the trajectory tab. A `headless` run completes and exits in ~11s.
|
|
192
|
+
|
|
193
|
+
## Test
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
node test/smoke.js
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Drives the kernel and wire protocol directly, with no harness: state persistence, `store_history`, magics, redundant-import hints, top-level await, the tool bridge, `ToolCallError`, `asyncio.gather`, traceback recovery (and that ANSI never leaks into the captured stderr), in-band interrupt, and the SIGKILL escalation.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Profile-level row for the BROWSER half only.
|
|
2
|
+
#
|
|
3
|
+
# The client bundle is served for packages named by an enabled Loader entry, and an agent preset's rows are per-session — they are not in the profile's entry list at boot, so a preset-only mount never gets its card served. This row puts the package in that list.
|
|
4
|
+
#
|
|
5
|
+
# `uiOnly` makes the host half a no-op here: mounting it at profile level would register `python` globally and apply exclusive mode's guard to EVERY preset. The real host half is mounted by the agent preset row.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: py-codeact-ui
|
|
8
|
+
name: dsh-py-codeact
|
|
9
|
+
config:
|
|
10
|
+
uiOnly: true
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# The `py-codeact` agent preset: the standard coding agent, with a persistent Python REPL as its action space instead of one-shot `run_code`.
|
|
2
|
+
#
|
|
3
|
+
# Copy this into ${DSH_HOME:-$HOME/.dsh}/.agent-presets/py-codeact/agent.cordis.yml (start from `copy('standard', 'py-codeact')` via ctx.agentPresets, then apply the two edits below — a composition written from scratch usually forgets a realm or a consumer row).
|
|
4
|
+
#
|
|
5
|
+
# The two edits, relative to a copy of `standard`:
|
|
6
|
+
#
|
|
7
|
+
# 1. Add the `py-codeact` row below.
|
|
8
|
+
# 2. Leave `tools.mode` at its default `native`. dsh's own Code Mode (`mode: code`) would ALSO reserve `run_code` and add its `tools:sdk` section — two code-execution surfaces in one prompt confuses the model. This plugin is a normal tool, so `native` is exactly right.
|
|
9
|
+
#
|
|
10
|
+
# PLANE: this row publishes no service — the kernels are private to the plugin fiber, keyed by session — so it needs NO `isolate` realm and sits loose, like `tool-bash`. It only CONSUMES the host `tools`, `systemPrompt`, and `sessions` registries; wrapping it in a realm would starve it of exactly those.
|
|
11
|
+
|
|
12
|
+
- id: py-codeact
|
|
13
|
+
name: dsh-py-codeact
|
|
14
|
+
config:
|
|
15
|
+
# Omit both `command` and `python` to spawn `uv run --script py/kernel.py`, which provisions IPython from the script's PEP 723 header. First use pays a resolve; warm afterwards.
|
|
16
|
+
#
|
|
17
|
+
# `python` names an interpreter that ALREADY has ipython + objprint — a prepared project venv, so the model can import the project's own packages:
|
|
18
|
+
# python: /path/to/.venv/bin/python
|
|
19
|
+
#
|
|
20
|
+
# `command` is the full argv escape hatch:
|
|
21
|
+
# command: ['uv', 'run', '--with', 'pandas', '--script', '...']
|
|
22
|
+
|
|
23
|
+
# Model-facing tool name. Keep it distinct from `run_code` — and from an MCP IPython server's, if one is mounted (see the README).
|
|
24
|
+
toolName: python
|
|
25
|
+
|
|
26
|
+
# 'code' (default): `python` is the ONLY directly callable tool; everything else is reachable solely as `from __dsh__.tools import ...` inside a cell.
|
|
27
|
+
# 'both' keeps the native schemas too — for debugging a composition.
|
|
28
|
+
mode: code
|
|
29
|
+
|
|
30
|
+
# false (default) spawns with an allowlisted environment — PATH, HOME, TMPDIR, LANG, LC_ALL, TERM — so no ambient credentials reach model code. (Not an EMPTY env like the worker-thread runtime: CPython running IPython needs a profile dir and a PATH for shell escapes.) Set true to pass the whole harness environment through.
|
|
31
|
+
inheritEnv: false
|
|
32
|
+
|
|
33
|
+
# TRUST: this is bash-equivalent. The kernel is containment (separate process, empty env), NOT a security boundary — model code can import `os` and do anything the harness user can. Compose it only where you would compose `tool-bash`, and keep the host sandbox/approval rows in place: they still gate every BRIDGED tool call, because sub-dispatches re-enter the full pre-execute -> guards -> execute -> post-execute pipeline.
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half: the `CodeAct` card for the `python` tool.
|
|
3
|
+
*
|
|
4
|
+
* Registers `tool.call.toolview` under `key: 'python'`. That slot is keyed by wire tool name and, per its contract, "registering is additive for your own tool" — it replaces ONLY the card body. `ToolCallBranch` renders `subCalls` as siblings of the slot occupant, so the native SUBTOOL nesting is untouched.
|
|
5
|
+
*
|
|
6
|
+
* Why a client half at all: `toolRowModel` in `dsh-client-ui-tool` ignores the host's `presentCall` view entirely and derives title/summary/body from the tool NAME plus raw args — `TOOL_TITLES[name] ?? VARIANT_TITLES[classifyTool(name)]`, which lands an unknown tool on "Tool call" with its args as JSON. Nothing host-side can change that. Upstream fix proposed in discussion #4724.
|
|
7
|
+
*
|
|
8
|
+
* This mirrors the shipped `ToolRow` rather than restyling: the same exported `DisclosureRow` and `CodeBlock` primitives, the same leading icon, and the same CSS module classes — read off the stylesheet that `dsh-client-ui-tool` already injected, so the card inherits every rule instead of approximating it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-py-codeact',
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
const module = { exports: {} }
|
|
15
|
+
|
|
16
|
+
const React = require('react')
|
|
17
|
+
const ui = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
18
|
+
const h = React.createElement
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The shipped ToolRow class names.
|
|
22
|
+
*
|
|
23
|
+
* CSS-module classes are hash-prefixed per build, so the prefix is recovered from the stylesheet `dsh-client-ui-tool` injects rather than pinned — a version bump changes the hash but not this lookup. Falls back to the bare name, which renders unstyled instead of throwing.
|
|
24
|
+
*
|
|
25
|
+
* The alternative — shipping our own `.module.css`, the way `dsh-client-ui-tool` and `dsh-client-ui-skill` do — needs a bundler this buildless package does not have, and would fork their rules: the card would stop tracking a future dsh restyle and drift out of the parity it was built for. Borrowing keeps it native by construction; the cost is the coupling, which the runtime lookup and the fallback bound.
|
|
26
|
+
*/
|
|
27
|
+
const css = (() => {
|
|
28
|
+
const tag = typeof document === 'undefined'
|
|
29
|
+
? null
|
|
30
|
+
: document.querySelector('style[data-plugin-css="@deepseek-ai/dsh-client-ui-tool/ToolRow.module.css"]')
|
|
31
|
+
const prefix = tag?.textContent?.match(/\.([A-Za-z0-9_-]+?)_root\b/)?.[1]
|
|
32
|
+
return (name) => (prefix === undefined ? name : `${prefix}_${name}`)
|
|
33
|
+
})()
|
|
34
|
+
|
|
35
|
+
/** Flatten a settled node's content blocks the way the shipped generic row does. */
|
|
36
|
+
function resultText(block) {
|
|
37
|
+
const parts = []
|
|
38
|
+
for (const content of block.content ?? []) {
|
|
39
|
+
if (content.type === 'text') parts.push(content.text)
|
|
40
|
+
else parts.push(JSON.stringify(content, null, 2))
|
|
41
|
+
}
|
|
42
|
+
if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
|
|
43
|
+
return parts.join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function CodeActCard({ toolName, block, inspect }) {
|
|
47
|
+
const [expanded, setExpanded] = React.useState(false)
|
|
48
|
+
|
|
49
|
+
const settled = 'kind' in block
|
|
50
|
+
const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
|
|
51
|
+
let args
|
|
52
|
+
try { args = JSON.parse(argsRaw) } catch { args = undefined }
|
|
53
|
+
const code = typeof args?.code === 'string' ? args.code : argsRaw
|
|
54
|
+
// The description is the row's summary — that is why the tool makes it required. Fall back to the first line of code only when it is absent.
|
|
55
|
+
const summary = typeof args?.description === 'string' && args.description.trim() !== ''
|
|
56
|
+
? args.description
|
|
57
|
+
: (code.split('\n')[0] ?? '')
|
|
58
|
+
|
|
59
|
+
const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok'
|
|
60
|
+
const output = settled ? resultText(block) || null : null
|
|
61
|
+
const failureLine = state === 'error' && output !== null ? (output.split('\n').find((line) => line.trim() !== '') ?? null) : null
|
|
62
|
+
const summaryText = failureLine ?? summary
|
|
63
|
+
const expandable = code !== '' || output !== null
|
|
64
|
+
|
|
65
|
+
const body = h(React.Fragment, null, [
|
|
66
|
+
code === '' ? null : h('div', { className: css('bodyScroll'), key: 'code' },
|
|
67
|
+
h(ui.CodeBlock, { code, lang: 'python', className: css('codeBody') })),
|
|
68
|
+
output === null ? null : h('div', { className: css('ioCard'), key: 'io' },
|
|
69
|
+
h('div', { className: css('ioSection') }, [
|
|
70
|
+
h('span', { className: css('ioLabel'), key: 'l' }, 'OUT'),
|
|
71
|
+
h('span', { className: css('ioText'), key: 't', 'data-error': state === 'error' || undefined }, output),
|
|
72
|
+
])),
|
|
73
|
+
inspect === undefined ? null : h('button', {
|
|
74
|
+
type: 'button', className: css('inspectButton'), onClick: inspect, key: 'inspect',
|
|
75
|
+
}, [h(ui.IconInspectOutline12, { key: 'i' }), 'Inspect']),
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
// Mirror the shipped row's state signalling. Without the dot, an interrupted cell is pixel-identical to a successful one — `failureLine` is null for `stopped`, so it does not even get the error colouring, and `data-state` is invisible. The hidden label is what assistive tech gets: both the dot and the running sweep are colour-only.
|
|
79
|
+
const status = { running: '运行中', error: '执行失败', stopped: '已中断' }[state] ?? null
|
|
80
|
+
const leading = state === 'error' ? h(ui.StateDot, { state: 'error' })
|
|
81
|
+
: state === 'stopped' ? h(ui.StateDot, { state: 'warning' })
|
|
82
|
+
: h(ui.IconCodeOutline16, { size: 14 })
|
|
83
|
+
|
|
84
|
+
return h('div', {
|
|
85
|
+
className: css('root'),
|
|
86
|
+
'data-variant': 'code',
|
|
87
|
+
'data-tool': toolName,
|
|
88
|
+
'data-state': state,
|
|
89
|
+
}, [
|
|
90
|
+
status === null ? null : h('span', { className: css('visuallyHidden'), key: 'status' }, status),
|
|
91
|
+
h(ui.DisclosureRow, {
|
|
92
|
+
key: 'row',
|
|
93
|
+
rowClassName: css('row'),
|
|
94
|
+
leadingClassName: css('leading'),
|
|
95
|
+
titleClassName: css('title'),
|
|
96
|
+
chevronClassName: css('chevron'),
|
|
97
|
+
icon: leading,
|
|
98
|
+
title: 'CodeAct',
|
|
99
|
+
open: expanded && expandable,
|
|
100
|
+
expandable,
|
|
101
|
+
expandOnRowClick: true,
|
|
102
|
+
keepContentWhenOpen: true,
|
|
103
|
+
onToggle: () => setExpanded((value) => !value),
|
|
104
|
+
collapsedContent: summaryText === '' ? undefined : h(React.Fragment, null, [
|
|
105
|
+
h('span', { className: css('sep'), 'aria-hidden': true, key: 's' }),
|
|
106
|
+
h('span', {
|
|
107
|
+
className: failureLine === null ? css('summary') : `${css('summary')} ${css('errorSummary')}`,
|
|
108
|
+
key: 'x',
|
|
109
|
+
}, summaryText),
|
|
110
|
+
]),
|
|
111
|
+
}, h('div', { className: css('bodyWrap') }, body)),
|
|
112
|
+
])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
name: 'dsh-py-codeact-client',
|
|
117
|
+
// Declared, not probed: cordis holds `apply` until `slots` exists. Reading `ctx.get('slots')` and returning when it is absent loses the race silently and is never retried — the `python` row falls back to the generic "Tool call" card for the rest of the session with nothing logged.
|
|
118
|
+
inject: ['slots'],
|
|
119
|
+
apply(ctx) {
|
|
120
|
+
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
|
|
121
|
+
{ name: 'tool.call.toolview', key: 'python' },
|
|
122
|
+
(props) => h(CodeActCard, props),
|
|
123
|
+
))
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return module.exports
|
|
128
|
+
},
|
|
129
|
+
})
|