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.
@@ -2,9 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import ast
4
4
  import asyncio
5
+ import os
6
+ import re
5
7
  import textwrap
6
8
  import time
7
- from contextlib import AsyncExitStack, asynccontextmanager
9
+ import uuid
10
+ from contextlib import AsyncExitStack, asynccontextmanager, suppress
8
11
  from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol, cast
9
12
 
10
13
  import pydantic_monty
@@ -25,7 +28,12 @@ from .chains import (
25
28
  SavedChainManifest,
26
29
  ScopedChainStore,
27
30
  )
28
- from .executor import ExecutionContext, ExecutionResponse, MontyExecutor
31
+ from .executor import (
32
+ ExecutionContext,
33
+ ExecutionFailureInfo,
34
+ ExecutionResponse,
35
+ MontyExecutor,
36
+ )
29
37
  from .mcp_config import NormalizedConfig, load_mcp_json, normalize_mcp_config
30
38
  from .models import (
31
39
  ExecutionLimitsView,
@@ -40,9 +48,10 @@ from .models import (
40
48
  UpstreamStatus,
41
49
  UpstreamToolStatus,
42
50
  )
51
+ from .refinement_cache import RefinementCache, ResultReferenceError
43
52
  from .runtime_paths import resolve_runtime_paths
44
53
  from .settings import CodeMcpSettings, load_settings
45
- from .stats import StatsStore
54
+ from .stats import OperationFailure, OperationObservation, StatsStore
46
55
  from .tool_catalog import ToolCatalog, schema_path_summary
47
56
 
48
57
  if TYPE_CHECKING:
@@ -55,6 +64,7 @@ if TYPE_CHECKING:
55
64
  from .runtime_paths import RuntimePaths
56
65
 
57
66
  MAX_INSPECT_CALLS = 20
67
+ UPSTREAM_FAILURE_MESSAGE_LIMIT = 500
58
68
  type ServerConfig = StdioMCPServer | RemoteMCPServer
59
69
  type JsonObject = json_types.JsonObject
60
70
  type JsonValue = json_types.JsonValue
@@ -127,7 +137,19 @@ class ServerHandle:
127
137
  ) -> mcp_types.CallToolResult:
128
138
  async with self._lock:
129
139
  client = await self._connect_locked()
130
- return await client.call_tool_mcp(name, arguments, timeout=timeout_seconds)
140
+ try:
141
+ return await client.call_tool_mcp(name, arguments, timeout=timeout_seconds)
142
+ except Exception as error:
143
+ if is_unusable_connection_error(error):
144
+ await self._discard_client(client)
145
+ raise
146
+
147
+ async def _discard_client(self, client: Client[ClientTransport]) -> None:
148
+ async with self._lock:
149
+ if self._client is not client:
150
+ return
151
+ with suppress(Exception):
152
+ await self._disconnect_locked()
131
153
 
132
154
  async def close(self) -> None:
133
155
  async with self._lock:
@@ -171,24 +193,30 @@ class SaveChainHandler(Protocol):
171
193
  code: str,
172
194
  input_schema: JsonObject,
173
195
  output_schema: JsonObject,
196
+ trace_id: str,
174
197
  ) -> SaveChainResponse: ...
175
198
 
176
199
 
177
200
  class SavedChainHandlers(NamedTuple):
178
- execute: Callable[[str, JsonObject], Awaitable[ExecutionResponse]]
201
+ execute: Callable[[str, JsonObject, str], Awaitable[ExecutionResponse]]
179
202
  save: SaveChainHandler
180
- list: Callable[[], ChainListResponse]
181
- set_enabled: Callable[[str, ChainScope, bool], Awaitable[ChainStatusView]]
182
- revalidate: Callable[[str, ChainScope], Awaitable[ChainStatusView]]
183
- delete: Callable[[str, ChainScope], Awaitable[ChainListResponse]]
203
+ list: Callable[[str], ChainListResponse]
204
+ set_enabled: Callable[[str, ChainScope, bool, str], Awaitable[ChainStatusView]]
205
+ revalidate: Callable[[str, ChainScope, str], Awaitable[ChainStatusView]]
206
+ delete: Callable[[str, ChainScope, str], Awaitable[ChainListResponse]]
184
207
 
185
208
 
186
209
  class SavedChainRuntime:
187
210
  def __init__(self, handlers: SavedChainHandlers) -> None:
188
211
  self.handlers = handlers
189
212
 
190
- async def execute(self, name: str, arguments: JsonObject) -> ExecutionResponse:
191
- return await self.handlers.execute(name, arguments)
213
+ async def execute(
214
+ self,
215
+ name: str,
216
+ arguments: JsonObject,
217
+ trace_id: str,
218
+ ) -> ExecutionResponse:
219
+ return await self.handlers.execute(name, arguments, trace_id)
192
220
 
193
221
  async def save(
194
222
  self,
@@ -199,6 +227,7 @@ class SavedChainRuntime:
199
227
  code: str,
200
228
  input_schema: JsonObject,
201
229
  output_schema: JsonObject,
230
+ trace_id: str,
202
231
  ) -> SaveChainResponse:
203
232
  return await self.handlers.save(
204
233
  scope=scope,
@@ -207,24 +236,36 @@ class SavedChainRuntime:
207
236
  code=code,
208
237
  input_schema=input_schema,
209
238
  output_schema=output_schema,
239
+ trace_id=trace_id,
210
240
  )
211
241
 
212
- def list(self) -> ChainListResponse:
213
- return self.handlers.list()
242
+ def list(self, trace_id: str) -> ChainListResponse:
243
+ return self.handlers.list(trace_id)
214
244
 
215
245
  async def set_enabled(
216
246
  self,
217
247
  name: str,
218
248
  scope: ChainScope,
219
249
  enabled: bool,
250
+ trace_id: str,
220
251
  ) -> ChainStatusView:
221
- return await self.handlers.set_enabled(name, scope, enabled)
252
+ return await self.handlers.set_enabled(name, scope, enabled, trace_id)
222
253
 
223
- async def revalidate(self, name: str, scope: ChainScope) -> ChainStatusView:
224
- return await self.handlers.revalidate(name, scope)
254
+ async def revalidate(
255
+ self,
256
+ name: str,
257
+ scope: ChainScope,
258
+ trace_id: str,
259
+ ) -> ChainStatusView:
260
+ return await self.handlers.revalidate(name, scope, trace_id)
225
261
 
226
- async def delete(self, name: str, scope: ChainScope) -> ChainListResponse:
227
- return await self.handlers.delete(name, scope)
262
+ async def delete(
263
+ self,
264
+ name: str,
265
+ scope: ChainScope,
266
+ trace_id: str,
267
+ ) -> ChainListResponse:
268
+ return await self.handlers.delete(name, scope, trace_id)
228
269
 
229
270
 
230
271
  class GatewayRuntime:
@@ -248,7 +289,11 @@ class GatewayRuntime:
248
289
  self.chain_store = chain_store
249
290
  self.catalog = catalog
250
291
  self.executor = executor
251
- self.stats_store = StatsStore(settings_path.parent / "stats.json")
292
+ self.refinement_cache = RefinementCache()
293
+ self.stats_store = StatsStore(
294
+ settings_path.parent / "stats.sqlite3",
295
+ package_version=os.environ.get("PI_CODEMCP_PACKAGE_VERSION", "unknown"),
296
+ )
252
297
  for handle in handles.values():
253
298
  self.stats_store.record_cache(hit=handle.tools is not None)
254
299
  self.chains = SavedChainRuntime(
@@ -315,6 +360,7 @@ class GatewayRuntime:
315
360
  )
316
361
 
317
362
  async def close(self) -> None:
363
+ self.refinement_cache.clear()
318
364
  await asyncio.gather(
319
365
  *(handle.close() for handle in self.handles.values()),
320
366
  return_exceptions=True,
@@ -329,6 +375,8 @@ class GatewayRuntime:
329
375
  detail: SearchDetail = "signatures",
330
376
  mode: SearchMode = "search",
331
377
  cursor: int = 0,
378
+ *,
379
+ trace_id: str,
332
380
  ) -> SearchResponse:
333
381
  started = time.perf_counter()
334
382
  input_bytes = len(
@@ -346,18 +394,26 @@ class GatewayRuntime:
346
394
  except BaseException:
347
395
  self.stats_store.record_operation(
348
396
  "search",
349
- duration_ms=_elapsed_ms(started),
350
- success=False,
351
- failure_stage="error",
352
- input_bytes=input_bytes,
397
+ OperationObservation(
398
+ duration_ms=_elapsed_ms(started),
399
+ success=False,
400
+ input_bytes=input_bytes,
401
+ failure=OperationFailure(
402
+ stage="error",
403
+ subtype="search_error",
404
+ trace_id=trace_id,
405
+ ),
406
+ ),
353
407
  )
354
408
  raise
355
409
  self.stats_store.record_operation(
356
410
  "search",
357
- duration_ms=_elapsed_ms(started),
358
- success=True,
359
- input_bytes=input_bytes,
360
- output_bytes=len(to_json(response.model_dump(mode="json"))),
411
+ OperationObservation(
412
+ duration_ms=_elapsed_ms(started),
413
+ success=True,
414
+ input_bytes=input_bytes,
415
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
416
+ ),
361
417
  )
362
418
  return response
363
419
 
@@ -450,25 +506,37 @@ class GatewayRuntime:
450
506
  results=results,
451
507
  )
452
508
 
453
- async def inspect(self, calls: list[str]) -> InspectResponse:
509
+ async def inspect(
510
+ self,
511
+ calls: list[str],
512
+ trace_id: str,
513
+ ) -> InspectResponse:
454
514
  started = time.perf_counter()
455
515
  try:
456
516
  response = await self._inspect_impl(calls)
457
517
  except BaseException:
458
518
  self.stats_store.record_operation(
459
519
  "inspect",
460
- duration_ms=_elapsed_ms(started),
461
- success=False,
462
- failure_stage="error",
463
- input_bytes=len(to_json(calls)),
520
+ OperationObservation(
521
+ duration_ms=_elapsed_ms(started),
522
+ success=False,
523
+ input_bytes=len(to_json(calls)),
524
+ failure=OperationFailure(
525
+ stage="error",
526
+ subtype="inspect_error",
527
+ trace_id=trace_id,
528
+ ),
529
+ ),
464
530
  )
465
531
  raise
466
532
  self.stats_store.record_operation(
467
533
  "inspect",
468
- duration_ms=_elapsed_ms(started),
469
- success=True,
470
- input_bytes=len(to_json(calls)),
471
- output_bytes=len(to_json(response.model_dump(mode="json"))),
534
+ OperationObservation(
535
+ duration_ms=_elapsed_ms(started),
536
+ success=True,
537
+ input_bytes=len(to_json(calls)),
538
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
539
+ ),
472
540
  )
473
541
  return response
474
542
 
@@ -521,9 +589,37 @@ class GatewayRuntime:
521
589
  result_limit_bytes=settings.result_byte_limit,
522
590
  )
523
591
 
524
- async def execute(self, code: str) -> ExecutionResponse:
592
+ async def execute(
593
+ self,
594
+ code: str,
595
+ trace_id: str,
596
+ input_ref: str | None = None,
597
+ ) -> ExecutionResponse:
525
598
  started = time.perf_counter()
526
- input_bytes = len(code.encode())
599
+ input_bytes = len(code.encode()) + len((input_ref or "").encode())
600
+ input_value: JsonValue = None
601
+ if input_ref is not None:
602
+ try:
603
+ input_value = self.refinement_cache.resolve(input_ref)
604
+ except ResultReferenceError as error:
605
+ message = str(error)
606
+ response = ExecutionResponse.failed(
607
+ failure_stage="preflight",
608
+ error=message,
609
+ failure=ExecutionFailureInfo(
610
+ kind="result_reference",
611
+ retryable=False,
612
+ message=message,
613
+ ),
614
+ )
615
+ self._record_execution(
616
+ "execute",
617
+ started,
618
+ input_bytes,
619
+ response,
620
+ trace_id,
621
+ )
622
+ return response
527
623
  discovery_started = time.perf_counter()
528
624
  try:
529
625
  await self._ensure_servers_discovered(self._required_servers_for_code(code))
@@ -534,26 +630,39 @@ class GatewayRuntime:
534
630
  started,
535
631
  input_bytes,
536
632
  failure_stage="discovery",
633
+ failure_subtype="discovery",
634
+ trace_id=trace_id,
537
635
  )
538
636
  raise
539
637
  self.stats_store.record_phase("discovery", _elapsed_ms(discovery_started))
540
638
  self.executor.update_catalog(self.catalog)
541
639
  try:
542
- response = await self.executor.execute_graph(code, self._dispatch)
640
+ response = await self.executor.execute_graph(
641
+ code,
642
+ self._dispatch,
643
+ input_value=input_value,
644
+ retain_result=self.refinement_cache.retain,
645
+ )
543
646
  except BaseException as error:
647
+ cancelled = isinstance(error, asyncio.CancelledError)
544
648
  self._record_operation_exception(
545
649
  "execute",
546
650
  started,
547
651
  input_bytes,
548
- failure_stage=(
549
- "cancelled" if isinstance(error, asyncio.CancelledError) else "error"
550
- ),
652
+ failure_stage="cancelled" if cancelled else "error",
653
+ failure_subtype="cancelled" if cancelled else "internal_error",
654
+ trace_id=trace_id,
551
655
  )
552
656
  raise
553
- self._record_execution("execute", started, input_bytes, response)
657
+ self._record_execution("execute", started, input_bytes, response, trace_id)
554
658
  return response
555
659
 
556
- async def _execute_chain(self, name: str, arguments: JsonObject) -> ExecutionResponse:
660
+ async def _execute_chain(
661
+ self,
662
+ name: str,
663
+ arguments: JsonObject,
664
+ trace_id: str,
665
+ ) -> ExecutionResponse:
557
666
  started = time.perf_counter()
558
667
  input_bytes = len(to_json(arguments))
559
668
  try:
@@ -564,19 +673,27 @@ class GatewayRuntime:
564
673
  started,
565
674
  input_bytes,
566
675
  failure_stage="preflight",
676
+ failure_subtype="saved_chain_lookup",
677
+ trace_id=trace_id,
567
678
  )
568
679
  raise
569
680
  if not chain.enabled:
570
- response = ExecutionResponse(
571
- ok=False,
681
+ message = f"Saved chain is disabled: {name}"
682
+ response = ExecutionResponse.failed(
572
683
  failure_stage="preflight",
573
- error=f"Saved chain is disabled: {name}",
684
+ error=message,
685
+ failure=ExecutionFailureInfo(
686
+ kind="preflight",
687
+ retryable=False,
688
+ message=message,
689
+ ),
574
690
  )
575
691
  self._record_execution(
576
692
  "execute_chain",
577
693
  started,
578
694
  input_bytes,
579
695
  response,
696
+ trace_id,
580
697
  )
581
698
  return response
582
699
  discovery_started = time.perf_counter()
@@ -589,20 +706,28 @@ class GatewayRuntime:
589
706
  started,
590
707
  input_bytes,
591
708
  failure_stage="discovery",
709
+ failure_subtype="discovery",
710
+ trace_id=trace_id,
592
711
  )
593
712
  raise
594
713
  self.stats_store.record_phase("discovery", _elapsed_ms(discovery_started))
595
714
  self.executor.update_catalog(self.catalog)
596
715
  try:
597
- response = await self.executor.execute_saved_chain(chain, arguments, self._dispatch)
716
+ response = await self.executor.execute_saved_chain(
717
+ chain,
718
+ arguments,
719
+ self._dispatch,
720
+ retain_result=self.refinement_cache.retain,
721
+ )
598
722
  except BaseException as error:
723
+ cancelled = isinstance(error, asyncio.CancelledError)
599
724
  self._record_operation_exception(
600
725
  "execute_chain",
601
726
  started,
602
727
  input_bytes,
603
- failure_stage=(
604
- "cancelled" if isinstance(error, asyncio.CancelledError) else "error"
605
- ),
728
+ failure_stage="cancelled" if cancelled else "error",
729
+ failure_subtype="cancelled" if cancelled else "internal_error",
730
+ trace_id=trace_id,
606
731
  )
607
732
  raise
608
733
  self._record_execution(
@@ -610,6 +735,7 @@ class GatewayRuntime:
610
735
  started,
611
736
  input_bytes,
612
737
  response,
738
+ trace_id,
613
739
  )
614
740
  return response
615
741
 
@@ -620,13 +746,21 @@ class GatewayRuntime:
620
746
  input_bytes: int,
621
747
  *,
622
748
  failure_stage: str,
749
+ failure_subtype: str,
750
+ trace_id: str,
623
751
  ) -> None:
624
752
  self.stats_store.record_operation(
625
753
  operation,
626
- duration_ms=_elapsed_ms(started),
627
- success=False,
628
- failure_stage=failure_stage,
629
- input_bytes=input_bytes,
754
+ OperationObservation(
755
+ duration_ms=_elapsed_ms(started),
756
+ success=False,
757
+ input_bytes=input_bytes,
758
+ failure=OperationFailure(
759
+ stage=failure_stage,
760
+ subtype=failure_subtype,
761
+ trace_id=trace_id,
762
+ ),
763
+ ),
630
764
  )
631
765
 
632
766
  def _record_execution(
@@ -635,16 +769,28 @@ class GatewayRuntime:
635
769
  started: float,
636
770
  input_bytes: int,
637
771
  response: ExecutionResponse,
772
+ trace_id: str,
638
773
  ) -> None:
774
+ failure = (
775
+ OperationFailure(
776
+ stage=response.failure_stage or "error",
777
+ subtype=_execution_failure_subtype(response),
778
+ trace_id=trace_id,
779
+ )
780
+ if not response.ok
781
+ else None
782
+ )
639
783
  self.stats_store.record_operation(
640
784
  operation,
641
- duration_ms=_elapsed_ms(started),
642
- success=response.ok,
643
- failure_stage=response.failure_stage,
644
- input_bytes=input_bytes,
645
- output_bytes=response.metrics.result_bytes,
646
- calls=response.calls_made,
647
- chain_calls=response.chain_calls,
785
+ OperationObservation(
786
+ duration_ms=_elapsed_ms(started),
787
+ success=response.ok,
788
+ input_bytes=input_bytes,
789
+ output_bytes=response.metrics.result_bytes,
790
+ calls=response.calls_made,
791
+ chain_calls=response.chain_calls,
792
+ failure=failure,
793
+ ),
648
794
  )
649
795
  metrics = response.metrics
650
796
  for phase, duration in (
@@ -664,6 +810,7 @@ class GatewayRuntime:
664
810
  code: str,
665
811
  input_schema: JsonObject,
666
812
  output_schema: JsonObject,
813
+ trace_id: str,
667
814
  ) -> SaveChainResponse:
668
815
  started = time.perf_counter()
669
816
  input_bytes = len(
@@ -688,18 +835,26 @@ class GatewayRuntime:
688
835
  except BaseException:
689
836
  self.stats_store.record_operation(
690
837
  "save_chain",
691
- duration_ms=_elapsed_ms(started),
692
- success=False,
693
- failure_stage="validation",
694
- input_bytes=input_bytes,
838
+ OperationObservation(
839
+ duration_ms=_elapsed_ms(started),
840
+ success=False,
841
+ input_bytes=input_bytes,
842
+ failure=OperationFailure(
843
+ stage="validation",
844
+ subtype="saved_chain_validation",
845
+ trace_id=trace_id,
846
+ ),
847
+ ),
695
848
  )
696
849
  raise
697
850
  self.stats_store.record_operation(
698
851
  "save_chain",
699
- duration_ms=_elapsed_ms(started),
700
- success=True,
701
- input_bytes=input_bytes,
702
- output_bytes=len(to_json(response.model_dump(mode="json"))),
852
+ OperationObservation(
853
+ duration_ms=_elapsed_ms(started),
854
+ success=True,
855
+ input_bytes=input_bytes,
856
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
857
+ ),
703
858
  )
704
859
  return response
705
860
 
@@ -768,14 +923,31 @@ class GatewayRuntime:
768
923
  created=previous is None,
769
924
  )
770
925
 
771
- def _list_chains(self) -> ChainListResponse:
926
+ def _list_chains(self, trace_id: str) -> ChainListResponse:
772
927
  started = time.perf_counter()
773
- response = ChainListResponse(chains=self._chain_views())
928
+ try:
929
+ response = ChainListResponse(chains=self._chain_views())
930
+ except BaseException:
931
+ self.stats_store.record_operation(
932
+ "list_chains",
933
+ OperationObservation(
934
+ duration_ms=_elapsed_ms(started),
935
+ success=False,
936
+ failure=OperationFailure(
937
+ stage="error",
938
+ subtype="saved_chain_list",
939
+ trace_id=trace_id,
940
+ ),
941
+ ),
942
+ )
943
+ raise
774
944
  self.stats_store.record_operation(
775
945
  "list_chains",
776
- duration_ms=_elapsed_ms(started),
777
- success=True,
778
- output_bytes=len(to_json(response.model_dump(mode="json"))),
946
+ OperationObservation(
947
+ duration_ms=_elapsed_ms(started),
948
+ success=True,
949
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
950
+ ),
779
951
  )
780
952
  return response
781
953
 
@@ -784,6 +956,7 @@ class GatewayRuntime:
784
956
  name: str,
785
957
  scope: ChainScope,
786
958
  enabled: bool,
959
+ trace_id: str,
787
960
  ) -> ChainStatusView:
788
961
  started = time.perf_counter()
789
962
  try:
@@ -793,36 +966,57 @@ class GatewayRuntime:
793
966
  except BaseException:
794
967
  self.stats_store.record_operation(
795
968
  "set_chain_enabled",
796
- duration_ms=_elapsed_ms(started),
797
- success=False,
798
- failure_stage="error",
969
+ OperationObservation(
970
+ duration_ms=_elapsed_ms(started),
971
+ success=False,
972
+ failure=OperationFailure(
973
+ stage="error",
974
+ subtype="saved_chain_update",
975
+ trace_id=trace_id,
976
+ ),
977
+ ),
799
978
  )
800
979
  raise
801
980
  self.stats_store.record_operation(
802
981
  "set_chain_enabled",
803
- duration_ms=_elapsed_ms(started),
804
- success=True,
805
- output_bytes=len(to_json(response.model_dump(mode="json"))),
982
+ OperationObservation(
983
+ duration_ms=_elapsed_ms(started),
984
+ success=True,
985
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
986
+ ),
806
987
  )
807
988
  return response
808
989
 
809
- async def _revalidate_chain(self, name: str, scope: ChainScope) -> ChainStatusView:
990
+ async def _revalidate_chain(
991
+ self,
992
+ name: str,
993
+ scope: ChainScope,
994
+ trace_id: str,
995
+ ) -> ChainStatusView:
810
996
  started = time.perf_counter()
811
997
  try:
812
998
  response = await self._revalidate_chain_impl(name, scope)
813
999
  except BaseException:
814
1000
  self.stats_store.record_operation(
815
1001
  "revalidate_chain",
816
- duration_ms=_elapsed_ms(started),
817
- success=False,
818
- failure_stage="validation",
1002
+ OperationObservation(
1003
+ duration_ms=_elapsed_ms(started),
1004
+ success=False,
1005
+ failure=OperationFailure(
1006
+ stage="validation",
1007
+ subtype="saved_chain_validation",
1008
+ trace_id=trace_id,
1009
+ ),
1010
+ ),
819
1011
  )
820
1012
  raise
821
1013
  self.stats_store.record_operation(
822
1014
  "revalidate_chain",
823
- duration_ms=_elapsed_ms(started),
824
- success=True,
825
- output_bytes=len(to_json(response.model_dump(mode="json"))),
1015
+ OperationObservation(
1016
+ duration_ms=_elapsed_ms(started),
1017
+ success=True,
1018
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
1019
+ ),
826
1020
  )
827
1021
  return response
828
1022
 
@@ -864,27 +1058,45 @@ class GatewayRuntime:
864
1058
  await self._rebuild_catalog()
865
1059
  return self._chain_view(name, scope)
866
1060
 
867
- async def _delete_chain(self, name: str, scope: ChainScope) -> ChainListResponse:
1061
+ async def _delete_chain(
1062
+ self,
1063
+ name: str,
1064
+ scope: ChainScope,
1065
+ trace_id: str,
1066
+ ) -> ChainListResponse:
868
1067
  started = time.perf_counter()
869
1068
  try:
870
- response = await self._delete_chain_impl(name, scope)
1069
+ response = await self._delete_chain_impl(name, scope, trace_id)
871
1070
  except BaseException:
872
1071
  self.stats_store.record_operation(
873
1072
  "delete_chain",
874
- duration_ms=_elapsed_ms(started),
875
- success=False,
876
- failure_stage="error",
1073
+ OperationObservation(
1074
+ duration_ms=_elapsed_ms(started),
1075
+ success=False,
1076
+ failure=OperationFailure(
1077
+ stage="error",
1078
+ subtype="saved_chain_delete",
1079
+ trace_id=trace_id,
1080
+ ),
1081
+ ),
877
1082
  )
878
1083
  raise
879
1084
  self.stats_store.record_operation(
880
1085
  "delete_chain",
881
- duration_ms=_elapsed_ms(started),
882
- success=True,
883
- output_bytes=len(to_json(response.model_dump(mode="json"))),
1086
+ OperationObservation(
1087
+ duration_ms=_elapsed_ms(started),
1088
+ success=True,
1089
+ output_bytes=len(to_json(response.model_dump(mode="json"))),
1090
+ ),
884
1091
  )
885
1092
  return response
886
1093
 
887
- async def _delete_chain_impl(self, name: str, scope: ChainScope) -> ChainListResponse:
1094
+ async def _delete_chain_impl(
1095
+ self,
1096
+ name: str,
1097
+ scope: ChainScope,
1098
+ trace_id: str,
1099
+ ) -> ChainListResponse:
888
1100
  effective = self.chain_store.get(name)
889
1101
  called_by = self._called_by().get(name, []) if effective.scope == scope else []
890
1102
  if called_by:
@@ -893,7 +1105,7 @@ class GatewayRuntime:
893
1105
  )
894
1106
  self.chain_store.delete(scope, name)
895
1107
  await self._rebuild_catalog()
896
- return self._list_chains()
1108
+ return self._list_chains(trace_id)
897
1109
 
898
1110
  async def _dispatch(
899
1111
  self,
@@ -919,7 +1131,22 @@ class GatewayRuntime:
919
1131
  ),
920
1132
  )
921
1133
  normalized = context.catalog.normalize_result(public_name, result)
922
- except BaseException:
1134
+ except BaseException as error:
1135
+ if isinstance(error, Exception) and context.failure is None:
1136
+ status = _upstream_status(error)
1137
+ transport_failure = is_unusable_connection_error(error)
1138
+ context.failure = ExecutionFailureInfo(
1139
+ kind="upstream_transport" if transport_failure else "upstream",
1140
+ server=spec.server,
1141
+ tool=spec.backend_name,
1142
+ retryable=transport_failure or _retryable_status(status),
1143
+ status=status,
1144
+ message=(
1145
+ "Upstream connection closed"
1146
+ if transport_failure
1147
+ else _upstream_failure_message(error)
1148
+ ),
1149
+ )
923
1150
  self.stats_store.record_upstream(
924
1151
  spec.server,
925
1152
  spec.backend_name,
@@ -1213,6 +1440,104 @@ def _elapsed_ms(started: float) -> float:
1213
1440
  return (time.perf_counter() - started) * 1_000
1214
1441
 
1215
1442
 
1443
+ def new_trace_id(source: str) -> str:
1444
+ return f"{source}:{uuid.uuid4()}"
1445
+
1446
+
1447
+ def _exception_messages(error: BaseException) -> list[str]:
1448
+ messages: list[str] = []
1449
+ pending = [error]
1450
+ seen: set[int] = set()
1451
+ while pending:
1452
+ current = pending.pop()
1453
+ identity = id(current)
1454
+ if identity in seen:
1455
+ continue
1456
+ seen.add(identity)
1457
+ rendered = str(current)
1458
+ messages.append(
1459
+ f"{type(current).__name__}: {rendered}" if rendered else type(current).__name__
1460
+ )
1461
+ if current.__cause__ is not None:
1462
+ pending.append(current.__cause__)
1463
+ if current.__context__ is not None:
1464
+ pending.append(current.__context__)
1465
+ if isinstance(current, BaseExceptionGroup):
1466
+ pending.extend(current.exceptions)
1467
+ return messages
1468
+
1469
+
1470
+ def is_unusable_connection_error(error: BaseException) -> bool:
1471
+ markers = (
1472
+ "connection closed",
1473
+ "connection reset",
1474
+ "broken pipe",
1475
+ "brokenresourceerror",
1476
+ "closedresourceerror",
1477
+ "endofstream",
1478
+ "client is not connected",
1479
+ "session terminated",
1480
+ "transport is closed",
1481
+ )
1482
+ return any(
1483
+ marker in message.lower() for message in _exception_messages(error) for marker in markers
1484
+ )
1485
+
1486
+
1487
+ def _upstream_status(error: BaseException) -> int | None:
1488
+ for message in _exception_messages(error):
1489
+ match = re.search(
1490
+ r"(?i)\b(?:http(?: status)?|status(?: code)?)[\"'\s:=]+([1-5]\d{2})\b",
1491
+ message,
1492
+ )
1493
+ if match is not None:
1494
+ return int(match.group(1))
1495
+ return None
1496
+
1497
+
1498
+ def _retryable_status(status: int | None) -> bool:
1499
+ return status in {408, 409, 425, 429, 500, 502, 503, 504}
1500
+
1501
+
1502
+ def _upstream_failure_message(error: BaseException) -> str:
1503
+ message = next((item.strip() for item in _exception_messages(error) if item.strip()), "")
1504
+ compact = " ".join((message or type(error).__name__).split())
1505
+ if len(compact) <= UPSTREAM_FAILURE_MESSAGE_LIMIT:
1506
+ return compact
1507
+ return f"{compact[: UPSTREAM_FAILURE_MESSAGE_LIMIT - 1].rstrip()}…"
1508
+
1509
+
1510
+ def _execution_failure_subtype(response: ExecutionResponse) -> str:
1511
+ failure = response.failure
1512
+ if failure is not None and failure.kind in {
1513
+ "result_reference",
1514
+ "sandbox_timeout",
1515
+ "upstream",
1516
+ "upstream_transport",
1517
+ "upstream_timeout",
1518
+ }:
1519
+ return failure.kind
1520
+ stage = response.failure_stage or "error"
1521
+ error = response.error or ""
1522
+ if stage == "preflight":
1523
+ return "preflight_typecheck"
1524
+ if stage == "result" and error.startswith("Returned value is "):
1525
+ return "result_too_large"
1526
+ if stage == "result":
1527
+ return "result_validation"
1528
+ if stage == "timeout":
1529
+ return "upstream_timeout"
1530
+ if stage == "cancelled":
1531
+ return "cancelled"
1532
+ if "Connection closed" in error:
1533
+ return "upstream_transport"
1534
+ if error.startswith("ValueError: inspect_json"):
1535
+ return "sandbox_contract"
1536
+ if stage == "runtime":
1537
+ return "upstream_runtime"
1538
+ return "internal_error"
1539
+
1540
+
1216
1541
  def _discovery_error_message(error: Exception) -> str:
1217
1542
  return str(error).strip() or type(error).__name__
1218
1543
 
@@ -1293,6 +1618,8 @@ mcp = FastMCP(
1293
1618
 
1294
1619
  @mcp.tool
1295
1620
  async def search(
1621
+ trace_id: str,
1622
+ *,
1296
1623
  query: str | None = None,
1297
1624
  limit: int = 5,
1298
1625
  server: str | None = None,
@@ -1301,13 +1628,21 @@ async def search(
1301
1628
  cursor: int = 0,
1302
1629
  ) -> SearchResponse:
1303
1630
  """Search or page through configured upstream MCP tools and saved chains."""
1304
- return await _require_runtime().search(query, limit, server, detail, mode, cursor)
1631
+ return await _require_runtime().search(
1632
+ query,
1633
+ limit,
1634
+ server,
1635
+ detail,
1636
+ mode,
1637
+ cursor,
1638
+ trace_id=trace_id,
1639
+ )
1305
1640
 
1306
1641
 
1307
1642
  @mcp.tool
1308
- async def inspect(calls: list[str]) -> InspectResponse:
1643
+ async def inspect(trace_id: str, calls: list[str]) -> InspectResponse:
1309
1644
  """Return exact typed SDK stubs for selected call identifiers."""
1310
- return await _require_runtime().inspect(calls)
1645
+ return await _require_runtime().inspect(calls, trace_id)
1311
1646
 
1312
1647
 
1313
1648
  @mcp.tool
@@ -1324,22 +1659,29 @@ async def reload_settings() -> StatusResponse:
1324
1659
 
1325
1660
  @mcp.tool
1326
1661
  async def set_chain_enabled(
1662
+ trace_id: str,
1327
1663
  name: str,
1328
1664
  scope: ChainScope,
1329
1665
  enabled: bool,
1330
1666
  ) -> ChainStatusView:
1331
1667
  """Enable or disable one saved chain in its storage scope."""
1332
- return await _require_runtime().chains.set_enabled(name, scope, enabled)
1668
+ return await _require_runtime().chains.set_enabled(name, scope, enabled, trace_id)
1333
1669
 
1334
1670
 
1335
1671
  @mcp.tool
1336
- async def execute(code: str) -> ExecutionResponse:
1672
+ async def execute(
1673
+ trace_id: str,
1674
+ code: str,
1675
+ input_ref: str | None = None,
1676
+ ) -> ExecutionResponse:
1337
1677
  """Type-check and run one sandboxed Python MCP SDK chain."""
1338
- return await _require_runtime().execute(code)
1678
+ return await _require_runtime().execute(code, trace_id, input_ref)
1339
1679
 
1340
1680
 
1341
1681
  @mcp.tool
1342
1682
  async def save_chain(
1683
+ trace_id: str,
1684
+ *,
1343
1685
  name: str,
1344
1686
  description: str,
1345
1687
  code: str,
@@ -1355,38 +1697,51 @@ async def save_chain(
1355
1697
  code=code,
1356
1698
  input_schema=json_types.JSON_OBJECT_ADAPTER.validate_python(input_schema),
1357
1699
  output_schema=json_types.JSON_OBJECT_ADAPTER.validate_python(output_schema),
1700
+ trace_id=trace_id,
1358
1701
  )
1359
1702
 
1360
1703
 
1361
1704
  @mcp.tool
1362
- def list_chains() -> ChainListResponse:
1705
+ def list_chains(trace_id: str) -> ChainListResponse:
1363
1706
  """List saved chains and their dependency state."""
1364
- return _require_runtime().chains.list()
1707
+ return _require_runtime().chains.list(trace_id)
1365
1708
 
1366
1709
 
1367
1710
  @mcp.tool
1368
- async def execute_chain(name: str, arguments: JsonObject) -> ExecutionResponse:
1711
+ async def execute_chain(
1712
+ trace_id: str,
1713
+ name: str,
1714
+ arguments: JsonObject,
1715
+ ) -> ExecutionResponse:
1369
1716
  """Execute one saved chain through its typed input contract."""
1370
1717
  validated_arguments = json_types.JSON_OBJECT_ADAPTER.validate_python(arguments)
1371
- return await _require_runtime().chains.execute(name, validated_arguments)
1718
+ return await _require_runtime().chains.execute(name, validated_arguments, trace_id)
1372
1719
 
1373
1720
 
1374
1721
  @mcp.tool
1375
- async def revalidate_chain(name: str, scope: ChainScope) -> ChainStatusView:
1722
+ async def revalidate_chain(
1723
+ trace_id: str,
1724
+ name: str,
1725
+ scope: ChainScope,
1726
+ ) -> ChainStatusView:
1376
1727
  """Revalidate one scoped saved chain against the current callable catalog."""
1377
- return await _require_runtime().chains.revalidate(name, scope)
1728
+ return await _require_runtime().chains.revalidate(name, scope, trace_id)
1378
1729
 
1379
1730
 
1380
1731
  @mcp.tool
1381
- async def delete_chain(name: str, scope: ChainScope) -> ChainListResponse:
1732
+ async def delete_chain(
1733
+ trace_id: str,
1734
+ name: str,
1735
+ scope: ChainScope,
1736
+ ) -> ChainListResponse:
1382
1737
  """Delete an unused saved chain from its storage scope."""
1383
- return await _require_runtime().chains.delete(name, scope)
1738
+ return await _require_runtime().chains.delete(name, scope, trace_id)
1384
1739
 
1385
1740
 
1386
1741
  @mcp.tool
1387
- def stats() -> JsonObject:
1742
+ async def stats() -> JsonObject:
1388
1743
  """Return bounded local CodeMCP telemetry rollups."""
1389
- return _require_runtime().stats_store.snapshot()
1744
+ return await _require_runtime().stats_store.snapshot()
1390
1745
 
1391
1746
 
1392
1747
  @mcp.tool