dsh-py-codeact 0.2.3 → 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/README.md +35 -16
- package/lib/index.js +286 -116
- package/package.json +1 -1
- package/py/kernel.py +93 -7
package/README.md
CHANGED
|
@@ -52,25 +52,40 @@ 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' = ..., limit: 'int' = ...) -> '
|
|
56
|
-
Docstring:
|
|
55
|
+
Signature: read(*, file_path: 'str', offset: 'int' = ..., limit: 'int' = ...) -> 'ReadOutput'
|
|
56
|
+
Docstring:
|
|
57
|
+
Read a file from the workspace. Results include line numbers…
|
|
58
|
+
|
|
59
|
+
Parameters:
|
|
60
|
+
file_path: Path to read, resolved by the filesystem backend.
|
|
61
|
+
offset: 1-based first line to return. Defaults to 1.
|
|
62
|
+
limit: Maximum number of lines to return. Defaults to 2000.
|
|
57
63
|
```
|
|
58
64
|
|
|
59
|
-
|
|
65
|
+
The block renders the same picture: one parameter per line, its description as a trailing comment, the tool's own as a docstring — so what a model reads in the prompt and what it gets from `?` are the same text, from the same source. Annotations are rendered host-side by dsh's own exported renderers, so there is no second JSON-Schema mapper to drift.
|
|
60
66
|
|
|
61
67
|
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
68
|
|
|
63
|
-
|
|
69
|
+
Every object in that schema is declared as a named `TypedDict` above the signatures, and an output that is a choice of shapes becomes a union of named branches. 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
70
|
|
|
65
71
|
```python
|
|
66
|
-
class
|
|
67
|
-
|
|
72
|
+
class BashOutput1(TypedDict):
|
|
73
|
+
kind: Literal["background"]
|
|
74
|
+
jobId: str
|
|
68
75
|
|
|
69
|
-
class
|
|
70
|
-
|
|
76
|
+
class BashOutput2Stdout(TypedDict):
|
|
77
|
+
text: str
|
|
78
|
+
truncated: bool
|
|
79
|
+
|
|
80
|
+
class BashOutput2(TypedDict):
|
|
81
|
+
kind: Literal["foreground"]
|
|
82
|
+
exitCode: int | None
|
|
83
|
+
stdout: BashOutput2Stdout
|
|
84
|
+
|
|
85
|
+
async def bash(*, command: str, run_in_background: bool = ...) -> BashOutput1 | BashOutput2: ...
|
|
71
86
|
```
|
|
72
87
|
|
|
73
|
-
`jsonSchemaToPy` cannot do this and says so — it is context-free, and naming a `TypedDict` needs the render context `renderToolsSdkPy` supplies.
|
|
88
|
+
`jsonSchemaToPy` cannot do this and says so — it is context-free, and naming a `TypedDict` needs the render context `renderToolsSdkPy` supplies. With nowhere to hang a declaration it degrades every object to `dict[str, Any]`, which on a stock catalogue is the return type of every tool but one. `renderType`, the context-carrying core, is not exported, so the context is borrowed instead: `renderToolsSdkPy` is called with the parameters stripped — the one input it renders without allocating a class, which makes every class it emits an output class — and the block it returns is read back for the declarations and each tool's return text. The `Literal`s, the nested classes, the collision suffixes and the Unicode identifier rules are then dsh's own rather than a second mapper drifting alongside them. The binding set is resent with every cell, because restrictions and mid-conversation tool changes can move a tool in or out between calls.
|
|
74
89
|
|
|
75
90
|
### The MCP wrapper does not reach the cell
|
|
76
91
|
|
|
@@ -79,8 +94,8 @@ dsh's MCP client resolves a call to `{ content, structuredContent? }` — the pr
|
|
|
79
94
|
So the bridge unwraps, and the signature describes what the cell actually receives:
|
|
80
95
|
|
|
81
96
|
```python
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
mcp.review.search(*, q: str) -> McpReviewSearchOutput # the payload, not the wrapper
|
|
98
|
+
mcp.email.ping() -> str # no declared payload: the text blocks, joined
|
|
84
99
|
```
|
|
85
100
|
|
|
86
101
|
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.
|
|
@@ -97,7 +112,7 @@ from __dsh__.tools import mcp
|
|
|
97
112
|
data = await mcp.gh.github_graphql(query="{ viewer { login } }")
|
|
98
113
|
```
|
|
99
114
|
|
|
100
|
-
dsh names them `mcp__<server>__<rawName>`, and with a hundred mounted that import line was most of the prompt block while every call site respelled its server. The flat names stay bound — `mcp` is how they are shown, not what they are — so a cell written before this still runs, `import *` still binds them, and only the listings drop them. A name the grouping cannot serve is still shown: dsh hashes a public name that needed normalising and the cut can land before the second `__`, and a raw name that is a true dunder is refused by `__getattr__`; either way the flat name is the only one that works. They are kept out of `dir(__dsh__.tools)` for the same reason the block stopped printing them; a name the grouping cannot reach (dsh hashes a public name that needed normalising, and the cut can land before the second `__`) stays listed, because `mcp` is not another way to say it. The block
|
|
115
|
+
dsh names them `mcp__<server>__<rawName>`, and with a hundred mounted that import line was most of the prompt block while every call site respelled its server. The flat names stay bound — `mcp` is how they are shown, not what they are — so a cell written before this still runs, `import *` still binds them, and only the listings drop them. A name the grouping cannot serve is still shown: dsh hashes a public name that needed normalising and the cut can land before the second `__`, and a raw name that is a true dunder is refused by `__getattr__`; either way the flat name is the only one that works. They are kept out of `dir(__dsh__.tools)` for the same reason the block stopped printing them; a name the grouping cannot reach (dsh hashes a public name that needed normalising, and the cut can land before the second `__`) stays listed, because `mcp` is not another way to say it. The block lists them one module per section — `# __dsh__.tools`, then `# __dsh__.tools.mcp.<server>` — because that is what they are. It used to declare `Protocol` stubs with `self`-taking methods, copied from dsh's own SDK renderer; that renderer describes a singleton object (`tools: Tools`), while this grouping is a real package, so the stub claimed a binding that never happens. `mcp.exa.web_search?` answers `(*, query: str) -> str`, and the block now says the same thing.
|
|
101
116
|
|
|
102
117
|
It is a real package, so a server can be imported as a module — which reads better than `mcp.` at every call site when a cell leans on one server:
|
|
103
118
|
|
|
@@ -108,13 +123,17 @@ from __dsh__.tools.mcp import calendar # or the server itself
|
|
|
108
123
|
|
|
109
124
|
The deep form is why each server gets a `sys.modules` entry of its own: `__getattr__` can serve `from __dsh__.tools.mcp import calendar`, but not `from __dsh__.tools.mcp.calendar import list_events` — the import machinery looks that one up as a module. No meta path finder is needed; registration is enough.
|
|
110
125
|
|
|
111
|
-
`mcp` and its server modules are live views of the catalogue, not snapshots of the cell they were imported in: a restriction or a reconnecting server moves tools in and out between calls, and unlike a single tool the model has no reason to ever import the namespace twice. (A name pulled OUT with `from ... import` is a snapshot, as it is for any Python import.) The name is reserved — a native tool called `mcp` is not bound.
|
|
126
|
+
`mcp` and its server modules are live views of the catalogue, not snapshots of the cell they were imported in: a restriction or a reconnecting server moves tools in and out between calls, and unlike a single tool the model has no reason to ever import the namespace twice. (A name pulled OUT with `from ... import` is a snapshot, as it is for any Python import.) The name is reserved — a native tool called `mcp` is not bound, nor is one called `ToolCallError`, nor any whose name starts with `_`. The prompt block mirrors all three, because a block that imports a name the kernel never binds is an `ImportError` on the first line the model copies.
|
|
112
127
|
|
|
113
128
|
dsh's MCP client is explicitly aware of this route — its canonical value "retains the complete JSON MCP blocks and optional structured content for programmatic and Code Mode callers" — and the sub-call logs a `SUBTOOL` row like any other.
|
|
114
129
|
|
|
115
130
|
Worth contrasting: Anthropic's server-side [programmatic tool calling](https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling) is *not* compatible with MCP tools. Owning the bridge host-side is what buys this.
|
|
116
131
|
|
|
117
|
-
|
|
132
|
+
A raw MCP name is a routine place to find a hyphen — dsh normalises over `[A-Za-z0-9_-]`, so `-` survives, and `-` is legal in no Python identifier. Such a tool is bound under BOTH spellings: its own, and the `-`→`_` fold that the listings show and the block spells. `mcp.notion.API_patch_block_children(...)` dispatches to `mcp__notion__API-patch-block-children`, and an existing `getattr(mcp.notion, "API-patch-block-children")` keeps working. The fold never displaces a real tool: a server exposing both `a-b` and `a_b` keeps `a_b` meaning `a_b`, and both stay listed.
|
|
133
|
+
|
|
134
|
+
Measured before adding it: on a 20-task benchmark run, 676 of 17030 dispatches (4.0%) went to twelve hyphenated tools, all from one server, and `API-patch-block-children` was the single most-dispatched tool of the whole catalogue — each call site paying for a `getattr`, with no signature in the block to go with it, because an unspellable name was excluded from the listing entirely. Across the 208 distinct tool names those runs dispatched, no fold collided with another fold or shadowed an existing name.
|
|
135
|
+
|
|
136
|
+
(Folding fixes only what a substitution can reach. A keyword (`class`) or a digit-leading name (`123tool`) is unspellable for reasons no rewrite fixes, and those keep the `getattr` route — `getattr(__dsh__.tools, "class")` — which the block still points at when there is one.)
|
|
118
137
|
|
|
119
138
|
## Exclusive mode
|
|
120
139
|
|
|
@@ -188,7 +207,7 @@ Sub-dispatches carry the outer execution's `parent` token, so they re-enter the
|
|
|
188
207
|
- **Redundant-import hints.** When an `import` rebinds a name to the object it already held, the result carries `` `json` is already imported in this session — no need to re-import it. `` A model driving a persistent REPL re-imports constantly; telling it is cheaper than letting it burn a line every cell. (Implemented with a `dict` subclass that watches top-level `STORE_NAME` and checks the preceding opcode was `IMPORT_NAME`/`IMPORT_FROM`, so `x = x` does not trip it.)
|
|
189
208
|
- **Readable reprs.** `objprint` + IPython's `pretty` for objects whose own `__repr__` is `object.__repr__` — an agent reading values needs structure, not `<Foo object at 0x…>`.
|
|
190
209
|
- **Tagged observations.** `<stdout>`, `<stderr>`, `<return>`, `<traceback>`, `<note>` — with four things possibly present at once, the model needs to know which is which. A plain successful value stays bare.
|
|
191
|
-
- **Introspection over prompt text.** `read?` for one tool's full description, `dir(__dsh__.tools)` for the list — `mcp` rather than the hundred flat names under it, matching what the block printed — then `dir(mcp)` and `dir(mcp.<server>)` for those, and `%whos` for its own bindings. `__all__` is left alone: it is what `import *` binds, not what the model is shown
|
|
210
|
+
- **Introspection over prompt text.** `read?` for one tool's full description, `dir(__dsh__.tools)` for the list — `mcp` rather than the hundred flat names under it, matching what the block printed — then `dir(mcp)` and `dir(mcp.<server>)` for those, and `%whos` for its own bindings. `__all__` is left alone: it is what `import *` binds, not what the model is shown — so it carries every flat name the listings drop, and `ToolCallError`, which no listing shows because it is not a tool but which `except ToolCallError` needs after an `import *`. What it introspects matches what the block showed it: the listing carries `mcp` rather than the hundred flat names under it, and an optional parameter renders `= ...` there as it does here — `inspect.signature` uses `repr`, and `repr(...)` is `Ellipsis`, which is what `read?` used to say.
|
|
192
211
|
|
|
193
212
|
## Cancellation
|
|
194
213
|
|
|
@@ -201,7 +220,7 @@ A pure CPU loop (`while True: pass`) never reaches an await point. After `hardIn
|
|
|
201
220
|
- **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.)
|
|
202
221
|
- **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).
|
|
203
222
|
- **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.
|
|
204
|
-
- **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.
|
|
223
|
+
- **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. A parameter named `self` is now just a parameter: every tool renders as a module-level function, so no enclosing signature has spent that name. A tool name Python refuses gets a class under one it accepts (`123tool` → `Tool123toolOutput`), rather than the `class 123toolOutput` that would be a SyntaxError taking the whole block with it; that tool has no `async def` line, but it is still bound, and `123tool?` names the same class. A schema with no declared properties still renders `Any`; claiming a type nobody declared would be worse.
|
|
205
224
|
- **Cold start.** The PEP 723 environment is resolved on first use (`uv python find --script`). Warm afterwards; pass `python` to skip it.
|
|
206
225
|
|
|
207
226
|
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
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
import { CallId } from '@deepseek-ai/dsh-llm/brand'
|
|
14
14
|
import { contentHasImage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
15
|
-
import { defineTool, jsonSchemaToPy } from '@deepseek-ai/dsh-tools'
|
|
15
|
+
import { defineTool, jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools'
|
|
16
16
|
import { KERNEL_PY, PythonKernel } from './kernel.js'
|
|
17
17
|
|
|
18
18
|
/** Same prompt band as Code Mode's `tools:sdk`: tool guidance is 100–199. */
|
|
19
|
+
const TOOLS_MODULE = '__dsh__.tools'
|
|
20
|
+
|
|
19
21
|
const SDK_SECTION_ORDER = 150
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -47,16 +49,16 @@ const INSTRUCTIONS = `## Writing code for the \`python\` tool
|
|
|
47
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]\`).
|
|
48
50
|
|
|
49
51
|
- Top-level \`await\` works. So do IPython magics: \`%whos\` to see what you have bound, \`%timeit\`, \`%run script.py\`, \`%%writefile\`, \`obj?\` / \`obj??\`, \`%cd\`.
|
|
50
|
-
- 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.
|
|
51
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.
|
|
52
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.
|
|
53
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.
|
|
54
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.
|
|
55
|
-
- \`!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)\`.
|
|
56
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.
|
|
57
59
|
- A FAILED tool call raises \`ToolCallError\` (\`.tool_name\`, plus the message); catch it and continue.
|
|
58
|
-
- Independent calls may overlap with \`asyncio.gather\`. Sequence dependent work with plain \`await\`.
|
|
59
|
-
- 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.
|
|
60
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.
|
|
61
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.
|
|
62
64
|
|
|
@@ -88,33 +90,59 @@ export function mcpPayload(value) {
|
|
|
88
90
|
return 'structuredContent' in value ? { value: value.structuredContent } : { value: contentText(value.content) }
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/** dsh's schema subset, from `assertSupportedJsonSchema`: eight constraint keywords plus four annotations, enforced whole-tree and all-or-nothing. */
|
|
94
|
+
const SCHEMA_SUBSET = new Set(['type', 'oneOf', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', 'description', 'title', 'default', 'examples'])
|
|
95
|
+
|
|
91
96
|
/**
|
|
92
|
-
*
|
|
97
|
+
* Rewrite a raw schema into {@link SCHEMA_SUBSET}, so a keyword that carries no type information cannot cost a field the type sitting right beside it.
|
|
98
|
+
*
|
|
99
|
+
* The subset is validated whole-tree and rejection is total, so ONE unrecognised keyword anywhere collapses the entire annotation: `{"type": "string", "minLength": 1}` renders `Any` where `{"type": "string"}` renders `str`. Those keywords are what Pydantic and FastMCP emit for `Field(min_length=1)`, `float` and `str | None`, and `$schema` sits on the root of most generated schemas — so this is not an exotic case, it is every MCP server built on one. A required search query arriving as `query: Any` tells the model nothing while looking like it did.
|
|
100
|
+
*
|
|
101
|
+
* Drop whatever the subset cannot take — by name, and for `additionalProperties` by form — and rewrite `anyOf` to `oneOf`, the one rejected keyword that DOES carry type information and which means exactly what a union annotation means. Deliberately not a second JSON-Schema mapper: it decides nothing about types, it only removes reasons to reject, which is why the rewrite stands down where a `oneOf` is already declared. Nor can it lose an annotation that renders today, since a node dsh already accepts has no key to drop and no `anyOf` to rewrite.
|
|
102
|
+
*/
|
|
103
|
+
function narrowed(node) {
|
|
104
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node)) return node
|
|
105
|
+
const rewrite = (key, value) => {
|
|
106
|
+
if (key === 'items') return narrowed(value)
|
|
107
|
+
if (key === 'oneOf' && Array.isArray(value)) return value.map((branch) => narrowed(branch))
|
|
108
|
+
if (key === 'properties' && typeof value === 'object' && value !== null) return Object.fromEntries(Object.entries(value).map(([name, child]) => [name, narrowed(child)]))
|
|
109
|
+
return value
|
|
110
|
+
}
|
|
111
|
+
// A name in the subset is not enough: dsh takes `additionalProperties` only as a BOOLEAN, so the schema-valued form Pydantic emits for `dict[str, str]` is rejected however clean its child is — narrowing that child repairs nothing, and dropping it costs the annotation nothing dsh could have expressed.
|
|
112
|
+
const keep = ([key, value]) => SCHEMA_SUBSET.has(key) && (key !== 'additionalProperties' || typeof value === 'boolean')
|
|
113
|
+
// Only where no `oneOf` is declared. With one already there, dropping `anyOf` loses no type information, and substituting its branches for the declared union would be this function deciding a type rather than removing a reason to reject.
|
|
114
|
+
const source = 'anyOf' in node && !('oneOf' in node) ? { ...node, oneOf: node.anyOf } : node
|
|
115
|
+
return Object.fromEntries(Object.entries(source).filter(keep).map(([key, value]) => [key, rewrite(key, value)]))
|
|
116
|
+
}
|
|
117
|
+
|
|
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. 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.
|
|
93
120
|
*
|
|
94
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.
|
|
95
122
|
*/
|
|
96
|
-
function toolSpec(schema,
|
|
123
|
+
function toolSpec(schema, returns, annotated) {
|
|
97
124
|
const parameters = schema.parameters ?? {}
|
|
98
125
|
const properties = parameters.properties ?? {}
|
|
99
126
|
const required = new Set(Array.isArray(parameters.required) ? parameters.required : [])
|
|
100
127
|
return {
|
|
101
128
|
name: schema.name,
|
|
102
129
|
doc: schema.description,
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
returns: schema.output === undefined ? 'Any' : mcpReturn(schema, declarations),
|
|
108
|
-
params: Object.entries(properties).map(([name, node]) => ({
|
|
130
|
+
// Absent only if the render dropped this tool, which it does not: `renderTypes` emits one
|
|
131
|
+
// entry per schema, and a schema with no `output` at all comes back as `Any` from there.
|
|
132
|
+
returns: returns.get(schema.name) ?? 'Any',
|
|
133
|
+
params: spellParams(Object.entries(properties).map(([name, node]) => ({
|
|
109
134
|
name,
|
|
110
|
-
|
|
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)),
|
|
111
137
|
required: required.has(name),
|
|
112
|
-
|
|
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.
|
|
139
|
+
doc: node?.description,
|
|
140
|
+
}))),
|
|
113
141
|
}
|
|
114
142
|
}
|
|
115
143
|
|
|
116
144
|
/**
|
|
117
|
-
* Every visible tool's spec, plus the `TypedDict`
|
|
145
|
+
* @internal exported for the suite, which runs the block it renders. Every visible tool's spec, plus the `TypedDict` declarations their return annotations name.
|
|
118
146
|
*
|
|
119
147
|
* Both consumers go through here so they cannot disagree: the prompt block declares the classes,
|
|
120
148
|
* and the kernel sends the same `returns` text on to `inspect.Signature`, so `read?` shows the
|
|
@@ -122,25 +150,23 @@ function toolSpec(schema, declarations) {
|
|
|
122
150
|
* second argument is the INDEX — a number, so a default parameter never fires and the accumulator
|
|
123
151
|
* is silently a `0`.
|
|
124
152
|
*/
|
|
125
|
-
function toolSpecs(schemas) {
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
153
|
+
export function toolSpecs(schemas) {
|
|
154
|
+
// Whatever the kernel refuses to bind, this side must not advertise. `build_bindings` drops every
|
|
155
|
+
// `_`-leading name plus `set(vars(ToolsModule)) | set(vars(types.ModuleType)) | {"ToolCallError",
|
|
156
|
+
// "mcp"}`, whose only members that do not already start with `_` are those last two — so the whole
|
|
157
|
+
// reservation mirrors as the three lines below. Only `mcp` was carried across, and the block
|
|
158
|
+
// happily wrote `from __dsh__.tools import ToolCallError, ToolCallError, _private, read`: an
|
|
159
|
+
// `ImportError` on a name nothing binds, and a duplicate that resolves to the exception class the
|
|
160
|
+
// instructions tell the model to catch, so `await ToolCallError(...)` raises an unexplainable
|
|
161
|
+
// `TypeError`. Dropped here rather than at the render, which is downstream of the declarations:
|
|
162
|
+
// one with an object output still had its `TypedDict` emitted, referenced by nothing.
|
|
163
|
+
const sorted = [...schemas].filter((schema) => !RESERVED_NAMES.has(schema.name) && !schema.name.startsWith('_')).sort(byName)
|
|
131
164
|
// Which tools DECLARE the wrapper. Unwrapping keys off this rather than the returned value:
|
|
132
165
|
// a tool whose own payload happens to be `{content: [...]}` is not wrapping anything, and
|
|
133
166
|
// replacing its value with the joined text would change what it returns with nothing to say so.
|
|
134
167
|
const envelopes = new Set(sorted.filter((schema) => mcpPayloadSchema(schema.output) !== null).map((schema) => schema.name))
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** The annotation for one tool's output: an MCP payload if it is an envelope, else the schema as declared. */
|
|
139
|
-
function mcpReturn(schema, declarations) {
|
|
140
|
-
const payload = mcpPayloadSchema(schema.output)
|
|
141
|
-
if (payload === null) return declareType(schema.output, `${pascal(schema.name)}Output`, declarations)
|
|
142
|
-
// An envelope with no declared payload resolves to the text blocks joined, so it really is a `str`.
|
|
143
|
-
return payload === undefined ? 'str' : declareType(payload, `${pascal(schema.name)}Output`, declarations)
|
|
168
|
+
const { returns, params, declarations } = renderTypes(sorted)
|
|
169
|
+
return { specs: sorted.map((schema) => toolSpec(schema, returns, params.get(schema.name) ?? new Map())), declarations, envelopes }
|
|
144
170
|
}
|
|
145
171
|
|
|
146
172
|
/**
|
|
@@ -176,7 +202,9 @@ let pythonEnv
|
|
|
176
202
|
function renderEnvironment() {
|
|
177
203
|
if (pythonEnv === undefined) return 'The session runs in its own Python interpreter, with `uv pip install <pkg>` available in a cell.'
|
|
178
204
|
const where = pythonEnv.venv ? `in the environment at \`${pythonEnv.prefix}\`` : `at \`${pythonEnv.executable}\``
|
|
179
|
-
|
|
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}. `
|
|
180
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.
|
|
181
209
|
return pythonEnv.disposable
|
|
182
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.`
|
|
@@ -205,45 +233,156 @@ export function needsRestartNotice(session, toolName, plugin) {
|
|
|
205
233
|
/** `keyword.kwlist + keyword.softkwlist` for the `requires-python = ">=3.12"` the kernel pins. */
|
|
206
234
|
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'])
|
|
207
235
|
|
|
236
|
+
/**
|
|
237
|
+
* The name Python can take for a raw MCP name, or the name itself when it already is one.
|
|
238
|
+
*
|
|
239
|
+
* dsh normalises over `[A-Za-z0-9_-]`, so `-` survives — and `-` is legal in no Python identifier.
|
|
240
|
+
* `API-patch-block-children` was therefore reachable only through `getattr`, at every call site and
|
|
241
|
+
* with no signature in this block at all; it was the most-dispatched tool of a 20-task benchmark
|
|
242
|
+
* run. The kernel binds the fold alongside the raw name (`spellable` in `py/kernel.py`), so this
|
|
243
|
+
* side may spell it. Only `-` folds: a keyword or a digit-leading name is unspellable for reasons
|
|
244
|
+
* no substitution fixes, and those keep the `getattr` route.
|
|
245
|
+
*/
|
|
246
|
+
const fold = (name) => name.replace(/-/g, '_')
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The name a server is SHOWN under: its fold, unless another server already owns that spelling.
|
|
250
|
+
*
|
|
251
|
+
* Folding unconditionally emitted two `# __dsh__.tools.mcp.a_b` sections for a catalogue holding
|
|
252
|
+
* both `a-b` and `a_b`, one of them spelling its calls against the wrong server. Mirrors
|
|
253
|
+
* `canonical` in `py/kernel.py`, which keeps the two modules distinct for the same reason.
|
|
254
|
+
*/
|
|
255
|
+
const serverName = (server, grouped) => (fold(server) !== server && grouped.has(fold(server)) ? server : fold(server))
|
|
256
|
+
|
|
208
257
|
/** A name this block can put in an `import`, a `def`, or a `TypedDict` field. */
|
|
209
258
|
const isUsableName = (name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !PY_KEYWORDS.has(name)
|
|
210
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
|
+
|
|
211
295
|
/** `mcp__calendar__list_events` -> `McpCalendarListEvents`, for naming that tool's declarations. */
|
|
212
296
|
const pascal = (name) => name.split(/[_\-]+/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('')
|
|
213
297
|
|
|
214
|
-
/**
|
|
215
|
-
const
|
|
298
|
+
/** Names `build_bindings` refuses that do not already start with `_`. Mirrored from `py/kernel.py`; the two halves are checked against each other in the suite. */
|
|
299
|
+
const RESERVED_NAMES = new Set(['ToolCallError', 'mcp'])
|
|
300
|
+
|
|
301
|
+
/** Every `typing` symbol this block can emit, in the order the import line wants them. Alphabetical, so the filtered list needs no sort. */
|
|
302
|
+
const TYPING_SYMBOLS = ['Any', 'Literal', 'NotRequired', 'Protocol', 'TypedDict']
|
|
303
|
+
|
|
304
|
+
/** The three fixed landmarks in a `renderToolsSdkPy` block: what precedes the class declarations, what follows them, and what closes the member list. */
|
|
305
|
+
const ERROR_STUB = 'class ToolCallError(Exception):\n toolName: str\n\n'
|
|
306
|
+
const PROTOCOL_HEAD = 'class Tools(Protocol):\n'
|
|
307
|
+
const PROTOCOL_TAIL = '\n\ntools: Tools'
|
|
216
308
|
|
|
217
309
|
/**
|
|
218
|
-
*
|
|
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.
|
|
313
|
+
*
|
|
314
|
+
* `jsonSchemaToPy` is context-free and says so — "naming a `TypedDict` requires the render context
|
|
315
|
+
* that `renderToolsSdkPy` supplies" — so with nowhere to hang a declaration it degrades every object
|
|
316
|
+
* to `dict[str, Any]`. That is not an edge case: on a stock dsh catalogue it is the return type of
|
|
317
|
+
* every tool but one, which erases exactly what a return annotation is here to say. `bash` resolves
|
|
318
|
+
* to one of two shapes discriminated by a `kind` literal; flattened, the model cannot see there is a
|
|
319
|
+
* discriminator, so it calls once and prints the result to find out — the turn this annotation
|
|
320
|
+
* exists to remove. `renderType`, the context-carrying core, is not exported and its `src/` is not
|
|
321
|
+
* shipped, so `renderToolsSdkPy` is the only door to it.
|
|
219
322
|
*
|
|
220
|
-
*
|
|
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.
|
|
221
329
|
*
|
|
222
|
-
*
|
|
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.
|
|
335
|
+
*
|
|
336
|
+
* Reading generated text back is the seam, and it buys the alternative's absence: the `Literal`s,
|
|
337
|
+
* nested classes, collision suffixes and Unicode identifier rules stay dsh's own instead of a second
|
|
338
|
+
* JSON-Schema mapper drifting alongside them. Should a future dsh reshape the block, the lookups
|
|
339
|
+
* miss and every tool falls back to `Any` — a visible annotation the suite asserts against, not a
|
|
340
|
+
* silently wrong one.
|
|
341
|
+
*
|
|
342
|
+
* @returns the return annotation per tool name, the parameter annotations per tool name, and the class declarations both reference as text.
|
|
223
343
|
*/
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
344
|
+
function renderTypes(sorted) {
|
|
345
|
+
const declared = (schema) => {
|
|
346
|
+
const payload = mcpPayloadSchema(schema.output)
|
|
347
|
+
// An envelope with no declared payload resolves to the text blocks joined, so it really is a `str`.
|
|
348
|
+
return narrowed(payload === null ? schema.output : (payload ?? { type: 'string' }))
|
|
228
349
|
}
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
for
|
|
245
|
-
|
|
246
|
-
|
|
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) })))
|
|
352
|
+
const at = text.indexOf(ERROR_STUB)
|
|
353
|
+
const to = text.indexOf(PROTOCOL_HEAD, at)
|
|
354
|
+
const returns = new Map()
|
|
355
|
+
const wrapperOf = new Map()
|
|
356
|
+
for (const line of text.slice(to + PROTOCOL_HEAD.length, text.indexOf(PROTOCOL_TAIL, to)).split('\n')) {
|
|
357
|
+
// Two shapes, because dsh routes a name Python cannot take to a subscript COMMENT rather than a
|
|
358
|
+
// method. Both are read: this block reaches such a tool through `getattr`, so its return type is
|
|
359
|
+
// as real as any other's.
|
|
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)
|
|
383
|
+
}
|
|
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() }
|
|
247
386
|
}
|
|
248
387
|
|
|
249
388
|
/**
|
|
@@ -269,68 +408,96 @@ export function renderToolsSection(schemas) {
|
|
|
269
408
|
const importable = plain.filter((spec) => isUsableName(spec.name))
|
|
270
409
|
const awkward = plain.filter((spec) => !isUsableName(spec.name))
|
|
271
410
|
// A server or tool whose name Python cannot take keeps its `getattr` route, one level deeper.
|
|
272
|
-
const servers = [...grouped].filter(([server]) => isUsableName(server)).sort(by(([server]) => server))
|
|
411
|
+
const servers = [...grouped].filter(([server]) => isUsableName(serverName(server, grouped))).sort(by(([server]) => server))
|
|
273
412
|
const oddMcp = [...grouped].flatMap(([server, tools]) =>
|
|
274
|
-
(isUsableName(server) ? tools.filter((t) => !isUsableName(t.tool)) : tools).map((t) => ({ server, tool: t.tool, usableServer: isUsableName(server) })))
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// `
|
|
279
|
-
//
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
413
|
+
(isUsableName(serverName(server, grouped)) ? tools.filter((t) => !isUsableName(fold(t.tool))) : tools).map((t) => ({ server, tool: t.tool, usableServer: isUsableName(serverName(server, grouped)) })))
|
|
414
|
+
// Every tool renders as a module-level function now, so there is no `self` to splice in front and
|
|
415
|
+
// no reserved name a parameter could collide with — `async def list(*, self: str)` is ordinary
|
|
416
|
+
// Python. That collision guard, and the `head` parameter it read, went with the Protocol stubs.
|
|
417
|
+
// `async def f(*, ) -> T` is still a SyntaxError, which is why a parameterless tool emits no `*`.
|
|
418
|
+
// 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.
|
|
419
|
+
const docstring = (doc) => {
|
|
420
|
+
if (!doc) return [' ...']
|
|
421
|
+
const text = doc.trim().replaceAll('\\', '\\\\').replaceAll('"""', '\\"\\"\\"')
|
|
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/)
|
|
426
|
+
return lines.length === 1 ? [` """${lines[0]}"""`] : [' """', ...lines.map((line) => ` ${line}`.trimEnd()), ' """']
|
|
427
|
+
}
|
|
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.
|
|
429
|
+
const trailing = (doc) => (doc ? ` # ${doc.trim().replace(/\s*(?:\r\n?|\n)\s*/g, ' ')}` : '')
|
|
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`. */
|
|
431
|
+
const signature = (spec) => {
|
|
432
|
+
const body = docstring(spec.doc)
|
|
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'])]
|
|
437
|
+
// `async def f(*, ) -> T` is still a SyntaxError, which is why a parameterless tool emits no `*`.
|
|
438
|
+
if (spec.params.length === 0) return { lines: [`async def ${spec.name}() -> ${spec.returns}:`, ...body], types }
|
|
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.
|
|
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 }
|
|
285
450
|
}
|
|
286
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.
|
|
287
452
|
// The declarations are stubs, not runtime objects — nothing constructs them — so the import line
|
|
288
453
|
// lists exactly what the render used. `Any` has always been reachable from a signature
|
|
289
454
|
// (`**kwargs: Any`, and any degraded annotation) and was never imported at all.
|
|
290
|
-
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
455
|
+
// One section per MODULE, because that is what these are. `mcp` and each server under it are real
|
|
456
|
+
// modules (`__dsh__.tools.mcp.exa`), so their tools are plain functions: attribute access on a
|
|
457
|
+
// module goes nowhere near the descriptor protocol and binds nothing. The block used to render
|
|
458
|
+
// them as `class _McpExa(Protocol)` with `async def web_search(self, ...)`, on a comment claiming
|
|
459
|
+
// `mcp` was an INSTANCE — true before the grouping became a package, false ever since, and the
|
|
460
|
+
// rendering was never revisited. `mcp.exa.web_search?` shows `(*, query: str) -> str` with no
|
|
461
|
+
// `self`, so the block was contradicting the interpreter the model can just ask.
|
|
462
|
+
const listing = (header, specs) => (specs.length === 0 ? [] : ['', `# ${header}`, ...specs])
|
|
463
|
+
const nativeSigs = importable.map((spec) => signature(spec))
|
|
464
|
+
// A blank line between definitions, or a docstring runs straight into the next `async def`.
|
|
465
|
+
const spaced = (blocks) => blocks.flatMap((block, index) => (index === 0 ? block : ['', ...block]))
|
|
466
|
+
const nativeBlock = listing(TOOLS_MODULE, spaced(nativeSigs.map((sig) => sig.lines)))
|
|
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.
|
|
473
|
+
// A server whose every tool name Python refuses gets no section: nothing here would be callable
|
|
474
|
+
// as written, and the `getattr` line below is where those tools actually live.
|
|
475
|
+
const mcpSigs = servers.flatMap(([server, tools]) => tools.filter((t) => isUsableName(fold(t.tool))).sort(by((t) => t.tool))
|
|
476
|
+
.map((t) => ({ server, ...signature({ ...t, name: fold(t.tool) }) })))
|
|
477
|
+
const mcpBlock = servers.flatMap(([server]) =>
|
|
478
|
+
listing(`${TOOLS_MODULE}.mcp.${serverName(server, grouped)}`, spaced(mcpSigs.filter((sig) => sig.server === server)
|
|
479
|
+
.map((sig) => sig.lines))))
|
|
480
|
+
const signatures = nativeBlock.concat(mcpBlock)
|
|
481
|
+
const classes = declarations === '' ? [] : ['', ...declarations.split('\n')]
|
|
482
|
+
// Whatever the emitted lines actually spell, rather than a condition per symbol: the conditions
|
|
483
|
+
// were three and the symbols four, so a `Literal` — which a `const` output or parameter renders
|
|
484
|
+
// and nothing here predicts — reached the block with no import and made it a `NameError`.
|
|
485
|
+
// Whole identifiers only: `AnyReportOutput` is a class name, not a use of `Any`.
|
|
486
|
+
const spelled = classes.concat(nativeSigs.concat(mcpSigs).flatMap((sig) => sig.types))
|
|
487
|
+
const typing = TYPING_SYMBOLS.filter((symbol) => spelled.some((line) => new RegExp(`\\b${symbol}\\b`).test(line)))
|
|
319
488
|
const lines = [
|
|
320
489
|
INSTRUCTIONS,
|
|
321
490
|
'',
|
|
322
491
|
renderEnvironment(),
|
|
323
492
|
'',
|
|
324
493
|
'```python',
|
|
325
|
-
...(typing.
|
|
494
|
+
...(typing.length === 0 ? [] : [`from typing import ${typing.join(', ')}`]),
|
|
326
495
|
// `grouped`, not `servers`: a server whose own name Python refuses has no Protocol stub, but its
|
|
327
496
|
// tools are still reached through `mcp` — the `getattr` line below names it. Keyed on the stubs,
|
|
328
497
|
// a catalogue of nothing but such servers advertised `getattr(getattr(mcp, …))` without ever
|
|
329
498
|
// importing `mcp`. The kernel binds it whenever an MCP tool exists, which is this condition.
|
|
330
499
|
`from __dsh__.tools import ToolCallError${grouped.size === 0 ? '' : ', mcp'}${importable.map((spec) => `, ${spec.name}`).join('')}`,
|
|
331
500
|
...classes,
|
|
332
|
-
...mcpBlock,
|
|
333
|
-
'',
|
|
334
501
|
...signatures,
|
|
335
502
|
'```',
|
|
336
503
|
]
|
|
@@ -340,14 +507,13 @@ export function renderToolsSection(schemas) {
|
|
|
340
507
|
if (awkward.length > 0) {
|
|
341
508
|
lines.push('', `Not valid Python identifiers — reach these with \`getattr\`: ${awkward.map((spec) => `\`getattr(__dsh__.tools, ${JSON.stringify(spec.name)})\``).join(', ')}.`)
|
|
342
509
|
}
|
|
343
|
-
// The
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
const example = servers.flatMap(([server, tools]) => tools.filter((t) => isUsableName(t.tool)).map((t) => [server, t.tool])).at(0)
|
|
510
|
+
// The section headers ARE the module paths now, so the sentence that used to advertise the import
|
|
511
|
+
// form says nothing the block does not already show. It was measured at zero uses across 22 trials
|
|
512
|
+
// and 6300 cells while it was a trailing note; whether a header does better is the open question,
|
|
513
|
+
// but paying for both is not.
|
|
514
|
+
const example = servers.flatMap(([server, tools]) => tools.filter((t) => isUsableName(fold(t.tool))).map((t) => [serverName(server, grouped), fold(t.tool)])).at(0)
|
|
349
515
|
if (example !== undefined) {
|
|
350
|
-
lines.push('', `
|
|
516
|
+
lines.push('', `A section header is the module: \`from ${TOOLS_MODULE}.mcp.${example[0]} import ${example[1]}\` and \`mcp.${example[0]}.${example[1]}\` reach the same function.`)
|
|
351
517
|
}
|
|
352
518
|
// Naming the second level matters once the grouping exists: `dir(__dsh__.tools)` shows `mcp`,
|
|
353
519
|
// not the hundred tools under it, so a model told only the first level reads a real catalogue as
|
|
@@ -356,10 +522,17 @@ export function renderToolsSection(schemas) {
|
|
|
356
522
|
return lines.join('\n')
|
|
357
523
|
}
|
|
358
524
|
|
|
359
|
-
/** Flatten model-facing content blocks
|
|
525
|
+
/** Flatten model-facing content blocks when an MCP result has no structured payload. */
|
|
360
526
|
function contentText(content) {
|
|
361
527
|
if (!Array.isArray(content)) return ''
|
|
362
|
-
return content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n')
|
|
528
|
+
return content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n\n')
|
|
529
|
+
}
|
|
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 }
|
|
363
536
|
}
|
|
364
537
|
|
|
365
538
|
/**
|
|
@@ -504,10 +677,7 @@ export function apply(ctx, config = {}) {
|
|
|
504
677
|
for (const context of outcome.additionalContexts ?? []) exec.deferContext(context)
|
|
505
678
|
// `=== undefined` rather than `??`: a payload that IS `null` is the server's answer, and
|
|
506
679
|
// falling back to the wrapper there would hand the cell the one shape it was promised not to see.
|
|
507
|
-
|
|
508
|
-
return outcome.isError
|
|
509
|
-
? { ok: false, message: contentText(outcome.content) || 'tool call failed' }
|
|
510
|
-
: { ok: true, value: unwrapped === undefined ? outcome.value : unwrapped.value }
|
|
680
|
+
return toolCallReply(outcome, entry.envelopes.get(from)?.has(name))
|
|
511
681
|
} catch (error) {
|
|
512
682
|
session?.append('tool/code-dispatch', { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
|
|
513
683
|
throw error
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-py-codeact",
|
|
3
|
-
"version": "0.
|
|
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
|
@@ -48,6 +48,7 @@ import contextlib
|
|
|
48
48
|
import inspect
|
|
49
49
|
import io
|
|
50
50
|
import json
|
|
51
|
+
import keyword
|
|
51
52
|
import select
|
|
52
53
|
import sys
|
|
53
54
|
import traceback
|
|
@@ -210,12 +211,22 @@ def _make_binding(bridge: Bridge, spec):
|
|
|
210
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."""
|
|
211
212
|
name = spec["name"]
|
|
212
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
|
+
|
|
213
217
|
async def call(**kwargs):
|
|
214
|
-
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)
|
|
215
219
|
|
|
216
220
|
call.__name__ = name if name.isidentifier() else "call"
|
|
217
221
|
call.__qualname__ = f"__dsh__.tools.{name}"
|
|
218
222
|
call.__doc__ = spec.get("doc") or None
|
|
223
|
+
# The same prose the block renders beside each parameter, repeated here so `read?` is not the poorer view: the block is read once a turn, while `?` is what a model reaches for to re-check ONE tool without scrolling back. Same source, so the two cannot drift.
|
|
224
|
+
documented = [p for p in spec.get("params") or [] if p.get("doc")]
|
|
225
|
+
if documented:
|
|
226
|
+
# Continuation lines indented, or a description carrying its own newlines reads as the next parameter's.
|
|
227
|
+
section = "Parameters:\n" + "\n".join(f" {p['name']}: {p['doc'].replace(chr(10), chr(10) + ' ')}" for p in documented)
|
|
228
|
+
call.__doc__ = f"{call.__doc__}\n\n{section}" if call.__doc__ else section
|
|
229
|
+
# A rebind builds NEW callables, so a name already pulled out with `from __dsh__.tools import read` keeps the docstring it was imported with. `__dsh__.tools.read?` is the authoritative view after a schema revision. True of the signature and return annotation too, and deliberate: the shell's namespace is the model's, and a rebind that reached into it could swap a binding under a cell mid-await.
|
|
219
230
|
# Per-parameter, not one suppress around the whole thing: a single exotic name (`class`, `file-path` — hyphens are routine for MCP tools) used to discard the ENTIRE signature, so `read?` showed `(**kwargs)` while the prompt showed the full parameter list, with no error either way. Anything unrenderable is folded into `**kwargs` so the picture stays honest.
|
|
220
231
|
params, dropped = [], False
|
|
221
232
|
for p in spec.get("params") or []:
|
|
@@ -270,9 +281,14 @@ class ToolsModule(types.ModuleType):
|
|
|
270
281
|
|
|
271
282
|
# NOT `listed_tools()`: `__all__` is what `from __dsh__.tools import *` BINDS, and narrowing it
|
|
272
283
|
# left `mcp__gh__ok` undefined in a cell that used to work. Display is `__dir__`'s job.
|
|
284
|
+
#
|
|
285
|
+
# `ToolCallError` is here and in no listing, for the same reason: it is not a tool, so showing it
|
|
286
|
+
# among them would be noise — but the instructions tell the model to catch it, and a cell that
|
|
287
|
+
# reached for `import *` instead of the block's import line lost the failure path it was told to
|
|
288
|
+
# handle, to a `NameError` raised from inside the `except` clause meant to explain the failure.
|
|
273
289
|
@property
|
|
274
290
|
def __all__(self):
|
|
275
|
-
return sorted(bound_tools())
|
|
291
|
+
return sorted([*bound_tools(), "ToolCallError"])
|
|
276
292
|
|
|
277
293
|
def __repr__(self) -> str:
|
|
278
294
|
# The cell's trailing expression is echoed back, so ending on `__dsh__.tools` is the
|
|
@@ -335,19 +351,85 @@ def listed_tools() -> dict:
|
|
|
335
351
|
return {name: call for name, call in bound_tools().items() if servable(name) is None}
|
|
336
352
|
|
|
337
353
|
|
|
354
|
+
def spellable(raw: str) -> str | None:
|
|
355
|
+
"""The name Python can take for `raw`, or `None` when `raw` already is one.
|
|
356
|
+
|
|
357
|
+
dsh normalises a tool name over `[A-Za-z0-9_-]`, so `-` survives — and `-` is legal in no Python
|
|
358
|
+
identifier. `API-patch-block-children` was therefore reachable only as
|
|
359
|
+
`getattr(mcp.notion, "API-patch-block-children")`, at every call site, with no signature in the
|
|
360
|
+
prompt block to go with it; it was the single most-dispatched tool of a 20-task benchmark run,
|
|
361
|
+
and 4.0% of all dispatches went to twelve tools shaped like it.
|
|
362
|
+
|
|
363
|
+
Only `-` is folded. A keyword (`class`) or a digit-leading name (`123tool`) is unspellable for
|
|
364
|
+
reasons no substitution fixes, and inventing a spelling for those would mean inventing a name.
|
|
365
|
+
"""
|
|
366
|
+
folded = raw.replace("-", "_")
|
|
367
|
+
# `isidentifier()` alone is not the host's rule: it accepts keywords, and the block's
|
|
368
|
+
# `isUsableName` refuses them. A raw `-` folds to `_`, a SOFT keyword — the kernel would have
|
|
369
|
+
# bound and listed `mcp.srv._` while the block routed the same tool to `getattr(mcp.srv, "-")`.
|
|
370
|
+
# The two halves have to agree on what is spellable; the suite checks that they do.
|
|
371
|
+
if folded == raw or not folded.isidentifier() or keyword.iskeyword(folded) or keyword.issoftkeyword(folded):
|
|
372
|
+
return None
|
|
373
|
+
return folded
|
|
374
|
+
|
|
375
|
+
|
|
338
376
|
def mcp_servers(bindings: dict) -> dict[str, dict]:
|
|
339
377
|
"""Group the flat `mcp__server__tool` bindings into `{server: {tool: call}}`.
|
|
340
378
|
|
|
341
379
|
The flat names stay bound too. They are what dsh dispatches on and what an older session may
|
|
342
380
|
already have imported; dropping them to tidy the surface would break a cell mid-conversation.
|
|
381
|
+
|
|
382
|
+
A hyphenated raw name is reachable under BOTH spellings: its own, so an existing
|
|
383
|
+
`getattr(mcp.srv, "a-b")` keeps working, and its folded alias, which is what the listings show.
|
|
384
|
+
The alias never displaces a real tool — a server exposing both `a-b` and `a_b` keeps `a_b`
|
|
385
|
+
meaning `a_b`, and `a-b` stays getattr-only rather than quietly answering for its neighbour.
|
|
343
386
|
"""
|
|
344
387
|
servers: dict[str, dict] = {}
|
|
345
388
|
for name, call in bindings.items():
|
|
346
389
|
if (parts := split_mcp(name)) is not None:
|
|
347
390
|
servers.setdefault(parts[0], {})[parts[1]] = call
|
|
391
|
+
for tools in servers.values():
|
|
392
|
+
alias(tools)
|
|
393
|
+
alias(servers)
|
|
348
394
|
return servers
|
|
349
395
|
|
|
350
396
|
|
|
397
|
+
def alias(members: dict) -> None:
|
|
398
|
+
"""Add each hyphenated key's folded spelling, in place, without displacing a real one.
|
|
399
|
+
|
|
400
|
+
`setdefault`, so a name that already exists keeps its own value: a server exposing `a-b` beside
|
|
401
|
+
`a_b` gets no alias for `a-b`, and both stay reachable under their own spellings.
|
|
402
|
+
"""
|
|
403
|
+
for raw in [name for name in members if spellable(name) is not None]:
|
|
404
|
+
members.setdefault(spellable(raw), members[raw])
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def canonical(server: str, servers: dict) -> str:
|
|
408
|
+
"""The module name for `server`: its fold, unless another server already owns that spelling.
|
|
409
|
+
|
|
410
|
+
Folding unconditionally made `a-b` and `a_b` ONE module — `mcp_server_module` is keyed by name,
|
|
411
|
+
so both root entries resolved to the fold's module and the hyphenated server's tools became
|
|
412
|
+
unreachable, through either spelling. This is the same rule the tool level applies by identity,
|
|
413
|
+
stated directly because a server's value here is a module built on demand, not a shared callable.
|
|
414
|
+
"""
|
|
415
|
+
folded = spellable(server)
|
|
416
|
+
return server if folded is None or servers.get(folded) is not servers.get(server) else folded
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def listed(members: dict) -> dict:
|
|
420
|
+
"""What a listing SHOWS: the folded spelling stands in for the name it was folded from.
|
|
421
|
+
|
|
422
|
+
The same split as `bound_tools` / `listed_tools` one level up, for the same reason — showing
|
|
423
|
+
both spellings would report twice as many tools as the server has, and showing only the raw one
|
|
424
|
+
would name something the model cannot type.
|
|
425
|
+
"""
|
|
426
|
+
# Identity, not membership: `alias` maps a fold to the SAME callable, so `members[fold] is value`
|
|
427
|
+
# is what distinguishes a name's own alias from a DIFFERENT tool that happens to own that
|
|
428
|
+
# spelling. A server exposing both `a-b` and `a_b` keeps both listed — dropping the hyphenated
|
|
429
|
+
# one there would hide a real tool behind its neighbour.
|
|
430
|
+
return {name: value for name, value in members.items() if (fold := spellable(name)) is None or members.get(fold) is not value}
|
|
431
|
+
|
|
432
|
+
|
|
351
433
|
MCP_MODULE = "__dsh__.tools.mcp"
|
|
352
434
|
|
|
353
435
|
|
|
@@ -359,7 +441,11 @@ def mcp_members(module_name: str) -> dict:
|
|
|
359
441
|
"""
|
|
360
442
|
servers = mcp_servers({name: call for name, call in bound_tools().items() if servable(name) is not None})
|
|
361
443
|
if module_name == MCP_MODULE:
|
|
362
|
-
|
|
444
|
+
# Keyed by the FOLDED name so a hyphenated server and its fold resolve to one module object,
|
|
445
|
+
# not two: `listed` tells an alias from a real neighbour by identity, and two objects would
|
|
446
|
+
# leave both spellings in `dir(mcp)`. Only the fold reaches `sys.modules`, which is the only
|
|
447
|
+
# spelling `from __dsh__.tools.mcp.<server> import …` could have used anyway.
|
|
448
|
+
return {server: mcp_server_module(canonical(server, servers)) for server in servers}
|
|
363
449
|
# `removeprefix`, not `rpartition`: a raw server name is not guaranteed dot-free, and taking
|
|
364
450
|
# the last segment of one would look up a server that does not exist and resolve it empty.
|
|
365
451
|
return servers.get(module_name.removeprefix(f"{MCP_MODULE}."), {})
|
|
@@ -388,7 +474,7 @@ class McpModule(types.ModuleType):
|
|
|
388
474
|
raise AttributeError(name)
|
|
389
475
|
members = mcp_members(self.__name__)
|
|
390
476
|
if name not in members:
|
|
391
|
-
available = ", ".join(sorted(members)) or "(none)"
|
|
477
|
+
available = ", ".join(sorted(listed(members))) or "(none)"
|
|
392
478
|
raise AttributeError(f"no such tool: {self.__name__.removeprefix('__dsh__.tools.')}.{name}. Available: {available}")
|
|
393
479
|
return members[name]
|
|
394
480
|
|
|
@@ -401,17 +487,17 @@ class McpModule(types.ModuleType):
|
|
|
401
487
|
super().__setattr__(name, value)
|
|
402
488
|
|
|
403
489
|
def __dir__(self):
|
|
404
|
-
return sorted(mcp_members(self.__name__))
|
|
490
|
+
return sorted(listed(mcp_members(self.__name__)))
|
|
405
491
|
|
|
406
492
|
# Star-import reads `__all__` (or `vars()`), never `__getattr__` or `__dir__`, and nothing ever
|
|
407
493
|
# lands in these modules' `__dict__` — so without this `from __dsh__.tools.mcp.x import *`
|
|
408
494
|
# succeeded and bound nothing. `ToolsModule` carries the same property for the same reason.
|
|
409
495
|
@property
|
|
410
496
|
def __all__(self):
|
|
411
|
-
return sorted(mcp_members(self.__name__))
|
|
497
|
+
return sorted(listed(mcp_members(self.__name__)))
|
|
412
498
|
|
|
413
499
|
def __repr__(self) -> str:
|
|
414
|
-
members = mcp_members(self.__name__)
|
|
500
|
+
members = listed(mcp_members(self.__name__))
|
|
415
501
|
return f"<module {self.__name__!r}: {', '.join(sorted(members)) or 'empty'}>"
|
|
416
502
|
|
|
417
503
|
|