pi-codemcp 1.2.2 → 1.3.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
@@ -36,7 +36,8 @@ I built this because I care a lot about software that is genuinely fast, efficie
36
36
  pi-codemcp is deliberately opinionated about operational quality:
37
37
 
38
38
  - Pi startup does not wait for Python or MCP servers.
39
- - Each upstream connection is lazy and independent.
39
+ - Each upstream connection is lazy and independent. A dead connection is evicted after the original call fails; that call is never replayed, and the next explicit call reconnects.
40
+ - Upstream failures include stable `kind`, `server`, `tool`, `retryable`, `status`, and `message` fields.
40
41
  - Tool catalogs are cached per server and invalidated independently.
41
42
  - Agent-written code is type-checked before execution.
42
43
  - Time, memory, call count, and output size are bounded.
@@ -172,7 +173,9 @@ return {"number": number["value"], "identifier": saved["identifier"]}
172
173
  '
173
174
  ```
174
175
 
175
- Incomplete upstream schemas become recursive `JsonValue`, not `Any`; unknown values must be narrowed explicitly before typed use. For unfamiliar outputs, `inspect_json(value, samples=2, max_depth=3)` returns a byte-bounded structural summary, cardinality, field sizes, and samples. Preflight type errors happen before any upstream call is made, and oversized final results fail explicitly with the same actionable inspection data.
176
+ Incomplete upstream schemas become recursive `JsonValue`, not `Any`; use the prebound `expect_object`, `expect_list`, `expect_string`, and `expect_integer` helpers to narrow unknown values explicitly. For unfamiliar outputs, `inspect_json(value, samples=2, max_depth=3)` returns a byte-bounded structural summary, cardinality, field sizes, and samples; `samples` is limited to 1–3 and `max_depth` to 1–6 during preflight. The generated prelude documents the sandbox surface: use `import asyncio` with `asyncio.gather`; unavailable host or stdlib APIs are rejected. Preflight type errors happen before any upstream call is made, and oversized final results fail explicitly with the same actionable inspection data.
177
+
178
+ When an oversized value fits the bounded in-memory refinement cache, the failure also returns an opaque `result_ref` and expiry. Pass that reference back as `inputRef` on one follow-up `codemcp_execute`; the retained JSON is exposed as `input`, so code can filter or aggregate it without repeating upstream calls. References expire after five minutes, are valid only in the originating sidecar, and are never persisted.
176
179
 
177
180
  ## Saved-chain CLI flow
178
181
 
@@ -4,7 +4,7 @@ import {
4
4
  type ExtensionAPI,
5
5
  type ExtensionCommandContext,
6
6
  } from "@earendil-works/pi-coding-agent";
7
- import { SavedChainManager } from "../src/chains.js";
7
+ import { newCodeMcpTraceId, SavedChainManager } from "../src/chains.js";
8
8
  import { setMcpServerEnabled } from "../src/config.js";
9
9
  import { summarizeError } from "../src/errors.js";
10
10
  import { CodeMcpLifecycle } from "../src/lifecycle.js";
@@ -37,7 +37,7 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
37
37
  bindProjectChainScope(ctx, lifecycle, chains);
38
38
  const [status, savedChains, settings, stats] = await Promise.all([
39
39
  lifecycle.request("status", {}),
40
- chains.list(),
40
+ chains.list(newCodeMcpTraceId("manager")),
41
41
  Promise.resolve(lifecycle.loadSettings()),
42
42
  lifecycle.request("stats", {}),
43
43
  ]);
@@ -75,15 +75,19 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
75
75
  return updated;
76
76
  },
77
77
  onSetChainEnabled: async (chain, enabled) => {
78
- await chains.setEnabled(chain.name, chain.scope, enabled);
79
- return chainStatesFromViews(await chains.list());
78
+ const traceId = newCodeMcpTraceId("manager");
79
+ await chains.setEnabled(chain.name, chain.scope, enabled, traceId);
80
+ return chainStatesFromViews(await chains.list(traceId));
80
81
  },
81
82
  onRevalidateChain: async (chain) => {
82
- await chains.revalidate(chain.name, chain.scope);
83
- return chainStatesFromViews(await chains.list());
83
+ const traceId = newCodeMcpTraceId("manager");
84
+ await chains.revalidate(chain.name, chain.scope, traceId);
85
+ return chainStatesFromViews(await chains.list(traceId));
86
+ },
87
+ onDeleteChain: async (chain) => {
88
+ const traceId = newCodeMcpTraceId("manager");
89
+ return chainStatesFromViews(await chains.delete(chain.name, chain.scope, traceId));
84
90
  },
85
- onDeleteChain: async (chain) =>
86
- chainStatesFromViews(await chains.delete(chain.name, chain.scope)),
87
91
  });
88
92
  if (managerResult === "report-problem") await promptForProblemReport(pi, ctx);
89
93
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codemcp",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Typed, sandboxed Code Mode access to configured MCP servers for Pi",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
package/sidecar/cli.py CHANGED
@@ -66,6 +66,10 @@ def build_parser() -> argparse.ArgumentParser:
66
66
  help="Type-check and execute one sandboxed Python MCP call graph.",
67
67
  )
68
68
  _add_runtime_path_args(execute)
69
+ execute.add_argument(
70
+ "--input-ref",
71
+ help="Opaque retained-result reference to expose as input.",
72
+ )
69
73
  _add_text_input_args(execute, "code", "Sandboxed Python body to execute.")
70
74
 
71
75
  chain = subcommands.add_parser("chain", help="Manage and run saved MCP chains.")
@@ -152,27 +156,42 @@ async def _dispatch_runtime_command(
152
156
  args: argparse.Namespace,
153
157
  runtime: gateway.GatewayRuntime,
154
158
  ) -> BaseModel:
159
+ trace_id = gateway.new_trace_id("cli")
155
160
  if args.command == "status":
156
161
  return runtime.status()
157
162
  if args.command == "discover":
158
163
  return await runtime.discover(args.server)
159
164
  if args.command == "search":
160
- return await runtime.search(args.query, args.limit, args.server)
165
+ return await runtime.search(
166
+ args.query,
167
+ args.limit,
168
+ args.server,
169
+ trace_id=trace_id,
170
+ )
161
171
  if args.command == "execute":
162
- return await runtime.execute(_read_text_input(args, "code"))
172
+ return await runtime.execute(
173
+ _read_text_input(args, "code"),
174
+ trace_id,
175
+ args.input_ref,
176
+ )
163
177
  if args.command == "chain":
164
- return await _dispatch_chain_command(args, runtime)
178
+ return await _dispatch_chain_command(args, runtime, trace_id)
165
179
  raise ValueError(f"unknown command: {args.command}")
166
180
 
167
181
 
168
182
  async def _dispatch_chain_command(
169
183
  args: argparse.Namespace,
170
184
  runtime: gateway.GatewayRuntime,
185
+ trace_id: str,
171
186
  ) -> BaseModel:
172
187
  if args.chain_command == "list":
173
- return runtime.chains.list()
188
+ return runtime.chains.list(trace_id)
174
189
  if args.chain_command == "run":
175
- return await runtime.chains.execute(args.name, _read_json_object_input(args, "input"))
190
+ return await runtime.chains.execute(
191
+ args.name,
192
+ _read_json_object_input(args, "input"),
193
+ trace_id,
194
+ )
176
195
  if args.chain_command == "save":
177
196
  return await runtime.chains.save(
178
197
  scope=args.scope,
@@ -181,11 +200,12 @@ async def _dispatch_chain_command(
181
200
  code=_read_text_input(args, "code"),
182
201
  input_schema=_read_json_object_input(args, "input_schema"),
183
202
  output_schema=_read_json_object_input(args, "output_schema"),
203
+ trace_id=trace_id,
184
204
  )
185
205
  if args.chain_command == "revalidate":
186
- return await runtime.chains.revalidate(args.name, args.scope)
206
+ return await runtime.chains.revalidate(args.name, args.scope, trace_id)
187
207
  if args.chain_command == "delete":
188
- return await runtime.chains.delete(args.name, args.scope)
208
+ return await runtime.chains.delete(args.name, args.scope, trace_id)
189
209
  raise ValueError(f"unknown chain command: {args.chain_command}")
190
210
 
191
211
 
@@ -24,6 +24,15 @@ from pydantic import (
24
24
  from pydantic_core import to_json
25
25
 
26
26
  from .json_types import JSON_VALUE_ADAPTER, JsonObject, JsonValue
27
+ from .refinement_cache import RetainedResult
28
+ from .sandbox_api import (
29
+ EXPECT_INTEGER_NAME,
30
+ EXPECT_LIST_NAME,
31
+ EXPECT_OBJECT_NAME,
32
+ EXPECT_STRING_NAME,
33
+ INSPECT_JSON_NAME,
34
+ SANDBOX_FUNCTION_EXTERNALS,
35
+ )
27
36
  from .tool_catalog import referenced_calls, schema_path_summary
28
37
 
29
38
  if TYPE_CHECKING:
@@ -36,10 +45,24 @@ INSPECT_SAMPLE_LIMIT = 3
36
45
  INSPECT_DEPTH_LIMIT = 6
37
46
  INSPECT_COLLECTION_LIMIT = 10
38
47
  INSPECT_STRING_LIMIT = 200
39
- INSPECT_KEY_LIMIT = 120
48
+ INSPECT_KEY_LIMIT = 80
40
49
  INSPECT_BYTE_LIMIT = 8 * 1024
41
50
  CHAIN_INPUT_EXTERNAL = "__codemcp_saved_chain_input"
42
- INSPECT_JSON_EXTERNAL = "__codemcp_inspect_json"
51
+
52
+
53
+ type FailureStage = Literal["preflight", "runtime", "timeout", "cancelled", "result"]
54
+ type FailureKind = Literal[
55
+ "preflight",
56
+ "result",
57
+ "result_reference",
58
+ "sandbox_runtime",
59
+ "sandbox_timeout",
60
+ "upstream",
61
+ "upstream_transport",
62
+ "upstream_timeout",
63
+ "cancelled",
64
+ "internal",
65
+ ]
43
66
 
44
67
 
45
68
  class ExecutionMetrics(BaseModel):
@@ -51,6 +74,17 @@ class ExecutionMetrics(BaseModel):
51
74
  result_bytes: int = Field(default=0, ge=0)
52
75
 
53
76
 
77
+ class ExecutionFailureInfo(BaseModel):
78
+ model_config = ConfigDict(extra="forbid", strict=True)
79
+
80
+ kind: FailureKind
81
+ server: str | None = None
82
+ tool: str | None = None
83
+ retryable: bool
84
+ status: int | None = Field(default=None, ge=100, le=599)
85
+ message: str = Field(min_length=1)
86
+
87
+
54
88
  class ExecutionContext:
55
89
  def __init__(
56
90
  self,
@@ -66,6 +100,7 @@ class ExecutionContext:
66
100
  self.calls_made = 0
67
101
  self.chain_calls = 0
68
102
  self.metrics = ExecutionMetrics()
103
+ self.failure: ExecutionFailureInfo | None = None
69
104
  self.chain_stack: ContextVar[tuple[str, ...]] = ContextVar(
70
105
  "codemcp_chain_stack",
71
106
  default=(),
@@ -82,9 +117,7 @@ class ExecutionContext:
82
117
  ToolCall = Callable[[str, JsonObject], Awaitable[JsonValue]]
83
118
  ContextToolCall = Callable[[str, JsonObject, ExecutionContext], Awaitable[JsonValue]]
84
119
  ExternalFunction = Callable[..., Awaitable[JsonValue]]
85
-
86
-
87
- type FailureStage = Literal["preflight", "runtime", "timeout", "cancelled", "result"]
120
+ ResultRetainer = Callable[[JsonValue], RetainedResult | None]
88
121
 
89
122
 
90
123
  class ExecutionResponse(BaseModel):
@@ -94,7 +127,10 @@ class ExecutionResponse(BaseModel):
94
127
  failure_stage: FailureStage | None = None
95
128
  result: JsonValue = None
96
129
  error: str | None = None
130
+ failure: ExecutionFailureInfo | None = None
97
131
  shape: JsonObject | None = None
132
+ result_ref: str | None = None
133
+ expires_in_seconds: int | None = Field(default=None, ge=1)
98
134
  calls_made: int = Field(default=0, ge=0)
99
135
  chain_calls: int = Field(default=0, ge=0)
100
136
  metrics: ExecutionMetrics = Field(default_factory=ExecutionMetrics, exclude=True)
@@ -115,33 +151,51 @@ class ExecutionResponse(BaseModel):
115
151
  )
116
152
 
117
153
  @classmethod
118
- def failure(
154
+ def failed(
119
155
  cls,
120
156
  *,
121
157
  failure_stage: FailureStage,
122
158
  error: str,
159
+ failure: ExecutionFailureInfo,
123
160
  calls_made: int = 0,
124
161
  chain_calls: int = 0,
125
162
  shape: JsonObject | None = None,
163
+ result_ref: str | None = None,
164
+ expires_in_seconds: int | None = None,
126
165
  ) -> Self:
127
166
  return cls(
128
167
  ok=False,
129
168
  failure_stage=failure_stage,
130
169
  error=error,
170
+ failure=failure,
131
171
  calls_made=calls_made,
132
172
  chain_calls=chain_calls,
133
173
  shape=shape,
174
+ result_ref=result_ref,
175
+ expires_in_seconds=expires_in_seconds,
134
176
  )
135
177
 
136
178
  @model_validator(mode="after")
137
179
  def validate_state(self) -> Self:
138
180
  if self.ok:
139
- if self.failure_stage is not None or self.error is not None or self.shape is not None:
181
+ failure_details = (
182
+ self.failure_stage,
183
+ self.error,
184
+ self.failure,
185
+ self.shape,
186
+ self.result_ref,
187
+ self.expires_in_seconds,
188
+ )
189
+ if any(detail is not None for detail in failure_details):
140
190
  raise ValueError("successful execution cannot contain failure details")
141
- elif self.failure_stage is None or self.error is None:
142
- raise ValueError("failed execution requires a failure stage and error")
191
+ elif self.failure_stage is None or self.error is None or self.failure is None:
192
+ raise ValueError("failed execution requires a failure stage, error, and details")
143
193
  elif self.result is not None:
144
194
  raise ValueError("failed execution cannot contain a result")
195
+ elif (self.result_ref is None) != (self.expires_in_seconds is None):
196
+ raise ValueError("result reference and expiry must be provided together")
197
+ elif self.result_ref is not None and self.failure_stage != "result":
198
+ raise ValueError("only a result failure can contain a result reference")
145
199
  return self
146
200
 
147
201
  @model_serializer(mode="plain")
@@ -153,14 +207,21 @@ class ExecutionResponse(BaseModel):
153
207
  "calls_made": self.calls_made,
154
208
  }
155
209
  else:
210
+ failure = self.failure
211
+ if failure is None:
212
+ raise RuntimeError("failed execution is missing structured details")
156
213
  response = {
157
214
  "ok": False,
158
215
  "failure_stage": self.failure_stage,
159
216
  "error": self.error,
217
+ "failure": failure.model_dump(mode="json"),
160
218
  "calls_made": self.calls_made,
161
219
  }
162
220
  if self.shape is not None:
163
221
  response["shape"] = self.shape
222
+ if self.result_ref is not None:
223
+ response["result_ref"] = self.result_ref
224
+ response["expires_in_seconds"] = self.expires_in_seconds
164
225
  if self.chain_calls > 0:
165
226
  response["chain_calls"] = self.chain_calls
166
227
  timings: JsonObject = {
@@ -200,7 +261,14 @@ class MontyExecutor:
200
261
  def update_catalog(self, catalog: ToolCatalog) -> None:
201
262
  self.catalog = catalog
202
263
 
203
- async def execute(self, code: str, call_tool: ToolCall) -> ExecutionResponse:
264
+ async def execute(
265
+ self,
266
+ code: str,
267
+ call_tool: ToolCall,
268
+ *,
269
+ input_value: JsonValue = None,
270
+ retain_result: ResultRetainer | None = None,
271
+ ) -> ExecutionResponse:
204
272
  async def adapted(
205
273
  name: str,
206
274
  arguments: JsonObject,
@@ -208,22 +276,39 @@ class MontyExecutor:
208
276
  ) -> JsonValue:
209
277
  return await call_tool(name, arguments)
210
278
 
211
- return await self.execute_graph(code, adapted)
279
+ return await self.execute_graph(
280
+ code,
281
+ adapted,
282
+ input_value=input_value,
283
+ retain_result=retain_result,
284
+ )
212
285
 
213
286
  async def execute_graph(
214
287
  self,
215
288
  code: str,
216
289
  call_tool: ContextToolCall,
290
+ *,
291
+ input_value: JsonValue = None,
292
+ retain_result: ResultRetainer | None = None,
217
293
  ) -> ExecutionResponse:
218
294
  async with self._execution_lock:
219
295
  context = self._new_context(self.catalog, call_tool)
220
- return await self._execute_program(code, context, enforce_result_limit=True)
296
+ return await self._execute_program(
297
+ code,
298
+ context,
299
+ input_value=input_value,
300
+ input_type="JsonValue" if input_value is not None else None,
301
+ enforce_result_limit=True,
302
+ retain_result=retain_result,
303
+ )
221
304
 
222
305
  async def execute_saved_chain(
223
306
  self,
224
307
  chain: SavedChainManifest,
225
308
  arguments: JsonObject,
226
309
  call_tool: ContextToolCall,
310
+ *,
311
+ retain_result: ResultRetainer | None = None,
227
312
  ) -> ExecutionResponse:
228
313
  async with self._execution_lock:
229
314
  catalog = self.catalog
@@ -231,9 +316,15 @@ class MontyExecutor:
231
316
  try:
232
317
  validated = catalog.validate_arguments(spec.name, arguments)
233
318
  except (TypeError, ValidationError, ValueError) as error:
234
- return ExecutionResponse.failure(
319
+ message = f"{chain.call}: invalid arguments: {error}"
320
+ return ExecutionResponse.failed(
235
321
  failure_stage="preflight",
236
- error=f"{chain.call}: invalid arguments: {error}",
322
+ error=message,
323
+ failure=ExecutionFailureInfo(
324
+ kind="preflight",
325
+ retryable=False,
326
+ message=message,
327
+ ),
237
328
  )
238
329
  context = self._new_context(catalog, call_tool)
239
330
  context.chain_stack.set((chain.name,))
@@ -245,6 +336,7 @@ class MontyExecutor:
245
336
  output_type=spec.output_type_name,
246
337
  output_spec_name=spec.name,
247
338
  enforce_result_limit=True,
339
+ retain_result=retain_result,
248
340
  )
249
341
 
250
342
  async def execute_nested_chain(
@@ -318,11 +410,12 @@ class MontyExecutor:
318
410
  code: str,
319
411
  context: ExecutionContext,
320
412
  *,
321
- input_value: JsonObject | None = None,
413
+ input_value: JsonValue = None,
322
414
  input_type: str | None = None,
323
415
  output_type: str | None = None,
324
416
  output_spec_name: str | None = None,
325
417
  enforce_result_limit: bool,
418
+ retain_result: ResultRetainer | None = None,
326
419
  ) -> ExecutionResponse:
327
420
  if not code.strip():
328
421
  return self._failure(context, "preflight", "Execution code must not be empty")
@@ -333,6 +426,7 @@ class MontyExecutor:
333
426
  input_type=input_type,
334
427
  output_type=output_type,
335
428
  typed=True,
429
+ has_input=has_input,
336
430
  )
337
431
  referenced = referenced_calls(code, context.catalog.facade_calls)
338
432
  type_stubs = context.catalog.type_stubs_for(
@@ -403,8 +497,19 @@ class MontyExecutor:
403
497
  if remaining <= 0:
404
498
  raise TimeoutError
405
499
  timeout = min(context.settings.tool_timeout_seconds, remaining)
406
- async with asyncio.timeout(timeout):
407
- return await context.call_tool(name, validated, context)
500
+ try:
501
+ async with asyncio.timeout(timeout):
502
+ return await context.call_tool(name, validated, context)
503
+ except TimeoutError:
504
+ message = f"{spec.call} timed out after {timeout:g}s"
505
+ context.failure = ExecutionFailureInfo(
506
+ kind="upstream_timeout",
507
+ server=spec.server,
508
+ tool=spec.backend_name,
509
+ retryable=True,
510
+ message=message,
511
+ )
512
+ raise
408
513
 
409
514
  external_functions: dict[str, ExternalFunction] = {}
410
515
 
@@ -430,7 +535,37 @@ class MontyExecutor:
430
535
  byte_limit=_inspection_byte_limit(context.settings.result_byte_limit),
431
536
  )
432
537
 
433
- external_functions[INSPECT_JSON_EXTERNAL] = inspect_json_external
538
+ async def expect_object_external(value: JsonValue) -> JsonValue:
539
+ await asyncio.sleep(0)
540
+ if not isinstance(value, dict):
541
+ raise TypeError(f"expect_object expected object, got {_shape_label(value)}")
542
+ return value
543
+
544
+ async def expect_list_external(value: JsonValue) -> JsonValue:
545
+ await asyncio.sleep(0)
546
+ if not isinstance(value, list):
547
+ raise TypeError(f"expect_list expected array, got {_shape_label(value)}")
548
+ return value
549
+
550
+ async def expect_string_external(value: JsonValue) -> JsonValue:
551
+ await asyncio.sleep(0)
552
+ if not isinstance(value, str):
553
+ raise TypeError(f"expect_string expected string, got {_shape_label(value)}")
554
+ return value
555
+
556
+ async def expect_integer_external(value: JsonValue) -> JsonValue:
557
+ await asyncio.sleep(0)
558
+ if not isinstance(value, int) or isinstance(value, bool):
559
+ raise TypeError(f"expect_integer expected integer, got {_shape_label(value)}")
560
+ return value
561
+
562
+ external_functions[SANDBOX_FUNCTION_EXTERNALS[INSPECT_JSON_NAME]] = inspect_json_external
563
+ external_functions[SANDBOX_FUNCTION_EXTERNALS[EXPECT_OBJECT_NAME]] = expect_object_external
564
+ external_functions[SANDBOX_FUNCTION_EXTERNALS[EXPECT_LIST_NAME]] = expect_list_external
565
+ external_functions[SANDBOX_FUNCTION_EXTERNALS[EXPECT_STRING_NAME]] = expect_string_external
566
+ external_functions[SANDBOX_FUNCTION_EXTERNALS[EXPECT_INTEGER_NAME]] = (
567
+ expect_integer_external
568
+ )
434
569
  for spec in context.catalog.tools.values():
435
570
 
436
571
  async def sdk_method(
@@ -491,7 +626,10 @@ class MontyExecutor:
491
626
  lowered = message.lower()
492
627
  stage: Literal["runtime", "timeout"] = (
493
628
  "timeout"
494
- if "duration" in lowered or "timed out" in lowered or "timeout" in lowered
629
+ if (context.failure is not None and context.failure.kind == "upstream_timeout")
630
+ or "duration" in lowered
631
+ or "timed out" in lowered
632
+ or "timeout" in lowered
495
633
  else "runtime"
496
634
  )
497
635
  return self._failure(context, stage, message)
@@ -513,11 +651,18 @@ class MontyExecutor:
513
651
  context.metrics.serialization_ms += _elapsed_ms(serialization_started)
514
652
  context.metrics.result_bytes = result_bytes
515
653
  if enforce_result_limit and result_bytes >= context.settings.result_byte_limit:
516
- response = ExecutionResponse.failure(
654
+ retained = retain_result(result) if retain_result is not None else None
655
+ message = (
656
+ f"Returned value is {result_bytes} bytes; reduce it below "
657
+ f"{context.settings.result_byte_limit} bytes"
658
+ )
659
+ response = ExecutionResponse.failed(
517
660
  failure_stage="result",
518
- error=(
519
- f"Returned value is {result_bytes} bytes; reduce it below "
520
- f"{context.settings.result_byte_limit} bytes"
661
+ error=message,
662
+ failure=ExecutionFailureInfo(
663
+ kind="result",
664
+ retryable=False,
665
+ message=message,
521
666
  ),
522
667
  shape=_inspect_json(
523
668
  result,
@@ -527,6 +672,8 @@ class MontyExecutor:
527
672
  ),
528
673
  calls_made=context.calls_made,
529
674
  chain_calls=context.chain_calls,
675
+ result_ref=retained.reference if retained is not None else None,
676
+ expires_in_seconds=(retained.expires_in_seconds if retained is not None else None),
530
677
  )
531
678
  response.metrics = context.metrics.model_copy()
532
679
  return response
@@ -584,9 +731,11 @@ class MontyExecutor:
584
731
  stage: Literal["preflight", "runtime", "timeout", "cancelled", "result"],
585
732
  error: str,
586
733
  ) -> ExecutionResponse:
587
- response = ExecutionResponse.failure(
734
+ failure = context.failure or _execution_failure_info(stage, error)
735
+ response = ExecutionResponse.failed(
588
736
  failure_stage=stage,
589
737
  error=error,
738
+ failure=failure,
590
739
  calls_made=context.calls_made,
591
740
  chain_calls=context.chain_calls,
592
741
  )
@@ -594,6 +743,27 @@ class MontyExecutor:
594
743
  return response
595
744
 
596
745
 
746
+ def _execution_failure_info(
747
+ stage: FailureStage,
748
+ message: str,
749
+ ) -> ExecutionFailureInfo:
750
+ if stage == "preflight":
751
+ kind: FailureKind = "preflight"
752
+ elif stage == "result":
753
+ kind = "result"
754
+ elif stage == "timeout":
755
+ kind = "sandbox_timeout"
756
+ elif stage == "cancelled":
757
+ kind = "cancelled"
758
+ else:
759
+ kind = "sandbox_runtime"
760
+ return ExecutionFailureInfo(
761
+ kind=kind,
762
+ retryable=False,
763
+ message=message,
764
+ )
765
+
766
+
597
767
  def _saved_chain_result_error(
598
768
  error: TypeError | ValidationError | ValueError,
599
769
  output_schema: JsonObject,
@@ -623,7 +793,7 @@ def _elapsed_ms(started: float) -> float:
623
793
 
624
794
 
625
795
  def _inspection_byte_limit(result_byte_limit: int) -> int:
626
- return max(256, min(INSPECT_BYTE_LIMIT, result_byte_limit * 3 // 4))
796
+ return max(256, min(INSPECT_BYTE_LIMIT, result_byte_limit * 2 // 3))
627
797
 
628
798
 
629
799
  def _inspect_json(
@@ -867,10 +1037,10 @@ def _wrap_code(
867
1037
  normalized = textwrap.dedent(code).strip("\n")
868
1038
  uses_input = input_type is not None if has_input is None else has_input
869
1039
  if typed and uses_input:
870
- if input_type is None or output_type is None:
871
- raise ValueError("Typed saved-chain code requires input and output types")
1040
+ if input_type is None:
1041
+ raise ValueError("Typed input code requires an input type")
872
1042
  signature = f"input: {input_type}"
873
- return_annotation = f" -> {output_type}"
1043
+ return_annotation = f" -> {output_type}" if output_type is not None else ""
874
1044
  elif uses_input:
875
1045
  signature = "input"
876
1046
  return_annotation = ""
@@ -898,13 +1068,15 @@ def _rewrite_sdk_calls(code: str, catalog: ToolCatalog) -> str:
898
1068
  def visit_Call(self, node: ast.Call) -> ast.AST:
899
1069
  self.generic_visit(node)
900
1070
  function = node.func
901
- if isinstance(function, ast.Name) and function.id == "inspect_json":
902
- external_call = ast.Call(
903
- func=ast.Name(id=INSPECT_JSON_EXTERNAL, ctx=ast.Load()),
904
- args=node.args,
905
- keywords=node.keywords,
906
- )
907
- return ast.copy_location(ast.Await(value=external_call), node)
1071
+ if isinstance(function, ast.Name):
1072
+ external_name = SANDBOX_FUNCTION_EXTERNALS.get(function.id)
1073
+ if external_name is not None:
1074
+ external_call = ast.Call(
1075
+ func=ast.Name(id=external_name, ctx=ast.Load()),
1076
+ args=node.args,
1077
+ keywords=node.keywords,
1078
+ )
1079
+ return ast.copy_location(ast.Await(value=external_call), node)
908
1080
  if not isinstance(function, ast.Attribute):
909
1081
  return node
910
1082
  if not isinstance(function.value, ast.Name):