nexus-coding-agent 0.1.0__py3-none-any.whl

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.
Files changed (131) hide show
  1. nexus/__init__.py +4 -0
  2. nexus/application/__init__.py +2 -0
  3. nexus/application/approval_service.py +130 -0
  4. nexus/application/diff_service.py +177 -0
  5. nexus/application/event_publisher.py +180 -0
  6. nexus/application/execution_context.py +29 -0
  7. nexus/application/execution_ledger.py +126 -0
  8. nexus/application/model_call_context.py +29 -0
  9. nexus/application/observed_model_gateway.py +194 -0
  10. nexus/application/plan_approval_service.py +250 -0
  11. nexus/application/planning.py +668 -0
  12. nexus/application/runtime.py +624 -0
  13. nexus/application/session_service.py +411 -0
  14. nexus/application/structured_output.py +96 -0
  15. nexus/application/telemetry.py +396 -0
  16. nexus/application/telemetry_subscriber.py +216 -0
  17. nexus/application/tool_runtime.py +496 -0
  18. nexus/application/tracing_dispatcher.py +283 -0
  19. nexus/application/validation.py +293 -0
  20. nexus/config/__init__.py +17 -0
  21. nexus/config/loader.py +253 -0
  22. nexus/config/models.py +310 -0
  23. nexus/context/__init__.py +6 -0
  24. nexus/context/budget.py +24 -0
  25. nexus/context/builder.py +92 -0
  26. nexus/context/chunking.py +65 -0
  27. nexus/context/explorer.py +246 -0
  28. nexus/context/integration.py +63 -0
  29. nexus/context/manager.py +328 -0
  30. nexus/context/retrieval.py +261 -0
  31. nexus/domain/__init__.py +2 -0
  32. nexus/domain/agent_decision.py +113 -0
  33. nexus/domain/agent_state.py +49 -0
  34. nexus/domain/approvals.py +64 -0
  35. nexus/domain/context.py +108 -0
  36. nexus/domain/exploration.py +86 -0
  37. nexus/domain/mcp.py +51 -0
  38. nexus/domain/model.py +173 -0
  39. nexus/domain/observability.py +326 -0
  40. nexus/domain/persistence.py +122 -0
  41. nexus/domain/planning.py +329 -0
  42. nexus/domain/ports/__init__.py +61 -0
  43. nexus/domain/ports/agent_decision.py +9 -0
  44. nexus/domain/ports/approval_unit_of_work.py +27 -0
  45. nexus/domain/ports/checkpoint_provider.py +11 -0
  46. nexus/domain/ports/context.py +71 -0
  47. nexus/domain/ports/events.py +21 -0
  48. nexus/domain/ports/graph_runtime.py +30 -0
  49. nexus/domain/ports/mcp.py +23 -0
  50. nexus/domain/ports/model_gateway.py +21 -0
  51. nexus/domain/ports/model_input_budget.py +15 -0
  52. nexus/domain/ports/planning.py +62 -0
  53. nexus/domain/ports/repositories.py +54 -0
  54. nexus/domain/ports/repository_context.py +18 -0
  55. nexus/domain/ports/session_unit_of_work.py +33 -0
  56. nexus/domain/ports/skills.py +41 -0
  57. nexus/domain/ports/tooling.py +39 -0
  58. nexus/domain/ports/tracer.py +13 -0
  59. nexus/domain/ports/validation.py +32 -0
  60. nexus/domain/runtime_events.py +445 -0
  61. nexus/domain/skills.py +129 -0
  62. nexus/domain/tooling.py +163 -0
  63. nexus/domain/validation.py +161 -0
  64. nexus/errors/__init__.py +82 -0
  65. nexus/evaluation/__init__.py +7 -0
  66. nexus/evaluation/assertions.py +4 -0
  67. nexus/evaluation/evaluator.py +263 -0
  68. nexus/evaluation/fixtures.py +36 -0
  69. nexus/evaluation/harness.py +606 -0
  70. nexus/evaluation/loader.py +415 -0
  71. nexus/evaluation/metrics.py +27 -0
  72. nexus/evaluation/models.py +362 -0
  73. nexus/evaluation/report.py +43 -0
  74. nexus/evaluation/runner.py +480 -0
  75. nexus/infrastructure/__init__.py +2 -0
  76. nexus/infrastructure/asyncio_compat.py +13 -0
  77. nexus/infrastructure/bootstrap/__init__.py +17 -0
  78. nexus/infrastructure/bootstrap/composition.py +663 -0
  79. nexus/infrastructure/checkpoint/__init__.py +5 -0
  80. nexus/infrastructure/checkpoint/postgres.py +62 -0
  81. nexus/infrastructure/database.py +39 -0
  82. nexus/infrastructure/embedding.py +61 -0
  83. nexus/infrastructure/graph/__init__.py +6 -0
  84. nexus/infrastructure/graph/day4_runtime.py +966 -0
  85. nexus/infrastructure/graph/langgraph_runtime.py +172 -0
  86. nexus/infrastructure/mcp/__init__.py +5 -0
  87. nexus/infrastructure/mcp/manager.py +380 -0
  88. nexus/infrastructure/model_gateway/__init__.py +6 -0
  89. nexus/infrastructure/model_gateway/openai_compatible.py +345 -0
  90. nexus/infrastructure/observability/__init__.py +7 -0
  91. nexus/infrastructure/observability/console.py +31 -0
  92. nexus/infrastructure/observability/langsmith.py +236 -0
  93. nexus/infrastructure/observability/structured_logger.py +70 -0
  94. nexus/infrastructure/persistence/__init__.py +13 -0
  95. nexus/infrastructure/persistence/approval_unit_of_work.py +74 -0
  96. nexus/infrastructure/persistence/models.py +146 -0
  97. nexus/infrastructure/persistence/repositories.py +326 -0
  98. nexus/infrastructure/persistence/unit_of_work.py +82 -0
  99. nexus/infrastructure/repository_files.py +117 -0
  100. nexus/infrastructure/sandbox/__init__.py +5 -0
  101. nexus/infrastructure/sandbox/local_process.py +353 -0
  102. nexus/infrastructure/semantic.py +276 -0
  103. nexus/infrastructure/semantic_models.py +56 -0
  104. nexus/interfaces/__init__.py +2 -0
  105. nexus/interfaces/cli/__init__.py +2 -0
  106. nexus/interfaces/cli/__main__.py +6 -0
  107. nexus/interfaces/cli/app.py +266 -0
  108. nexus/interfaces/cli/renderer.py +118 -0
  109. nexus/security/__init__.py +14 -0
  110. nexus/security/approval_policies.py +73 -0
  111. nexus/security/command_policy.py +117 -0
  112. nexus/security/executables.py +107 -0
  113. nexus/security/git_commands.py +65 -0
  114. nexus/security/workspace.py +93 -0
  115. nexus/skills/__init__.py +7 -0
  116. nexus/skills/builtin/__init__.py +1 -0
  117. nexus/skills/builtin/debug-python/SKILL.md +26 -0
  118. nexus/skills/builtin/review-repository/SKILL.md +26 -0
  119. nexus/skills/builtin/write-tests/SKILL.md +26 -0
  120. nexus/skills/loader.py +439 -0
  121. nexus/skills/registry.py +146 -0
  122. nexus/skills/selector.py +152 -0
  123. nexus/tools/__init__.py +6 -0
  124. nexus/tools/editing.py +312 -0
  125. nexus/tools/mcp.py +160 -0
  126. nexus/tools/native.py +654 -0
  127. nexus/tools/registry.py +27 -0
  128. nexus_coding_agent-0.1.0.dist-info/METADATA +142 -0
  129. nexus_coding_agent-0.1.0.dist-info/RECORD +131 -0
  130. nexus_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  131. nexus_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,172 @@
1
+ """Minimal Day 1 LangGraph implementation behind GraphRuntime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypedDict, cast
6
+
7
+ from langchain_core.runnables import RunnableConfig
8
+ from langgraph.checkpoint.base import BaseCheckpointSaver
9
+ from langgraph.graph import END, START, StateGraph
10
+
11
+ from nexus.application.model_call_context import bind_model_call_phase
12
+ from nexus.domain.agent_state import AgentState
13
+ from nexus.domain.model import ModelCallPhase, ModelMessage
14
+ from nexus.domain.planning import PlanApprovalResumeInput
15
+ from nexus.domain.ports.model_gateway import ModelGateway
16
+ from nexus.domain.runtime_events import RuntimeStatus
17
+ from nexus.errors import NexusError
18
+
19
+
20
+ class _GraphState(TypedDict):
21
+ task: str
22
+ messages: list[ModelMessage]
23
+ run_id: str
24
+ session_id: str | None
25
+ status: RuntimeStatus
26
+
27
+
28
+ class LangGraphRuntime:
29
+ """Compile and execute START -> model_response -> END."""
30
+
31
+ def __init__(
32
+ self,
33
+ model_gateway: ModelGateway,
34
+ *,
35
+ checkpointer: BaseCheckpointSaver[Any] | None = None,
36
+ interrupt_before_model_response: bool = False,
37
+ ) -> None:
38
+ async def model_response(state: _GraphState) -> dict[str, object]:
39
+ with bind_model_call_phase(ModelCallPhase.DIRECT_RESPONSE):
40
+ response = await model_gateway.complete(state["messages"])
41
+ assistant = ModelMessage(role="assistant", content=response.content)
42
+ return {
43
+ "messages": [*state["messages"], assistant],
44
+ "status": RuntimeStatus.COMPLETED,
45
+ }
46
+
47
+ builder = StateGraph(_GraphState)
48
+ builder.add_node("model_response", model_response)
49
+ builder.add_edge(START, "model_response")
50
+ builder.add_edge("model_response", END)
51
+ self._graph = builder.compile(
52
+ checkpointer=checkpointer,
53
+ interrupt_before=["model_response"] if interrupt_before_model_response else None,
54
+ )
55
+
56
+ async def run(
57
+ self,
58
+ state: AgentState,
59
+ *,
60
+ thread_id: str | None = None,
61
+ ) -> AgentState:
62
+ """Translate Nexus state into/out of the hidden LangGraph implementation."""
63
+
64
+ graph_state: _GraphState = {
65
+ "task": state.task,
66
+ "messages": state.messages,
67
+ "run_id": state.run_id,
68
+ "session_id": state.session_id,
69
+ "status": state.status,
70
+ }
71
+ config = _thread_config(
72
+ thread_id,
73
+ run_id=state.run_id,
74
+ session_id=state.session_id,
75
+ )
76
+ try:
77
+ result = await self._graph.ainvoke(graph_state, config=config)
78
+ except NexusError:
79
+ raise
80
+ except Exception as exc:
81
+ raise NexusError(
82
+ "The graph execution failed.",
83
+ code="GRAPH_EXECUTION_ERROR",
84
+ retryable=True,
85
+ ) from exc
86
+ status_override = (
87
+ RuntimeStatus.INTERRUPTED
88
+ if thread_id is not None and result.get("status") == RuntimeStatus.STARTED
89
+ else None
90
+ )
91
+ return self._to_agent_state(
92
+ cast(_GraphState, result), state, status_override=status_override
93
+ )
94
+
95
+ async def resume(
96
+ self,
97
+ *,
98
+ thread_id: str,
99
+ resume_input: PlanApprovalResumeInput | None = None,
100
+ ) -> AgentState:
101
+ """Resume exclusively from the checkpointer-owned latest thread snapshot."""
102
+
103
+ if resume_input is not None:
104
+ raise NexusError(
105
+ "This checkpoint is not waiting for Plan approval.",
106
+ code="INVALID_APPROVAL_TRANSITION",
107
+ )
108
+
109
+ try:
110
+ result = await self._graph.ainvoke(None, config=_thread_config(thread_id))
111
+ except Exception as exc:
112
+ raise NexusError(
113
+ "Nexus could not resume the persisted graph execution.",
114
+ code="GRAPH_RESUME_ERROR",
115
+ retryable=True,
116
+ ) from exc
117
+ if not isinstance(result, dict):
118
+ raise NexusError(
119
+ "The resumed graph returned invalid state.",
120
+ code="GRAPH_INVALID_STATE",
121
+ )
122
+ return self._to_agent_state(cast(_GraphState, result), None)
123
+
124
+ @staticmethod
125
+ def _to_agent_state(
126
+ result: _GraphState,
127
+ original: AgentState | None,
128
+ *,
129
+ status_override: RuntimeStatus | None = None,
130
+ ) -> AgentState:
131
+ messages = result.get("messages")
132
+ if not isinstance(messages, list) or not all(
133
+ isinstance(message, ModelMessage) for message in messages
134
+ ):
135
+ raise NexusError("The graph returned invalid messages.", code="GRAPH_INVALID_STATE")
136
+
137
+ try:
138
+ status = status_override or RuntimeStatus(result["status"])
139
+ except (KeyError, ValueError) as exc:
140
+ raise NexusError(
141
+ "The graph returned an invalid status.",
142
+ code="GRAPH_INVALID_STATE",
143
+ ) from exc
144
+
145
+ return AgentState(
146
+ task=str(result.get("task", original.task if original is not None else "")),
147
+ messages=messages,
148
+ run_id=str(result.get("run_id", original.run_id if original is not None else "")),
149
+ session_id=result.get(
150
+ "session_id", original.session_id if original is not None else None
151
+ ),
152
+ status=status,
153
+ )
154
+
155
+
156
+ def _thread_config(
157
+ thread_id: str | None,
158
+ *,
159
+ run_id: str | None = None,
160
+ session_id: str | None = None,
161
+ ) -> RunnableConfig | None:
162
+ if thread_id is None:
163
+ return None
164
+ metadata = {
165
+ key: value
166
+ for key, value in {"run_id": run_id, "session_id": session_id}.items()
167
+ if value is not None
168
+ }
169
+ return {
170
+ "configurable": {"thread_id": thread_id},
171
+ "metadata": metadata,
172
+ }
@@ -0,0 +1,5 @@
1
+ """Official SDK-backed MCP client infrastructure."""
2
+
3
+ from nexus.infrastructure.mcp.manager import SDKMCPManager
4
+
5
+ __all__ = ["SDKMCPManager"]
@@ -0,0 +1,380 @@
1
+ """Sequential stdio MCP lifecycle implemented behind Nexus-owned values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol, cast
11
+
12
+ from mcp import Client, StdioServerParameters
13
+ from mcp.shared.exceptions import MCPError as SDKMCPError
14
+ from mcp_types import REQUEST_TIMEOUT
15
+
16
+ from nexus.config.models import MCPServerConfig
17
+ from nexus.domain.mcp import MCPConnectionInfo, MCPToolDescriptor
18
+ from nexus.domain.tooling import JsonObject
19
+ from nexus.errors import MCPError
20
+
21
+ _MAX_DESCRIPTION_CHARS = 2_000
22
+ _MAX_SCHEMA_BYTES = 64_000
23
+ _MAX_TEXT_CHARS = 16_000
24
+ _MAX_BINARY_CHARS = 4_096
25
+
26
+
27
+ class _SessionLike(Protocol):
28
+ async def call_tool(
29
+ self,
30
+ name: str,
31
+ arguments: dict[str, Any] | None = None,
32
+ read_timeout_seconds: float | None = None,
33
+ progress_callback: object | None = None,
34
+ *,
35
+ input_responses: object | None = None,
36
+ request_state: str | None = None,
37
+ meta: object | None = None,
38
+ allow_input_required: bool = False,
39
+ allow_claimed: bool = False,
40
+ ) -> object: ...
41
+
42
+
43
+ class _ClientLike(Protocol):
44
+ server_info: object | None
45
+ protocol_version: str | None
46
+ session: _SessionLike
47
+
48
+ async def __aenter__(self) -> _ClientLike: ...
49
+
50
+ async def __aexit__(
51
+ self,
52
+ exc_type: type[BaseException] | None,
53
+ exc_val: BaseException | None,
54
+ exc_tb: object,
55
+ ) -> None: ...
56
+
57
+ async def list_tools(
58
+ self,
59
+ *,
60
+ cursor: str | None = None,
61
+ meta: object | None = None,
62
+ cache_mode: str = "use",
63
+ ) -> object: ...
64
+
65
+
66
+ ClientFactory = Callable[[MCPServerConfig], _ClientLike]
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class _Connection:
71
+ config: MCPServerConfig
72
+ client: _ClientLike
73
+ remote_names: frozenset[str] = frozenset()
74
+
75
+
76
+ class SDKMCPManager:
77
+ def __init__(
78
+ self,
79
+ servers: tuple[MCPServerConfig, ...],
80
+ *,
81
+ client_factory: ClientFactory | None = None,
82
+ ) -> None:
83
+ self._servers = tuple(server for server in servers if server.enabled)
84
+ self._client_factory = client_factory or _sdk_client
85
+ self._connections: list[_Connection] = []
86
+ self._descriptors: tuple[MCPToolDescriptor, ...] | None = None
87
+ self._connection_info: tuple[MCPConnectionInfo, ...] | None = None
88
+
89
+ async def connect(self) -> tuple[MCPConnectionInfo, ...]:
90
+ if self._connection_info is not None:
91
+ return self._connection_info
92
+ try:
93
+ for config in self._servers:
94
+ self._connections.append(await self._connect_one(config))
95
+ except asyncio.CancelledError:
96
+ await self.close()
97
+ raise
98
+ except Exception:
99
+ await self.close()
100
+ raise
101
+ self._connection_info = tuple(
102
+ MCPConnectionInfo(
103
+ connection.config.server_id,
104
+ True,
105
+ _optional_text(connection.client.server_info, "name"),
106
+ _optional_text(connection.client.server_info, "version"),
107
+ connection.client.protocol_version,
108
+ )
109
+ for connection in self._connections
110
+ )
111
+ return self._connection_info
112
+
113
+ async def _connect_one(self, config: MCPServerConfig) -> _Connection:
114
+ for attempt in range(1, config.max_connect_attempts + 1):
115
+ client: _ClientLike | None = None
116
+ try:
117
+ client = self._client_factory(config)
118
+ async with asyncio.timeout(config.connect_timeout_seconds):
119
+ await client.__aenter__()
120
+ return _Connection(config, client)
121
+ except asyncio.CancelledError:
122
+ if client is not None:
123
+ await _close_client(client)
124
+ raise
125
+ except Exception as exc:
126
+ if client is not None:
127
+ await _close_client(client)
128
+ retryable = _is_retryable_connect_failure(exc)
129
+ if not retryable or attempt == config.max_connect_attempts:
130
+ raise MCPError(
131
+ f"MCP server {config.server_id!r} failed during connect.",
132
+ code="MCP_CONNECT_FAILED",
133
+ retryable=False,
134
+ ) from exc
135
+ await asyncio.sleep(0)
136
+ raise AssertionError("MCP connect attempt loop did not terminate.")
137
+
138
+ async def list_tools(self) -> tuple[MCPToolDescriptor, ...]:
139
+ if self._connection_info is None:
140
+ raise MCPError(
141
+ "MCP discovery requires a successful connection.",
142
+ code="MCP_DISCOVERY_FAILED",
143
+ )
144
+ if self._descriptors is not None:
145
+ return self._descriptors
146
+ descriptors: list[MCPToolDescriptor] = []
147
+ try:
148
+ for connection in self._connections:
149
+ names: set[str] = set()
150
+ cursor: str | None = None
151
+ while True:
152
+ async with asyncio.timeout(
153
+ connection.config.connect_timeout_seconds
154
+ ):
155
+ page = await connection.client.list_tools(cursor=cursor)
156
+ tools = getattr(page, "tools", None)
157
+ if not isinstance(tools, list):
158
+ raise MCPError(
159
+ f"MCP server {connection.config.server_id!r} returned "
160
+ "an invalid discovery result.",
161
+ code="MCP_DISCOVERY_FAILED",
162
+ )
163
+ for tool in tools:
164
+ descriptor = _descriptor(connection.config.server_id, tool)
165
+ if descriptor.remote_name in names:
166
+ raise MCPError(
167
+ f"MCP server {connection.config.server_id!r} advertised "
168
+ "a duplicate Tool name.",
169
+ code="MCP_DISCOVERY_FAILED",
170
+ )
171
+ names.add(descriptor.remote_name)
172
+ descriptors.append(descriptor)
173
+ next_cursor = getattr(page, "next_cursor", None)
174
+ if next_cursor is None:
175
+ break
176
+ if not isinstance(next_cursor, str) or not next_cursor:
177
+ raise MCPError(
178
+ f"MCP server {connection.config.server_id!r} returned "
179
+ "an invalid discovery cursor.",
180
+ code="MCP_DISCOVERY_FAILED",
181
+ )
182
+ cursor = next_cursor
183
+ connection.remote_names = frozenset(names)
184
+ except asyncio.CancelledError:
185
+ raise
186
+ except MCPError:
187
+ raise
188
+ except Exception as exc:
189
+ raise MCPError(
190
+ "MCP Tool discovery failed.",
191
+ code="MCP_DISCOVERY_FAILED",
192
+ retryable=False,
193
+ ) from exc
194
+ registry_names = [item.registry_name for item in descriptors]
195
+ if len(registry_names) != len(set(registry_names)):
196
+ raise MCPError(
197
+ "MCP Tool discovery produced duplicate registry names.",
198
+ code="MCP_DISCOVERY_FAILED",
199
+ )
200
+ self._descriptors = tuple(descriptors)
201
+ return self._descriptors
202
+
203
+ async def call_tool(
204
+ self,
205
+ *,
206
+ server_id: str,
207
+ remote_name: str,
208
+ arguments: JsonObject,
209
+ timeout_seconds: float,
210
+ ) -> JsonObject:
211
+ connection = next(
212
+ (item for item in self._connections if item.config.server_id == server_id),
213
+ None,
214
+ )
215
+ if connection is None or remote_name not in connection.remote_names:
216
+ raise MCPError(
217
+ f"MCP Tool {server_id!r}/{remote_name!r} is not connected and discovered.",
218
+ code="MCP_CALL_FAILED",
219
+ )
220
+ timeout = min(timeout_seconds, connection.config.tool_timeout_seconds)
221
+ try:
222
+ async with asyncio.timeout(timeout):
223
+ result = await connection.client.session.call_tool(
224
+ remote_name,
225
+ dict(arguments),
226
+ read_timeout_seconds=timeout,
227
+ allow_input_required=True,
228
+ )
229
+ return _normalize_result(result)
230
+ except asyncio.CancelledError:
231
+ raise
232
+ except TimeoutError as exc:
233
+ raise MCPError(
234
+ f"MCP Tool {server_id!r}/{remote_name!r} timed out.",
235
+ code="MCP_TOOL_TIMEOUT",
236
+ retryable=True,
237
+ ) from exc
238
+ except SDKMCPError as exc:
239
+ if exc.code == REQUEST_TIMEOUT:
240
+ raise MCPError(
241
+ f"MCP Tool {server_id!r}/{remote_name!r} timed out.",
242
+ code="MCP_TOOL_TIMEOUT",
243
+ retryable=True,
244
+ ) from exc
245
+ raise MCPError(
246
+ f"MCP Tool {server_id!r}/{remote_name!r} failed during call.",
247
+ code="MCP_CALL_FAILED",
248
+ retryable=False,
249
+ ) from exc
250
+ except MCPError:
251
+ raise
252
+ except Exception as exc:
253
+ raise MCPError(
254
+ f"MCP Tool {server_id!r}/{remote_name!r} failed during call.",
255
+ code="MCP_CALL_FAILED",
256
+ retryable=False,
257
+ ) from exc
258
+
259
+ async def close(self) -> None:
260
+ connections = tuple(reversed(self._connections))
261
+ self._connections.clear()
262
+ self._connection_info = None
263
+ self._descriptors = None
264
+ for connection in connections:
265
+ await _close_client(connection.client)
266
+
267
+
268
+ def _sdk_client(config: MCPServerConfig) -> _ClientLike:
269
+ parameters = StdioServerParameters(
270
+ command=config.command,
271
+ args=list(config.args),
272
+ env=dict(os.environ) if config.inherit_environment else {},
273
+ )
274
+ return cast(_ClientLike, Client(parameters, input_required_max_rounds=0))
275
+
276
+
277
+ def _is_retryable_connect_failure(exc: Exception) -> bool:
278
+ """Return whether a connect failure is an explicit transient transport signal."""
279
+
280
+ if isinstance(exc, ExceptionGroup):
281
+ return bool(exc.exceptions) and all(
282
+ _is_retryable_connect_failure(item) for item in exc.exceptions
283
+ )
284
+ return isinstance(exc, (TimeoutError, ConnectionError))
285
+
286
+
287
+ async def _close_client(client: _ClientLike) -> None:
288
+ try:
289
+ await client.__aexit__(None, None, None)
290
+ except asyncio.CancelledError:
291
+ raise
292
+ except Exception:
293
+ return
294
+
295
+
296
+ def _descriptor(server_id: str, tool: object) -> MCPToolDescriptor:
297
+ remote_name = getattr(tool, "name", None)
298
+ description = getattr(tool, "description", None)
299
+ schema = getattr(tool, "input_schema", None)
300
+ if not isinstance(remote_name, str) or not remote_name:
301
+ raise MCPError(
302
+ f"MCP server {server_id!r} advertised an invalid Tool name.",
303
+ code="MCP_INVALID_SCHEMA",
304
+ )
305
+ if description is not None and not isinstance(description, str):
306
+ raise MCPError(
307
+ f"MCP Tool {server_id!r}/{remote_name!r} has an invalid description.",
308
+ code="MCP_INVALID_SCHEMA",
309
+ )
310
+ if not isinstance(schema, dict) or any(not isinstance(key, str) for key in schema):
311
+ raise MCPError(
312
+ f"MCP Tool {server_id!r}/{remote_name!r} has an invalid input schema.",
313
+ code="MCP_INVALID_SCHEMA",
314
+ )
315
+ try:
316
+ encoded = json.dumps(schema, ensure_ascii=False, separators=(",", ":"))
317
+ except (TypeError, ValueError) as exc:
318
+ raise MCPError(
319
+ f"MCP Tool {server_id!r}/{remote_name!r} has a non-JSON input schema.",
320
+ code="MCP_INVALID_SCHEMA",
321
+ ) from exc
322
+ if len(encoded.encode("utf-8")) > _MAX_SCHEMA_BYTES:
323
+ raise MCPError(
324
+ f"MCP Tool {server_id!r}/{remote_name!r} input schema is too large.",
325
+ code="MCP_INVALID_SCHEMA",
326
+ )
327
+ return MCPToolDescriptor(
328
+ server_id,
329
+ remote_name,
330
+ f"mcp.{server_id}.{remote_name}",
331
+ None if description is None else description[:_MAX_DESCRIPTION_CHARS],
332
+ cast(JsonObject, schema),
333
+ )
334
+
335
+
336
+ def _normalize_result(result: object) -> JsonObject:
337
+ content = getattr(result, "content", None)
338
+ structured = getattr(result, "structured_content", None)
339
+ is_error = getattr(result, "is_error", None)
340
+ if not isinstance(content, list) or not isinstance(is_error, bool):
341
+ raise MCPError("MCP returned an invalid Tool result.", code="MCP_INVALID_RESULT")
342
+ if structured is not None and not isinstance(structured, dict):
343
+ raise MCPError("MCP returned invalid structured content.", code="MCP_INVALID_RESULT")
344
+ return {
345
+ "content": [_safe_content(block) for block in content],
346
+ "structured_content": None if structured is None else _safe_json(structured),
347
+ "is_error": is_error,
348
+ }
349
+
350
+
351
+ def _safe_content(block: object) -> object:
352
+ dump = getattr(block, "model_dump", None)
353
+ if callable(dump):
354
+ value = dump(mode="json", by_alias=False)
355
+ else:
356
+ value = {"type": type(block).__name__, "text": str(block)[:_MAX_TEXT_CHARS]}
357
+ return _safe_json(value)
358
+
359
+
360
+ def _safe_json(value: object, *, key: str | None = None) -> object:
361
+ if value is None or isinstance(value, (bool, int, float)):
362
+ return value
363
+ if isinstance(value, str):
364
+ limit = _MAX_BINARY_CHARS if key in {"data", "blob"} else _MAX_TEXT_CHARS
365
+ if len(value) <= limit:
366
+ return value
367
+ return value[:limit] + "...[truncated]"
368
+ if isinstance(value, list):
369
+ return [_safe_json(item) for item in value[:100]]
370
+ if isinstance(value, dict):
371
+ return {
372
+ str(item_key): _safe_json(item, key=str(item_key))
373
+ for item_key, item in list(value.items())[:100]
374
+ }
375
+ raise MCPError("MCP returned non-JSON content.", code="MCP_INVALID_RESULT")
376
+
377
+
378
+ def _optional_text(value: object | None, attribute: str) -> str | None:
379
+ result = None if value is None else getattr(value, attribute, None)
380
+ return result if isinstance(result, str) else None
@@ -0,0 +1,6 @@
1
+ """Concrete model gateway adapters."""
2
+
3
+ from nexus.infrastructure.model_gateway.openai_compatible import OpenAICompatibleModelGateway
4
+
5
+ __all__ = ["OpenAICompatibleModelGateway"]
6
+