dsh-py-codeact 0.0.0 → 0.1.0
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 +201 -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 +53 -9
- package/py/kernel.py +614 -0
- package/index.js +0 -1
package/lib/index.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dsh-py-codeact` — a CodeAct agent loop over a PERSISTENT Python REPL.
|
|
3
|
+
*
|
|
4
|
+
* Relation to dsh's built-in Code Mode (`run_code`): same idea, opposite state model. Code Mode runs one fresh program per call and deliberately keeps no state — `CodeRuntime`'s contract says "no state survives between runs", and its Agent Note records a persistent REPL kernel as rejected-for-MVP because cross-call state would be invisible to the session log.
|
|
5
|
+
*
|
|
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
|
+
*
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-py-codeact
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { CallId } from '@deepseek-ai/dsh-llm/brand'
|
|
14
|
+
import { contentHasImage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
15
|
+
import { defineTool, jsonSchemaToPy } from '@deepseek-ai/dsh-tools'
|
|
16
|
+
import { KERNEL_PY, PythonKernel } from './kernel.js'
|
|
17
|
+
|
|
18
|
+
/** Same prompt band as Code Mode's `tools:sdk`: tool guidance is 100–199. */
|
|
19
|
+
const SDK_SECTION_ORDER = 150
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 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.
|
|
23
|
+
*/
|
|
24
|
+
const RULE_SECTION_ORDER = 99
|
|
25
|
+
|
|
26
|
+
const PLUGIN_NAME = 'dsh-py-codeact'
|
|
27
|
+
|
|
28
|
+
const EXCLUSIVE_RULE = `## Tool access
|
|
29
|
+
|
|
30
|
+
\`python\` is the ONLY tool you may call directly. Every other capability is a function inside this interpreter, imported from \`__dsh__.tools\` — that virtual module is the seam between your Python session and the harness. Reach for a capability by writing code that calls it, not by emitting a tool call.
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Shown for the rest of the conversation once this agent's interpreter has been replaced — not just in the observation of the cell that noticed.
|
|
35
|
+
*
|
|
36
|
+
* A one-shot prefix is the wrong shape for this fact. Several turns later the transcript still shows the cells that bound those names, and nothing on screen says they are dead; the model reads a `NameError` on a name it can see above as its own mistake and retries. A section is re-read every turn, which is what a standing fact needs.
|
|
37
|
+
*/
|
|
38
|
+
const RESTART_NOTICE = `## Python session lost
|
|
39
|
+
|
|
40
|
+
The dsh process restarted, so the interpreter went with it: every variable, import, open handle and background task from the cells above is gone. Their code is still in the transcript; nothing it bound is live.
|
|
41
|
+
|
|
42
|
+
A \`NameError\` on a name you can see bound above is that, not your mistake. Rebuild what you need.
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
const INSTRUCTIONS = `## Writing code for the \`python\` tool
|
|
46
|
+
|
|
47
|
+
Your action space is Python. Each call runs one cell in a **persistent IPython session**: names you bind stay bound for the rest of the session, so build state up across calls instead of re-deriving it. Imports, dataframes, open handles, connections all survive — and so does execution history (\`_\`, \`__\`, \`Out[n]\`).
|
|
48
|
+
|
|
49
|
+
- Top-level \`await\` works. So do IPython magics: \`%whos\` to see what you have bound, \`%timeit\`, \`%run script.py\`, \`%%writefile\`, \`obj?\` / \`obj??\`, \`%cd\`.
|
|
50
|
+
- The cell's LAST expression is echoed back to you, like a REPL prompt — that is the return channel, and reaching for it first keeps cells short. A cell that is one tool call needs no \`print\` at all: end it with \`await read(file_path=p)\` and you get the result. Use \`print(...)\` when you want to RESHAPE what comes back — label several values, format a table, show a slice of something large — not to hand over a value the last line would have echoed anyway.
|
|
51
|
+
- Mind what the last line evaluates TO. Ending on \`Path(p).write_text(text)\` echoes the byte count; ending on \`d[k] = v\` echoes nothing. Put the thing worth seeing last, or end with an explicit \`None\` when the cell has nothing to report.
|
|
52
|
+
- Tools are awaitable functions: \`from __dsh__.tools import read\`, then \`await read(file_path=...)\`. Keyword arguments only. Each returns that tool's canonical JSON value. The import survives, so import once and reuse.
|
|
53
|
+
- Compose them with ordinary Python — that is the whole point of this loop. Discover targets in code and feed them straight in rather than naming each one literally: \`for p in Path('src').rglob('*.py'): await read(file_path=p)\`, or \`await asyncio.gather(*(read(file_path=p) for p in paths))\`. Arguments are serialized for you, so \`Path\`, \`datetime\` and friends can be passed as they are.
|
|
54
|
+
- You also have DIRECT filesystem access, and for bulk work it is the better tool: \`Path(p).read_text()\` is one syscall, while \`read\` is a full round-trip through the harness plus a row in the trajectory. Walking a tree, counting matches, reading fifty files to keep three — do it with \`pathlib\`/\`re\` and surface only the conclusion. Reach for the bridged \`read\` when you want what it adds on top: \`offset\`/\`limit\` windowing and its truncation budget for a file too big to hold, or \`read_image\`. An image cannot come back through the cell — a tool result carrying one is attached to the conversation AFTER the run, so call it, end the cell, and look at the image on your next step. What the cell itself receives is only the metadata (path, dimensions), which is not something you can read.
|
|
55
|
+
- \`!uv pip install <pkg>\` installs into this interpreter's environment and the package imports in the same session. Shell escapes (\`!cmd\`) do not fail the cell on a non-zero exit — check the output, or use \`subprocess.run(..., check=True)\`.
|
|
56
|
+
- Delegating? A subagent runs in this same interpreter with its OWN globals, so it cannot see your variables. \`__dsh__.shared\` is the exception: set an attribute on it and every agent here can read that live object — hand over a dataframe or an index by name instead of describing it in the prompt.
|
|
57
|
+
- A FAILED tool call raises \`ToolCallError\` (\`.tool_name\`, plus the message); catch it and continue.
|
|
58
|
+
- Independent calls may overlap with \`asyncio.gather\`. Sequence dependent work with plain \`await\`.
|
|
59
|
+
- ONLY the cell's output and its final expression come back to you. Tool results consumed inside the cell never enter the conversation, so filter and aggregate in code and surface just the conclusion.
|
|
60
|
+
- A cell that raises returns the traceback and the session keeps every prior binding — fix it in the next cell rather than starting over.
|
|
61
|
+
- Only \`await\` points are interruptible. Prefer async APIs over blocking ones, and avoid unbounded CPU loops: they can only be stopped by killing the interpreter, which loses all state.
|
|
62
|
+
|
|
63
|
+
The available tools:`
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Project one tool schema onto the wire spec the kernel builds a binding from: dsh's description becomes the function's docstring, its parameters become a keyword-only signature. Annotations are rendered here with dsh's own `jsonSchemaToPy`, so the kernel needs no second JSON-Schema mapper.
|
|
67
|
+
*/
|
|
68
|
+
function toolSpec(schema) {
|
|
69
|
+
const parameters = schema.parameters ?? {}
|
|
70
|
+
const properties = parameters.properties ?? {}
|
|
71
|
+
const required = new Set(Array.isArray(parameters.required) ? parameters.required : [])
|
|
72
|
+
return {
|
|
73
|
+
name: schema.name,
|
|
74
|
+
doc: schema.description,
|
|
75
|
+
params: Object.entries(properties).map(([name, node]) => ({
|
|
76
|
+
name,
|
|
77
|
+
type: jsonSchemaToPy(node),
|
|
78
|
+
required: required.has(name),
|
|
79
|
+
})),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Deterministic (lexicographic) order, so an unchanged tool set renders byte-identically. */
|
|
84
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
85
|
+
|
|
86
|
+
/** The kernel's own wording for a shell whose previous cell has not finished. Matched, not parsed: it is the one failure that leaves the bindings uninstalled. */
|
|
87
|
+
const KERNEL_BUSY = 'kernel busy: a previous cell is still running'
|
|
88
|
+
|
|
89
|
+
/** Identity of one binding set, for skipping a rebind that would change nothing. Over the whole spec, not just the names: a tool can keep its name and change its description or its parameters — an MCP server reconnecting with a revised schema is the ordinary case — and hashing names alone left the kernel serving the old signature while the prompt, re-rendered from live schemas every turn, showed the new one. */
|
|
90
|
+
export const specsKey = (specs) => JSON.stringify(specs)
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The live interpreter's own account of itself, captured from the `ready` frame.
|
|
94
|
+
*
|
|
95
|
+
* Dynamic on purpose, the way `ipython-mcp.py` renders its instructions: which Python, and which environment `uv pip install` lands in, are facts the model cannot see for itself and that change what code it should write. The host cannot state them either — on the default PEP 723 route it does not know the interpreter until `uv` has resolved one. Undefined until the first kernel of this plugin instance comes up; they share a configuration, so one answer holds for all of them.
|
|
96
|
+
*/
|
|
97
|
+
let pythonEnv
|
|
98
|
+
|
|
99
|
+
function renderEnvironment() {
|
|
100
|
+
if (pythonEnv === undefined) return 'The session runs in its own Python interpreter, with `uv pip install <pkg>` available in a cell.'
|
|
101
|
+
const where = pythonEnv.venv ? `in the environment at \`${pythonEnv.prefix}\`` : `at \`${pythonEnv.executable}\``
|
|
102
|
+
const head = `Python ${pythonEnv.version} ${where}, working directory \`${pythonEnv.cwd}\`. ` + '`!uv pip install <pkg>` installs there and imports immediately. '
|
|
103
|
+
// Only true on the throwaway-venv route. Under `config.python` the interpreter is one the user pointed at — often their own project venv — and every clause of the sentence below is false there, while the prompt still invites the model to install into it.
|
|
104
|
+
return pythonEnv.disposable
|
|
105
|
+
? `${head}That environment is this session's alone — it inherits the base packages, nothing you install escapes to the project or to another session, and it is discarded when the session ends.`
|
|
106
|
+
: `${head}This interpreter is NOT disposable: it was configured for this agent and an install persists in it, visible to everything else that uses it. Install only what the task needs.`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Does this conversation have cells whose interpreter is gone, and has it not already been told?
|
|
111
|
+
*
|
|
112
|
+
* Read from the durable history because that is the only thing that outlives the event it has to detect: `kernels` is in memory, so a harness restart erases the evidence along with the interpreter, while the session log is replayed intact. This is also why `kernelFor`'s `restarted` flag cannot answer it — that one compares against a `previous` entry, and after a harness restart there is no `previous` at all. A restart is by far the more common case, too: restarting `dsh web` is routine, a cell refusing to yield is not.
|
|
113
|
+
*
|
|
114
|
+
* The "not already been told" half is what keeps repeated restarts quiet. The notice is committed to the log, so a plain "did we restart" test would append another one every time the session is reopened, however many times in a row, with nothing in between them. Comparing positions answers the question that actually matters: has anything run in the dead interpreter SINCE the last notice? If not, the standing notice still says everything true.
|
|
115
|
+
*/
|
|
116
|
+
export function needsRestartNotice(session, toolName, plugin) {
|
|
117
|
+
const messages = session?.deriveMessages?.()
|
|
118
|
+
if (messages === undefined) return false
|
|
119
|
+
let lastCell = -1
|
|
120
|
+
let lastNotice = -1
|
|
121
|
+
messages.forEach((message, index) => {
|
|
122
|
+
if ((message.content ?? []).some((block) => block?.type === 'tool-call' && block.name === toolName)) lastCell = index
|
|
123
|
+
if (message.source?.kind === 'plugin' && message.source.plugin === plugin) lastNotice = index
|
|
124
|
+
})
|
|
125
|
+
return lastCell > lastNotice
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `keyword.kwlist + keyword.softkwlist` for the `requires-python = ">=3.12"` the kernel pins. */
|
|
129
|
+
const PY_KEYWORDS = new Set(['False', 'None', 'True', '_', 'and', 'as', 'assert', 'async', 'await', 'break', 'case', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'match', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'type', 'while', 'with', 'yield'])
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The prompt block. Signatures only, with the description carried as a real docstring the model can read with `read?` instead — so the resident prompt stays small and the detail is fetched on demand.
|
|
133
|
+
*/
|
|
134
|
+
export function renderToolsSection(schemas) {
|
|
135
|
+
const specs = [...schemas].sort(byName).map(toolSpec)
|
|
136
|
+
// MCP tools are named `mcp__<server>__<rawName>` over a `[A-Za-z0-9_-]` alphabet, so hyphens are routine — and legal nowhere in `import` or `def`. One such tool used to make the ENTIRE block a SyntaxError, so not a single line in it could be copied, with nothing saying why.
|
|
137
|
+
//
|
|
138
|
+
// A keyword is the same failure wearing a legal shape: `class` and `None` match the identifier pattern and then break `import` and `def` just as hard. `_` and the soft keywords (`match`, `case`, `type`) are in the list because they are legal identifiers everywhere EXCEPT where this block puts them.
|
|
139
|
+
const isUsableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !PY_KEYWORDS.has(name)
|
|
140
|
+
const importable = specs.filter((spec) => isUsableName(spec.name))
|
|
141
|
+
const awkward = specs.filter((spec) => !isUsableName(spec.name))
|
|
142
|
+
const signature = (spec) => {
|
|
143
|
+
// The same rule applies one level down, and used to not be applied at all: `file-path` is routine for MCP tools, and one such PARAMETER made every other tool's signature unusable too. The binding still takes it — the kernel folds unnameable parameters into `**kwargs` — so the tool stays importable and only its signature goes vague; `name?` still shows the real one.
|
|
144
|
+
if (!spec.params.every((p) => isUsableName(p.name))) return `async def ${spec.name}(**kwargs: Any) -> Any: ... # parameter names are not all valid Python; see ${spec.name}?`
|
|
145
|
+
const fields = spec.params.map((p) => (p.required ? `${p.name}: ${p.type}` : `${p.name}: ${p.type} = ...`))
|
|
146
|
+
// `async def f(*, ) -> Any` is a SyntaxError: a `*` needs at least one name after it, and a parameterless tool takes no keywords at all.
|
|
147
|
+
return `async def ${spec.name}(${fields.length === 0 ? '' : `*, ${fields.join(', ')}`}) -> Any: ...`
|
|
148
|
+
}
|
|
149
|
+
// `ToolCallError` leads the import because the instructions tell the model to catch it; without it here the natural `except ToolCallError` NameErrors on the failure path, masking the tool failure it was meant to handle. It also keeps the line valid when no tool is importable.
|
|
150
|
+
const lines = [
|
|
151
|
+
INSTRUCTIONS,
|
|
152
|
+
'',
|
|
153
|
+
renderEnvironment(),
|
|
154
|
+
'',
|
|
155
|
+
'```python',
|
|
156
|
+
`from __dsh__.tools import ToolCallError${importable.map((spec) => `, ${spec.name}`).join('')}`,
|
|
157
|
+
'',
|
|
158
|
+
...importable.map(signature),
|
|
159
|
+
'```',
|
|
160
|
+
]
|
|
161
|
+
if (awkward.length > 0) {
|
|
162
|
+
lines.push('', `Not valid Python identifiers — reach these with \`getattr\`: ${awkward.map((spec) => `\`getattr(__dsh__.tools, ${JSON.stringify(spec.name)})\``).join(', ')}.`)
|
|
163
|
+
}
|
|
164
|
+
lines.push('', 'Each is a real function: `name?` shows its full description, `dir(__dsh__.tools)` lists them.')
|
|
165
|
+
return lines.join('\n')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Flatten model-facing content blocks to the text a program-visible error carries. */
|
|
169
|
+
function contentText(content) {
|
|
170
|
+
if (!Array.isArray(content)) return ''
|
|
171
|
+
return content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Shape one finished cell into the model's observation.
|
|
176
|
+
*
|
|
177
|
+
* Tagged sections rather than one blob: with stdout, stderr, a return value and a traceback all possible at once, the model needs to know which is which. A bare successful value stays bare — the common case should not be noisy.
|
|
178
|
+
*
|
|
179
|
+
* Deliberately UNBOUNDED. `dsh-spill-policy` is a `tools/post-execute` waterfall over every tool, and at its configured `maxInlineBytes` it saves the full result to `ctx.spillStore` and hands the model a head/tail preview plus the path to `read` or `grep`. Eliding here would run FIRST and lose those bytes for good: the spill artifact holds what the tool returned, not what it had.
|
|
180
|
+
*/
|
|
181
|
+
function renderCell(result) {
|
|
182
|
+
const sections = []
|
|
183
|
+
const add = (tag, value) => {
|
|
184
|
+
const text = (value ?? '').trim()
|
|
185
|
+
if (text.length > 0) sections.push(`<${tag}>${text.includes('\n') ? `\n${text}\n` : text}</${tag}>`)
|
|
186
|
+
}
|
|
187
|
+
if (result.ok && !result.stdout.trim() && !result.stderr.trim() && !result.note) {
|
|
188
|
+
return result.repr ?? '[[ execution successful, no output ]]'
|
|
189
|
+
}
|
|
190
|
+
add('stdout', result.stdout)
|
|
191
|
+
add('stderr', result.stderr)
|
|
192
|
+
if (result.ok) add('return', result.repr)
|
|
193
|
+
else add('traceback', result.error ?? 'cell failed')
|
|
194
|
+
add('note', result.note)
|
|
195
|
+
return sections.join('\n') || '[[ execution successful, no output ]]'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const name = PLUGIN_NAME
|
|
199
|
+
export const inject = ['tools', 'systemPrompt']
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The tool name the browser card registers for.
|
|
203
|
+
*
|
|
204
|
+
* Client bundles are composed once per package into a static boot graph — there is no per-session config channel — so the slot key cannot follow a configured `toolName`. Renaming the tool is supported; it just falls back to the generic row, and saying so beats letting the card vanish silently.
|
|
205
|
+
*/
|
|
206
|
+
const CARD_TOOL_NAME = 'python'
|
|
207
|
+
|
|
208
|
+
export function apply(ctx, config = {}) {
|
|
209
|
+
// The profile-level row exists only so this package's browser half is scanned and served (see cordis.patch.yml). Registering the tool there would make it global; the agent preset row is what actually mounts the host half.
|
|
210
|
+
if (config.uiOnly === true) return
|
|
211
|
+
|
|
212
|
+
const toolName = config.toolName ?? 'python'
|
|
213
|
+
// 'code' (default): `python` is the only directly callable tool — full CodeAct. 'both': the native schemas stay too, which is handy while debugging a composition.
|
|
214
|
+
const exclusive = (config.mode ?? 'code') === 'code'
|
|
215
|
+
if (toolName !== CARD_TOOL_NAME) {
|
|
216
|
+
console.warn(`[dsh-py-codeact] toolName is ${JSON.stringify(toolName)}; the CodeAct card only renders for ${JSON.stringify(CARD_TOOL_NAME)}, so this tool falls back to the generic row`)
|
|
217
|
+
}
|
|
218
|
+
let warnedNoSession = false
|
|
219
|
+
/** @type {Map<string, {kernel: PythonKernel, current: Map<string, unknown>, calls: number, sent: Map<string, string>, started: Promise<unknown> | undefined}>} */
|
|
220
|
+
const kernels = new Map()
|
|
221
|
+
|
|
222
|
+
ctx.on('dispose', () => {
|
|
223
|
+
for (const entry of kernels.values()) entry.kernel.dispose()
|
|
224
|
+
kernels.clear()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Without this a kernel outlives its session: ~60-100MB of resident CPython per conversation tree that ever ran a cell, held until the harness exits.
|
|
228
|
+
ctx.on('session/disposed', (session) => {
|
|
229
|
+
const entry = kernels.get(session.id)
|
|
230
|
+
if (entry === undefined) return
|
|
231
|
+
kernels.delete(session.id)
|
|
232
|
+
entry.kernel.closeShell(session.id)
|
|
233
|
+
// The process belongs to the tree, not to any one agent: only tear it down once no agent still points at it.
|
|
234
|
+
if (![...kernels.values()].includes(entry)) entry.kernel.dispose()
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The tools ONE agent can see. The scope argument is not optional in practice: omitting it yields the global view, which in a preset composition holds only host-registered tools — the preset's own `read`/`bash`/etc. live in the agent scope and would silently go missing.
|
|
239
|
+
*/
|
|
240
|
+
const visibleSchemas = (scope) => ctx.tools.schemas(scope).filter((schema) => schema.name !== toolName)
|
|
241
|
+
|
|
242
|
+
ctx.systemPrompt.section({
|
|
243
|
+
name: 'py-codeact:sdk',
|
|
244
|
+
order: SDK_SECTION_ORDER,
|
|
245
|
+
text: (assembly) => renderToolsSection(visibleSchemas(assembly.scope)),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// 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.
|
|
249
|
+
ctx.on('agent/session-start', ({ agent }) => {
|
|
250
|
+
// `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.
|
|
251
|
+
if (kernels.get(agent.id)?.kernel.alive === true) return
|
|
252
|
+
if (!needsRestartNotice(agent.session, toolName, PLUGIN_NAME)) return
|
|
253
|
+
agent.inject(createUserMessage({
|
|
254
|
+
content: [{ type: 'text', text: RESTART_NOTICE }],
|
|
255
|
+
source: { kind: 'plugin', plugin: PLUGIN_NAME, form: 'notice', summary: 'dsh restarted — the Python session is gone' },
|
|
256
|
+
}))
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
if (exclusive) {
|
|
260
|
+
ctx.systemPrompt.section({ name: 'py-codeact:code-only', order: RULE_SECTION_ORDER, text: EXCLUSIVE_RULE })
|
|
261
|
+
|
|
262
|
+
// Drop every other schema from the request. `ctx.tools.restrict()` cannot do this — it masks GLOBAL tools only, and in a preset the tools are scope-local ("scoped registrations remain visible"). The assemble waterfall is the layer that owns the model-facing list.
|
|
263
|
+
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
|
|
264
|
+
const result = await next()
|
|
265
|
+
result.tools = result.tools.filter((schema) => schema.name === toolName)
|
|
266
|
+
return result
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Presentation alone is not enforcement: a model that recalls a tool from earlier context can still emit a direct call. Deny it and name the route back, the way Code Mode's own denial does — a bare rejection reads as a broken deployment. Sub-dispatches carry `parent` and are exempt.
|
|
270
|
+
ctx.effect(() => ctx.tools.guard((execution) => {
|
|
271
|
+
if (execution.parent !== undefined || execution.name === toolName) return undefined
|
|
272
|
+
return `only \`${toolName}\` is callable directly — call \`${execution.name}\` from inside a \`${toolName}\` cell instead: \`from __dsh__.tools import ${execution.name}\``
|
|
273
|
+
}))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Dispatch one bridged call under the cell running in the SHELL that made it. The kernel outlives any single execution, so the parent token, root id, and signal are read from the live slot rather than captured at kernel construction.
|
|
278
|
+
*
|
|
279
|
+
* Keyed by shell, not one slot per kernel: one entry backs every shell in the conversation tree, so a single slot meant a subagent's cell overwrote the parent's while the parent was still awaiting a tool. The parent's pending call then dispatched under the CHILD's callId, agent and signal — logged against the wrong agent, cancelled by the wrong turn — and when the child's cell ended it cleared the slot, so the parent's still-running cell was told no cell was running.
|
|
280
|
+
*/
|
|
281
|
+
async function dispatch(entry, name, args, from) {
|
|
282
|
+
const exec = entry.current.get(from)
|
|
283
|
+
// Reachable two ways: a task the model detached with `create_task` calling a tool after its cell returned, and a genuinely unknown shell. Both are the same answer — there is no turn to attribute the call to, and no signal to cancel it with.
|
|
284
|
+
if (exec === undefined) return { ok: false, message: `no cell is running in this shell — a tool call has to happen while a cell is on the stack, so a task detached with create_task cannot make one after its cell returned. Await it inside a cell instead.` }
|
|
285
|
+
const subCallId = `${exec.callId}:py:${++entry.calls}`
|
|
286
|
+
// `agent.session` is the same handle Code Mode's bridge logs through. Not optional-chained into silence: without it the sub-call still runs but leaves no SUBTOOL row, and a trace that quietly stops appearing is worse than a noisy one.
|
|
287
|
+
const session = exec.agent?.session
|
|
288
|
+
if (session === undefined && !warnedNoSession) {
|
|
289
|
+
warnedNoSession = true
|
|
290
|
+
console.warn(`[dsh-py-codeact] no agent on execution ${exec.callId}: sub-calls will run but not appear as SUBTOOL rows`)
|
|
291
|
+
}
|
|
292
|
+
const trace = { rootCallId: exec.rootCallId, parentCallId: exec.callId, subCallId, name, arguments: args }
|
|
293
|
+
session?.append('tool/code-dispatch-start', trace)
|
|
294
|
+
|
|
295
|
+
// 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.
|
|
296
|
+
try {
|
|
297
|
+
const outcome = await ctx.tools.execute({
|
|
298
|
+
callId: CallId(subCallId),
|
|
299
|
+
rootCallId: exec.rootCallId,
|
|
300
|
+
name,
|
|
301
|
+
arguments: args,
|
|
302
|
+
agent: exec.agent,
|
|
303
|
+
parent: exec.token, // marks this as a transport sub-dispatch, not a model-direct call
|
|
304
|
+
signal: exec.signal,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
session?.append('tool/code-dispatch', { ...trace, isError: outcome.isError, content: outcome.content })
|
|
308
|
+
// 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.
|
|
309
|
+
if (!outcome.isError && contentHasImage(outcome.content ?? [])) {
|
|
310
|
+
exec.deferContext(createUserMessage({ content: outcome.content, source: { kind: 'plugin', plugin: 'dsh-py-codeact' } }))
|
|
311
|
+
}
|
|
312
|
+
for (const context of outcome.additionalContexts ?? []) exec.deferContext(context)
|
|
313
|
+
return outcome.isError
|
|
314
|
+
? { ok: false, message: contentText(outcome.content) || 'tool call failed' }
|
|
315
|
+
: { ok: true, value: outcome.value }
|
|
316
|
+
} catch (error) {
|
|
317
|
+
session?.append('tool/code-dispatch', { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
|
|
318
|
+
throw error
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The kernel serving one agent, and the shell within it.
|
|
324
|
+
*
|
|
325
|
+
* ONE process per conversation tree, one shell per agent inside it. A subagent therefore costs an `init` frame rather than another interpreter: it shares the event loop, `sys.modules`, the installed packages and `__dsh__.shared`, while its globals stay its own.
|
|
326
|
+
*
|
|
327
|
+
* The tree is found without any session lookup — a parent necessarily runs a cell before it can delegate, so by the time a child executes, its `parentSession` is already a key here.
|
|
328
|
+
*/
|
|
329
|
+
|
|
330
|
+
async function kernelFor(exec, specs) {
|
|
331
|
+
const shell = exec.agent?.id ?? 'main'
|
|
332
|
+
const parent = exec.agent?.session?.header?.parentSession
|
|
333
|
+
const previous = kernels.get(shell) ?? (parent === undefined ? undefined : kernels.get(parent))
|
|
334
|
+
if (previous !== undefined) {
|
|
335
|
+
try {
|
|
336
|
+
await previous.started
|
|
337
|
+
if (previous.kernel.alive) {
|
|
338
|
+
kernels.set(shell, previous)
|
|
339
|
+
await previous.kernel.start(specs, shell) // no-op once this shell exists
|
|
340
|
+
return { entry: previous, shell, restarted: false }
|
|
341
|
+
}
|
|
342
|
+
} catch { /* it never came up — fall through and replace it */ }
|
|
343
|
+
}
|
|
344
|
+
// One entry backs every shell in the conversation tree, so `sent` is keyed BY SHELL. `calls` is the exception: it only has to make `subCallId` unique, so process-wide is right.
|
|
345
|
+
const entry = { kernel: undefined, current: new Map(), calls: 0, sent: new Map(), started: undefined }
|
|
346
|
+
entry.kernel = new PythonKernel({
|
|
347
|
+
command: config.command ?? (config.python === undefined ? undefined : [config.python, KERNEL_PY]),
|
|
348
|
+
cwd: exec.agent?.session?.header?.cwd ?? process.cwd(),
|
|
349
|
+
env: config.inheritEnv === true ? process.env : undefined,
|
|
350
|
+
ephemeralEnv: config.ephemeralEnv !== false,
|
|
351
|
+
hardInterruptMs: config.hardInterruptMs,
|
|
352
|
+
onCall: (name, args, from) => dispatch(entry, name, args, from),
|
|
353
|
+
})
|
|
354
|
+
entry.started = entry.kernel.start(specs, shell)
|
|
355
|
+
kernels.set(shell, entry)
|
|
356
|
+
await entry.started
|
|
357
|
+
pythonEnv = entry.kernel.pythonEnv ?? pythonEnv
|
|
358
|
+
// A kernel replaced mid-conversation (an unyielding cell got killed) is the one case `agent/session-start` cannot cover: no session began, so nothing fires. The observation prefix carries it instead, at the exact cell that noticed.
|
|
359
|
+
return { entry, shell, restarted: previous !== undefined }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
363
|
+
name: toolName,
|
|
364
|
+
description:
|
|
365
|
+
'Run one cell in a persistent IPython session. State (imports, variables, open handles) survives between calls. Top-level `await` works and a trailing expression is echoed. Every other harness tool is an awaitable in the `__dsh__.tools` module — `from __dsh__.tools import read`, then `await read(...)`. Only the cell output and its final expression return to you. A successful tool result containing an image is attached after the run so you can inspect it on the next step.',
|
|
366
|
+
parameters: {
|
|
367
|
+
code: { type: 'string', required: true, description: 'Python source for this cell.' },
|
|
368
|
+
// Required, like `run_code`'s: it IS the card title, so an optional one leaves the UI showing the first line of code as a header.
|
|
369
|
+
description: { type: 'string', required: true, description: 'Short summary of what this cell does.' },
|
|
370
|
+
},
|
|
371
|
+
output: {
|
|
372
|
+
schema: { type: 'string' },
|
|
373
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
374
|
+
},
|
|
375
|
+
// No `isConcurrencySafe`: cells mutate the shell's namespace, so this agent's cells must stay exclusive ordering barriers. That is a per-AGENT constraint, not a per-process one — the kernel tracks one in-flight task per shell, so a subagent's cell runs alongside its parent's just fine.
|
|
376
|
+
async execute(args, exec) {
|
|
377
|
+
const specs = visibleSchemas(exec.agent).sort(byName).map(toolSpec)
|
|
378
|
+
const { entry, shell, restarted: wasRestarted } = await kernelFor(exec, specs)
|
|
379
|
+
entry.current.set(shell, exec)
|
|
380
|
+
try {
|
|
381
|
+
// Only resend the bindings when the visible set actually moved — an unchanged catalogue is the norm, and it is 30+ schemas on the wire.
|
|
382
|
+
const key = specsKey(specs)
|
|
383
|
+
const rebind = key === entry.sent.get(shell) ? undefined : specs
|
|
384
|
+
const result = await entry.kernel.exec(args.code, exec.signal, rebind, shell)
|
|
385
|
+
const observation = renderCell(result)
|
|
386
|
+
// Only once the kernel has actually taken them: a cell that never dispatched (an already-cancelled turn) would otherwise leave us believing the bindings landed, and the next cell would skip the rebind and run against a stale tool table. A busy kernel answers the same way — an ordinary `ok: false` frame, so this resolves rather than throwing — and it rebinds NOTHING before doing so, which is why the outcome has to be inspected and not just awaited.
|
|
387
|
+
if (result.ok || result.error !== KERNEL_BUSY) entry.sent.set(shell, key)
|
|
388
|
+
return wasRestarted
|
|
389
|
+
? `[the interpreter was restarted; every earlier binding is gone]\n${observation}`
|
|
390
|
+
: observation
|
|
391
|
+
} catch (error) {
|
|
392
|
+
// The kernel dying mid-cell is not this cell's fault and usually not its doing: `hardInterruptMs` escalates to SIGKILL on the PROCESS, but the interrupt it backstops is per-shell, so a sibling's runaway loop takes this shell's namespace with it. Surfacing the raw `KernelDeadError` read as a harness fault for a cancellation this agent had no part in. State really is gone either way — say that, in the same words the restart notice uses, and let the model rebuild.
|
|
393
|
+
if (error?.name !== 'KernelDeadError') throw error
|
|
394
|
+
entry.sent.delete(shell)
|
|
395
|
+
return `[the interpreter is gone; every earlier binding with it. This can happen without anything wrong in this cell — one process backs every agent in the conversation tree, so a sibling's cell that would not yield to an interrupt is killed outright and takes the process with it. Rebuild what you need.]\n<error>\n${error.message}\n</error>`
|
|
396
|
+
} finally {
|
|
397
|
+
entry.current.delete(shell)
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
// Same shape `run_code` uses. NOTE: it will not render as the syntax- highlighted "Code" card — `dsh-client-ui-tool` keys that variant off a hardcoded `TOOL_VARIANTS` map (`run_code: 'code'`, `lang: 'typescript'`), not off the call view, and `run_code` is a reserved name. A terminal card was worse: it rendered the cell's first line as if it were a shell command. No `presentResult` — the raw result content is what a code card shows, and the tagged sections already read well.
|
|
401
|
+
presentCall: (args) => ({
|
|
402
|
+
card: 'generic',
|
|
403
|
+
title: args.description,
|
|
404
|
+
kind: 'execute',
|
|
405
|
+
rawInput: args.code,
|
|
406
|
+
}),
|
|
407
|
+
})))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export default { name, inject, apply }
|