dsh-py-codeact 0.3.0 → 0.3.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.
package/lib/index.js CHANGED
@@ -10,11 +10,14 @@
10
10
  * @module dsh-py-codeact
11
11
  */
12
12
 
13
- import { CallId } from '@deepseek-ai/dsh-llm/brand'
13
+ import * as brand from '@deepseek-ai/dsh-llm/brand'
14
14
  import { contentHasImage, createUserMessage } from '@deepseek-ai/dsh-llm'
15
15
  import { defineTool, jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools'
16
16
  import { KERNEL_PY, PythonKernel } from './kernel.js'
17
17
 
18
+ // dsh renamed this brander in 0.1.2-alpha.2; both names remain in the declared peer range.
19
+ const CallId = brand.ToolCallId ?? brand.CallId
20
+
18
21
  /** Same prompt band as Code Mode's `tools:sdk`: tool guidance is 100–199. */
19
22
  const TOOLS_MODULE = '__dsh__.tools'
20
23
 
@@ -49,16 +52,16 @@ const INSTRUCTIONS = `## Writing code for the \`python\` tool
49
52
  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]\`).
50
53
 
51
54
  - Top-level \`await\` works. So do IPython magics: \`%whos\` to see what you have bound, \`%timeit\`, \`%run script.py\`, \`%%writefile\`, \`obj?\` / \`obj??\`, \`%cd\`.
52
- - 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.
55
+ - 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. 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.
53
56
  - 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.
54
57
  - 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.
55
58
  - 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.
56
59
  - 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.
57
- - \`!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)\`.
60
+ - \`!uv pip install <pkg>\` installs into this interpreter's environment and the package imports in the SAME CELL — no restart, no second cell to pick it up. Shell escapes (\`!cmd\`) do not fail the cell on a non-zero exit — check the output, or use \`subprocess.run(..., check=True)\`.
58
61
  - 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.
59
62
  - A FAILED tool call raises \`ToolCallError\` (\`.tool_name\`, plus the message); catch it and continue.
60
- - Independent calls may overlap with \`asyncio.gather\`. Sequence dependent work with plain \`await\`.
61
- - 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.
63
+ - Independent calls may overlap with \`asyncio.gather\`. Sequence dependent work with plain \`await\`. A gather hands back every answer in FULL, so the line AFTER it decides what the batch costs: end on what you pulled out of \`res\`, never on \`res\` itself.
64
+ - 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. Don't know the shape yet? Ask for the SHAPE, not the payload — \`r.keys()\`, \`len(r["results"])\`, \`r["results"][0]\` — then extract on the next line.
62
65
  - A cell that raises returns the traceback and the session keeps every prior binding — fix it in the next cell rather than starting over.
63
66
  - 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.
64
67
 
@@ -116,27 +119,28 @@ function narrowed(node) {
116
119
  }
117
120
 
118
121
  /**
119
- * 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. Parameter annotations are rendered here with dsh's own `jsonSchemaToPy`, and the return type comes from {@link renderOutputTypes}, so the kernel needs no JSON-Schema mapper of its own.
122
+ * 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. Both the parameter annotations and the return type come from {@link renderTypes}, which reads them out of dsh's own render, so the kernel needs no JSON-Schema mapper of its own.
120
123
  *
121
124
  * 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.
122
125
  */
123
- function toolSpec(schema, returns) {
126
+ function toolSpec(schema, returns, annotated) {
124
127
  const parameters = schema.parameters ?? {}
125
128
  const properties = parameters.properties ?? {}
126
129
  const required = new Set(Array.isArray(parameters.required) ? parameters.required : [])
127
130
  return {
128
131
  name: schema.name,
129
132
  doc: schema.description,
130
- // Absent only if the render dropped this tool, which it does not: `renderOutputTypes` emits one
133
+ // Absent only if the render dropped this tool, which it does not: `renderTypes` emits one
131
134
  // entry per schema, and a schema with no `output` at all comes back as `Any` from there.
132
135
  returns: returns.get(schema.name) ?? 'Any',
133
- params: Object.entries(properties).map(([name, node]) => ({
136
+ params: spellParams(Object.entries(properties).map(([name, node]) => ({
134
137
  name,
135
- type: jsonSchemaToPy(narrowed(node)),
138
+ // `annotated` is dsh's own render of this parameter, read back from the block it names its classes in; `jsonSchemaToPy` is the fallback for what that render degrades — a `oneOf` at any property collapses the whole args type, and it has no name to give an object anyway.
139
+ type: annotated.get(name) ?? jsonSchemaToPy(narrowed(node)),
136
140
  required: required.has(name),
137
141
  // What the parameter MEANS, which its type cannot say — `queries` is `list[str]` either way, and only the prose says 1–4 of them. Rendered beside the parameter as a comment AND carried to the kernel, so `read?` agrees with the block instead of being the poorer of the two. `undefined` drops out of `JSON.stringify`, so a parameter without one costs the wire and `specsKey` nothing.
138
142
  doc: node?.description,
139
- })),
143
+ }))),
140
144
  }
141
145
  }
142
146
 
@@ -164,8 +168,8 @@ export function toolSpecs(schemas) {
164
168
  // a tool whose own payload happens to be `{content: [...]}` is not wrapping anything, and
165
169
  // replacing its value with the joined text would change what it returns with nothing to say so.
166
170
  const envelopes = new Set(sorted.filter((schema) => mcpPayloadSchema(schema.output) !== null).map((schema) => schema.name))
167
- const { returns, declarations } = renderOutputTypes(sorted)
168
- return { specs: sorted.map((schema) => toolSpec(schema, returns)), declarations, envelopes }
171
+ const { returns, params, declarations } = renderTypes(sorted)
172
+ return { specs: sorted.map((schema) => toolSpec(schema, returns, params.get(schema.name) ?? new Map())), declarations, envelopes }
169
173
  }
170
174
 
171
175
  /**
@@ -201,7 +205,9 @@ let pythonEnv
201
205
  function renderEnvironment() {
202
206
  if (pythonEnv === undefined) return 'The session runs in its own Python interpreter, with `uv pip install <pkg>` available in a cell.'
203
207
  const where = pythonEnv.venv ? `in the environment at \`${pythonEnv.prefix}\`` : `at \`${pythonEnv.executable}\``
204
- const head = `Python ${pythonEnv.version} ${where}, working directory \`${pythonEnv.cwd}\`. ` + '`!uv pip install <pkg>` installs there and imports immediately. '
208
+ // No working directory here: dsh states it in its own section, and `installs there` must point at the environment named just before it — with the cwd in between, `there` read as the cwd, which is the one place an install does NOT land.
209
+ // No install clause here either: the bullet list already carries it, and said it better — `installs there and imports immediately` never says immediately after WHAT, and reads as though the install does an import. Two statements of one fact is how the two drift. What this line is for is naming the environment; `nothing you install escapes` below already implies where an install goes.
210
+ const head = `Python ${pythonEnv.version} ${where}. `
205
211
  // 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.
206
212
  return pythonEnv.disposable
207
213
  ? `${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.`
@@ -254,6 +260,41 @@ const serverName = (server, grouped) => (fold(server) !== server && grouped.has(
254
260
  /** A name this block can put in an `import`, a `def`, or a `TypedDict` field. */
255
261
  const isUsableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !PY_KEYWORDS.has(name)
256
262
 
263
+ /** The 35 names a parameter genuinely cannot take — `keyword.kwlist`, verified to have 35 entries on 3.12, 3.13 and 3.14. The header's floor is open-ended, so a later release may add one; the suite catches that rather than this comment, by taking the list from the interpreter that is about to compile the block. The other four in {@link PY_KEYWORDS} are `keyword.softkwlist`: `_`, `case`, `match`, `type` — `def f(*, type: str)` compiles, and rejecting them cost a tool its whole signature over a parameter name as ordinary as `type`. They stay refused as TOOL names, where the block imports them and `type` would shadow the builtin for the rest of the session. (`type` only joined `softkwlist` in 3.12, which is where the header's floor is.) */
264
+ const PY_HARD_KEYWORDS = new Set([...PY_KEYWORDS].filter((word) => !['_', 'case', 'match', 'type'].includes(word)))
265
+
266
+ /**
267
+ * The name a parameter can be CALLED by, or `null` when Python has none to offer.
268
+ *
269
+ * A parameter name travels to the tool as a JSON key, so unlike a tool name this cannot simply be renamed: the kernel maps the spelling back before dispatch, and `raw` below is what it maps to. Two normalisations, mirroring the two shapes that occur: `-` is legal in an MCP name and in no identifier, and a hard keyword takes the trailing underscore Python programmers already write for it (PEP 8's `class_`).
270
+ */
271
+ const paramName = (raw) => {
272
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) return PY_HARD_KEYWORDS.has(raw) ? `${raw}_` : raw
273
+ const folded = fold(raw)
274
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(folded) && !PY_HARD_KEYWORDS.has(folded) ? folded : null
275
+ }
276
+
277
+ /**
278
+ * Spell every parameter of one tool, refusing any normalisation that would displace a sibling.
279
+ *
280
+ * The lesson from folding MCP names one level up: an alias must never take a name that something real answers to. A tool declaring both `file-path` and `file_path` keeps `file_path` meaning `file_path`, and the hyphenated one falls back to `**kwargs` rather than quietly stealing it.
281
+ */
282
+ function spellParams(params) {
283
+ const taken = new Set(params.map((p) => p.name))
284
+ const claimed = new Set()
285
+ return params.map((p) => {
286
+ const name = paramName(p.name)
287
+ if (name === null || name === p.name) return p
288
+ // `raw` is the wire key, present only where the two differ — which is also how both halves tell a renamed parameter from one that simply cannot be spelled.
289
+ if (taken.has(name) || claimed.has(name)) return p
290
+ claimed.add(name)
291
+ return { ...p, name, raw: p.name }
292
+ })
293
+ }
294
+
295
+ /** Whether the block can put this parameter in a signature: it was renamed, or it needed no renaming. */
296
+ const isSpelled = (p) => p.raw !== undefined || paramName(p.name) === p.name
297
+
257
298
  /** `mcp__calendar__list_events` -> `McpCalendarListEvents`, for naming that tool's declarations. */
258
299
  const pascal = (name) => name.split(/[_\-]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('')
259
300
 
@@ -269,8 +310,9 @@ const PROTOCOL_HEAD = 'class Tools(Protocol):\n'
269
310
  const PROTOCOL_TAIL = '\n\ntools: Tools'
270
311
 
271
312
  /**
272
- * dsh's own Code Mode render, read back for the two things `jsonSchemaToPy` cannot produce: a NAMED
273
- * `TypedDict` per output object, and the union of named branches a `oneOf` output resolves to.
313
+ * dsh's own Code Mode render, read back for the things `jsonSchemaToPy` cannot produce: a NAMED
314
+ * `TypedDict` per object — on either side of the call — and the union of named branches a `oneOf`
315
+ * output resolves to.
274
316
  *
275
317
  * `jsonSchemaToPy` is context-free and says so — "naming a `TypedDict` requires the render context
276
318
  * that `renderToolsSdkPy` supplies" — so with nowhere to hang a declaration it degrades every object
@@ -281,12 +323,18 @@ const PROTOCOL_TAIL = '\n\ntools: Tools'
281
323
  * exists to remove. `renderType`, the context-carrying core, is not exported and its `src/` is not
282
324
  * shipped, so `renderToolsSdkPy` is the only door to it.
283
325
  *
284
- * The context is therefore borrowed rather than rebuilt. Parameters are stripped before the call —
285
- * `{}` is the one input that renders as `Any` without allocating a class, so every class in the
286
- * returned block belongs to an OUTPUT — and descriptions are dropped so the `Tools` body is exactly
287
- * one line per tool. MCP envelopes are unwrapped first, because the cell receives the payload and
288
- * annotating the transport wrapper is accurate and useless: the model then hand-writes
289
- * `r["structuredContent"]["result"]`, and calls once just to learn that.
326
+ * The context is therefore borrowed rather than rebuilt, for both halves in ONE call — so dsh's own
327
+ * collision suffixing settles a parameter class and an output class that would otherwise pick the
328
+ * same name. Descriptions are dropped so the `Tools` body is exactly one line per tool. MCP envelopes
329
+ * are unwrapped first, because the cell receives the payload and annotating the transport wrapper is
330
+ * accurate and useless: the model then hand-writes `r["structuredContent"]["result"]`, and calls once
331
+ * just to learn that.
332
+ *
333
+ * The parameter side arrives as a `<Tool>Args` wrapper, dsh's one-dict-per-call convention. This
334
+ * block spells parameters out instead, so the wrapper is read for its members and then dropped,
335
+ * while anything it references stays. Two shapes still degrade and fall back to `jsonSchemaToPy`: a
336
+ * `oneOf` at any property collapses the whole args type, and so does a stray `$schema` key — which
337
+ * is why `narrowed` runs on parameters too, not only on outputs.
290
338
  *
291
339
  * Reading generated text back is the seam, and it buys the alternative's absence: the `Literal`s,
292
340
  * nested classes, collision suffixes and Unicode identifier rules stay dsh's own instead of a second
@@ -294,28 +342,50 @@ const PROTOCOL_TAIL = '\n\ntools: Tools'
294
342
  * miss and every tool falls back to `Any` — a visible annotation the suite asserts against, not a
295
343
  * silently wrong one.
296
344
  *
297
- * @returns the return annotation per tool name, and the class declarations they reference as text.
345
+ * @returns the return annotation per tool name, the parameter annotations per tool name, and the class declarations both reference as text.
298
346
  */
299
- function renderOutputTypes(sorted) {
347
+ function renderTypes(sorted) {
300
348
  const declared = (schema) => {
301
349
  const payload = mcpPayloadSchema(schema.output)
302
350
  // An envelope with no declared payload resolves to the text blocks joined, so it really is a `str`.
303
351
  return narrowed(payload === null ? schema.output : (payload ?? { type: 'string' }))
304
352
  }
305
- const text = renderToolsSdkPy(sorted.map((schema) => ({ name: schema.name, parameters: {}, output: declared(schema) })))
353
+ // Parameters go through `narrowed` for the same reason outputs do, plus one of their own: a `$schema` key — which real MCP catalogues carry — makes dsh degrade the WHOLE args type to `Any`, taking every sibling parameter's annotation down with it. Dropping it is the difference between `window: ReadArgsWindow` and `window: dict[str, Any]`.
354
+ const text = renderToolsSdkPy(sorted.map((schema) => ({ name: schema.name, parameters: narrowed(schema.parameters ?? {}), output: declared(schema) })))
306
355
  const at = text.indexOf(ERROR_STUB)
307
356
  const to = text.indexOf(PROTOCOL_HEAD, at)
308
357
  const returns = new Map()
358
+ const wrapperOf = new Map()
309
359
  for (const line of text.slice(to + PROTOCOL_HEAD.length, text.indexOf(PROTOCOL_TAIL, to)).split('\n')) {
310
360
  // Two shapes, because dsh routes a name Python cannot take to a subscript COMMENT rather than a
311
361
  // method. Both are read: this block reaches such a tool through `getattr`, so its return type is
312
362
  // as real as any other's.
313
- const method = / {4}async def ([^(]+)\(self, args: Any\) -> (.+): \.\.\.$/.exec(line)
314
- if (method !== null) { returns.set(method[1], method[2]); continue }
315
- const subscript = / {4}# tools\[(".*")\]\(args: Any\) -> (.+)$/.exec(line)
316
- if (subscript !== null) returns.set(JSON.parse(subscript[1]), subscript[2])
363
+ const method = / {4}async def ([^(]+)\(self, args: ([^)]+)\) -> (.+): \.\.\.$/.exec(line)
364
+ if (method !== null) { returns.set(method[1], method[3]); wrapperOf.set(method[1], method[2]); continue }
365
+ const subscript = / {4}# tools\[(".*")\]\(args: ([^)]+)\) -> (.+)$/.exec(line)
366
+ if (subscript !== null) { const name = JSON.parse(subscript[1]); returns.set(name, subscript[3]); wrapperOf.set(name, subscript[2]) }
367
+ }
368
+ // `<Tool>Args` exists for dsh's calling convention — one `args` dict per call. This block spells
369
+ // parameters out instead, so the wrapper is read for its members and then dropped, while anything
370
+ // it REFERENCES (a nested object's own class) is declared separately and stays. Reading it back is
371
+ // what makes `window: ReadArgsWindow` possible at all: `jsonSchemaToPy` is context-free and has
372
+ // nowhere to hang a declaration, so on its own it degrades every parameter object to
373
+ // `dict[str, Any]` — the same erasure #16 fixed on the return side, still standing on this one.
374
+ const blocks = text.slice(at + ERROR_STUB.length, to).split('\n\n').filter((block) => block.trim() !== '')
375
+ const wrappers = new Set(wrapperOf.values())
376
+ const isWrapper = (block) => wrappers.has(/^class (\w+)\(TypedDict\):$/.exec(block.split('\n')[0])?.[1])
377
+ const members = new Map()
378
+ for (const block of blocks.filter(isWrapper)) {
379
+ const fields = new Map()
380
+ for (const line of block.split('\n').slice(1)) {
381
+ const field = / {4}(\w+): (.+)$/.exec(line)
382
+ // `NotRequired[...]` is what `required` already says here, and the signature spells optionality with a default instead.
383
+ if (field !== null) fields.set(field[1], /^NotRequired\[(.+)\]$/.exec(field[2])?.[1] ?? field[2])
384
+ }
385
+ members.set(/^class (\w+)/.exec(block)[1], fields)
317
386
  }
318
- return { returns, declarations: text.slice(at + ERROR_STUB.length, to).trimEnd() }
387
+ const params = new Map([...wrapperOf].map(([tool, wrapper]) => [tool, members.get(wrapper) ?? new Map()]))
388
+ return { returns, params, declarations: blocks.filter((block) => !isWrapper(block)).join('\n\n').trimEnd() }
319
389
  }
320
390
 
321
391
  /**
@@ -349,27 +419,39 @@ export function renderToolsSection(schemas) {
349
419
  // Python. That collision guard, and the `head` parameter it read, went with the Protocol stubs.
350
420
  // `async def f(*, ) -> T` is still a SyntaxError, which is why a parameterless tool emits no `*`.
351
421
  // Escaped for the block to stay COMPILABLE, which matters more here than fidelity: one description carrying a `\"\"\"` or ending in a backslash would close its own docstring and take every tool below it with it — the failure class where one tool invalidates the whole block. Neither appears in a live catalogue; a future MCP server is not bound by that.
422
+ // dsh-system-prompt has no literal `{{...}}` escape, so split whole runs of openers before tool prose enters a prompt section; pairwise replacement leaves a pair behind in odd runs.
423
+ const escapePromptGroups = (doc) => doc.replace(/\{{2,}/g, (run) => [...run].join(' '))
352
424
  const docstring = (doc) => {
353
425
  if (!doc) return [' ...']
354
- const text = doc.trim().replaceAll('\\', '\\\\').replaceAll('"""', '\\"\\"\\"')
355
- const lines = text.split('\n')
426
+ const text = escapePromptGroups(doc.trim()).replaceAll('\\', '\\\\').replaceAll('"""', '\\"\\"\\"')
427
+ // Split on every terminator Python's tokenizer honours, not just `\n`: CPython applies universal-newline translation to source, so a lone `\r` reaching the render becomes a real line break. Inside a COMMENTED MCP entry that break escapes the `# ` prefix and the rest of the description becomes live code.
428
+ //
429
+ // EXACTLY these three, which is narrower than it looks like it should be. `str.splitlines()` splits on eight more (`\v`, `\f`, `\x1c`-`\x1e`, `\x85`, `\u2028`, `\u2029`) and PCRE's `\R` on five of those — and every one of them stays INSIDE a `#` comment as far as the tokenizer is concerned, verified by compiling `# comment<ch>x = 1`. Widening to either would break descriptions apart at characters that never needed it.
430
+ const lines = text.split(/\r\n?|\n/)
356
431
  return lines.length === 1 ? [` """${lines[0]}"""`] : [' """', ...lines.map((line) => ` ${line}`.trimEnd()), ' """']
357
432
  }
358
433
  // A `#` comment cannot span lines, so a description that carries newlines is collapsed rather than emitted: left alone its second line parses as code.
359
- const trailing = (doc) => (doc ? ` # ${doc.trim().replace(/\s*\n\s*/g, ' ')}` : '')
434
+ const trailing = (doc) => (doc ? ` # ${escapePromptGroups(doc.trim()).replace(/\s*(?:\r\n?|\n)\s*/g, ' ')}` : '')
360
435
  /** @returns the lines to emit, and — separately — the type expressions they spell, which is what the `typing` import is derived from. Kept apart because prose is now emitted too: a description mentioning "Any file" must not import `Any`. */
361
436
  const signature = (spec) => {
362
437
  const body = docstring(spec.doc)
363
- // 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.
364
- if (!spec.params.every((p) => isUsableName(p.name))) {
365
- return { lines: [`async def ${spec.name}(**kwargs: Any) -> ${spec.returns}: # not every parameter name can be spelled here; see ${spec.name}?`, ...body], types: ['Any', spec.returns] }
366
- }
367
- const types = [spec.returns, ...spec.params.map((p) => p.type)]
438
+ // One unspellable PARAMETER used to cost the tool its whole signature `file-path` is routine one level up, so it is routine here. Now only that parameter goes to `**kwargs`, which is what the kernel has always done with it: the two halves show the same picture instead of the block being the vaguer one.
439
+ const named = spec.params.filter((p) => isSpelled(p))
440
+ const rest = spec.params.filter((p) => !isSpelled(p))
441
+ const types = [spec.returns, ...named.map((p) => p.type), ...(rest.length === 0 ? [] : ['Any'])]
368
442
  // `async def f(*, ) -> T` is still a SyntaxError, which is why a parameterless tool emits no `*`.
369
443
  if (spec.params.length === 0) return { lines: [`async def ${spec.name}() -> ${spec.returns}:`, ...body], types }
370
444
  // One parameter per line, so each can carry what it MEANS beside what it is: `queries` is `list[str]` either way, and only the comment says 1–4 of them.
371
- const fields = spec.params.map((p) => ` ${p.name}: ${p.type}${p.required ? '' : ' = ...'},${trailing(p.doc)}`)
372
- return { lines: [`async def ${spec.name}(`, ' *,', ...fields, `) -> ${spec.returns}:`, ...body], types }
445
+ const fields = named.map((p) => ` ${p.name}: ${p.type}${p.required ? '' : ' = ...'},${trailing(p.doc)}`)
446
+ // Named, because nothing else lists them and a required argument the model never sees is one the host rejects it for. `**kwargs` takes no trailing comma, and a lone `*` before it is a SyntaxError — hence the two shapes below.
447
+ if (rest.length > 0) {
448
+ // The overflow must not collide with a parameter that is really called `kwargs`, or the block stops compiling — for every tool, not just this one. Same rule the kernel uses for its own overflow, so the two agree without coordinating.
449
+ let overflow = 'kwargs'
450
+ while (named.some((p) => p.name === overflow)) overflow = `_${overflow}`
451
+ fields.push(` **${overflow}: Any # spell as dict keys: ${rest.map((p) => JSON.stringify(p.name)).join(', ')}`)
452
+ }
453
+ const open = named.length === 0 ? [`async def ${spec.name}(`] : [`async def ${spec.name}(`, ' *,']
454
+ return { lines: [...open, ...fields, `) -> ${spec.returns}:`, ...body], types }
373
455
  }
374
456
  // `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.
375
457
  // The declarations are stubs, not runtime objects — nothing constructs them — so the import line
@@ -387,18 +469,19 @@ export function renderToolsSection(schemas) {
387
469
  // A blank line between definitions, or a docstring runs straight into the next `async def`.
388
470
  const spaced = (blocks) => blocks.flatMap((block, index) => (index === 0 ? block : ['', ...block]))
389
471
  const nativeBlock = listing(TOOLS_MODULE, spaced(nativeSigs.map((sig) => sig.lines)))
390
- // Commented, and spelled the way the call site spells them, because these names are NOT bound at
391
- // the top level only `mcp` is. A bare `async def read(...)` under a server header claims a
392
- // top-level `read` that does not exist, and worse, it BINDS one: three tools named `read` (a
393
- // native one and two servers') left the last stub shadowing the imported function, so the block
394
- // executed as written broke the very tool its first section had just declared.
472
+ // Rendered exactly like the native ones. These names are not bound at the top level only `mcp`
473
+ // is — and the header above each section is what says so, the same way it does for `__dsh__.tools`.
474
+ // Commenting them out was meant to stop a bare `async def read(...)` from shadowing the imported
475
+ // `read`, but the native section is bare too and shadows it just as thoroughly: running this block
476
+ // is not something the shape of the MCP half can make safe. It is a signature listing, read for
477
+ // the signatures.
395
478
  // A server whose every tool name Python refuses gets no section: nothing here would be callable
396
479
  // as written, and the `getattr` line below is where those tools actually live.
397
480
  const mcpSigs = servers.flatMap(([server, tools]) => tools.filter((t) => isUsableName(fold(t.tool))).sort(by((t) => t.tool))
398
481
  .map((t) => ({ server, ...signature({ ...t, name: fold(t.tool) }) })))
399
482
  const mcpBlock = servers.flatMap(([server]) =>
400
483
  listing(`${TOOLS_MODULE}.mcp.${serverName(server, grouped)}`, spaced(mcpSigs.filter((sig) => sig.server === server)
401
- .map((sig) => sig.lines.map((line, index) => `# ${index === 0 ? line.replace(/^async def /, `mcp.${serverName(server, grouped)}.`) : line}`.trimEnd())))))
484
+ .map((sig) => sig.lines))))
402
485
  const signatures = nativeBlock.concat(mcpBlock)
403
486
  const classes = declarations === '' ? [] : ['', ...declarations.split('\n')]
404
487
  // Whatever the emitted lines actually spell, rather than a condition per symbol: the conditions
@@ -444,12 +527,19 @@ export function renderToolsSection(schemas) {
444
527
  return lines.join('\n')
445
528
  }
446
529
 
447
- /** Flatten model-facing content blocks to the text a program-visible error carries. */
530
+ /** Flatten model-facing content blocks when an MCP result has no structured payload. */
448
531
  function contentText(content) {
449
532
  if (!Array.isArray(content)) return ''
450
533
  return content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n\n')
451
534
  }
452
535
 
536
+ /** @internal exported for the suite. The program-visible reply for one completed sub-call. */
537
+ export function toolCallReply(outcome, unwrapMcp = false) {
538
+ if (outcome.isError) return { ok: false, message: outcome.error.message }
539
+ const unwrapped = unwrapMcp ? mcpPayload(outcome.value) : undefined
540
+ return { ok: true, value: unwrapped === undefined ? outcome.value : unwrapped.value }
541
+ }
542
+
453
543
  /**
454
544
  * Shape one finished cell into the model's observation.
455
545
  *
@@ -592,10 +682,7 @@ export function apply(ctx, config = {}) {
592
682
  for (const context of outcome.additionalContexts ?? []) exec.deferContext(context)
593
683
  // `=== undefined` rather than `??`: a payload that IS `null` is the server's answer, and
594
684
  // falling back to the wrapper there would hand the cell the one shape it was promised not to see.
595
- const unwrapped = entry.envelopes.get(from)?.has(name) ? mcpPayload(outcome.value) : undefined
596
- return outcome.isError
597
- ? { ok: false, message: contentText(outcome.content) || 'tool call failed' }
598
- : { ok: true, value: unwrapped === undefined ? outcome.value : unwrapped.value }
685
+ return toolCallReply(outcome, entry.envelopes.get(from)?.has(name))
599
686
  } catch (error) {
600
687
  session?.append('tool/code-dispatch', { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
601
688
  throw error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-py-codeact",
3
- "version": "0.3.0",
3
+ "version": "0.3.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": {
package/py/kernel.py CHANGED
@@ -211,8 +211,11 @@ def _make_binding(bridge: Bridge, spec):
211
211
  """One tool as a real `async def`: dsh's description becomes its docstring and its parameters become a keyword-only signature, so `read?`, `help(read)`, and tab-completion all work inside the REPL. The annotations arrive pre-rendered from the host, which projects them with dsh's own `jsonSchemaToPy` — no second mapper to drift out of sync."""
212
212
  name = spec["name"]
213
213
 
214
+ # A parameter name travels to the tool as a JSON key, so a renamed one has to travel back: the block spells `file_path` and `from_`, the tool still expects `file-path` and `from`. A raw key passed straight through is left alone, which is what a cell written before the rename does.
215
+ renames = {p["name"]: p["raw"] for p in spec.get("params") or [] if p.get("raw")}
216
+
214
217
  async def call(**kwargs):
215
- return await bridge.call(name, kwargs)
218
+ return await bridge.call(name, {renames.get(key, key): value for key, value in kwargs.items()} if renames else kwargs)
216
219
 
217
220
  call.__name__ = name if name.isidentifier() else "call"
218
221
  call.__qualname__ = f"__dsh__.tools.{name}"