dsh-py-codeact 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +28 -2
  2. package/lib/index.js +122 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -58,7 +58,33 @@ 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. The binding set is resent with every cell, because restrictions and mid-conversation tool changes can move a tool in or out between calls.
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. For a bridged MCP tool the schema described here is the payload, not the transport wrapper — see below; `dict[str, Any]` would say a dict arrives without saying which keys, which is the one thing a return annotation exists to say.
64
+
65
+ ```python
66
+ class McpCalendarListEventsOutput(TypedDict):
67
+ result: str
68
+
69
+ async def mcp__calendar__list_events(*, calendar_id: str) -> McpCalendarListEventsOutput: ...
70
+ ```
71
+
72
+ `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.
73
+
74
+ ### The MCP wrapper does not reach the cell
75
+
76
+ dsh's MCP client resolves a call to `{ content, structuredContent? }` — the protocol's block array plus the server's own payload — and declares exactly that as the tool's output. That wrapper is transport, not API: `content`'s text duplicates the payload, and an image inside it is re-attached to the conversation separately, so a cell can do nothing with it. Handed the wrapper, a model writes `r["structuredContent"]["result"]` — and spends a call discovering it has to.
77
+
78
+ So the bridge unwraps, and the signature describes what the cell actually receives:
79
+
80
+ ```python
81
+ async def mcp__review__search(*, q: str) -> McpReviewSearchOutput: ... # the payload, not the wrapper
82
+ async def mcp__email__ping() -> str: ... # no declared payload: the text blocks, joined
83
+ ```
84
+
85
+ A server that declares no output schema is the common case in the wild, and its result really is just text — one text block, in every one of the 4142 MCP results measured across six trials, which is why this is a `str` and not a `list[str]` that would cost an `r[0]` at every call site to preserve a boundary that never appears. A result carrying no text block at all resolves to the empty string: an image there is re-attached to the conversation and read on the next step, so there was never anything for the cell to receive. One divergence is possible and belongs to the server: a tool that declares an output schema but omits `structuredContent` on some call resolves to that call's text, where the signature promised the payload type.
86
+
87
+ Unwrapping keys off the tool's declared output schema, not off the value that comes back: a tool whose own payload happens to be `{content: [...]}` is not wrapping anything, and replacing its value with joined text would change what that tool returns with nothing to say so. This deliberately differs from Code Mode, which hands the whole envelope to `tools.name(args)`.
62
88
 
63
89
  ### MCP tools work, with nothing special
64
90
 
@@ -161,7 +187,7 @@ A pure CPU loop (`while True: pass`) never reaches an await point. After `hardIn
161
187
  - **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
188
  - **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
189
  - **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
- - **MCP return types are shapeless.** An MCP tool's canonical value is the envelope (`content`, plus `structuredContent` when the server advertises an output schema), and `jsonSchemaToPy` collapses an object to `dict[str, Any]` so the annotation says a dict arrives without saying which keys. dsh renders that shape as a `TypedDict` for Code Mode via `renderToolsSdkPy`, but that renderer emits a whole document in Code Mode's own `tools.name(args)` contract, not a line per signature. Since the envelope is uniform, a sentence of prose buys more here than a per-tool emitter that would duplicate dsh's mapper.
190
+ - **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
191
  - **Cold start.** The PEP 723 environment is resolved on first use (`uv python find --script`). Warm afterwards; pass `python` to skip it.
166
192
 
167
193
  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
@@ -62,12 +62,38 @@ Your action space is Python. Each call runs one cell in a **persistent IPython s
62
62
 
63
63
  The available tools:`
64
64
 
65
+ /**
66
+ * The wrapper dsh's MCP client puts around every result: `{ content, structuredContent? }`, with
67
+ * `content` the protocol's block array and `structuredContent` the server's own payload. Detected
68
+ * by shape rather than by an `mcp__` name, because the shape IS the declared contract — the client
69
+ * builds exactly this schema, `additionalProperties: false` and all.
70
+ *
71
+ * @returns the payload's schema when the server declares one, `null` when this is not an envelope,
72
+ * and `undefined` for an envelope carrying only text.
73
+ */
74
+ function mcpPayloadSchema(schema) {
75
+ if (schema?.type !== 'object' || schema.additionalProperties !== false) return null
76
+ const keys = Object.keys(schema.properties ?? {})
77
+ if (keys.length !== 2 || !keys.includes('content') || !keys.includes('structuredContent')) return null
78
+ if (schema.properties.content?.type !== 'array') return null
79
+ return (schema.required ?? []).includes('structuredContent') ? schema.properties.structuredContent : undefined
80
+ }
81
+
82
+ /** @internal exported for the suite. The same wrapper at run time. A value is only unwrapped when it carries nothing but the wrapper's own keys. */
83
+ export function mcpPayload(value) {
84
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
85
+ const keys = Object.keys(value)
86
+ if (!keys.includes('content') || !Array.isArray(value.content)) return undefined
87
+ if (keys.some((k) => k !== 'content' && k !== 'structuredContent')) return undefined
88
+ return 'structuredContent' in value ? { value: value.structuredContent } : { value: contentText(value.content) }
89
+ }
90
+
65
91
  /**
66
92
  * 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
93
  *
68
94
  * 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
95
  */
70
- function toolSpec(schema) {
96
+ function toolSpec(schema, declarations) {
71
97
  const parameters = schema.parameters ?? {}
72
98
  const properties = parameters.properties ?? {}
73
99
  const required = new Set(Array.isArray(parameters.required) ? parameters.required : [])
@@ -75,7 +101,10 @@ function toolSpec(schema) {
75
101
  name: schema.name,
76
102
  doc: schema.description,
77
103
  // `output` is absent only if the caller passed a `schemas()` projection; `sdkSchemas()` always carries it.
78
- returns: schema.output === undefined ? 'Any' : jsonSchemaToPy(schema.output),
104
+ // An MCP tool is annotated by what the CELL receives, which is the payload the bridge unwraps
105
+ // to — never the transport wrapper. Describing the wrapper is accurate and useless: the model
106
+ // then hand-writes `r["structuredContent"]["result"]`, and calls once just to learn that.
107
+ returns: schema.output === undefined ? 'Any' : mcpReturn(schema, declarations),
79
108
  params: Object.entries(properties).map(([name, node]) => ({
80
109
  name,
81
110
  type: jsonSchemaToPy(node),
@@ -84,6 +113,33 @@ function toolSpec(schema) {
84
113
  }
85
114
  }
86
115
 
116
+ /**
117
+ * Every visible tool's spec, plus the `TypedDict` bodies their return annotations name.
118
+ *
119
+ * Both consumers go through here so they cannot disagree: the prompt block declares the classes,
120
+ * and the kernel sends the same `returns` text on to `inspect.Signature`, so `read?` shows the
121
+ * name the block above it defines. It also keeps `toolSpec` off `.map`, where the callback's
122
+ * second argument is the INDEX — a number, so a default parameter never fires and the accumulator
123
+ * is silently a `0`.
124
+ */
125
+ function toolSpecs(schemas) {
126
+ const declarations = new Map()
127
+ const sorted = [...schemas].sort(byName)
128
+ // Which tools DECLARE the wrapper. Unwrapping keys off this rather than the returned value:
129
+ // a tool whose own payload happens to be `{content: [...]}` is not wrapping anything, and
130
+ // replacing its value with the joined text would change what it returns with nothing to say so.
131
+ const envelopes = new Set(sorted.filter((schema) => mcpPayloadSchema(schema.output) !== null).map((schema) => schema.name))
132
+ return { specs: sorted.map((schema) => toolSpec(schema, declarations)), declarations, envelopes }
133
+ }
134
+
135
+ /** The annotation for one tool's output: an MCP payload if it is an envelope, else the schema as declared. */
136
+ function mcpReturn(schema, declarations) {
137
+ const payload = mcpPayloadSchema(schema.output)
138
+ if (payload === null) return declareType(schema.output, `${pascal(schema.name)}Output`, declarations)
139
+ // An envelope with no declared payload resolves to the text blocks joined, so it really is a `str`.
140
+ return payload === undefined ? 'str' : declareType(payload, `${pascal(schema.name)}Output`, declarations)
141
+ }
142
+
87
143
  /** Deterministic (lexicographic) order, so an unchanged tool set renders byte-identically. */
88
144
  const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
89
145
 
@@ -132,15 +188,55 @@ export function needsRestartNotice(session, toolName, plugin) {
132
188
  /** `keyword.kwlist + keyword.softkwlist` for the `requires-python = ">=3.12"` the kernel pins. */
133
189
  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
190
 
191
+ /** A name this block can put in an `import`, a `def`, or a `TypedDict` field. */
192
+ const isUsableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !PY_KEYWORDS.has(name)
193
+
194
+ /** `mcp__calendar__list_events` -> `McpCalendarListEvents`, for naming that tool's declarations. */
195
+ const pascal = (name) => name.split(/[_\-]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('')
196
+
197
+ /** `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. */
198
+ const dedupeUnion = (expr) => [...new Set(expr.split(' | '))].join(' | ')
199
+
200
+ /**
201
+ * Render one output schema, declaring a named `TypedDict` for every object shape it contains.
202
+ *
203
+ * `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.
204
+ *
205
+ * 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.
206
+ */
207
+ function declareType(schema, baseName, declarations) {
208
+ if (schema === null || typeof schema !== 'object') return jsonSchemaToPy(schema)
209
+ if (schema.type === 'array' && schema.items !== null && typeof schema.items === 'object') {
210
+ return `list[${declareType(schema.items, `${baseName}Item`, declarations)}]`
211
+ }
212
+ const properties = schema.type === 'object' ? (schema.properties ?? {}) : {}
213
+ const names = Object.keys(properties)
214
+ // `baseName` too, not just the fields: a tool name may start with a digit or carry a character
215
+ // `pascal` does not split on, and `123toolOutput` is a SyntaxError that takes the WHOLE block
216
+ // with it — including the tools that were fine. It bites hardest for a tool that is not even
217
+ // importable, whose class nothing would have referenced.
218
+ if (names.length === 0 || !isUsableName(baseName) || !names.every(isUsableName)) return dedupeUnion(jsonSchemaToPy(schema))
219
+ const required = new Set(Array.isArray(schema.required) ? schema.required : [])
220
+ const body = names.map((name) => {
221
+ const type = declareType(properties[name], `${baseName}${pascal(name)}`, declarations)
222
+ return ` ${name}: ${required.has(name) ? type : `NotRequired[${type}]`}`
223
+ })
224
+ // 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.
225
+ const declared = body.join('\n')
226
+ let name = baseName
227
+ for (let n = 2; declarations.has(name) && declarations.get(name) !== declared; n++) name = `${baseName}${n}`
228
+ declarations.set(name, declared)
229
+ return name
230
+ }
231
+
135
232
  /**
136
233
  * 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
234
  */
138
235
  export function renderToolsSection(schemas) {
139
- const specs = [...schemas].sort(byName).map(toolSpec)
236
+ const { specs, declarations } = toolSpecs(schemas)
140
237
  // 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
238
  //
142
239
  // 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
240
  const importable = specs.filter((spec) => isUsableName(spec.name))
145
241
  const awkward = specs.filter((spec) => !isUsableName(spec.name))
146
242
  const signature = (spec) => {
@@ -151,15 +247,29 @@ export function renderToolsSection(schemas) {
151
247
  return `async def ${spec.name}(${fields.length === 0 ? '' : `*, ${fields.join(', ')}`}) -> ${spec.returns}: ...`
152
248
  }
153
249
  // `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.
250
+ // The declarations are stubs, not runtime objects — nothing constructs them — so the import line
251
+ // lists exactly what the render used. `Any` has always been reachable from a signature
252
+ // (`**kwargs: Any`, and any degraded annotation) and was never imported at all.
253
+ const signatures = importable.map(signature)
254
+ const classes = [...declarations].flatMap(([name, body]) => ['', `class ${name}(TypedDict):`, ...body.split('\n')])
255
+ const typing = [
256
+ // Whole identifiers: `AnyReportOutput` is a class name, not a use of `Any`, and a bare
257
+ // `includes` imports a symbol a tool can spell into existence without ever needing it.
258
+ signatures.concat(classes).some((line) => /\bAny\b/.test(line)) && 'Any',
259
+ classes.some((line) => line.includes('NotRequired[')) && 'NotRequired',
260
+ declarations.size > 0 && 'TypedDict',
261
+ ].filter(Boolean)
154
262
  const lines = [
155
263
  INSTRUCTIONS,
156
264
  '',
157
265
  renderEnvironment(),
158
266
  '',
159
267
  '```python',
268
+ ...(typing.length === 0 ? [] : [`from typing import ${typing.join(', ')}`]),
160
269
  `from __dsh__.tools import ToolCallError${importable.map((spec) => `, ${spec.name}`).join('')}`,
270
+ ...classes,
161
271
  '',
162
- ...importable.map(signature),
272
+ ...signatures,
163
273
  '```',
164
274
  ]
165
275
  if (awkward.length > 0) {
@@ -315,9 +425,12 @@ export function apply(ctx, config = {}) {
315
425
  exec.deferContext(createUserMessage({ content: outcome.content, source: { kind: 'plugin', plugin: 'dsh-py-codeact' } }))
316
426
  }
317
427
  for (const context of outcome.additionalContexts ?? []) exec.deferContext(context)
428
+ // `=== undefined` rather than `??`: a payload that IS `null` is the server's answer, and
429
+ // falling back to the wrapper there would hand the cell the one shape it was promised not to see.
430
+ const unwrapped = entry.envelopes.get(from)?.has(name) ? mcpPayload(outcome.value) : undefined
318
431
  return outcome.isError
319
432
  ? { ok: false, message: contentText(outcome.content) || 'tool call failed' }
320
- : { ok: true, value: outcome.value }
433
+ : { ok: true, value: unwrapped === undefined ? outcome.value : unwrapped.value }
321
434
  } catch (error) {
322
435
  session?.append('tool/code-dispatch', { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
323
436
  throw error
@@ -347,7 +460,7 @@ export function apply(ctx, config = {}) {
347
460
  } catch { /* it never came up — fall through and replace it */ }
348
461
  }
349
462
  // 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.
350
- const entry = { kernel: undefined, current: new Map(), calls: 0, sent: new Map(), started: undefined }
463
+ const entry = { kernel: undefined, current: new Map(), calls: 0, sent: new Map(), envelopes: new Map(), started: undefined }
351
464
  entry.kernel = new PythonKernel({
352
465
  command: config.command ?? (config.python === undefined ? undefined : [config.python, KERNEL_PY]),
353
466
  cwd: exec.agent?.session?.header?.cwd ?? process.cwd(),
@@ -379,9 +492,10 @@ export function apply(ctx, config = {}) {
379
492
  },
380
493
  // 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
494
  async execute(args, exec) {
382
- const specs = visibleSchemas(exec.agent).sort(byName).map(toolSpec)
495
+ const { specs, envelopes } = toolSpecs(visibleSchemas(exec.agent))
383
496
  const { entry, shell, restarted: wasRestarted } = await kernelFor(exec, specs)
384
497
  entry.current.set(shell, exec)
498
+ entry.envelopes.set(shell, envelopes)
385
499
  try {
386
500
  // Only resend the bindings when the visible set actually moved — an unchanged catalogue is the norm, and it is 30+ schemas on the wire.
387
501
  const key = specsKey(specs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-py-codeact",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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": {