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.
@@ -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)