dsh-py-codeact 0.2.0 → 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 +17 -2
- package/lib/index.js +74 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,22 @@ Docstring: Read a file from the workspace. Results include line numbers…
|
|
|
58
58
|
|
|
59
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
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.
|
|
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.
|
|
62
77
|
|
|
63
78
|
### MCP tools work, with nothing special
|
|
64
79
|
|
|
@@ -161,7 +176,7 @@ A pure CPU loop (`while True: pass`) never reaches an await point. After `hardIn
|
|
|
161
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.)
|
|
162
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).
|
|
163
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.
|
|
164
|
-
- **
|
|
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.
|
|
165
180
|
- **Cold start.** The PEP 723 environment is resolved on first use (`uv python find --script`). Warm afterwards; pass `python` to skip it.
|
|
166
181
|
|
|
167
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
|
@@ -67,7 +67,7 @@ The available tools:`
|
|
|
67
67
|
*
|
|
68
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.
|
|
69
69
|
*/
|
|
70
|
-
function toolSpec(schema) {
|
|
70
|
+
function toolSpec(schema, declarations) {
|
|
71
71
|
const parameters = schema.parameters ?? {}
|
|
72
72
|
const properties = parameters.properties ?? {}
|
|
73
73
|
const required = new Set(Array.isArray(parameters.required) ? parameters.required : [])
|
|
@@ -75,7 +75,7 @@ function toolSpec(schema) {
|
|
|
75
75
|
name: schema.name,
|
|
76
76
|
doc: schema.description,
|
|
77
77
|
// `output` is absent only if the caller passed a `schemas()` projection; `sdkSchemas()` always carries it.
|
|
78
|
-
returns: schema.output === undefined ? 'Any' :
|
|
78
|
+
returns: schema.output === undefined ? 'Any' : declareType(schema.output, `${pascal(schema.name)}Output`, declarations),
|
|
79
79
|
params: Object.entries(properties).map(([name, node]) => ({
|
|
80
80
|
name,
|
|
81
81
|
type: jsonSchemaToPy(node),
|
|
@@ -84,6 +84,20 @@ function toolSpec(schema) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
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
|
+
|
|
87
101
|
/** Deterministic (lexicographic) order, so an unchanged tool set renders byte-identically. */
|
|
88
102
|
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
89
103
|
|
|
@@ -132,15 +146,55 @@ export function needsRestartNotice(session, toolName, plugin) {
|
|
|
132
146
|
/** `keyword.kwlist + keyword.softkwlist` for the `requires-python = ">=3.12"` the kernel pins. */
|
|
133
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'])
|
|
134
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
|
+
|
|
135
190
|
/**
|
|
136
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.
|
|
137
192
|
*/
|
|
138
193
|
export function renderToolsSection(schemas) {
|
|
139
|
-
const specs =
|
|
194
|
+
const { specs, declarations } = toolSpecs(schemas)
|
|
140
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.
|
|
141
196
|
//
|
|
142
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.
|
|
143
|
-
const isUsableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !PY_KEYWORDS.has(name)
|
|
144
198
|
const importable = specs.filter((spec) => isUsableName(spec.name))
|
|
145
199
|
const awkward = specs.filter((spec) => !isUsableName(spec.name))
|
|
146
200
|
const signature = (spec) => {
|
|
@@ -151,15 +205,29 @@ export function renderToolsSection(schemas) {
|
|
|
151
205
|
return `async def ${spec.name}(${fields.length === 0 ? '' : `*, ${fields.join(', ')}`}) -> ${spec.returns}: ...`
|
|
152
206
|
}
|
|
153
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)
|
|
154
220
|
const lines = [
|
|
155
221
|
INSTRUCTIONS,
|
|
156
222
|
'',
|
|
157
223
|
renderEnvironment(),
|
|
158
224
|
'',
|
|
159
225
|
'```python',
|
|
226
|
+
...(typing.length === 0 ? [] : [`from typing import ${typing.join(', ')}`]),
|
|
160
227
|
`from __dsh__.tools import ToolCallError${importable.map((spec) => `, ${spec.name}`).join('')}`,
|
|
228
|
+
...classes,
|
|
161
229
|
'',
|
|
162
|
-
...
|
|
230
|
+
...signatures,
|
|
163
231
|
'```',
|
|
164
232
|
]
|
|
165
233
|
if (awkward.length > 0) {
|
|
@@ -379,7 +447,7 @@ export function apply(ctx, config = {}) {
|
|
|
379
447
|
},
|
|
380
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.
|
|
381
449
|
async execute(args, exec) {
|
|
382
|
-
const specs = visibleSchemas(exec.agent)
|
|
450
|
+
const { specs } = toolSpecs(visibleSchemas(exec.agent))
|
|
383
451
|
const { entry, shell, restarted: wasRestarted } = await kernelFor(exec, specs)
|
|
384
452
|
entry.current.set(shell, exec)
|
|
385
453
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-py-codeact",
|
|
3
|
-
"version": "0.2.
|
|
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": {
|