dsh-py-codeact 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +17 -6
  2. package/lib/index.js +51 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -60,21 +60,32 @@ That is why the prompt block carries signatures only — the descriptions are on
60
60
 
61
61
  The **return** type comes from the tool's own `output` schema, by way of `ctx.tools.sdkSchemas(scope)` — the projection that carries it. It is the one annotation the model cannot recover by reading harder: a wrong argument fails loudly at the call, while an unknown return shape is only discoverable by calling once and printing the result, which costs a whole turn per tool. A tool that declares no output schema still renders `Any`; claiming a type nobody declared would be worse than admitting ignorance.
62
62
 
63
- Each object in that schema whose keys — and whose own generated class name — Python can take is declared as a named `TypedDict` above the signatures, because for an MCP tool the flat form is not merely vague — it is the whole message. dsh's MCP client resolves a call to the **envelope** `{ content, structuredContent }` and declares exactly that as the tool's output, so a model told only `dict[str, Any]` is not told the envelope exists: it reaches for the payload directly, gets nothing, and spends the turn printing the result to find the wrapper — the exact cost this annotation is here to remove.
63
+ Each object in that schema whose keys — and whose own generated class name — Python can take is declared as a named `TypedDict` above the signatures. For a bridged MCP tool the schema described here is the payload, not the transport wrapper see below; `dict[str, Any]` would say a dict arrives without saying which keys, which is the one thing a return annotation exists to say.
64
64
 
65
65
  ```python
66
- class McpCalendarListEventsOutputStructuredContent(TypedDict):
67
- result: str
68
-
69
66
  class McpCalendarListEventsOutput(TypedDict):
70
- content: list[Any]
71
- structuredContent: McpCalendarListEventsOutputStructuredContent
67
+ result: str
72
68
 
73
69
  async def mcp__calendar__list_events(*, calendar_id: str) -> McpCalendarListEventsOutput: ...
74
70
  ```
75
71
 
76
72
  `jsonSchemaToPy` cannot do this and says so — it is context-free, and naming a `TypedDict` needs the render context `renderToolsSdkPy` supplies. That renderer is not reusable here: it emits a whole document in Code Mode's own `tools.name(args)` contract. So only the object and array branches are handled locally, every leaf still going through `jsonSchemaToPy` — a place to hang the names, not a second JSON-Schema mapper. The binding set is resent with every cell, because restrictions and mid-conversation tool changes can move a tool in or out between calls.
77
73
 
74
+ ### The MCP wrapper does not reach the cell
75
+
76
+ dsh's MCP client resolves a call to `{ content, structuredContent? }` — the protocol's block array plus the server's own payload — and declares exactly that as the tool's output. That wrapper is transport, not API: `content`'s text duplicates the payload, and an image inside it is re-attached to the conversation separately, so a cell can do nothing with it. Handed the wrapper, a model writes `r["structuredContent"]["result"]` — and spends a call discovering it has to.
77
+
78
+ So the bridge unwraps, and the signature describes what the cell actually receives:
79
+
80
+ ```python
81
+ async def mcp__review__search(*, q: str) -> McpReviewSearchOutput: ... # the payload, not the wrapper
82
+ async def mcp__email__ping() -> str: ... # no declared payload: the text blocks, joined
83
+ ```
84
+
85
+ A server that declares no output schema is the common case in the wild, and its result really is just text — one text block, in every one of the 4142 MCP results measured across six trials, which is why this is a `str` and not a `list[str]` that would cost an `r[0]` at every call site to preserve a boundary that never appears. A result carrying no text block at all resolves to the empty string: an image there is re-attached to the conversation and read on the next step, so there was never anything for the cell to receive. One divergence is possible and belongs to the server: a tool that declares an output schema but omits `structuredContent` on some call resolves to that call's text, where the signature promised the payload type.
86
+
87
+ Unwrapping keys off the tool's declared output schema, not off the value that comes back: a tool whose own payload happens to be `{content: [...]}` is not wrapping anything, and replacing its value with joined text would change what that tool returns with nothing to say so. This deliberately differs from Code Mode, which hands the whole envelope to `tools.name(args)`.
88
+
78
89
  ### MCP tools work, with nothing special
79
90
 
80
91
  MCP servers register into the same `ctx.tools` registry as everything else, so they arrive in `__dsh__.tools` like any other binding and dispatch through the same pipeline:
package/lib/index.js CHANGED
@@ -62,6 +62,32 @@ Your action space is Python. Each call runs one cell in a **persistent IPython s
62
62
 
63
63
  The available tools:`
64
64
 
65
+ /**
66
+ * The wrapper dsh's MCP client puts around every result: `{ content, structuredContent? }`, with
67
+ * `content` the protocol's block array and `structuredContent` the server's own payload. Detected
68
+ * by shape rather than by an `mcp__` name, because the shape IS the declared contract — the client
69
+ * builds exactly this schema, `additionalProperties: false` and all.
70
+ *
71
+ * @returns the payload's schema when the server declares one, `null` when this is not an envelope,
72
+ * and `undefined` for an envelope carrying only text.
73
+ */
74
+ function mcpPayloadSchema(schema) {
75
+ if (schema?.type !== 'object' || schema.additionalProperties !== false) return null
76
+ const keys = Object.keys(schema.properties ?? {})
77
+ if (keys.length !== 2 || !keys.includes('content') || !keys.includes('structuredContent')) return null
78
+ if (schema.properties.content?.type !== 'array') return null
79
+ return (schema.required ?? []).includes('structuredContent') ? schema.properties.structuredContent : undefined
80
+ }
81
+
82
+ /** @internal exported for the suite. The same wrapper at run time. A value is only unwrapped when it carries nothing but the wrapper's own keys. */
83
+ export function mcpPayload(value) {
84
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
85
+ const keys = Object.keys(value)
86
+ if (!keys.includes('content') || !Array.isArray(value.content)) return undefined
87
+ if (keys.some((k) => k !== 'content' && k !== 'structuredContent')) return undefined
88
+ return 'structuredContent' in value ? { value: value.structuredContent } : { value: contentText(value.content) }
89
+ }
90
+
65
91
  /**
66
92
  * Project one tool schema onto the wire spec the kernel builds a binding from: dsh's description becomes the function's docstring, its parameters become a keyword-only signature. Annotations are rendered here with dsh's own `jsonSchemaToPy`, so the kernel needs no second JSON-Schema mapper.
67
93
  *
@@ -75,7 +101,10 @@ function toolSpec(schema, declarations) {
75
101
  name: schema.name,
76
102
  doc: schema.description,
77
103
  // `output` is absent only if the caller passed a `schemas()` projection; `sdkSchemas()` always carries it.
78
- returns: schema.output === undefined ? 'Any' : declareType(schema.output, `${pascal(schema.name)}Output`, declarations),
104
+ // An MCP tool is annotated by what the CELL receives, which is the payload the bridge unwraps
105
+ // to — never the transport wrapper. Describing the wrapper is accurate and useless: the model
106
+ // then hand-writes `r["structuredContent"]["result"]`, and calls once just to learn that.
107
+ returns: schema.output === undefined ? 'Any' : mcpReturn(schema, declarations),
79
108
  params: Object.entries(properties).map(([name, node]) => ({
80
109
  name,
81
110
  type: jsonSchemaToPy(node),
@@ -95,7 +124,20 @@ function toolSpec(schema, declarations) {
95
124
  */
96
125
  function toolSpecs(schemas) {
97
126
  const declarations = new Map()
98
- return { specs: [...schemas].sort(byName).map((schema) => toolSpec(schema, declarations)), declarations }
127
+ const sorted = [...schemas].sort(byName)
128
+ // Which tools DECLARE the wrapper. Unwrapping keys off this rather than the returned value:
129
+ // a tool whose own payload happens to be `{content: [...]}` is not wrapping anything, and
130
+ // replacing its value with the joined text would change what it returns with nothing to say so.
131
+ const envelopes = new Set(sorted.filter((schema) => mcpPayloadSchema(schema.output) !== null).map((schema) => schema.name))
132
+ return { specs: sorted.map((schema) => toolSpec(schema, declarations)), declarations, envelopes }
133
+ }
134
+
135
+ /** The annotation for one tool's output: an MCP payload if it is an envelope, else the schema as declared. */
136
+ function mcpReturn(schema, declarations) {
137
+ const payload = mcpPayloadSchema(schema.output)
138
+ if (payload === null) return declareType(schema.output, `${pascal(schema.name)}Output`, declarations)
139
+ // An envelope with no declared payload resolves to the text blocks joined, so it really is a `str`.
140
+ return payload === undefined ? 'str' : declareType(payload, `${pascal(schema.name)}Output`, declarations)
99
141
  }
100
142
 
101
143
  /** Deterministic (lexicographic) order, so an unchanged tool set renders byte-identically. */
@@ -383,9 +425,12 @@ export function apply(ctx, config = {}) {
383
425
  exec.deferContext(createUserMessage({ content: outcome.content, source: { kind: 'plugin', plugin: 'dsh-py-codeact' } }))
384
426
  }
385
427
  for (const context of outcome.additionalContexts ?? []) exec.deferContext(context)
428
+ // `=== undefined` rather than `??`: a payload that IS `null` is the server's answer, and
429
+ // falling back to the wrapper there would hand the cell the one shape it was promised not to see.
430
+ const unwrapped = entry.envelopes.get(from)?.has(name) ? mcpPayload(outcome.value) : undefined
386
431
  return outcome.isError
387
432
  ? { ok: false, message: contentText(outcome.content) || 'tool call failed' }
388
- : { ok: true, value: outcome.value }
433
+ : { ok: true, value: unwrapped === undefined ? outcome.value : unwrapped.value }
389
434
  } catch (error) {
390
435
  session?.append('tool/code-dispatch', { ...trace, isError: true, content: [{ type: 'text', text: String(error?.message ?? error) }] })
391
436
  throw error
@@ -415,7 +460,7 @@ export function apply(ctx, config = {}) {
415
460
  } catch { /* it never came up — fall through and replace it */ }
416
461
  }
417
462
  // One entry backs every shell in the conversation tree, so `sent` is keyed BY SHELL. `calls` is the exception: it only has to make `subCallId` unique, so process-wide is right.
418
- const entry = { kernel: undefined, current: new Map(), calls: 0, sent: new Map(), started: undefined }
463
+ const entry = { kernel: undefined, current: new Map(), calls: 0, sent: new Map(), envelopes: new Map(), started: undefined }
419
464
  entry.kernel = new PythonKernel({
420
465
  command: config.command ?? (config.python === undefined ? undefined : [config.python, KERNEL_PY]),
421
466
  cwd: exec.agent?.session?.header?.cwd ?? process.cwd(),
@@ -447,9 +492,10 @@ export function apply(ctx, config = {}) {
447
492
  },
448
493
  // No `isConcurrencySafe`: cells mutate the shell's namespace, so this agent's cells must stay exclusive ordering barriers. That is a per-AGENT constraint, not a per-process one — the kernel tracks one in-flight task per shell, so a subagent's cell runs alongside its parent's just fine.
449
494
  async execute(args, exec) {
450
- const { specs } = toolSpecs(visibleSchemas(exec.agent))
495
+ const { specs, envelopes } = toolSpecs(visibleSchemas(exec.agent))
451
496
  const { entry, shell, restarted: wasRestarted } = await kernelFor(exec, specs)
452
497
  entry.current.set(shell, exec)
498
+ entry.envelopes.set(shell, envelopes)
453
499
  try {
454
500
  // Only resend the bindings when the visible set actually moved — an unchanged catalogue is the norm, and it is 30+ schemas on the wire.
455
501
  const key = specsKey(specs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-py-codeact",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "CodeAct agent loop for the DeepSeek Harness: a persistent IPython session as the model's action space, with harness tools bridged in as a virtual `__dsh__.tools` module.",
5
5
  "license": "MIT",
6
6
  "repository": {