dsh-py-codeact 0.1.2 → 0.2.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/README.md +21 -4
- package/lib/index.js +82 -9
- package/package.json +1 -1
- package/py/kernel.py +3 -2
package/README.md
CHANGED
|
@@ -52,11 +52,28 @@ A dunder-named package reads as harness-owned, survives `%reset`, and leaves the
|
|
|
52
52
|
|
|
53
53
|
```
|
|
54
54
|
In [1]: read?
|
|
55
|
-
Signature: read(*, file_path: 'str', offset: 'int' = Ellipsis, limit: 'int' = Ellipsis)
|
|
55
|
+
Signature: read(*, file_path: 'str', offset: 'int' = Ellipsis, limit: 'int' = Ellipsis) -> 'str'
|
|
56
56
|
Docstring: Read a file from the workspace. Results include line numbers…
|
|
57
57
|
```
|
|
58
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.
|
|
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.
|
|
60
|
+
|
|
61
|
+
The **return** type comes from the tool's own `output` schema, by way of `ctx.tools.sdkSchemas(scope)` — the projection that carries it. It is the one annotation the model cannot recover by reading harder: a wrong argument fails loudly at the call, while an unknown return shape is only discoverable by calling once and printing the result, which costs a whole turn per tool. A tool that declares no output schema still renders `Any`; claiming a type nobody declared would be worse than admitting ignorance.
|
|
62
|
+
|
|
63
|
+
Each object in that schema whose keys — and whose own generated class name — Python can take is declared as a named `TypedDict` above the signatures, because for an MCP tool the flat form is not merely vague — it is the whole message. dsh's MCP client resolves a call to the **envelope** `{ content, structuredContent }` and declares exactly that as the tool's output, so a model told only `dict[str, Any]` is not told the envelope exists: it reaches for the payload directly, gets nothing, and spends the turn printing the result to find the wrapper — the exact cost this annotation is here to remove.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
class McpCalendarListEventsOutputStructuredContent(TypedDict):
|
|
67
|
+
result: str
|
|
68
|
+
|
|
69
|
+
class McpCalendarListEventsOutput(TypedDict):
|
|
70
|
+
content: list[Any]
|
|
71
|
+
structuredContent: McpCalendarListEventsOutputStructuredContent
|
|
72
|
+
|
|
73
|
+
async def mcp__calendar__list_events(*, calendar_id: str) -> McpCalendarListEventsOutput: ...
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`jsonSchemaToPy` cannot do this and says so — it is context-free, and naming a `TypedDict` needs the render context `renderToolsSdkPy` supplies. That renderer is not reusable here: it emits a whole document in Code Mode's own `tools.name(args)` contract. So only the object and array branches are handled locally, every leaf still going through `jsonSchemaToPy` — a place to hang the names, not a second JSON-Schema mapper. 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
77
|
|
|
61
78
|
### MCP tools work, with nothing special
|
|
62
79
|
|
|
@@ -157,9 +174,9 @@ A pure CPU loop (`while True: pass`) never reaches an await point. After `hardIn
|
|
|
157
174
|
## Known limitations
|
|
158
175
|
|
|
159
176
|
- **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.
|
|
177
|
+
- **Scope is not optional.** The visible tool set comes from `ctx.tools.sdkSchemas(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
178
|
- **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
|
-
- **
|
|
179
|
+
- **A shape Python cannot name stays vague.** A field whose key is not a valid identifier degrades its own class back to `dict[str, Any]` rather than emitting a body that will not parse — the tool stays callable and only that one annotation goes quiet. The same guard covers the class name itself, which is not local damage: a tool named `123tool` would otherwise emit `class 123toolOutput`, a SyntaxError that takes the whole block with it. A schema with no declared properties still renders `Any`; claiming a type nobody declared would be worse.
|
|
163
180
|
- **Cold start.** The PEP 723 environment is resolved on first use (`uv python find --script`). Warm afterwards; pass `python` to skip it.
|
|
164
181
|
|
|
165
182
|
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.
|
package/lib/index.js
CHANGED
|
@@ -64,14 +64,18 @@ The available tools:`
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
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
|
+
* The return type comes from the tool's own `output` schema — the shape of the canonical value the call actually resolves to. It is the one annotation a model cannot recover by reading harder: a parameter it gets wrong fails loudly at the call, while an unknown return shape is only discoverable by calling once and printing the result, which is a whole extra turn per tool.
|
|
67
69
|
*/
|
|
68
|
-
function toolSpec(schema) {
|
|
70
|
+
function toolSpec(schema, declarations) {
|
|
69
71
|
const parameters = schema.parameters ?? {}
|
|
70
72
|
const properties = parameters.properties ?? {}
|
|
71
73
|
const required = new Set(Array.isArray(parameters.required) ? parameters.required : [])
|
|
72
74
|
return {
|
|
73
75
|
name: schema.name,
|
|
74
76
|
doc: schema.description,
|
|
77
|
+
// `output` is absent only if the caller passed a `schemas()` projection; `sdkSchemas()` always carries it.
|
|
78
|
+
returns: schema.output === undefined ? 'Any' : declareType(schema.output, `${pascal(schema.name)}Output`, declarations),
|
|
75
79
|
params: Object.entries(properties).map(([name, node]) => ({
|
|
76
80
|
name,
|
|
77
81
|
type: jsonSchemaToPy(node),
|
|
@@ -80,6 +84,20 @@ function toolSpec(schema) {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Every visible tool's spec, plus the `TypedDict` bodies their return annotations name.
|
|
89
|
+
*
|
|
90
|
+
* Both consumers go through here so they cannot disagree: the prompt block declares the classes,
|
|
91
|
+
* and the kernel sends the same `returns` text on to `inspect.Signature`, so `read?` shows the
|
|
92
|
+
* name the block above it defines. It also keeps `toolSpec` off `.map`, where the callback's
|
|
93
|
+
* second argument is the INDEX — a number, so a default parameter never fires and the accumulator
|
|
94
|
+
* is silently a `0`.
|
|
95
|
+
*/
|
|
96
|
+
function toolSpecs(schemas) {
|
|
97
|
+
const declarations = new Map()
|
|
98
|
+
return { specs: [...schemas].sort(byName).map((schema) => toolSpec(schema, declarations)), declarations }
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
/** Deterministic (lexicographic) order, so an unchanged tool set renders byte-identically. */
|
|
84
102
|
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
85
103
|
|
|
@@ -128,34 +146,88 @@ export function needsRestartNotice(session, toolName, plugin) {
|
|
|
128
146
|
/** `keyword.kwlist + keyword.softkwlist` for the `requires-python = ">=3.12"` the kernel pins. */
|
|
129
147
|
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
148
|
|
|
149
|
+
/** A name this block can put in an `import`, a `def`, or a `TypedDict` field. */
|
|
150
|
+
const isUsableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !PY_KEYWORDS.has(name)
|
|
151
|
+
|
|
152
|
+
/** `mcp__calendar__list_events` -> `McpCalendarListEvents`, for naming that tool's declarations. */
|
|
153
|
+
const pascal = (name) => name.split(/[_\-]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('')
|
|
154
|
+
|
|
155
|
+
/** `dict[str, Any] | dict[str, Any]` is what a `oneOf` of two objects collapses to once both branches degrade to the same text. Rendering the duplicate says there is a choice to make where there is none. */
|
|
156
|
+
const dedupeUnion = (expr) => [...new Set(expr.split(' | '))].join(' | ')
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Render one output schema, declaring a named `TypedDict` for every object shape it contains.
|
|
160
|
+
*
|
|
161
|
+
* `jsonSchemaToPy` alone cannot do this and says so: it is context-free, and "naming a `TypedDict` requires the render context that `renderToolsSdkPy` supplies" — with nowhere to put a class declaration it degrades every object to `dict[str, Any]`. That erases the one thing an MCP return needs to say. dsh's MCP client resolves a call to the ENVELOPE, `{ content, structuredContent }`, and declares exactly that as the tool's `output`; flattened to `dict[str, Any]` the model is not told the envelope exists, so it reaches for the payload directly, gets nothing, and spends a turn printing the result to find the wrapper — the very cost the return annotation is here to remove.
|
|
162
|
+
*
|
|
163
|
+
* Only the object and array branches are handled here; every leaf still goes through `jsonSchemaToPy`, so there is no second JSON-Schema mapper, only a place to hang the names. A field name Python cannot take degrades that one class back to `dict[str, Any]` rather than emitting a class body that will not parse.
|
|
164
|
+
*/
|
|
165
|
+
function declareType(schema, baseName, declarations) {
|
|
166
|
+
if (schema === null || typeof schema !== 'object') return jsonSchemaToPy(schema)
|
|
167
|
+
if (schema.type === 'array' && schema.items !== null && typeof schema.items === 'object') {
|
|
168
|
+
return `list[${declareType(schema.items, `${baseName}Item`, declarations)}]`
|
|
169
|
+
}
|
|
170
|
+
const properties = schema.type === 'object' ? (schema.properties ?? {}) : {}
|
|
171
|
+
const names = Object.keys(properties)
|
|
172
|
+
// `baseName` too, not just the fields: a tool name may start with a digit or carry a character
|
|
173
|
+
// `pascal` does not split on, and `123toolOutput` is a SyntaxError that takes the WHOLE block
|
|
174
|
+
// with it — including the tools that were fine. It bites hardest for a tool that is not even
|
|
175
|
+
// importable, whose class nothing would have referenced.
|
|
176
|
+
if (names.length === 0 || !isUsableName(baseName) || !names.every(isUsableName)) return dedupeUnion(jsonSchemaToPy(schema))
|
|
177
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : [])
|
|
178
|
+
const body = names.map((name) => {
|
|
179
|
+
const type = declareType(properties[name], `${baseName}${pascal(name)}`, declarations)
|
|
180
|
+
return ` ${name}: ${required.has(name) ? type : `NotRequired[${type}]`}`
|
|
181
|
+
})
|
|
182
|
+
// Two tools whose names differ only in separators pascal-case to the same string; keep the first and let the second carry a suffix rather than silently serving one tool the other's shape. Keyed by name, valued by body, so an identical shape reuses the class instead of declaring it twice.
|
|
183
|
+
const declared = body.join('\n')
|
|
184
|
+
let name = baseName
|
|
185
|
+
for (let n = 2; declarations.has(name) && declarations.get(name) !== declared; n++) name = `${baseName}${n}`
|
|
186
|
+
declarations.set(name, declared)
|
|
187
|
+
return name
|
|
188
|
+
}
|
|
189
|
+
|
|
131
190
|
/**
|
|
132
191
|
* 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
192
|
*/
|
|
134
193
|
export function renderToolsSection(schemas) {
|
|
135
|
-
const specs =
|
|
194
|
+
const { specs, declarations } = toolSpecs(schemas)
|
|
136
195
|
// 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
196
|
//
|
|
138
197
|
// 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
198
|
const importable = specs.filter((spec) => isUsableName(spec.name))
|
|
141
199
|
const awkward = specs.filter((spec) => !isUsableName(spec.name))
|
|
142
200
|
const signature = (spec) => {
|
|
143
201
|
// 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) ->
|
|
202
|
+
if (!spec.params.every((p) => isUsableName(p.name))) return `async def ${spec.name}(**kwargs: Any) -> ${spec.returns}: ... # parameter names are not all valid Python; see ${spec.name}?`
|
|
145
203
|
const fields = spec.params.map((p) => (p.required ? `${p.name}: ${p.type}` : `${p.name}: ${p.type} = ...`))
|
|
146
|
-
// `async def f(*, ) ->
|
|
147
|
-
return `async def ${spec.name}(${fields.length === 0 ? '' : `*, ${fields.join(', ')}`}) ->
|
|
204
|
+
// `async def f(*, ) -> T` is a SyntaxError: a `*` needs at least one name after it, and a parameterless tool takes no keywords at all.
|
|
205
|
+
return `async def ${spec.name}(${fields.length === 0 ? '' : `*, ${fields.join(', ')}`}) -> ${spec.returns}: ...`
|
|
148
206
|
}
|
|
149
207
|
// `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.
|
|
208
|
+
// The declarations are stubs, not runtime objects — nothing constructs them — so the import line
|
|
209
|
+
// lists exactly what the render used. `Any` has always been reachable from a signature
|
|
210
|
+
// (`**kwargs: Any`, and any degraded annotation) and was never imported at all.
|
|
211
|
+
const signatures = importable.map(signature)
|
|
212
|
+
const classes = [...declarations].flatMap(([name, body]) => ['', `class ${name}(TypedDict):`, ...body.split('\n')])
|
|
213
|
+
const typing = [
|
|
214
|
+
// Whole identifiers: `AnyReportOutput` is a class name, not a use of `Any`, and a bare
|
|
215
|
+
// `includes` imports a symbol a tool can spell into existence without ever needing it.
|
|
216
|
+
signatures.concat(classes).some((line) => /\bAny\b/.test(line)) && 'Any',
|
|
217
|
+
classes.some((line) => line.includes('NotRequired[')) && 'NotRequired',
|
|
218
|
+
declarations.size > 0 && 'TypedDict',
|
|
219
|
+
].filter(Boolean)
|
|
150
220
|
const lines = [
|
|
151
221
|
INSTRUCTIONS,
|
|
152
222
|
'',
|
|
153
223
|
renderEnvironment(),
|
|
154
224
|
'',
|
|
155
225
|
'```python',
|
|
226
|
+
...(typing.length === 0 ? [] : [`from typing import ${typing.join(', ')}`]),
|
|
156
227
|
`from __dsh__.tools import ToolCallError${importable.map((spec) => `, ${spec.name}`).join('')}`,
|
|
228
|
+
...classes,
|
|
157
229
|
'',
|
|
158
|
-
...
|
|
230
|
+
...signatures,
|
|
159
231
|
'```',
|
|
160
232
|
]
|
|
161
233
|
if (awkward.length > 0) {
|
|
@@ -237,7 +309,8 @@ export function apply(ctx, config = {}) {
|
|
|
237
309
|
/**
|
|
238
310
|
* 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
311
|
*/
|
|
240
|
-
|
|
312
|
+
// `sdkSchemas`, not `schemas`: the latter is the native-function-calling projection and drops `output`, which left every signature returning `Any`. Code Mode uses this same projection for the same reason.
|
|
313
|
+
const visibleSchemas = (scope) => ctx.tools.sdkSchemas(scope).filter((schema) => schema.name !== toolName)
|
|
241
314
|
|
|
242
315
|
ctx.systemPrompt.section({
|
|
243
316
|
name: 'py-codeact:sdk',
|
|
@@ -374,7 +447,7 @@ export function apply(ctx, config = {}) {
|
|
|
374
447
|
},
|
|
375
448
|
// 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
449
|
async execute(args, exec) {
|
|
377
|
-
const specs = visibleSchemas(exec.agent)
|
|
450
|
+
const { specs } = toolSpecs(visibleSchemas(exec.agent))
|
|
378
451
|
const { entry, shell, restarted: wasRestarted } = await kernelFor(exec, specs)
|
|
379
452
|
entry.current.set(shell, exec)
|
|
380
453
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-py-codeact",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
@@ -18,7 +18,7 @@ Wire protocol: JSON-lines on fd 3. stdout/stderr stay free for native writes.
|
|
|
18
18
|
|
|
19
19
|
Every frame but `result` and `shutdown` carries `shell` — the agent whose shell it addresses, defaulting to "main". One process holds one shell per agent, so a fan-out of subagents shares this interpreter, its event loop and its packages while keeping separate globals.
|
|
20
20
|
|
|
21
|
-
host -> child {"t":"init","shell":S,"tools":[{"name","doc","params":[...]}]}
|
|
21
|
+
host -> child {"t":"init","shell":S,"tools":[{"name","doc","returns","params":[...]}]}
|
|
22
22
|
{"t":"exec","id":N,"shell":S,"code":"...","tools":[...]}
|
|
23
23
|
{"t":"result","id":N,"ok":true,"value":<json>}
|
|
24
24
|
{"t":"result","id":N,"ok":false,"tool":"read","message":"..."}
|
|
@@ -233,7 +233,8 @@ def _make_binding(bridge: Bridge, spec):
|
|
|
233
233
|
if dropped:
|
|
234
234
|
params.append(inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD))
|
|
235
235
|
with contextlib.suppress(ValueError, TypeError):
|
|
236
|
-
call
|
|
236
|
+
# The return annotation matters as much as the parameters here: it is what `read?` can tell the model that no amount of re-reading the call site can. Same source as the prompt block, so the two never disagree.
|
|
237
|
+
call.__signature__ = inspect.Signature(params, return_annotation=spec.get("returns") or "Any") # type: ignore
|
|
237
238
|
return call
|
|
238
239
|
|
|
239
240
|
|