pi-codemcp 1.2.2 → 1.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 +5 -2
- package/extensions/index.ts +12 -8
- package/package.json +1 -1
- package/sidecar/cli.py +27 -7
- package/sidecar/executor.py +207 -35
- package/sidecar/gateway.py +470 -115
- package/sidecar/refinement_cache.py +154 -0
- package/sidecar/sandbox_api.py +44 -0
- package/sidecar/stats.py +801 -194
- package/sidecar/tool_catalog.py +43 -26
- package/src/chains.ts +53 -13
- package/src/mcp-client.ts +1 -0
- package/src/modal.ts +11 -0
- package/src/prompts.ts +3 -3
- package/src/tools.ts +37 -14
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`;
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
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(
|
|
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(
|
|
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(
|
|
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
|
|
package/sidecar/executor.py
CHANGED
|
@@ -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 =
|
|
48
|
+
INSPECT_KEY_LIMIT = 80
|
|
40
49
|
INSPECT_BYTE_LIMIT = 8 * 1024
|
|
41
50
|
CHAIN_INPUT_EXTERNAL = "__codemcp_saved_chain_input"
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
319
|
+
message = f"{chain.call}: invalid arguments: {error}"
|
|
320
|
+
return ExecutionResponse.failed(
|
|
235
321
|
failure_stage="preflight",
|
|
236
|
-
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:
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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 *
|
|
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
|
|
871
|
-
raise ValueError("Typed
|
|
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)
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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):
|