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 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. The binding set is resent with every cell, because restrictions and mid-conversation tool changes can move a tool in or out between calls.
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.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).
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
- - **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.
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 = [...schemas].sort(byName).map(toolSpec)
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) -> Any: ... # parameter names are not all valid Python; see ${spec.name}?`
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(*, ) -> 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: ...`
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
- ...importable.map(signature),
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
- const visibleSchemas = (scope) => ctx.tools.schemas(scope).filter((schema) => schema.name !== toolName)
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).sort(byName).map(toolSpec)
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.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": {
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.__signature__ = inspect.Signature(params) # type: ignore
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