pi-codemcp 0.1.0 → 1.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/README.md +144 -13
- package/extensions/index.ts +4 -1
- package/package.json +7 -7
- package/sidecar/catalog_cache.py +2 -1
- package/sidecar/cli.py +402 -0
- package/sidecar/executor.py +349 -36
- package/sidecar/gateway.py +499 -60
- package/sidecar/models.py +41 -4
- package/sidecar/pyproject.toml +33 -40
- package/sidecar/runtime_paths.py +71 -0
- package/sidecar/settings.py +7 -3
- package/sidecar/stats.py +424 -0
- package/sidecar/tool_catalog.py +311 -46
- package/sidecar/uv.lock +99 -244
- package/src/chains.ts +1 -2
- package/src/execution-rendering.ts +7 -5
- package/src/mcp-client.ts +5 -1
- package/src/modal.ts +258 -14
- package/src/output.ts +27 -24
- package/src/prompts.ts +24 -0
- package/src/settings.ts +18 -17
- package/src/tools.ts +270 -36
package/sidecar/executor.py
CHANGED
|
@@ -3,10 +3,14 @@ from __future__ import annotations
|
|
|
3
3
|
import ast
|
|
4
4
|
import asyncio
|
|
5
5
|
import hashlib
|
|
6
|
+
import heapq
|
|
6
7
|
import textwrap
|
|
8
|
+
import time
|
|
9
|
+
from collections import Counter
|
|
7
10
|
from collections.abc import Awaitable, Callable
|
|
8
11
|
from contextvars import ContextVar
|
|
9
|
-
from
|
|
12
|
+
from itertools import islice
|
|
13
|
+
from typing import TYPE_CHECKING, Literal, Self, assert_never, override
|
|
10
14
|
|
|
11
15
|
import pydantic_monty
|
|
12
16
|
from pydantic import (
|
|
@@ -20,6 +24,7 @@ from pydantic import (
|
|
|
20
24
|
from pydantic_core import to_json
|
|
21
25
|
|
|
22
26
|
from .json_types import JSON_VALUE_ADAPTER, JsonObject, JsonValue
|
|
27
|
+
from .tool_catalog import referenced_calls, schema_path_summary
|
|
23
28
|
|
|
24
29
|
if TYPE_CHECKING:
|
|
25
30
|
from .chains import SavedChainManifest
|
|
@@ -27,7 +32,23 @@ if TYPE_CHECKING:
|
|
|
27
32
|
|
|
28
33
|
RESULT_BYTE_LIMIT = 16 * 1024
|
|
29
34
|
SHAPE_FIELD_LIMIT = 20
|
|
35
|
+
INSPECT_SAMPLE_LIMIT = 3
|
|
36
|
+
INSPECT_DEPTH_LIMIT = 6
|
|
37
|
+
INSPECT_COLLECTION_LIMIT = 10
|
|
38
|
+
INSPECT_STRING_LIMIT = 200
|
|
39
|
+
INSPECT_KEY_LIMIT = 120
|
|
40
|
+
INSPECT_BYTE_LIMIT = 8 * 1024
|
|
30
41
|
CHAIN_INPUT_EXTERNAL = "__codemcp_saved_chain_input"
|
|
42
|
+
INSPECT_JSON_EXTERNAL = "__codemcp_inspect_json"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExecutionMetrics(BaseModel):
|
|
46
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
47
|
+
|
|
48
|
+
typecheck_ms: float = Field(default=0.0, ge=0)
|
|
49
|
+
runtime_ms: float = Field(default=0.0, ge=0)
|
|
50
|
+
serialization_ms: float = Field(default=0.0, ge=0)
|
|
51
|
+
result_bytes: int = Field(default=0, ge=0)
|
|
31
52
|
|
|
32
53
|
|
|
33
54
|
class ExecutionContext:
|
|
@@ -44,6 +65,7 @@ class ExecutionContext:
|
|
|
44
65
|
self.deadline = deadline
|
|
45
66
|
self.calls_made = 0
|
|
46
67
|
self.chain_calls = 0
|
|
68
|
+
self.metrics = ExecutionMetrics()
|
|
47
69
|
self.chain_stack: ContextVar[tuple[str, ...]] = ContextVar(
|
|
48
70
|
"codemcp_chain_stack",
|
|
49
71
|
default=(),
|
|
@@ -75,6 +97,7 @@ class ExecutionResponse(BaseModel):
|
|
|
75
97
|
shape: JsonObject | None = None
|
|
76
98
|
calls_made: int = Field(default=0, ge=0)
|
|
77
99
|
chain_calls: int = Field(default=0, ge=0)
|
|
100
|
+
metrics: ExecutionMetrics = Field(default_factory=ExecutionMetrics, exclude=True)
|
|
78
101
|
|
|
79
102
|
@classmethod
|
|
80
103
|
def success(
|
|
@@ -140,6 +163,12 @@ class ExecutionResponse(BaseModel):
|
|
|
140
163
|
response["shape"] = self.shape
|
|
141
164
|
if self.chain_calls > 0:
|
|
142
165
|
response["chain_calls"] = self.chain_calls
|
|
166
|
+
timings: JsonObject = {
|
|
167
|
+
"typecheck_ms": round(self.metrics.typecheck_ms, 3),
|
|
168
|
+
"execution_ms": round(self.metrics.runtime_ms, 3),
|
|
169
|
+
"serialization_ms": round(self.metrics.serialization_ms, 3),
|
|
170
|
+
}
|
|
171
|
+
response["timings"] = timings
|
|
143
172
|
return response
|
|
144
173
|
|
|
145
174
|
|
|
@@ -265,7 +294,8 @@ class MontyExecutor:
|
|
|
265
294
|
output_type=spec.output_type_name,
|
|
266
295
|
typed=True,
|
|
267
296
|
)
|
|
268
|
-
|
|
297
|
+
referenced = referenced_calls(code, catalog.facade_calls)
|
|
298
|
+
type_stubs = catalog.type_stubs_for(referenced, include=spec.name)
|
|
269
299
|
async with self._execution_lock:
|
|
270
300
|
await self._type_check(wrapped, catalog, type_stubs)
|
|
271
301
|
|
|
@@ -282,7 +312,7 @@ class MontyExecutor:
|
|
|
282
312
|
deadline=loop.time() + self.settings.timeout_seconds,
|
|
283
313
|
)
|
|
284
314
|
|
|
285
|
-
async def _execute_program( #
|
|
315
|
+
async def _execute_program( # ruff:ignore[complex-structure, too-many-statements]
|
|
286
316
|
self,
|
|
287
317
|
code: str,
|
|
288
318
|
context: ExecutionContext,
|
|
@@ -303,27 +333,32 @@ class MontyExecutor:
|
|
|
303
333
|
output_type=output_type,
|
|
304
334
|
typed=True,
|
|
305
335
|
)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
336
|
+
referenced = referenced_calls(code, context.catalog.facade_calls)
|
|
337
|
+
type_stubs = context.catalog.type_stubs_for(
|
|
338
|
+
referenced,
|
|
339
|
+
include=output_spec_name,
|
|
310
340
|
)
|
|
341
|
+
typecheck_started = time.perf_counter()
|
|
311
342
|
try:
|
|
312
343
|
await self._type_check(typed_code, context.catalog, type_stubs)
|
|
313
344
|
except pydantic_monty.MontyTypingError as error:
|
|
345
|
+
context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
|
|
314
346
|
return self._failure(
|
|
315
347
|
context,
|
|
316
348
|
"preflight",
|
|
317
349
|
error.display("concise", color=False).strip(),
|
|
318
350
|
)
|
|
319
351
|
except pydantic_monty.MontySyntaxError as error:
|
|
352
|
+
context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
|
|
320
353
|
return self._failure(context, "preflight", error.display("type-msg").strip())
|
|
321
354
|
except RuntimeError as error:
|
|
355
|
+
context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
|
|
322
356
|
return self._failure(
|
|
323
357
|
context,
|
|
324
358
|
"preflight",
|
|
325
359
|
f"Type-check setup failed: {error}",
|
|
326
360
|
)
|
|
361
|
+
context.metrics.typecheck_ms += _elapsed_ms(typecheck_started)
|
|
327
362
|
|
|
328
363
|
runtime_wrapped = _wrap_code(code, typed=False, has_input=has_input)
|
|
329
364
|
runtime_code = _rewrite_sdk_calls(runtime_wrapped, context.catalog)
|
|
@@ -364,6 +399,30 @@ class MontyExecutor:
|
|
|
364
399
|
return await context.call_tool(name, validated, context)
|
|
365
400
|
|
|
366
401
|
external_functions: dict[str, ExternalFunction] = {}
|
|
402
|
+
|
|
403
|
+
async def inspect_json_external(
|
|
404
|
+
value: JsonValue,
|
|
405
|
+
*,
|
|
406
|
+
samples: JsonValue = 2,
|
|
407
|
+
max_depth: JsonValue = 3,
|
|
408
|
+
) -> JsonValue:
|
|
409
|
+
await asyncio.sleep(0)
|
|
410
|
+
if not isinstance(samples, int) or isinstance(samples, bool):
|
|
411
|
+
raise TypeError("inspect_json samples must be an integer")
|
|
412
|
+
if not isinstance(max_depth, int) or isinstance(max_depth, bool):
|
|
413
|
+
raise TypeError("inspect_json max_depth must be an integer")
|
|
414
|
+
if not 1 <= samples <= INSPECT_SAMPLE_LIMIT:
|
|
415
|
+
raise ValueError(f"inspect_json samples must be from 1 to {INSPECT_SAMPLE_LIMIT}")
|
|
416
|
+
if not 1 <= max_depth <= INSPECT_DEPTH_LIMIT:
|
|
417
|
+
raise ValueError(f"inspect_json max_depth must be from 1 to {INSPECT_DEPTH_LIMIT}")
|
|
418
|
+
return _inspect_json(
|
|
419
|
+
value,
|
|
420
|
+
samples=samples,
|
|
421
|
+
max_depth=max_depth,
|
|
422
|
+
byte_limit=_inspection_byte_limit(context.settings.result_byte_limit),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
external_functions[INSPECT_JSON_EXTERNAL] = inspect_json_external
|
|
367
426
|
for spec in context.catalog.tools.values():
|
|
368
427
|
|
|
369
428
|
async def sdk_method(
|
|
@@ -377,7 +436,7 @@ class MontyExecutor:
|
|
|
377
436
|
|
|
378
437
|
if input_value is not None:
|
|
379
438
|
|
|
380
|
-
async def chain_input() -> JsonValue: #
|
|
439
|
+
async def chain_input() -> JsonValue: # ruff:ignore[unused-async]
|
|
381
440
|
return input_value
|
|
382
441
|
|
|
383
442
|
external_functions[CHAIN_INPUT_EXTERNAL] = chain_input
|
|
@@ -393,6 +452,7 @@ class MontyExecutor:
|
|
|
393
452
|
"max_duration_secs": remaining,
|
|
394
453
|
"max_memory": context.settings.max_memory_bytes,
|
|
395
454
|
}
|
|
455
|
+
runtime_started = time.perf_counter()
|
|
396
456
|
try:
|
|
397
457
|
async with asyncio.timeout(remaining + 0.1):
|
|
398
458
|
result = JSON_VALUE_ADAPTER.validate_python(
|
|
@@ -404,18 +464,21 @@ class MontyExecutor:
|
|
|
404
464
|
except asyncio.CancelledError:
|
|
405
465
|
raise
|
|
406
466
|
except TimeoutError:
|
|
467
|
+
context.metrics.runtime_ms += _elapsed_ms(runtime_started)
|
|
407
468
|
return self._failure(
|
|
408
469
|
context,
|
|
409
470
|
"timeout",
|
|
410
471
|
f"Execution timed out after {context.settings.timeout_seconds:g}s",
|
|
411
472
|
)
|
|
412
473
|
except ValidationError as error:
|
|
474
|
+
context.metrics.runtime_ms += _elapsed_ms(runtime_started)
|
|
413
475
|
return self._failure(
|
|
414
476
|
context,
|
|
415
477
|
"result",
|
|
416
478
|
f"Returned value is not JSON-compatible: {error.errors()[0]['msg']}",
|
|
417
479
|
)
|
|
418
480
|
except pydantic_monty.MontyRuntimeError as error:
|
|
481
|
+
context.metrics.runtime_ms += _elapsed_ms(runtime_started)
|
|
419
482
|
message = error.display("type-msg").strip()
|
|
420
483
|
lowered = message.lower()
|
|
421
484
|
stage: Literal["runtime", "timeout"] = (
|
|
@@ -424,35 +487,48 @@ class MontyExecutor:
|
|
|
424
487
|
else "runtime"
|
|
425
488
|
)
|
|
426
489
|
return self._failure(context, stage, message)
|
|
490
|
+
context.metrics.runtime_ms += _elapsed_ms(runtime_started)
|
|
427
491
|
|
|
428
492
|
if output_spec_name is not None:
|
|
429
493
|
try:
|
|
430
494
|
result = context.catalog.validate_saved_chain_result(output_spec_name, result)
|
|
431
495
|
except (TypeError, ValidationError, ValueError) as error:
|
|
496
|
+
spec = context.catalog.tools[output_spec_name]
|
|
432
497
|
return self._failure(
|
|
433
498
|
context,
|
|
434
499
|
"result",
|
|
435
|
-
|
|
500
|
+
_saved_chain_result_error(error, spec.output_schema or {}),
|
|
436
501
|
)
|
|
437
502
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
503
|
+
serialization_started = time.perf_counter()
|
|
504
|
+
result_bytes = len(to_json(result))
|
|
505
|
+
context.metrics.serialization_ms += _elapsed_ms(serialization_started)
|
|
506
|
+
context.metrics.result_bytes = result_bytes
|
|
507
|
+
if enforce_result_limit and result_bytes >= context.settings.result_byte_limit:
|
|
508
|
+
response = ExecutionResponse.failure(
|
|
509
|
+
failure_stage="result",
|
|
510
|
+
error=(
|
|
511
|
+
f"Returned value is {result_bytes} bytes; reduce it below "
|
|
512
|
+
f"{context.settings.result_byte_limit} bytes"
|
|
513
|
+
),
|
|
514
|
+
shape=_inspect_json(
|
|
515
|
+
result,
|
|
516
|
+
samples=2,
|
|
517
|
+
max_depth=3,
|
|
518
|
+
byte_limit=_inspection_byte_limit(context.settings.result_byte_limit),
|
|
519
|
+
),
|
|
520
|
+
calls_made=context.calls_made,
|
|
521
|
+
chain_calls=context.chain_calls,
|
|
522
|
+
)
|
|
523
|
+
response.metrics = context.metrics.model_copy()
|
|
524
|
+
return response
|
|
525
|
+
response = ExecutionResponse.success(
|
|
452
526
|
result=result,
|
|
453
527
|
calls_made=context.calls_made,
|
|
454
528
|
chain_calls=context.chain_calls,
|
|
455
529
|
)
|
|
530
|
+
response.metrics = context.metrics.model_copy()
|
|
531
|
+
return response
|
|
456
532
|
|
|
457
533
|
async def _type_check(
|
|
458
534
|
self,
|
|
@@ -486,28 +562,257 @@ class MontyExecutor:
|
|
|
486
562
|
stage: Literal["preflight", "runtime", "timeout", "cancelled", "result"],
|
|
487
563
|
error: str,
|
|
488
564
|
) -> ExecutionResponse:
|
|
489
|
-
|
|
565
|
+
response = ExecutionResponse.failure(
|
|
490
566
|
failure_stage=stage,
|
|
491
567
|
error=error,
|
|
492
568
|
calls_made=context.calls_made,
|
|
493
569
|
chain_calls=context.chain_calls,
|
|
494
570
|
)
|
|
571
|
+
response.metrics = context.metrics.model_copy()
|
|
572
|
+
return response
|
|
495
573
|
|
|
496
574
|
|
|
497
|
-
def
|
|
498
|
-
|
|
575
|
+
def _saved_chain_result_error(
|
|
576
|
+
error: TypeError | ValidationError | ValueError,
|
|
577
|
+
output_schema: JsonObject,
|
|
578
|
+
) -> str:
|
|
579
|
+
expected = "\n".join(f" - {path}" for path in schema_path_summary(output_schema))
|
|
580
|
+
if isinstance(error, ValidationError):
|
|
581
|
+
actual_lines = []
|
|
582
|
+
for item in error.errors()[:SHAPE_FIELD_LIMIT]:
|
|
583
|
+
location = "$" + "".join(
|
|
584
|
+
f"[{part}]" if isinstance(part, int) else f".{part}" for part in item["loc"]
|
|
585
|
+
)
|
|
586
|
+
actual_lines.append(
|
|
587
|
+
f" - {location}: {item['msg']} (actual {type(item.get('input')).__name__})"
|
|
588
|
+
)
|
|
589
|
+
actual = "\n".join(actual_lines)
|
|
590
|
+
else:
|
|
591
|
+
actual = f" - $: {error}"
|
|
592
|
+
return (
|
|
593
|
+
"Saved chain result violates outputSchema.\n"
|
|
594
|
+
f"Expected output paths:\n{expected}\n"
|
|
595
|
+
f"Actual result paths:\n{actual}"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _elapsed_ms(started: float) -> float:
|
|
600
|
+
return (time.perf_counter() - started) * 1_000
|
|
499
601
|
|
|
500
602
|
|
|
501
|
-
def
|
|
502
|
-
|
|
503
|
-
|
|
603
|
+
def _inspection_byte_limit(result_byte_limit: int) -> int:
|
|
604
|
+
return max(256, min(INSPECT_BYTE_LIMIT, result_byte_limit * 3 // 4))
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _inspect_json(
|
|
608
|
+
value: JsonValue,
|
|
609
|
+
*,
|
|
610
|
+
samples: int,
|
|
611
|
+
max_depth: int,
|
|
612
|
+
byte_limit: int,
|
|
613
|
+
) -> JsonObject:
|
|
504
614
|
summary: JsonObject = {
|
|
505
|
-
|
|
615
|
+
"type": _shape_label(value),
|
|
616
|
+
"serialized_bytes": len(to_json(value)),
|
|
617
|
+
"shape": _describe_shape(value, depth=0, max_depth=max_depth),
|
|
618
|
+
"truncated": _inspection_is_truncated(value, depth=0, max_depth=max_depth),
|
|
506
619
|
}
|
|
507
|
-
|
|
508
|
-
if
|
|
509
|
-
summary["
|
|
510
|
-
|
|
620
|
+
cardinality = _cardinality(value)
|
|
621
|
+
if cardinality is not None:
|
|
622
|
+
summary["cardinality"] = cardinality
|
|
623
|
+
if isinstance(value, dict):
|
|
624
|
+
ranked_fields = heapq.nsmallest(
|
|
625
|
+
SHAPE_FIELD_LIMIT,
|
|
626
|
+
((str(key), len(to_json(item))) for key, item in value.items()),
|
|
627
|
+
key=lambda item: (-item[1], item[0]),
|
|
628
|
+
)
|
|
629
|
+
summary["field_sizes"] = [
|
|
630
|
+
{"path": f"$.{_bounded_key(key, index)}", "bytes": size}
|
|
631
|
+
for index, (key, size) in enumerate(ranked_fields)
|
|
632
|
+
]
|
|
633
|
+
scalar_types: dict[str, set[str]] = {}
|
|
634
|
+
_collect_scalar_types(
|
|
635
|
+
value,
|
|
636
|
+
path="$",
|
|
637
|
+
depth=0,
|
|
638
|
+
max_depth=max_depth,
|
|
639
|
+
result=scalar_types,
|
|
640
|
+
)
|
|
641
|
+
summary["scalar_types"] = JSON_VALUE_ADAPTER.validate_python({
|
|
642
|
+
path: sorted(types) for path, types in sorted(scalar_types.items())
|
|
643
|
+
})
|
|
644
|
+
if isinstance(value, list):
|
|
645
|
+
sampled_items = list(islice(value, INSPECT_COLLECTION_LIMIT))
|
|
646
|
+
object_items = [item for item in sampled_items if isinstance(item, dict)]
|
|
647
|
+
if object_items:
|
|
648
|
+
key_counts = Counter(
|
|
649
|
+
str(key) for item in object_items for key in islice(item, SHAPE_FIELD_LIMIT)
|
|
650
|
+
)
|
|
651
|
+
summary["common_keys"] = JSON_VALUE_ADAPTER.validate_python(
|
|
652
|
+
[
|
|
653
|
+
_bounded_key(key, index)
|
|
654
|
+
for index, (key, count) in enumerate(
|
|
655
|
+
sorted(key_counts.items(), key=lambda item: (-item[1], item[0]))
|
|
656
|
+
)
|
|
657
|
+
if count == len(object_items)
|
|
658
|
+
][:SHAPE_FIELD_LIMIT]
|
|
659
|
+
)
|
|
660
|
+
summary["item_types"] = JSON_VALUE_ADAPTER.validate_python(
|
|
661
|
+
sorted({_shape_label(item) for item in sampled_items})
|
|
662
|
+
)
|
|
663
|
+
if samples > 0:
|
|
664
|
+
raw_samples = value[:samples] if isinstance(value, list) else [value]
|
|
665
|
+
summary["samples"] = [
|
|
666
|
+
_bounded_sample(item, depth=0, max_depth=max_depth) for item in raw_samples
|
|
667
|
+
]
|
|
668
|
+
return _fit_inspection_budget(summary, byte_limit)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _fit_inspection_budget(summary: JsonObject, byte_limit: int) -> JsonObject:
|
|
672
|
+
candidates = [
|
|
673
|
+
summary,
|
|
674
|
+
{**summary, "samples": [], "diagnostic_truncated": True},
|
|
675
|
+
{
|
|
676
|
+
key: value
|
|
677
|
+
for key, value in summary.items()
|
|
678
|
+
if key not in {"samples", "field_sizes", "scalar_types", "common_keys"}
|
|
679
|
+
}
|
|
680
|
+
| {"diagnostic_truncated": True},
|
|
681
|
+
{
|
|
682
|
+
"type": summary["type"],
|
|
683
|
+
"serialized_bytes": summary["serialized_bytes"],
|
|
684
|
+
"shape": summary["type"],
|
|
685
|
+
"truncated": True,
|
|
686
|
+
"diagnostic_truncated": True,
|
|
687
|
+
},
|
|
688
|
+
]
|
|
689
|
+
for candidate in candidates:
|
|
690
|
+
if len(to_json(candidate)) <= byte_limit:
|
|
691
|
+
return candidate
|
|
692
|
+
raise ValueError(f"inspection byte limit must be at least {len(to_json(candidates[-1]))}")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _bounded_key(value: str, index: int) -> str:
|
|
696
|
+
if len(value) <= INSPECT_KEY_LIMIT:
|
|
697
|
+
return value
|
|
698
|
+
suffix = f"…[{index}]"
|
|
699
|
+
return f"{value[: INSPECT_KEY_LIMIT - len(suffix)]}{suffix}"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _collect_scalar_types(
|
|
703
|
+
value: JsonValue,
|
|
704
|
+
*,
|
|
705
|
+
path: str,
|
|
706
|
+
depth: int,
|
|
707
|
+
max_depth: int,
|
|
708
|
+
result: dict[str, set[str]],
|
|
709
|
+
) -> None:
|
|
710
|
+
if len(result) >= SHAPE_FIELD_LIMIT:
|
|
711
|
+
return
|
|
712
|
+
if depth >= max_depth and isinstance(value, (dict, list)):
|
|
713
|
+
return
|
|
714
|
+
if isinstance(value, dict):
|
|
715
|
+
for index, (key, item) in enumerate(islice(value.items(), INSPECT_COLLECTION_LIMIT)):
|
|
716
|
+
_collect_scalar_types(
|
|
717
|
+
item,
|
|
718
|
+
path=f"{path}.{_bounded_key(str(key), index)}",
|
|
719
|
+
depth=depth + 1,
|
|
720
|
+
max_depth=max_depth,
|
|
721
|
+
result=result,
|
|
722
|
+
)
|
|
723
|
+
return
|
|
724
|
+
if isinstance(value, list):
|
|
725
|
+
for item in islice(value, INSPECT_COLLECTION_LIMIT):
|
|
726
|
+
_collect_scalar_types(
|
|
727
|
+
item,
|
|
728
|
+
path=f"{path}[]",
|
|
729
|
+
depth=depth + 1,
|
|
730
|
+
max_depth=max_depth,
|
|
731
|
+
result=result,
|
|
732
|
+
)
|
|
733
|
+
return
|
|
734
|
+
result.setdefault(path, set()).add(_shape_label(value))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _describe_shape(value: JsonValue, *, depth: int, max_depth: int) -> JsonValue:
|
|
738
|
+
if depth >= max_depth:
|
|
739
|
+
return _shape_label(value)
|
|
740
|
+
if isinstance(value, dict):
|
|
741
|
+
fields: JsonObject = {}
|
|
742
|
+
for index, (key, item) in enumerate(islice(value.items(), SHAPE_FIELD_LIMIT)):
|
|
743
|
+
fields[_bounded_key(str(key), index)] = _describe_shape(
|
|
744
|
+
item,
|
|
745
|
+
depth=depth + 1,
|
|
746
|
+
max_depth=max_depth,
|
|
747
|
+
)
|
|
748
|
+
remaining = len(value) - min(len(value), SHAPE_FIELD_LIMIT)
|
|
749
|
+
if remaining > 0:
|
|
750
|
+
fields["<remaining>"] = f"{remaining} more fields"
|
|
751
|
+
return fields
|
|
752
|
+
if isinstance(value, list):
|
|
753
|
+
shapes: list[JsonValue] = []
|
|
754
|
+
seen: set[str] = set()
|
|
755
|
+
for item in islice(value, INSPECT_COLLECTION_LIMIT):
|
|
756
|
+
shape = _describe_shape(item, depth=depth + 1, max_depth=max_depth)
|
|
757
|
+
fingerprint = to_json(shape).decode()
|
|
758
|
+
if fingerprint in seen:
|
|
759
|
+
continue
|
|
760
|
+
seen.add(fingerprint)
|
|
761
|
+
shapes.append(shape)
|
|
762
|
+
return {"items": shapes, "count": len(value)}
|
|
763
|
+
return _shape_label(value)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _bounded_sample(value: JsonValue, *, depth: int, max_depth: int) -> JsonValue:
|
|
767
|
+
if depth >= max_depth:
|
|
768
|
+
return _shape_label(value)
|
|
769
|
+
if isinstance(value, str):
|
|
770
|
+
return value if len(value) <= INSPECT_STRING_LIMIT else f"{value[:INSPECT_STRING_LIMIT]}…"
|
|
771
|
+
if isinstance(value, dict):
|
|
772
|
+
object_sample: JsonObject = {}
|
|
773
|
+
for index, (key, item) in enumerate(islice(value.items(), INSPECT_COLLECTION_LIMIT)):
|
|
774
|
+
object_sample[_bounded_key(str(key), index)] = _bounded_sample(
|
|
775
|
+
item,
|
|
776
|
+
depth=depth + 1,
|
|
777
|
+
max_depth=max_depth,
|
|
778
|
+
)
|
|
779
|
+
if len(value) > INSPECT_COLLECTION_LIMIT:
|
|
780
|
+
object_sample["<remaining>"] = len(value) - INSPECT_COLLECTION_LIMIT
|
|
781
|
+
return object_sample
|
|
782
|
+
if isinstance(value, list):
|
|
783
|
+
list_sample: list[JsonValue] = [
|
|
784
|
+
_bounded_sample(item, depth=depth + 1, max_depth=max_depth)
|
|
785
|
+
for item in islice(value, INSPECT_COLLECTION_LIMIT)
|
|
786
|
+
]
|
|
787
|
+
if len(value) > INSPECT_COLLECTION_LIMIT:
|
|
788
|
+
list_sample.append(f"<{len(value) - INSPECT_COLLECTION_LIMIT} more items>")
|
|
789
|
+
return list_sample
|
|
790
|
+
return value
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _inspection_is_truncated(value: JsonValue, *, depth: int, max_depth: int) -> bool:
|
|
794
|
+
if depth >= max_depth and isinstance(value, (dict, list)):
|
|
795
|
+
return True
|
|
796
|
+
if isinstance(value, dict):
|
|
797
|
+
return len(value) > SHAPE_FIELD_LIMIT or any(
|
|
798
|
+
len(str(key)) > INSPECT_KEY_LIMIT
|
|
799
|
+
or _inspection_is_truncated(item, depth=depth + 1, max_depth=max_depth)
|
|
800
|
+
for key, item in islice(value.items(), SHAPE_FIELD_LIMIT)
|
|
801
|
+
)
|
|
802
|
+
if isinstance(value, list):
|
|
803
|
+
return len(value) > INSPECT_COLLECTION_LIMIT or any(
|
|
804
|
+
_inspection_is_truncated(item, depth=depth + 1, max_depth=max_depth)
|
|
805
|
+
for item in islice(value, INSPECT_COLLECTION_LIMIT)
|
|
806
|
+
)
|
|
807
|
+
if isinstance(value, str):
|
|
808
|
+
return len(value) > INSPECT_STRING_LIMIT
|
|
809
|
+
return False
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _cardinality(value: JsonValue) -> int | None:
|
|
813
|
+
if isinstance(value, (dict, list, str)):
|
|
814
|
+
return len(value)
|
|
815
|
+
return None
|
|
511
816
|
|
|
512
817
|
|
|
513
818
|
def _shape_label(value: JsonValue) -> str:
|
|
@@ -517,7 +822,7 @@ def _shape_label(value: JsonValue) -> str:
|
|
|
517
822
|
return "boolean"
|
|
518
823
|
if isinstance(value, dict):
|
|
519
824
|
return "object"
|
|
520
|
-
if isinstance(value,
|
|
825
|
+
if isinstance(value, list):
|
|
521
826
|
return f"array[{len(value)}]"
|
|
522
827
|
if isinstance(value, str):
|
|
523
828
|
return "string"
|
|
@@ -525,7 +830,7 @@ def _shape_label(value: JsonValue) -> str:
|
|
|
525
830
|
return "integer"
|
|
526
831
|
if isinstance(value, float):
|
|
527
832
|
return "number"
|
|
528
|
-
|
|
833
|
+
assert_never(value)
|
|
529
834
|
|
|
530
835
|
|
|
531
836
|
def _wrap_code(
|
|
@@ -567,9 +872,17 @@ def _rewrite_sdk_calls(code: str, catalog: ToolCatalog) -> str:
|
|
|
567
872
|
tree = ast.parse(code, filename="codemcp_execute.py", mode="exec")
|
|
568
873
|
|
|
569
874
|
class FacadeCallRewriter(ast.NodeTransformer):
|
|
875
|
+
@override
|
|
570
876
|
def visit_Call(self, node: ast.Call) -> ast.AST:
|
|
571
877
|
self.generic_visit(node)
|
|
572
878
|
function = node.func
|
|
879
|
+
if isinstance(function, ast.Name) and function.id == "inspect_json":
|
|
880
|
+
external_call = ast.Call(
|
|
881
|
+
func=ast.Name(id=INSPECT_JSON_EXTERNAL, ctx=ast.Load()),
|
|
882
|
+
args=node.args,
|
|
883
|
+
keywords=node.keywords,
|
|
884
|
+
)
|
|
885
|
+
return ast.copy_location(ast.Await(value=external_call), node)
|
|
573
886
|
if not isinstance(function, ast.Attribute):
|
|
574
887
|
return node
|
|
575
888
|
if not isinstance(function.value, ast.Name):
|