dsh-py-codeact 0.3.0 → 0.3.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/lib/index.js +131 -49
- package/package.json +1 -1
- package/py/kernel.py +4 -1
package/lib/index.js
CHANGED
|
@@ -49,16 +49,16 @@ const INSTRUCTIONS = `## Writing code for the \`python\` tool
|
|
|
49
49
|
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
50
|
|
|
51
51
|
- 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.
|
|
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. 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
53
|
- 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
54
|
- 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
55
|
- 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
56
|
- 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
|
|
57
|
+
- \`!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
58
|
- 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
59
|
- 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.
|
|
60
|
+
- 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.
|
|
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. 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
62
|
- 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
63
|
- 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
64
|
|
|
@@ -116,27 +116,28 @@ function narrowed(node) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
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.
|
|
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. 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
120
|
*
|
|
121
121
|
* 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
122
|
*/
|
|
123
|
-
function toolSpec(schema, returns) {
|
|
123
|
+
function toolSpec(schema, returns, annotated) {
|
|
124
124
|
const parameters = schema.parameters ?? {}
|
|
125
125
|
const properties = parameters.properties ?? {}
|
|
126
126
|
const required = new Set(Array.isArray(parameters.required) ? parameters.required : [])
|
|
127
127
|
return {
|
|
128
128
|
name: schema.name,
|
|
129
129
|
doc: schema.description,
|
|
130
|
-
// Absent only if the render dropped this tool, which it does not: `
|
|
130
|
+
// Absent only if the render dropped this tool, which it does not: `renderTypes` emits one
|
|
131
131
|
// entry per schema, and a schema with no `output` at all comes back as `Any` from there.
|
|
132
132
|
returns: returns.get(schema.name) ?? 'Any',
|
|
133
|
-
params: Object.entries(properties).map(([name, node]) => ({
|
|
133
|
+
params: spellParams(Object.entries(properties).map(([name, node]) => ({
|
|
134
134
|
name,
|
|
135
|
-
|
|
135
|
+
// `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.
|
|
136
|
+
type: annotated.get(name) ?? jsonSchemaToPy(narrowed(node)),
|
|
136
137
|
required: required.has(name),
|
|
137
138
|
// 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
139
|
doc: node?.description,
|
|
139
|
-
})),
|
|
140
|
+
}))),
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
@@ -164,8 +165,8 @@ export function toolSpecs(schemas) {
|
|
|
164
165
|
// a tool whose own payload happens to be `{content: [...]}` is not wrapping anything, and
|
|
165
166
|
// replacing its value with the joined text would change what it returns with nothing to say so.
|
|
166
167
|
const envelopes = new Set(sorted.filter((schema) => mcpPayloadSchema(schema.output) !== null).map((schema) => schema.name))
|
|
167
|
-
const { returns, declarations } =
|
|
168
|
-
return { specs: sorted.map((schema) => toolSpec(schema, returns)), declarations, envelopes }
|
|
168
|
+
const { returns, params, declarations } = renderTypes(sorted)
|
|
169
|
+
return { specs: sorted.map((schema) => toolSpec(schema, returns, params.get(schema.name) ?? new Map())), declarations, envelopes }
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/**
|
|
@@ -201,7 +202,9 @@ let pythonEnv
|
|
|
201
202
|
function renderEnvironment() {
|
|
202
203
|
if (pythonEnv === undefined) return 'The session runs in its own Python interpreter, with `uv pip install <pkg>` available in a cell.'
|
|
203
204
|
const where = pythonEnv.venv ? `in the environment at \`${pythonEnv.prefix}\`` : `at \`${pythonEnv.executable}\``
|
|
204
|
-
|
|
205
|
+
// 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.
|
|
206
|
+
// 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.
|
|
207
|
+
const head = `Python ${pythonEnv.version} ${where}. `
|
|
205
208
|
// 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
209
|
return pythonEnv.disposable
|
|
207
210
|
? `${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 +257,41 @@ const serverName = (server, grouped) => (fold(server) !== server && grouped.has(
|
|
|
254
257
|
/** A name this block can put in an `import`, a `def`, or a `TypedDict` field. */
|
|
255
258
|
const isUsableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !PY_KEYWORDS.has(name)
|
|
256
259
|
|
|
260
|
+
/** 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.) */
|
|
261
|
+
const PY_HARD_KEYWORDS = new Set([...PY_KEYWORDS].filter((word) => !['_', 'case', 'match', 'type'].includes(word)))
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The name a parameter can be CALLED by, or `null` when Python has none to offer.
|
|
265
|
+
*
|
|
266
|
+
* 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_`).
|
|
267
|
+
*/
|
|
268
|
+
const paramName = (raw) => {
|
|
269
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw)) return PY_HARD_KEYWORDS.has(raw) ? `${raw}_` : raw
|
|
270
|
+
const folded = fold(raw)
|
|
271
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(folded) && !PY_HARD_KEYWORDS.has(folded) ? folded : null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Spell every parameter of one tool, refusing any normalisation that would displace a sibling.
|
|
276
|
+
*
|
|
277
|
+
* 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.
|
|
278
|
+
*/
|
|
279
|
+
function spellParams(params) {
|
|
280
|
+
const taken = new Set(params.map((p) => p.name))
|
|
281
|
+
const claimed = new Set()
|
|
282
|
+
return params.map((p) => {
|
|
283
|
+
const name = paramName(p.name)
|
|
284
|
+
if (name === null || name === p.name) return p
|
|
285
|
+
// `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.
|
|
286
|
+
if (taken.has(name) || claimed.has(name)) return p
|
|
287
|
+
claimed.add(name)
|
|
288
|
+
return { ...p, name, raw: p.name }
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Whether the block can put this parameter in a signature: it was renamed, or it needed no renaming. */
|
|
293
|
+
const isSpelled = (p) => p.raw !== undefined || paramName(p.name) === p.name
|
|
294
|
+
|
|
257
295
|
/** `mcp__calendar__list_events` -> `McpCalendarListEvents`, for naming that tool's declarations. */
|
|
258
296
|
const pascal = (name) => name.split(/[_\-]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('')
|
|
259
297
|
|
|
@@ -269,8 +307,9 @@ const PROTOCOL_HEAD = 'class Tools(Protocol):\n'
|
|
|
269
307
|
const PROTOCOL_TAIL = '\n\ntools: Tools'
|
|
270
308
|
|
|
271
309
|
/**
|
|
272
|
-
* dsh's own Code Mode render, read back for the
|
|
273
|
-
* `TypedDict` per
|
|
310
|
+
* dsh's own Code Mode render, read back for the things `jsonSchemaToPy` cannot produce: a NAMED
|
|
311
|
+
* `TypedDict` per object — on either side of the call — and the union of named branches a `oneOf`
|
|
312
|
+
* output resolves to.
|
|
274
313
|
*
|
|
275
314
|
* `jsonSchemaToPy` is context-free and says so — "naming a `TypedDict` requires the render context
|
|
276
315
|
* that `renderToolsSdkPy` supplies" — so with nowhere to hang a declaration it degrades every object
|
|
@@ -281,12 +320,18 @@ const PROTOCOL_TAIL = '\n\ntools: Tools'
|
|
|
281
320
|
* exists to remove. `renderType`, the context-carrying core, is not exported and its `src/` is not
|
|
282
321
|
* shipped, so `renderToolsSdkPy` is the only door to it.
|
|
283
322
|
*
|
|
284
|
-
* The context is therefore borrowed rather than rebuilt
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
323
|
+
* The context is therefore borrowed rather than rebuilt, for both halves in ONE call — so dsh's own
|
|
324
|
+
* collision suffixing settles a parameter class and an output class that would otherwise pick the
|
|
325
|
+
* same name. Descriptions are dropped so the `Tools` body is exactly one line per tool. MCP envelopes
|
|
326
|
+
* are unwrapped first, because the cell receives the payload and annotating the transport wrapper is
|
|
327
|
+
* accurate and useless: the model then hand-writes `r["structuredContent"]["result"]`, and calls once
|
|
328
|
+
* just to learn that.
|
|
329
|
+
*
|
|
330
|
+
* The parameter side arrives as a `<Tool>Args` wrapper, dsh's one-dict-per-call convention. This
|
|
331
|
+
* block spells parameters out instead, so the wrapper is read for its members and then dropped,
|
|
332
|
+
* while anything it references stays. Two shapes still degrade and fall back to `jsonSchemaToPy`: a
|
|
333
|
+
* `oneOf` at any property collapses the whole args type, and so does a stray `$schema` key — which
|
|
334
|
+
* is why `narrowed` runs on parameters too, not only on outputs.
|
|
290
335
|
*
|
|
291
336
|
* Reading generated text back is the seam, and it buys the alternative's absence: the `Literal`s,
|
|
292
337
|
* nested classes, collision suffixes and Unicode identifier rules stay dsh's own instead of a second
|
|
@@ -294,28 +339,50 @@ const PROTOCOL_TAIL = '\n\ntools: Tools'
|
|
|
294
339
|
* miss and every tool falls back to `Any` — a visible annotation the suite asserts against, not a
|
|
295
340
|
* silently wrong one.
|
|
296
341
|
*
|
|
297
|
-
* @returns the return annotation per tool name, and the class declarations
|
|
342
|
+
* @returns the return annotation per tool name, the parameter annotations per tool name, and the class declarations both reference as text.
|
|
298
343
|
*/
|
|
299
|
-
function
|
|
344
|
+
function renderTypes(sorted) {
|
|
300
345
|
const declared = (schema) => {
|
|
301
346
|
const payload = mcpPayloadSchema(schema.output)
|
|
302
347
|
// An envelope with no declared payload resolves to the text blocks joined, so it really is a `str`.
|
|
303
348
|
return narrowed(payload === null ? schema.output : (payload ?? { type: 'string' }))
|
|
304
349
|
}
|
|
305
|
-
|
|
350
|
+
// 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]`.
|
|
351
|
+
const text = renderToolsSdkPy(sorted.map((schema) => ({ name: schema.name, parameters: narrowed(schema.parameters ?? {}), output: declared(schema) })))
|
|
306
352
|
const at = text.indexOf(ERROR_STUB)
|
|
307
353
|
const to = text.indexOf(PROTOCOL_HEAD, at)
|
|
308
354
|
const returns = new Map()
|
|
355
|
+
const wrapperOf = new Map()
|
|
309
356
|
for (const line of text.slice(to + PROTOCOL_HEAD.length, text.indexOf(PROTOCOL_TAIL, to)).split('\n')) {
|
|
310
357
|
// Two shapes, because dsh routes a name Python cannot take to a subscript COMMENT rather than a
|
|
311
358
|
// method. Both are read: this block reaches such a tool through `getattr`, so its return type is
|
|
312
359
|
// as real as any other's.
|
|
313
|
-
const method = / {4}async def ([^(]+)\(self, args:
|
|
314
|
-
if (method !== null) { returns.set(method[1], method[2]); continue }
|
|
315
|
-
const subscript = / {4}# tools\[(".*")\]\(args:
|
|
316
|
-
if (subscript !== null)
|
|
360
|
+
const method = / {4}async def ([^(]+)\(self, args: ([^)]+)\) -> (.+): \.\.\.$/.exec(line)
|
|
361
|
+
if (method !== null) { returns.set(method[1], method[3]); wrapperOf.set(method[1], method[2]); continue }
|
|
362
|
+
const subscript = / {4}# tools\[(".*")\]\(args: ([^)]+)\) -> (.+)$/.exec(line)
|
|
363
|
+
if (subscript !== null) { const name = JSON.parse(subscript[1]); returns.set(name, subscript[3]); wrapperOf.set(name, subscript[2]) }
|
|
364
|
+
}
|
|
365
|
+
// `<Tool>Args` exists for dsh's calling convention — one `args` dict per call. This block spells
|
|
366
|
+
// parameters out instead, so the wrapper is read for its members and then dropped, while anything
|
|
367
|
+
// it REFERENCES (a nested object's own class) is declared separately and stays. Reading it back is
|
|
368
|
+
// what makes `window: ReadArgsWindow` possible at all: `jsonSchemaToPy` is context-free and has
|
|
369
|
+
// nowhere to hang a declaration, so on its own it degrades every parameter object to
|
|
370
|
+
// `dict[str, Any]` — the same erasure #16 fixed on the return side, still standing on this one.
|
|
371
|
+
const blocks = text.slice(at + ERROR_STUB.length, to).split('\n\n').filter((block) => block.trim() !== '')
|
|
372
|
+
const wrappers = new Set(wrapperOf.values())
|
|
373
|
+
const isWrapper = (block) => wrappers.has(/^class (\w+)\(TypedDict\):$/.exec(block.split('\n')[0])?.[1])
|
|
374
|
+
const members = new Map()
|
|
375
|
+
for (const block of blocks.filter(isWrapper)) {
|
|
376
|
+
const fields = new Map()
|
|
377
|
+
for (const line of block.split('\n').slice(1)) {
|
|
378
|
+
const field = / {4}(\w+): (.+)$/.exec(line)
|
|
379
|
+
// `NotRequired[...]` is what `required` already says here, and the signature spells optionality with a default instead.
|
|
380
|
+
if (field !== null) fields.set(field[1], /^NotRequired\[(.+)\]$/.exec(field[2])?.[1] ?? field[2])
|
|
381
|
+
}
|
|
382
|
+
members.set(/^class (\w+)/.exec(block)[1], fields)
|
|
317
383
|
}
|
|
318
|
-
|
|
384
|
+
const params = new Map([...wrapperOf].map(([tool, wrapper]) => [tool, members.get(wrapper) ?? new Map()]))
|
|
385
|
+
return { returns, params, declarations: blocks.filter((block) => !isWrapper(block)).join('\n\n').trimEnd() }
|
|
319
386
|
}
|
|
320
387
|
|
|
321
388
|
/**
|
|
@@ -352,24 +419,34 @@ export function renderToolsSection(schemas) {
|
|
|
352
419
|
const docstring = (doc) => {
|
|
353
420
|
if (!doc) return [' ...']
|
|
354
421
|
const text = doc.trim().replaceAll('\\', '\\\\').replaceAll('"""', '\\"\\"\\"')
|
|
355
|
-
|
|
422
|
+
// 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.
|
|
423
|
+
//
|
|
424
|
+
// 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.
|
|
425
|
+
const lines = text.split(/\r\n?|\n/)
|
|
356
426
|
return lines.length === 1 ? [` """${lines[0]}"""`] : [' """', ...lines.map((line) => ` ${line}`.trimEnd()), ' """']
|
|
357
427
|
}
|
|
358
428
|
// 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
|
|
429
|
+
const trailing = (doc) => (doc ? ` # ${doc.trim().replace(/\s*(?:\r\n?|\n)\s*/g, ' ')}` : '')
|
|
360
430
|
/** @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
431
|
const signature = (spec) => {
|
|
362
432
|
const body = docstring(spec.doc)
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const types = [spec.returns, ...spec.params.map((p) => p.type)]
|
|
433
|
+
// 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.
|
|
434
|
+
const named = spec.params.filter((p) => isSpelled(p))
|
|
435
|
+
const rest = spec.params.filter((p) => !isSpelled(p))
|
|
436
|
+
const types = [spec.returns, ...named.map((p) => p.type), ...(rest.length === 0 ? [] : ['Any'])]
|
|
368
437
|
// `async def f(*, ) -> T` is still a SyntaxError, which is why a parameterless tool emits no `*`.
|
|
369
438
|
if (spec.params.length === 0) return { lines: [`async def ${spec.name}() -> ${spec.returns}:`, ...body], types }
|
|
370
439
|
// 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 =
|
|
372
|
-
|
|
440
|
+
const fields = named.map((p) => ` ${p.name}: ${p.type}${p.required ? '' : ' = ...'},${trailing(p.doc)}`)
|
|
441
|
+
// 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.
|
|
442
|
+
if (rest.length > 0) {
|
|
443
|
+
// 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.
|
|
444
|
+
let overflow = 'kwargs'
|
|
445
|
+
while (named.some((p) => p.name === overflow)) overflow = `_${overflow}`
|
|
446
|
+
fields.push(` **${overflow}: Any # spell as dict keys: ${rest.map((p) => JSON.stringify(p.name)).join(', ')}`)
|
|
447
|
+
}
|
|
448
|
+
const open = named.length === 0 ? [`async def ${spec.name}(`] : [`async def ${spec.name}(`, ' *,']
|
|
449
|
+
return { lines: [...open, ...fields, `) -> ${spec.returns}:`, ...body], types }
|
|
373
450
|
}
|
|
374
451
|
// `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
452
|
// The declarations are stubs, not runtime objects — nothing constructs them — so the import line
|
|
@@ -387,18 +464,19 @@ export function renderToolsSection(schemas) {
|
|
|
387
464
|
// A blank line between definitions, or a docstring runs straight into the next `async def`.
|
|
388
465
|
const spaced = (blocks) => blocks.flatMap((block, index) => (index === 0 ? block : ['', ...block]))
|
|
389
466
|
const nativeBlock = listing(TOOLS_MODULE, spaced(nativeSigs.map((sig) => sig.lines)))
|
|
390
|
-
//
|
|
391
|
-
// the
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
467
|
+
// Rendered exactly like the native ones. These names are not bound at the top level — only `mcp`
|
|
468
|
+
// is — and the header above each section is what says so, the same way it does for `__dsh__.tools`.
|
|
469
|
+
// Commenting them out was meant to stop a bare `async def read(...)` from shadowing the imported
|
|
470
|
+
// `read`, but the native section is bare too and shadows it just as thoroughly: running this block
|
|
471
|
+
// is not something the shape of the MCP half can make safe. It is a signature listing, read for
|
|
472
|
+
// the signatures.
|
|
395
473
|
// A server whose every tool name Python refuses gets no section: nothing here would be callable
|
|
396
474
|
// as written, and the `getattr` line below is where those tools actually live.
|
|
397
475
|
const mcpSigs = servers.flatMap(([server, tools]) => tools.filter((t) => isUsableName(fold(t.tool))).sort(by((t) => t.tool))
|
|
398
476
|
.map((t) => ({ server, ...signature({ ...t, name: fold(t.tool) }) })))
|
|
399
477
|
const mcpBlock = servers.flatMap(([server]) =>
|
|
400
478
|
listing(`${TOOLS_MODULE}.mcp.${serverName(server, grouped)}`, spaced(mcpSigs.filter((sig) => sig.server === server)
|
|
401
|
-
.map((sig) => sig.lines
|
|
479
|
+
.map((sig) => sig.lines))))
|
|
402
480
|
const signatures = nativeBlock.concat(mcpBlock)
|
|
403
481
|
const classes = declarations === '' ? [] : ['', ...declarations.split('\n')]
|
|
404
482
|
// Whatever the emitted lines actually spell, rather than a condition per symbol: the conditions
|
|
@@ -444,12 +522,19 @@ export function renderToolsSection(schemas) {
|
|
|
444
522
|
return lines.join('\n')
|
|
445
523
|
}
|
|
446
524
|
|
|
447
|
-
/** Flatten model-facing content blocks
|
|
525
|
+
/** Flatten model-facing content blocks when an MCP result has no structured payload. */
|
|
448
526
|
function contentText(content) {
|
|
449
527
|
if (!Array.isArray(content)) return ''
|
|
450
528
|
return content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n\n')
|
|
451
529
|
}
|
|
452
530
|
|
|
531
|
+
/** @internal exported for the suite. The program-visible reply for one completed sub-call. */
|
|
532
|
+
export function toolCallReply(outcome, unwrapMcp = false) {
|
|
533
|
+
if (outcome.isError) return { ok: false, message: outcome.error.message }
|
|
534
|
+
const unwrapped = unwrapMcp ? mcpPayload(outcome.value) : undefined
|
|
535
|
+
return { ok: true, value: unwrapped === undefined ? outcome.value : unwrapped.value }
|
|
536
|
+
}
|
|
537
|
+
|
|
453
538
|
/**
|
|
454
539
|
* Shape one finished cell into the model's observation.
|
|
455
540
|
*
|
|
@@ -592,10 +677,7 @@ export function apply(ctx, config = {}) {
|
|
|
592
677
|
for (const context of outcome.additionalContexts ?? []) exec.deferContext(context)
|
|
593
678
|
// `=== undefined` rather than `??`: a payload that IS `null` is the server's answer, and
|
|
594
679
|
// falling back to the wrapper there would hand the cell the one shape it was promised not to see.
|
|
595
|
-
|
|
596
|
-
return outcome.isError
|
|
597
|
-
? { ok: false, message: contentText(outcome.content) || 'tool call failed' }
|
|
598
|
-
: { ok: true, value: unwrapped === undefined ? outcome.value : unwrapped.value }
|
|
680
|
+
return toolCallReply(outcome, entry.envelopes.get(from)?.has(name))
|
|
599
681
|
} catch (error) {
|
|
600
682
|
session?.append('tool/code-dispatch', { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
|
|
601
683
|
throw error
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-py-codeact",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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
|
@@ -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}"
|