dsh-py-codeact 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,11 +52,13 @@ A dunder-named package reads as harness-owned, survives `%reset`, and leaves the
52
52
 
53
53
  ```
54
54
  In [1]: read?
55
- Signature: read(*, file_path: 'str', offset: 'int' = Ellipsis, limit: 'int' = Ellipsis)
55
+ Signature: read(*, file_path: 'str', offset: 'int' = Ellipsis, limit: 'int' = Ellipsis) -> 'str'
56
56
  Docstring: Read a file from the workspace. Results include line numbers…
57
57
  ```
58
58
 
59
- That is why the prompt block carries signatures only — the descriptions are one `?` away instead of resident in every request. Annotations are rendered host-side with dsh's own exported `jsonSchemaToPy`, so there is no second JSON-Schema mapper to drift. The binding set is resent with every cell, because restrictions and mid-conversation tool changes can move a tool in or out between calls.
59
+ That is why the prompt block carries signatures only — the descriptions are one `?` away instead of resident in every request. Annotations are rendered host-side with dsh's own exported `jsonSchemaToPy`, so there is no second JSON-Schema mapper to drift.
60
+
61
+ The **return** type comes from the tool's own `output` schema, by way of `ctx.tools.sdkSchemas(scope)` — the projection that carries it. It is the one annotation the model cannot recover by reading harder: a wrong argument fails loudly at the call, while an unknown return shape is only discoverable by calling once and printing the result, which costs a whole turn per tool. A tool that declares no output schema still renders `Any`; claiming a type nobody declared would be worse than admitting ignorance. The binding set is resent with every cell, because restrictions and mid-conversation tool changes can move a tool in or out between calls.
60
62
 
61
63
  ### MCP tools work, with nothing special
62
64
 
@@ -157,9 +159,9 @@ A pure CPU loop (`while True: pass`) never reaches an await point. After `hardIn
157
159
  ## Known limitations
158
160
 
159
161
  - **Native writes are not captured.** stdout/stderr are captured at the Python level, so `print` is captured but a subprocess writing to fd 1 is not. Use `subprocess.run(..., capture_output=True)`, or `%run`. (Anything that does reach fd 1/2 — including IPython's own colored traceback, deliberately routed there — is retained only for the crash message.)
160
- - **Scope is not optional.** The visible tool set comes from `ctx.tools.schemas(scope)` — the scope being the agent. Omitting it yields the *global* view, which in a preset composition holds only host-registered tools; the preset's own `read`/`bash`/`edit` live in the agent scope and vanish. The prompt section reads `assembly.scope`, and the kernel's name list is resent with every cell (restrictions and mid-conversation tool changes can move a tool in or out between calls).
162
+ - **Scope is not optional.** The visible tool set comes from `ctx.tools.sdkSchemas(scope)` — the scope being the agent. Omitting it yields the *global* view, which in a preset composition holds only host-registered tools; the preset's own `read`/`bash`/`edit` live in the agent scope and vanish. The prompt section reads `assembly.scope`, and the kernel's name list is resent with every cell (restrictions and mid-conversation tool changes can move a tool in or out between calls).
161
163
  - **Pick a `toolName` nothing else answers to.** With an MCP IPython server also mounted, a model told to "use the python tool" reaches for `mcp__py__ipython_execute_code` — which has no `tools` binding — and then reports that your tool does not exist.
162
- - **Return types render as `Any`.** The prompt section is generated from `ctx.tools.schemas()`, which whitelists name/description/parameters; the canonical *output* schemas sit behind the registry's private `sdkSchemas`. Argument annotations are complete; return annotations are not. A public accessor upstream would fix this `renderToolsSdkPy` is already exported and takes exactly the `ToolSchema + output` shape it would provide.
164
+ - **MCP return types are shapeless.** An MCP tool's canonical value is the envelope (`content`, plus `structuredContent` when the server advertises an output schema), and `jsonSchemaToPy` collapses an object to `dict[str, Any]` so the annotation says a dict arrives without saying which keys. dsh renders that shape as a `TypedDict` for Code Mode via `renderToolsSdkPy`, but that renderer emits a whole document in Code Mode's own `tools.name(args)` contract, not a line per signature. Since the envelope is uniform, a sentence of prose buys more here than a per-tool emitter that would duplicate dsh's mapper.
163
165
  - **Cold start.** The PEP 723 environment is resolved on first use (`uv python find --script`). Warm afterwards; pass `python` to skip it.
164
166
 
165
167
  The interpreter is then spawned **directly**, never behind `uv run --script`. A wrapper stays in the process tree as the interpreter's parent: when it exits first, the interpreter is reparented to init, the handle the host holds reports an exit, and a perfectly live kernel looks dead — so the next cell respawns and the session's state vanishes with an `[the interpreter was restarted]` notice nothing actually caused. `alive` is likewise tracked from the exit event rather than read off `proc.killed`, which Node sets on any `kill()` call, including a signal the process survived.
package/lib/index.js CHANGED
@@ -64,6 +64,8 @@ The available tools:`
64
64
 
65
65
  /**
66
66
  * Project one tool schema onto the wire spec the kernel builds a binding from: dsh's description becomes the function's docstring, its parameters become a keyword-only signature. Annotations are rendered here with dsh's own `jsonSchemaToPy`, so the kernel needs no second JSON-Schema mapper.
67
+ *
68
+ * The return type comes from the tool's own `output` schema — the shape of the canonical value the call actually resolves to. It is the one annotation a model cannot recover by reading harder: a parameter it gets wrong fails loudly at the call, while an unknown return shape is only discoverable by calling once and printing the result, which is a whole extra turn per tool.
67
69
  */
68
70
  function toolSpec(schema) {
69
71
  const parameters = schema.parameters ?? {}
@@ -72,6 +74,8 @@ function toolSpec(schema) {
72
74
  return {
73
75
  name: schema.name,
74
76
  doc: schema.description,
77
+ // `output` is absent only if the caller passed a `schemas()` projection; `sdkSchemas()` always carries it.
78
+ returns: schema.output === undefined ? 'Any' : jsonSchemaToPy(schema.output),
75
79
  params: Object.entries(properties).map(([name, node]) => ({
76
80
  name,
77
81
  type: jsonSchemaToPy(node),
@@ -141,10 +145,10 @@ export function renderToolsSection(schemas) {
141
145
  const awkward = specs.filter((spec) => !isUsableName(spec.name))
142
146
  const signature = (spec) => {
143
147
  // The same rule applies one level down, and used to not be applied at all: `file-path` is routine for MCP tools, and one such PARAMETER made every other tool's signature unusable too. The binding still takes it — the kernel folds unnameable parameters into `**kwargs` — so the tool stays importable and only its signature goes vague; `name?` still shows the real one.
144
- if (!spec.params.every((p) => isUsableName(p.name))) return `async def ${spec.name}(**kwargs: Any) -> Any: ... # parameter names are not all valid Python; see ${spec.name}?`
148
+ if (!spec.params.every((p) => isUsableName(p.name))) return `async def ${spec.name}(**kwargs: Any) -> ${spec.returns}: ... # parameter names are not all valid Python; see ${spec.name}?`
145
149
  const fields = spec.params.map((p) => (p.required ? `${p.name}: ${p.type}` : `${p.name}: ${p.type} = ...`))
146
- // `async def f(*, ) -> Any` is a SyntaxError: a `*` needs at least one name after it, and a parameterless tool takes no keywords at all.
147
- return `async def ${spec.name}(${fields.length === 0 ? '' : `*, ${fields.join(', ')}`}) -> Any: ...`
150
+ // `async def f(*, ) -> T` is a SyntaxError: a `*` needs at least one name after it, and a parameterless tool takes no keywords at all.
151
+ return `async def ${spec.name}(${fields.length === 0 ? '' : `*, ${fields.join(', ')}`}) -> ${spec.returns}: ...`
148
152
  }
149
153
  // `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.
150
154
  const lines = [
@@ -237,7 +241,8 @@ export function apply(ctx, config = {}) {
237
241
  /**
238
242
  * The tools ONE agent can see. The scope argument is not optional in practice: omitting it yields the global view, which in a preset composition holds only host-registered tools — the preset's own `read`/`bash`/etc. live in the agent scope and would silently go missing.
239
243
  */
240
- const visibleSchemas = (scope) => ctx.tools.schemas(scope).filter((schema) => schema.name !== toolName)
244
+ // `sdkSchemas`, not `schemas`: the latter is the native-function-calling projection and drops `output`, which left every signature returning `Any`. Code Mode uses this same projection for the same reason.
245
+ const visibleSchemas = (scope) => ctx.tools.sdkSchemas(scope).filter((schema) => schema.name !== toolName)
241
246
 
242
247
  ctx.systemPrompt.section({
243
248
  name: 'py-codeact:sdk',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-py-codeact",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "CodeAct agent loop for the DeepSeek Harness: a persistent IPython session as the model's action space, with harness tools bridged in as a virtual `__dsh__.tools` module.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/py/kernel.py CHANGED
@@ -18,7 +18,7 @@ Wire protocol: JSON-lines on fd 3. stdout/stderr stay free for native writes.
18
18
 
19
19
  Every frame but `result` and `shutdown` carries `shell` — the agent whose shell it addresses, defaulting to "main". One process holds one shell per agent, so a fan-out of subagents shares this interpreter, its event loop and its packages while keeping separate globals.
20
20
 
21
- host -> child {"t":"init","shell":S,"tools":[{"name","doc","params":[...]}]}
21
+ host -> child {"t":"init","shell":S,"tools":[{"name","doc","returns","params":[...]}]}
22
22
  {"t":"exec","id":N,"shell":S,"code":"...","tools":[...]}
23
23
  {"t":"result","id":N,"ok":true,"value":<json>}
24
24
  {"t":"result","id":N,"ok":false,"tool":"read","message":"..."}
@@ -233,7 +233,8 @@ def _make_binding(bridge: Bridge, spec):
233
233
  if dropped:
234
234
  params.append(inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD))
235
235
  with contextlib.suppress(ValueError, TypeError):
236
- call.__signature__ = inspect.Signature(params) # type: ignore
236
+ # The return annotation matters as much as the parameters here: it is what `read?` can tell the model that no amount of re-reading the call site can. Same source as the prompt block, so the two never disagree.
237
+ call.__signature__ = inspect.Signature(params, return_annotation=spec.get("returns") or "Any") # type: ignore
237
238
  return call
238
239
 
239
240