pi-codemcp 0.1.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/.python-version +1 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/extensions/index.ts +188 -0
- package/package.json +91 -0
- package/sidecar/__init__.py +1 -0
- package/sidecar/catalog_cache.py +89 -0
- package/sidecar/chains.py +316 -0
- package/sidecar/executor.py +591 -0
- package/sidecar/gateway.py +893 -0
- package/sidecar/json_types.py +11 -0
- package/sidecar/mcp_config.py +278 -0
- package/sidecar/models.py +92 -0
- package/sidecar/pyproject.toml +144 -0
- package/sidecar/settings.py +59 -0
- package/sidecar/tool_catalog.py +838 -0
- package/sidecar/uv.lock +1775 -0
- package/src/chains.ts +452 -0
- package/src/config.ts +58 -0
- package/src/errors.ts +9 -0
- package/src/execution-rendering.ts +183 -0
- package/src/json-file.ts +54 -0
- package/src/lifecycle.ts +59 -0
- package/src/mcp-client.ts +303 -0
- package/src/modal.ts +1233 -0
- package/src/output.ts +52 -0
- package/src/settings.ts +144 -0
- package/src/tools.ts +332 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import textwrap
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from typing import TYPE_CHECKING, Literal, Self
|
|
10
|
+
|
|
11
|
+
import pydantic_monty
|
|
12
|
+
from pydantic import (
|
|
13
|
+
BaseModel,
|
|
14
|
+
ConfigDict,
|
|
15
|
+
Field,
|
|
16
|
+
ValidationError,
|
|
17
|
+
model_serializer,
|
|
18
|
+
model_validator,
|
|
19
|
+
)
|
|
20
|
+
from pydantic_core import to_json
|
|
21
|
+
|
|
22
|
+
from .json_types import JSON_VALUE_ADAPTER, JsonObject, JsonValue
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .chains import SavedChainManifest
|
|
26
|
+
from .tool_catalog import ToolCatalog, ToolSpec
|
|
27
|
+
|
|
28
|
+
RESULT_BYTE_LIMIT = 16 * 1024
|
|
29
|
+
SHAPE_FIELD_LIMIT = 20
|
|
30
|
+
CHAIN_INPUT_EXTERNAL = "__codemcp_saved_chain_input"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExecutionContext:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
catalog: ToolCatalog,
|
|
37
|
+
call_tool: ContextToolCall,
|
|
38
|
+
settings: ExecutionSettings,
|
|
39
|
+
deadline: float,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.catalog = catalog
|
|
42
|
+
self.call_tool = call_tool
|
|
43
|
+
self.settings = settings
|
|
44
|
+
self.deadline = deadline
|
|
45
|
+
self.calls_made = 0
|
|
46
|
+
self.chain_calls = 0
|
|
47
|
+
self.chain_stack: ContextVar[tuple[str, ...]] = ContextVar(
|
|
48
|
+
"codemcp_chain_stack",
|
|
49
|
+
default=(),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_calls(self) -> int:
|
|
54
|
+
return self.calls_made + self.chain_calls
|
|
55
|
+
|
|
56
|
+
def remaining_seconds(self) -> float:
|
|
57
|
+
return self.deadline - asyncio.get_running_loop().time()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ToolCall = Callable[[str, JsonObject], Awaitable[JsonValue]]
|
|
61
|
+
ContextToolCall = Callable[[str, JsonObject, ExecutionContext], Awaitable[JsonValue]]
|
|
62
|
+
ExternalFunction = Callable[..., Awaitable[JsonValue]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
type FailureStage = Literal["preflight", "runtime", "timeout", "cancelled", "result"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ExecutionResponse(BaseModel):
|
|
69
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
70
|
+
|
|
71
|
+
ok: bool
|
|
72
|
+
failure_stage: FailureStage | None = None
|
|
73
|
+
result: JsonValue = None
|
|
74
|
+
error: str | None = None
|
|
75
|
+
shape: JsonObject | None = None
|
|
76
|
+
calls_made: int = Field(default=0, ge=0)
|
|
77
|
+
chain_calls: int = Field(default=0, ge=0)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def success(
|
|
81
|
+
cls,
|
|
82
|
+
result: JsonValue,
|
|
83
|
+
*,
|
|
84
|
+
calls_made: int = 0,
|
|
85
|
+
chain_calls: int = 0,
|
|
86
|
+
) -> Self:
|
|
87
|
+
return cls(
|
|
88
|
+
ok=True,
|
|
89
|
+
result=result,
|
|
90
|
+
calls_made=calls_made,
|
|
91
|
+
chain_calls=chain_calls,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def failure(
|
|
96
|
+
cls,
|
|
97
|
+
*,
|
|
98
|
+
failure_stage: FailureStage,
|
|
99
|
+
error: str,
|
|
100
|
+
calls_made: int = 0,
|
|
101
|
+
chain_calls: int = 0,
|
|
102
|
+
shape: JsonObject | None = None,
|
|
103
|
+
) -> Self:
|
|
104
|
+
return cls(
|
|
105
|
+
ok=False,
|
|
106
|
+
failure_stage=failure_stage,
|
|
107
|
+
error=error,
|
|
108
|
+
calls_made=calls_made,
|
|
109
|
+
chain_calls=chain_calls,
|
|
110
|
+
shape=shape,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@model_validator(mode="after")
|
|
114
|
+
def validate_state(self) -> Self:
|
|
115
|
+
if self.ok:
|
|
116
|
+
if self.failure_stage is not None or self.error is not None or self.shape is not None:
|
|
117
|
+
raise ValueError("successful execution cannot contain failure details")
|
|
118
|
+
elif self.failure_stage is None or self.error is None:
|
|
119
|
+
raise ValueError("failed execution requires a failure stage and error")
|
|
120
|
+
elif self.result is not None:
|
|
121
|
+
raise ValueError("failed execution cannot contain a result")
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
@model_serializer(mode="plain")
|
|
125
|
+
def serialize_compact(self) -> JsonObject:
|
|
126
|
+
if self.ok:
|
|
127
|
+
response: JsonObject = {
|
|
128
|
+
"ok": True,
|
|
129
|
+
"result": self.result,
|
|
130
|
+
"calls_made": self.calls_made,
|
|
131
|
+
}
|
|
132
|
+
else:
|
|
133
|
+
response = {
|
|
134
|
+
"ok": False,
|
|
135
|
+
"failure_stage": self.failure_stage,
|
|
136
|
+
"error": self.error,
|
|
137
|
+
"calls_made": self.calls_made,
|
|
138
|
+
}
|
|
139
|
+
if self.shape is not None:
|
|
140
|
+
response["shape"] = self.shape
|
|
141
|
+
if self.chain_calls > 0:
|
|
142
|
+
response["chain_calls"] = self.chain_calls
|
|
143
|
+
return response
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ExecutionSettings(BaseModel):
|
|
147
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
148
|
+
|
|
149
|
+
timeout_seconds: float = Field(default=30.0, gt=0)
|
|
150
|
+
max_memory_bytes: int = Field(default=100 * 1024 * 1024, gt=0)
|
|
151
|
+
max_calls: int = Field(default=50, gt=0)
|
|
152
|
+
tool_timeout_seconds: float = Field(default=30.0, gt=0)
|
|
153
|
+
result_byte_limit: int = Field(default=RESULT_BYTE_LIMIT, gt=0)
|
|
154
|
+
max_chain_depth: int = Field(default=16, gt=0)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MontyExecutor:
|
|
158
|
+
"""Type-check and execute one model-authored call graph at a time."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
catalog: ToolCatalog,
|
|
163
|
+
*,
|
|
164
|
+
settings: ExecutionSettings | None = None,
|
|
165
|
+
) -> None:
|
|
166
|
+
self.catalog = catalog
|
|
167
|
+
self.settings = settings or ExecutionSettings()
|
|
168
|
+
self._execution_lock = asyncio.Lock()
|
|
169
|
+
self._validated_programs: set[str] = set()
|
|
170
|
+
|
|
171
|
+
def update_catalog(self, catalog: ToolCatalog) -> None:
|
|
172
|
+
self.catalog = catalog
|
|
173
|
+
|
|
174
|
+
async def execute(self, code: str, call_tool: ToolCall) -> ExecutionResponse:
|
|
175
|
+
async def adapted(
|
|
176
|
+
name: str,
|
|
177
|
+
arguments: JsonObject,
|
|
178
|
+
_context: ExecutionContext,
|
|
179
|
+
) -> JsonValue:
|
|
180
|
+
return await call_tool(name, arguments)
|
|
181
|
+
|
|
182
|
+
return await self.execute_graph(code, adapted)
|
|
183
|
+
|
|
184
|
+
async def execute_graph(
|
|
185
|
+
self,
|
|
186
|
+
code: str,
|
|
187
|
+
call_tool: ContextToolCall,
|
|
188
|
+
) -> ExecutionResponse:
|
|
189
|
+
async with self._execution_lock:
|
|
190
|
+
context = self._new_context(self.catalog, call_tool)
|
|
191
|
+
return await self._execute_program(code, context, enforce_result_limit=True)
|
|
192
|
+
|
|
193
|
+
async def execute_saved_chain(
|
|
194
|
+
self,
|
|
195
|
+
chain: SavedChainManifest,
|
|
196
|
+
arguments: JsonObject,
|
|
197
|
+
call_tool: ContextToolCall,
|
|
198
|
+
) -> ExecutionResponse:
|
|
199
|
+
async with self._execution_lock:
|
|
200
|
+
catalog = self.catalog
|
|
201
|
+
spec = self._chain_spec(catalog, chain)
|
|
202
|
+
try:
|
|
203
|
+
validated = catalog.validate_arguments(spec.name, arguments)
|
|
204
|
+
except (TypeError, ValidationError, ValueError) as error:
|
|
205
|
+
return ExecutionResponse.failure(
|
|
206
|
+
failure_stage="preflight",
|
|
207
|
+
error=f"{chain.call}: invalid arguments: {error}",
|
|
208
|
+
)
|
|
209
|
+
context = self._new_context(catalog, call_tool)
|
|
210
|
+
context.chain_stack.set((chain.name,))
|
|
211
|
+
return await self._execute_program(
|
|
212
|
+
chain.code,
|
|
213
|
+
context,
|
|
214
|
+
input_value=validated,
|
|
215
|
+
input_type=spec.input_type_name,
|
|
216
|
+
output_type=spec.output_type_name,
|
|
217
|
+
output_spec_name=spec.name,
|
|
218
|
+
enforce_result_limit=True,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def execute_nested_chain(
|
|
222
|
+
self,
|
|
223
|
+
chain: SavedChainManifest,
|
|
224
|
+
arguments: JsonObject,
|
|
225
|
+
context: ExecutionContext,
|
|
226
|
+
) -> JsonValue:
|
|
227
|
+
stack = context.chain_stack.get()
|
|
228
|
+
if len(stack) >= context.settings.max_chain_depth:
|
|
229
|
+
path = " -> ".join((*stack, chain.name))
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f"Saved chain recursion depth exceeded {context.settings.max_chain_depth}: {path}"
|
|
232
|
+
)
|
|
233
|
+
spec = self._chain_spec(context.catalog, chain)
|
|
234
|
+
token = context.chain_stack.set((*stack, chain.name))
|
|
235
|
+
try:
|
|
236
|
+
response = await self._execute_program(
|
|
237
|
+
chain.code,
|
|
238
|
+
context,
|
|
239
|
+
input_value=arguments,
|
|
240
|
+
input_type=spec.input_type_name,
|
|
241
|
+
output_type=spec.output_type_name,
|
|
242
|
+
output_spec_name=spec.name,
|
|
243
|
+
enforce_result_limit=False,
|
|
244
|
+
)
|
|
245
|
+
finally:
|
|
246
|
+
context.chain_stack.reset(token)
|
|
247
|
+
if not response.ok:
|
|
248
|
+
path = " -> ".join((*stack, chain.name))
|
|
249
|
+
raise RuntimeError(
|
|
250
|
+
f"Saved chain {path} failed at {response.failure_stage}: {response.error}"
|
|
251
|
+
)
|
|
252
|
+
return response.result
|
|
253
|
+
|
|
254
|
+
async def validate_saved_chain(
|
|
255
|
+
self,
|
|
256
|
+
code: str,
|
|
257
|
+
catalog: ToolCatalog,
|
|
258
|
+
spec: ToolSpec,
|
|
259
|
+
) -> None:
|
|
260
|
+
if not code.strip():
|
|
261
|
+
raise ValueError("Saved chain code must not be empty")
|
|
262
|
+
wrapped = _wrap_code(
|
|
263
|
+
code,
|
|
264
|
+
input_type=spec.input_type_name,
|
|
265
|
+
output_type=spec.output_type_name,
|
|
266
|
+
typed=True,
|
|
267
|
+
)
|
|
268
|
+
type_stubs = _chain_type_stubs(catalog, spec.input_type_name)
|
|
269
|
+
async with self._execution_lock:
|
|
270
|
+
await self._type_check(wrapped, catalog, type_stubs)
|
|
271
|
+
|
|
272
|
+
def _new_context(
|
|
273
|
+
self,
|
|
274
|
+
catalog: ToolCatalog,
|
|
275
|
+
call_tool: ContextToolCall,
|
|
276
|
+
) -> ExecutionContext:
|
|
277
|
+
loop = asyncio.get_running_loop()
|
|
278
|
+
return ExecutionContext(
|
|
279
|
+
catalog=catalog,
|
|
280
|
+
call_tool=call_tool,
|
|
281
|
+
settings=self.settings,
|
|
282
|
+
deadline=loop.time() + self.settings.timeout_seconds,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
async def _execute_program( # noqa: C901, PLR0915
|
|
286
|
+
self,
|
|
287
|
+
code: str,
|
|
288
|
+
context: ExecutionContext,
|
|
289
|
+
*,
|
|
290
|
+
input_value: JsonObject | None = None,
|
|
291
|
+
input_type: str | None = None,
|
|
292
|
+
output_type: str | None = None,
|
|
293
|
+
output_spec_name: str | None = None,
|
|
294
|
+
enforce_result_limit: bool,
|
|
295
|
+
) -> ExecutionResponse:
|
|
296
|
+
if not code.strip():
|
|
297
|
+
return self._failure(context, "preflight", "Execution code must not be empty")
|
|
298
|
+
|
|
299
|
+
has_input = input_value is not None
|
|
300
|
+
typed_code = _wrap_code(
|
|
301
|
+
code,
|
|
302
|
+
input_type=input_type,
|
|
303
|
+
output_type=output_type,
|
|
304
|
+
typed=True,
|
|
305
|
+
)
|
|
306
|
+
type_stubs = (
|
|
307
|
+
_chain_type_stubs(context.catalog, input_type)
|
|
308
|
+
if input_type is not None
|
|
309
|
+
else context.catalog.type_stubs
|
|
310
|
+
)
|
|
311
|
+
try:
|
|
312
|
+
await self._type_check(typed_code, context.catalog, type_stubs)
|
|
313
|
+
except pydantic_monty.MontyTypingError as error:
|
|
314
|
+
return self._failure(
|
|
315
|
+
context,
|
|
316
|
+
"preflight",
|
|
317
|
+
error.display("concise", color=False).strip(),
|
|
318
|
+
)
|
|
319
|
+
except pydantic_monty.MontySyntaxError as error:
|
|
320
|
+
return self._failure(context, "preflight", error.display("type-msg").strip())
|
|
321
|
+
except RuntimeError as error:
|
|
322
|
+
return self._failure(
|
|
323
|
+
context,
|
|
324
|
+
"preflight",
|
|
325
|
+
f"Type-check setup failed: {error}",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
runtime_wrapped = _wrap_code(code, typed=False, has_input=has_input)
|
|
329
|
+
runtime_code = _rewrite_sdk_calls(runtime_wrapped, context.catalog)
|
|
330
|
+
try:
|
|
331
|
+
monty = await pydantic_monty.Monty.acreate(
|
|
332
|
+
runtime_code,
|
|
333
|
+
script_name="codemcp_execute.py",
|
|
334
|
+
)
|
|
335
|
+
except (pydantic_monty.MontySyntaxError, RuntimeError) as error:
|
|
336
|
+
return self._failure(
|
|
337
|
+
context,
|
|
338
|
+
"preflight",
|
|
339
|
+
f"SDK facade compilation failed: {error}",
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
async def dispatch_wrapper(name: str, arguments: JsonObject) -> JsonValue:
|
|
343
|
+
catalog = context.catalog
|
|
344
|
+
spec = catalog.tools.get(name)
|
|
345
|
+
if spec is None:
|
|
346
|
+
raise RuntimeError(f"Unknown tool: {name}")
|
|
347
|
+
if not isinstance(arguments, dict):
|
|
348
|
+
raise TypeError("SDK method arguments must be an object")
|
|
349
|
+
if context.total_calls >= context.settings.max_calls:
|
|
350
|
+
raise RuntimeError(
|
|
351
|
+
f"Call limit exceeded: maximum {context.settings.max_calls} total calls"
|
|
352
|
+
)
|
|
353
|
+
validated = catalog.validate_arguments(name, arguments)
|
|
354
|
+
if spec.kind == "saved_chain":
|
|
355
|
+
context.chain_calls += 1
|
|
356
|
+
return await context.call_tool(name, validated, context)
|
|
357
|
+
|
|
358
|
+
context.calls_made += 1
|
|
359
|
+
remaining = context.remaining_seconds()
|
|
360
|
+
if remaining <= 0:
|
|
361
|
+
raise TimeoutError
|
|
362
|
+
timeout = min(context.settings.tool_timeout_seconds, remaining)
|
|
363
|
+
async with asyncio.timeout(timeout):
|
|
364
|
+
return await context.call_tool(name, validated, context)
|
|
365
|
+
|
|
366
|
+
external_functions: dict[str, ExternalFunction] = {}
|
|
367
|
+
for spec in context.catalog.tools.values():
|
|
368
|
+
|
|
369
|
+
async def sdk_method(
|
|
370
|
+
arguments: JsonObject,
|
|
371
|
+
*,
|
|
372
|
+
_name: str = spec.name,
|
|
373
|
+
) -> JsonValue:
|
|
374
|
+
return await dispatch_wrapper(_name, arguments)
|
|
375
|
+
|
|
376
|
+
external_functions[spec.external_name] = sdk_method
|
|
377
|
+
|
|
378
|
+
if input_value is not None:
|
|
379
|
+
|
|
380
|
+
async def chain_input() -> JsonValue: # noqa: RUF029
|
|
381
|
+
return input_value
|
|
382
|
+
|
|
383
|
+
external_functions[CHAIN_INPUT_EXTERNAL] = chain_input
|
|
384
|
+
|
|
385
|
+
remaining = context.remaining_seconds()
|
|
386
|
+
if remaining <= 0:
|
|
387
|
+
return self._failure(
|
|
388
|
+
context,
|
|
389
|
+
"timeout",
|
|
390
|
+
f"Execution timed out after {context.settings.timeout_seconds:g}s",
|
|
391
|
+
)
|
|
392
|
+
limits: pydantic_monty.ResourceLimits = {
|
|
393
|
+
"max_duration_secs": remaining,
|
|
394
|
+
"max_memory": context.settings.max_memory_bytes,
|
|
395
|
+
}
|
|
396
|
+
try:
|
|
397
|
+
async with asyncio.timeout(remaining + 0.1):
|
|
398
|
+
result = JSON_VALUE_ADAPTER.validate_python(
|
|
399
|
+
await monty.run_async(
|
|
400
|
+
external_functions=external_functions,
|
|
401
|
+
limits=limits,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
except asyncio.CancelledError:
|
|
405
|
+
raise
|
|
406
|
+
except TimeoutError:
|
|
407
|
+
return self._failure(
|
|
408
|
+
context,
|
|
409
|
+
"timeout",
|
|
410
|
+
f"Execution timed out after {context.settings.timeout_seconds:g}s",
|
|
411
|
+
)
|
|
412
|
+
except ValidationError as error:
|
|
413
|
+
return self._failure(
|
|
414
|
+
context,
|
|
415
|
+
"result",
|
|
416
|
+
f"Returned value is not JSON-compatible: {error.errors()[0]['msg']}",
|
|
417
|
+
)
|
|
418
|
+
except pydantic_monty.MontyRuntimeError as error:
|
|
419
|
+
message = error.display("type-msg").strip()
|
|
420
|
+
lowered = message.lower()
|
|
421
|
+
stage: Literal["runtime", "timeout"] = (
|
|
422
|
+
"timeout"
|
|
423
|
+
if "duration" in lowered or "timed out" in lowered or "timeout" in lowered
|
|
424
|
+
else "runtime"
|
|
425
|
+
)
|
|
426
|
+
return self._failure(context, stage, message)
|
|
427
|
+
|
|
428
|
+
if output_spec_name is not None:
|
|
429
|
+
try:
|
|
430
|
+
result = context.catalog.validate_saved_chain_result(output_spec_name, result)
|
|
431
|
+
except (TypeError, ValidationError, ValueError) as error:
|
|
432
|
+
return self._failure(
|
|
433
|
+
context,
|
|
434
|
+
"result",
|
|
435
|
+
f"Saved chain result violates its output schema: {error}",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if enforce_result_limit:
|
|
439
|
+
result_bytes = len(to_json(result))
|
|
440
|
+
if result_bytes >= context.settings.result_byte_limit:
|
|
441
|
+
return ExecutionResponse.failure(
|
|
442
|
+
failure_stage="result",
|
|
443
|
+
error=(
|
|
444
|
+
f"Returned value is {result_bytes} bytes; reduce it below "
|
|
445
|
+
f"{context.settings.result_byte_limit} bytes"
|
|
446
|
+
),
|
|
447
|
+
shape=_summarize_shape(result),
|
|
448
|
+
calls_made=context.calls_made,
|
|
449
|
+
chain_calls=context.chain_calls,
|
|
450
|
+
)
|
|
451
|
+
return ExecutionResponse.success(
|
|
452
|
+
result=result,
|
|
453
|
+
calls_made=context.calls_made,
|
|
454
|
+
chain_calls=context.chain_calls,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
async def _type_check(
|
|
458
|
+
self,
|
|
459
|
+
wrapped_code: str,
|
|
460
|
+
catalog: ToolCatalog,
|
|
461
|
+
type_stubs: str,
|
|
462
|
+
) -> None:
|
|
463
|
+
key = hashlib.sha256(
|
|
464
|
+
f"{catalog.fingerprint}\0{wrapped_code}\0{type_stubs}".encode()
|
|
465
|
+
).hexdigest()
|
|
466
|
+
if key in self._validated_programs:
|
|
467
|
+
return
|
|
468
|
+
await pydantic_monty.Monty.acreate(
|
|
469
|
+
wrapped_code,
|
|
470
|
+
script_name="codemcp_execute.py",
|
|
471
|
+
type_check=True,
|
|
472
|
+
type_check_stubs=type_stubs,
|
|
473
|
+
)
|
|
474
|
+
self._validated_programs.add(key)
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def _chain_spec(catalog: ToolCatalog, chain: SavedChainManifest) -> ToolSpec:
|
|
478
|
+
spec = catalog.tools.get(chain.public_name)
|
|
479
|
+
if spec is None or spec.kind != "saved_chain":
|
|
480
|
+
raise ValueError(f"Saved chain is not active in the catalog: {chain.name}")
|
|
481
|
+
return spec
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _failure(
|
|
485
|
+
context: ExecutionContext,
|
|
486
|
+
stage: Literal["preflight", "runtime", "timeout", "cancelled", "result"],
|
|
487
|
+
error: str,
|
|
488
|
+
) -> ExecutionResponse:
|
|
489
|
+
return ExecutionResponse.failure(
|
|
490
|
+
failure_stage=stage,
|
|
491
|
+
error=error,
|
|
492
|
+
calls_made=context.calls_made,
|
|
493
|
+
chain_calls=context.chain_calls,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _chain_type_stubs(catalog: ToolCatalog, _input_type: str) -> str:
|
|
498
|
+
return catalog.type_stubs
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _summarize_shape(value: JsonValue) -> JsonObject:
|
|
502
|
+
if not isinstance(value, dict):
|
|
503
|
+
return {"result": _shape_label(value)}
|
|
504
|
+
summary: JsonObject = {
|
|
505
|
+
str(key): _shape_label(item) for key, item in list(value.items())[:SHAPE_FIELD_LIMIT]
|
|
506
|
+
}
|
|
507
|
+
remaining = len(value) - len(summary)
|
|
508
|
+
if remaining > 0:
|
|
509
|
+
summary["<remaining>"] = f"{remaining} more fields"
|
|
510
|
+
return summary
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _shape_label(value: JsonValue) -> str:
|
|
514
|
+
if value is None:
|
|
515
|
+
return "null"
|
|
516
|
+
if isinstance(value, bool):
|
|
517
|
+
return "boolean"
|
|
518
|
+
if isinstance(value, dict):
|
|
519
|
+
return "object"
|
|
520
|
+
if isinstance(value, (list, tuple)):
|
|
521
|
+
return f"array[{len(value)}]"
|
|
522
|
+
if isinstance(value, str):
|
|
523
|
+
return "string"
|
|
524
|
+
if isinstance(value, int):
|
|
525
|
+
return "integer"
|
|
526
|
+
if isinstance(value, float):
|
|
527
|
+
return "number"
|
|
528
|
+
return type(value).__name__
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _wrap_code(
|
|
532
|
+
code: str,
|
|
533
|
+
*,
|
|
534
|
+
input_type: str | None = None,
|
|
535
|
+
output_type: str | None = None,
|
|
536
|
+
typed: bool,
|
|
537
|
+
has_input: bool | None = None,
|
|
538
|
+
) -> str:
|
|
539
|
+
"""Allow a natural top-level return and optionally bind typed saved-chain input."""
|
|
540
|
+
normalized = textwrap.dedent(code).strip("\n")
|
|
541
|
+
uses_input = input_type is not None if has_input is None else has_input
|
|
542
|
+
if typed and uses_input:
|
|
543
|
+
if input_type is None or output_type is None:
|
|
544
|
+
raise ValueError("Typed saved-chain code requires input and output types")
|
|
545
|
+
signature = f"input: {input_type}"
|
|
546
|
+
return_annotation = f" -> {output_type}"
|
|
547
|
+
elif uses_input:
|
|
548
|
+
signature = "input"
|
|
549
|
+
return_annotation = ""
|
|
550
|
+
else:
|
|
551
|
+
signature = ""
|
|
552
|
+
return_annotation = ""
|
|
553
|
+
if typed and uses_input:
|
|
554
|
+
invocation = ""
|
|
555
|
+
elif uses_input:
|
|
556
|
+
invocation = f"await __codemcp_main(await {CHAIN_INPUT_EXTERNAL}())\n"
|
|
557
|
+
else:
|
|
558
|
+
invocation = "await __codemcp_main()\n"
|
|
559
|
+
return (
|
|
560
|
+
f"async def __codemcp_main({signature}){return_annotation}:\n"
|
|
561
|
+
f"{textwrap.indent(normalized, ' ')}\n\n"
|
|
562
|
+
f"{invocation}"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _rewrite_sdk_calls(code: str, catalog: ToolCatalog) -> str:
|
|
567
|
+
tree = ast.parse(code, filename="codemcp_execute.py", mode="exec")
|
|
568
|
+
|
|
569
|
+
class FacadeCallRewriter(ast.NodeTransformer):
|
|
570
|
+
def visit_Call(self, node: ast.Call) -> ast.AST:
|
|
571
|
+
self.generic_visit(node)
|
|
572
|
+
function = node.func
|
|
573
|
+
if not isinstance(function, ast.Attribute):
|
|
574
|
+
return node
|
|
575
|
+
if not isinstance(function.value, ast.Name):
|
|
576
|
+
return node
|
|
577
|
+
public_name = catalog.facade_calls.get((function.value.id, function.attr))
|
|
578
|
+
if public_name is None:
|
|
579
|
+
return node
|
|
580
|
+
node.func = ast.copy_location(
|
|
581
|
+
ast.Name(
|
|
582
|
+
id=catalog.tools[public_name].external_name,
|
|
583
|
+
ctx=ast.Load(),
|
|
584
|
+
),
|
|
585
|
+
function,
|
|
586
|
+
)
|
|
587
|
+
return node
|
|
588
|
+
|
|
589
|
+
rewritten = FacadeCallRewriter().visit(tree)
|
|
590
|
+
ast.fix_missing_locations(rewritten)
|
|
591
|
+
return ast.unparse(rewritten)
|