nighthawk-python 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.
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import tiktoken
6
+ from opentelemetry import context as otel_context
7
+ from opentelemetry.context import Context as OtelContext
8
+
9
+ from ..runtime.step_context import DEFAULT_TOOL_RESULT_RENDERING_POLICY, ToolResultRenderingPolicy, get_current_step_context
10
+ from ..tools.contracts import render_tool_result_json_text
11
+ from .tool_bridge import ToolHandler
12
+
13
+
14
+ def _resolve_tool_result_rendering_policy() -> ToolResultRenderingPolicy:
15
+ try:
16
+ step_context = get_current_step_context()
17
+ except Exception:
18
+ return DEFAULT_TOOL_RESULT_RENDERING_POLICY
19
+
20
+ if step_context.tool_result_rendering_policy is None:
21
+ return DEFAULT_TOOL_RESULT_RENDERING_POLICY
22
+ return step_context.tool_result_rendering_policy
23
+
24
+
25
+ def _tool_boundary_failure_text(*, message: str, guidance: str) -> str:
26
+ rendering_policy = _resolve_tool_result_rendering_policy()
27
+ encoding = tiktoken.get_encoding(rendering_policy.tokenizer_encoding_name)
28
+ return render_tool_result_json_text(
29
+ value=None,
30
+ error={"kind": "internal", "message": message, "guidance": guidance},
31
+ max_tokens=rendering_policy.tool_result_max_tokens,
32
+ encoding=encoding,
33
+ style=rendering_policy.json_renderer_style,
34
+ )
35
+
36
+
37
+ def _minimal_tool_boundary_failure_json_text(*, message: str, guidance: str) -> str:
38
+ payload = {
39
+ "value": None,
40
+ "error": {
41
+ "kind": "internal",
42
+ "message": message,
43
+ "guidance": guidance,
44
+ },
45
+ }
46
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
47
+
48
+
49
+ async def _call_tool_handler_result_text(
50
+ *,
51
+ tool_name: str,
52
+ arguments: dict[str, object],
53
+ tool_handler: ToolHandler,
54
+ parent_otel_context: OtelContext,
55
+ ) -> str:
56
+ _ = tool_name
57
+
58
+ context_token = otel_context.attach(parent_otel_context)
59
+ try:
60
+ return await tool_handler(arguments)
61
+ except Exception as exception:
62
+ error_message = str(exception) or "Tool boundary wrapper failed"
63
+ try:
64
+ return _tool_boundary_failure_text(
65
+ message=error_message,
66
+ guidance="The tool boundary wrapper failed. Retry or report this error.",
67
+ )
68
+ except Exception:
69
+ return _minimal_tool_boundary_failure_json_text(
70
+ message=error_message,
71
+ guidance="The tool boundary wrapper failed and could not access the environment. Retry or report this error.",
72
+ )
73
+ finally:
74
+ otel_context.detach(context_token)
75
+
76
+
77
+ async def _get_safe_tool_result_text(
78
+ *,
79
+ tool_name: str,
80
+ arguments: dict[str, object],
81
+ tool_handler: ToolHandler,
82
+ parent_otel_context: OtelContext,
83
+ ) -> str:
84
+ try:
85
+ return await _call_tool_handler_result_text(
86
+ tool_name=tool_name,
87
+ arguments=arguments,
88
+ tool_handler=tool_handler,
89
+ parent_otel_context=parent_otel_context,
90
+ )
91
+ except Exception as exception:
92
+ return _minimal_tool_boundary_failure_json_text(
93
+ message=str(exception) or "Tool boundary wrapper failed",
94
+ guidance="The tool boundary wrapper failed. Retry or report this error.",
95
+ )
96
+
97
+
98
+ async def call_tool_for_claude_code_sdk(
99
+ *,
100
+ tool_name: str,
101
+ arguments: dict[str, object],
102
+ tool_handler: ToolHandler,
103
+ parent_otel_context: OtelContext,
104
+ ) -> dict[str, object]:
105
+ result_text = await _get_safe_tool_result_text(
106
+ tool_name=tool_name,
107
+ arguments=arguments,
108
+ tool_handler=tool_handler,
109
+ parent_otel_context=parent_otel_context,
110
+ )
111
+ return {"content": [{"type": "text", "text": result_text}]}
112
+
113
+
114
+ async def call_tool_for_low_level_mcp_server(
115
+ *,
116
+ tool_name: str,
117
+ arguments: dict[str, object],
118
+ tool_handler: ToolHandler,
119
+ parent_otel_context: OtelContext,
120
+ ) -> list[object]:
121
+ from mcp import types as mcp_types
122
+
123
+ result_text = await _get_safe_tool_result_text(
124
+ tool_name=tool_name,
125
+ arguments=arguments,
126
+ tool_handler=tool_handler,
127
+ parent_otel_context=parent_otel_context,
128
+ )
129
+ return [mcp_types.TextContent(type="text", text=result_text)]
@@ -0,0 +1,226 @@
1
+ """Embedded MCP tool server for provider backends.
2
+
3
+ Starts a Streamable HTTP MCP server in a background thread, exposing
4
+ Nighthawk tools to CLI-based backends (e.g. Codex) that consume tools
5
+ via an MCP endpoint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import socket
13
+ import threading
14
+ import time
15
+ from collections.abc import AsyncIterator, Awaitable, Callable
16
+ from contextlib import asynccontextmanager
17
+ from contextvars import copy_context
18
+ from typing import Any, cast
19
+
20
+ import tiktoken
21
+ from opentelemetry import context as otel_context
22
+ from pydantic_ai import RunContext
23
+ from pydantic_ai.exceptions import UnexpectedModelBehavior
24
+ from pydantic_ai.tools import ToolDefinition
25
+
26
+ from ..runtime.step_context import (
27
+ DEFAULT_TOOL_RESULT_RENDERING_POLICY,
28
+ StepContext,
29
+ ToolResultRenderingPolicy,
30
+ )
31
+ from .mcp_boundary import call_tool_for_low_level_mcp_server
32
+
33
+
34
+ class McpServer:
35
+ """In-process MCP tool server backed by Nighthawk tool handlers."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ tool_name_to_tool_definition: dict[str, ToolDefinition],
41
+ tool_name_to_handler: dict[str, Callable[[dict[str, Any]], Awaitable[str]]],
42
+ tool_result_rendering_policy: ToolResultRenderingPolicy,
43
+ parent_otel_context: Any,
44
+ ) -> None:
45
+ self._tool_name_to_tool_definition = tool_name_to_tool_definition
46
+ self._tool_name_to_handler = tool_name_to_handler
47
+ self._tool_result_rendering_policy = tool_result_rendering_policy
48
+ self._parent_otel_context = parent_otel_context
49
+
50
+ self._server: Any | None = None
51
+ self._server_thread: threading.Thread | None = None
52
+ self._listening_socket: socket.socket | None = None
53
+ self._url: str | None = None
54
+
55
+ @property
56
+ def url(self) -> str:
57
+ if self._url is None:
58
+ raise RuntimeError("MCP tool server is not started")
59
+ return self._url
60
+
61
+ def start(self) -> None:
62
+ if self._server_thread is not None:
63
+ raise RuntimeError("MCP tool server is already started")
64
+
65
+ import uvicorn
66
+ from mcp.server.fastmcp.server import StreamableHTTPASGIApp
67
+ from mcp.server.lowlevel.server import Server as McpLowLevelServer
68
+ from mcp.server.streamable_http_manager import (
69
+ StreamableHTTPSessionManager,
70
+ )
71
+ from starlette.applications import Starlette
72
+ from starlette.routing import Route
73
+
74
+ mcp_server = McpLowLevelServer("nighthawk")
75
+
76
+ @mcp_server.list_tools()
77
+ async def list_tools() -> list[Any]:
78
+ from mcp import types as mcp_types
79
+
80
+ tools: list[mcp_types.Tool] = []
81
+ for tool_name in sorted(self._tool_name_to_handler.keys()):
82
+ tool_definition = self._tool_name_to_tool_definition.get(tool_name)
83
+ if tool_definition is None:
84
+ raise RuntimeError(f"Tool definition missing for {tool_name!r}")
85
+
86
+ tools.append(
87
+ mcp_types.Tool(
88
+ name=tool_name,
89
+ description=tool_definition.description or "",
90
+ inputSchema=tool_definition.parameters_json_schema,
91
+ )
92
+ )
93
+ return tools
94
+
95
+ @mcp_server.call_tool(validate_input=False)
96
+ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
97
+ from mcp import types as mcp_types
98
+
99
+ from ..tools.contracts import render_tool_result_json_text
100
+
101
+ handler = self._tool_name_to_handler.get(name)
102
+ if handler is None:
103
+ policy = self._tool_result_rendering_policy
104
+ result_text = render_tool_result_json_text(
105
+ value=None,
106
+ error={"kind": "resolution", "message": f"Unknown tool: {name}", "guidance": "Choose a visible tool name and retry."},
107
+ max_tokens=policy.tool_result_max_tokens,
108
+ encoding=tiktoken.get_encoding(policy.tokenizer_encoding_name),
109
+ style=policy.json_renderer_style,
110
+ )
111
+ return [mcp_types.TextContent(type="text", text=result_text)]
112
+
113
+ return await call_tool_for_low_level_mcp_server(
114
+ tool_name=name,
115
+ arguments=arguments,
116
+ tool_handler=handler,
117
+ parent_otel_context=self._parent_otel_context,
118
+ )
119
+
120
+ session_manager = StreamableHTTPSessionManager(app=mcp_server)
121
+ streamable_http_asgi = StreamableHTTPASGIApp(session_manager)
122
+
123
+ starlette_application = Starlette(
124
+ routes=[Route("/mcp", endpoint=streamable_http_asgi)],
125
+ lifespan=lambda _app: session_manager.run(),
126
+ )
127
+
128
+ listening_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
129
+ listening_socket.bind(("127.0.0.1", 0))
130
+ listening_socket.listen(128)
131
+ host, port = listening_socket.getsockname()
132
+
133
+ configuration = uvicorn.Config(
134
+ starlette_application,
135
+ host=str(host),
136
+ port=int(port),
137
+ log_level="warning",
138
+ lifespan="on",
139
+ )
140
+ server = uvicorn.Server(configuration)
141
+
142
+ def run_server() -> None:
143
+ server.run(sockets=[listening_socket])
144
+
145
+ context = copy_context()
146
+ server_thread = threading.Thread(
147
+ target=context.run,
148
+ args=(run_server,),
149
+ name="nighthawk-mcp-server",
150
+ daemon=True,
151
+ )
152
+ server_thread.start()
153
+
154
+ self._server = server
155
+ self._server_thread = server_thread
156
+ self._listening_socket = listening_socket
157
+ self._url = f"http://127.0.0.1:{port}/mcp"
158
+
159
+ async def stop(self) -> None:
160
+ if self._server is None or self._server_thread is None:
161
+ return
162
+
163
+ self._server.should_exit = True
164
+ await asyncio.to_thread(self._server_thread.join, 5)
165
+
166
+ if self._listening_socket is not None:
167
+ with contextlib.suppress(Exception):
168
+ self._listening_socket.close()
169
+
170
+ self._server = None
171
+ self._server_thread = None
172
+ self._listening_socket = None
173
+ self._url = None
174
+
175
+
176
+ async def _wait_for_tcp_listen(host: str, port: int, *, timeout_seconds: float) -> None:
177
+ deadline = time.monotonic() + timeout_seconds
178
+ while time.monotonic() < deadline:
179
+ try:
180
+ reader, writer = await asyncio.open_connection(host, port)
181
+ except OSError:
182
+ await asyncio.sleep(0.01)
183
+ continue
184
+ else:
185
+ writer.close()
186
+ await writer.wait_closed()
187
+ return
188
+ raise UnexpectedModelBehavior("Timed out waiting for MCP tool server to start")
189
+
190
+
191
+ @asynccontextmanager
192
+ async def mcp_server_if_needed(
193
+ *,
194
+ tool_name_to_tool_definition: dict[str, ToolDefinition],
195
+ tool_name_to_handler: dict[str, Callable[[dict[str, Any]], Awaitable[str]]],
196
+ ) -> AsyncIterator[str | None]:
197
+ if not tool_name_to_handler:
198
+ yield None
199
+ return
200
+
201
+ from pydantic_ai._run_context import get_current_run_context
202
+
203
+ parent_otel_context = otel_context.get_current()
204
+ parent_run_context = get_current_run_context()
205
+ if parent_run_context is None:
206
+ raise RuntimeError("Codex MCP tool server requires an active RunContext")
207
+ typed_parent_run_context = cast(RunContext[StepContext], parent_run_context)
208
+ tool_result_rendering_policy = typed_parent_run_context.deps.tool_result_rendering_policy
209
+ if tool_result_rendering_policy is None:
210
+ tool_result_rendering_policy = DEFAULT_TOOL_RESULT_RENDERING_POLICY
211
+
212
+ server = McpServer(
213
+ tool_name_to_tool_definition=tool_name_to_tool_definition,
214
+ tool_name_to_handler=tool_name_to_handler,
215
+ tool_result_rendering_policy=tool_result_rendering_policy,
216
+ parent_otel_context=parent_otel_context,
217
+ )
218
+ server.start()
219
+
220
+ try:
221
+ url = server.url
222
+ port = int(url.split(":")[2].split("/", 1)[0])
223
+ await _wait_for_tcp_listen("127.0.0.1", port, timeout_seconds=2.0)
224
+ yield url
225
+ finally:
226
+ await server.stop()
@@ -0,0 +1,240 @@
1
+ """Bridge between Nighthawk tools and provider backends.
2
+
3
+ Provides tool handler construction and tool execution for backends that
4
+ expose Nighthawk tools through Pydantic AI FunctionToolset.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any, cast
11
+
12
+ import tiktoken
13
+ from pydantic_ai import RunContext
14
+
15
+ # NOTE: pydantic_ai private API — no public alternative for custom backends
16
+ # that need to set RunContext for tool execution. Monitor pydantic_ai releases.
17
+ from pydantic_ai._run_context import set_current_run_context
18
+ from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry, UnexpectedModelBehavior, UserError
19
+ from pydantic_ai.messages import RetryPromptPart
20
+ from pydantic_ai.models import ModelRequestParameters
21
+ from pydantic_ai.toolsets.function import FunctionToolset
22
+
23
+ from ..runtime.step_context import DEFAULT_TOOL_RESULT_RENDERING_POLICY, StepContext, ToolResultRenderingPolicy
24
+ from ..runtime.tool_calls import generate_tool_call_id, run_tool_instrumented
25
+ from ..tools.contracts import (
26
+ ToolBoundaryError,
27
+ ToolResult,
28
+ render_tool_result_json_text,
29
+ )
30
+ from ..tools.execution import ToolResultWrapperToolset
31
+
32
+ type ToolHandler = Callable[[dict[str, Any]], Awaitable[str]]
33
+
34
+
35
+ def resolve_tool_result_rendering_policy(run_context: RunContext[StepContext]) -> ToolResultRenderingPolicy:
36
+ policy = run_context.deps.tool_result_rendering_policy
37
+ if policy is None:
38
+ return DEFAULT_TOOL_RESULT_RENDERING_POLICY
39
+ return policy
40
+
41
+
42
+ def get_current_run_context_required() -> RunContext[StepContext]:
43
+ from pydantic_ai._run_context import get_current_run_context
44
+
45
+ run_context = get_current_run_context()
46
+ if run_context is None:
47
+ raise RuntimeError("Nighthawk tool boundaries require an active RunContext")
48
+ return cast(RunContext[StepContext], run_context)
49
+
50
+
51
+ def resolve_allowed_tool_names(
52
+ *,
53
+ model_request_parameters: ModelRequestParameters,
54
+ configured_allowed_tool_names: tuple[str, ...] | None,
55
+ available_tool_names: tuple[str, ...],
56
+ ) -> tuple[str, ...]:
57
+ if configured_allowed_tool_names is None:
58
+ return tuple(
59
+ name for name in (tool_definition.name for tool_definition in model_request_parameters.function_tools) if name in available_tool_names
60
+ )
61
+
62
+ unknown = [name for name in configured_allowed_tool_names if name not in available_tool_names]
63
+ if unknown:
64
+ unknown_list = ", ".join(repr(name) for name in unknown)
65
+ raise ValueError(f"Configured allowed_tool_names includes unknown tools: {unknown_list}")
66
+
67
+ return configured_allowed_tool_names
68
+
69
+
70
+ async def execute_tool_call(
71
+ *,
72
+ tool_name: str,
73
+ tool: Any,
74
+ arguments: dict[str, Any],
75
+ run_context: RunContext[StepContext],
76
+ tool_call_id: str,
77
+ rendering_policy: ToolResultRenderingPolicy,
78
+ encoding: tiktoken.Encoding,
79
+ ) -> str:
80
+ """Execute a single tool call: validate arguments, call the toolset, and render the result."""
81
+ with set_current_run_context(run_context):
82
+ try:
83
+ validated_arguments = tool.args_validator.validate_python(arguments)
84
+ except Exception as exception:
85
+ errors_method = getattr(exception, "errors", None)
86
+ if callable(errors_method):
87
+ try:
88
+ error_details = errors_method(include_url=False, include_context=False)
89
+ if isinstance(error_details, list):
90
+ return RetryPromptPart(
91
+ tool_name=tool_name,
92
+ content=error_details,
93
+ tool_call_id=tool_call_id,
94
+ ).model_response()
95
+ except Exception:
96
+ pass
97
+
98
+ return RetryPromptPart(
99
+ tool_name=tool_name,
100
+ content=str(exception) or "Invalid tool arguments",
101
+ tool_call_id=tool_call_id,
102
+ ).model_response()
103
+
104
+ try:
105
+ tool_result = await tool.toolset.call_tool(tool_name, validated_arguments, run_context, tool)
106
+ except (ModelRetry, CallDeferred, ApprovalRequired):
107
+ raise
108
+ except ToolBoundaryError as exception:
109
+ return render_tool_result_json_text(
110
+ value=None,
111
+ error={"kind": exception.kind, "message": str(exception), "guidance": exception.guidance},
112
+ max_tokens=rendering_policy.tool_result_max_tokens,
113
+ encoding=encoding,
114
+ style=rendering_policy.json_renderer_style,
115
+ )
116
+ except (UserError, UnexpectedModelBehavior) as exception:
117
+ return render_tool_result_json_text(
118
+ value=None,
119
+ error={"kind": "internal", "message": str(exception), "guidance": "The tool backend failed. Retry or report this error."},
120
+ max_tokens=rendering_policy.tool_result_max_tokens,
121
+ encoding=encoding,
122
+ style=rendering_policy.json_renderer_style,
123
+ )
124
+ except Exception as exception:
125
+ return render_tool_result_json_text(
126
+ value=None,
127
+ error={
128
+ "kind": "internal",
129
+ "message": str(exception) or "Tool execution failed",
130
+ "guidance": "The tool execution raised an unexpected error. Retry or report this error.",
131
+ },
132
+ max_tokens=rendering_policy.tool_result_max_tokens,
133
+ encoding=encoding,
134
+ style=rendering_policy.json_renderer_style,
135
+ )
136
+
137
+ if isinstance(tool_result, ToolResult):
138
+ return render_tool_result_json_text(
139
+ value=tool_result.value,
140
+ error=None,
141
+ max_tokens=rendering_policy.tool_result_max_tokens,
142
+ encoding=encoding,
143
+ style=rendering_policy.json_renderer_style,
144
+ )
145
+
146
+ return render_tool_result_json_text(
147
+ value=tool_result,
148
+ error=None,
149
+ max_tokens=rendering_policy.tool_result_max_tokens,
150
+ encoding=encoding,
151
+ style=rendering_policy.json_renderer_style,
152
+ )
153
+
154
+
155
+ async def build_tool_name_to_handler(
156
+ *,
157
+ model_request_parameters: ModelRequestParameters,
158
+ visible_tools: list[Any],
159
+ ) -> dict[str, ToolHandler]:
160
+ run_context = get_current_run_context_required()
161
+
162
+ toolset = ToolResultWrapperToolset(FunctionToolset(visible_tools))
163
+
164
+ tool_name_to_tool = await toolset.get_tools(run_context)
165
+
166
+ function_tool_names = {tool_definition.name for tool_definition in model_request_parameters.function_tools}
167
+
168
+ tool_name_to_handler: dict[str, ToolHandler] = {}
169
+
170
+ for tool_name, tool in tool_name_to_tool.items():
171
+ if tool_name not in function_tool_names:
172
+ continue
173
+
174
+ async def handler(
175
+ arguments: dict[str, Any],
176
+ *,
177
+ tool_name: str = tool_name,
178
+ tool: Any = tool,
179
+ ) -> str:
180
+ tool_call_id = generate_tool_call_id()
181
+ from dataclasses import replace
182
+
183
+ tool_run_context = replace(
184
+ run_context,
185
+ tool_name=tool_name,
186
+ tool_call_id=tool_call_id,
187
+ )
188
+
189
+ rendering_policy = resolve_tool_result_rendering_policy(run_context)
190
+ encoding = tiktoken.get_encoding(rendering_policy.tokenizer_encoding_name)
191
+
192
+ async def call() -> str:
193
+ return await execute_tool_call(
194
+ tool_name=tool_name,
195
+ tool=tool,
196
+ arguments=arguments,
197
+ run_context=tool_run_context,
198
+ tool_call_id=tool_call_id,
199
+ rendering_policy=rendering_policy,
200
+ encoding=encoding,
201
+ )
202
+
203
+ return await run_tool_instrumented(
204
+ tool_name=tool_name,
205
+ arguments=arguments,
206
+ call=call,
207
+ run_context=tool_run_context,
208
+ tool_call_id=tool_call_id,
209
+ )
210
+
211
+ tool_name_to_handler[tool_name] = handler
212
+
213
+ return tool_name_to_handler
214
+
215
+
216
+ async def prepare_allowed_tools(
217
+ *,
218
+ model_request_parameters: ModelRequestParameters,
219
+ configured_allowed_tool_names: tuple[str, ...] | None,
220
+ visible_tools: list[Any],
221
+ ) -> tuple[dict[str, Any], dict[str, ToolHandler], tuple[str, ...]]:
222
+ """Build tool definitions, handlers, and allowed names for a backend request."""
223
+ tool_name_to_handler = await build_tool_name_to_handler(
224
+ model_request_parameters=model_request_parameters,
225
+ visible_tools=visible_tools,
226
+ )
227
+ available_tool_names = tuple(tool_name_to_handler.keys())
228
+
229
+ allowed_tool_names = resolve_allowed_tool_names(
230
+ model_request_parameters=model_request_parameters,
231
+ configured_allowed_tool_names=configured_allowed_tool_names,
232
+ available_tool_names=available_tool_names,
233
+ )
234
+
235
+ tool_name_to_tool_definition = {
236
+ name: tool_definition for name, tool_definition in model_request_parameters.tool_defs.items() if name in allowed_tool_names
237
+ }
238
+ tool_name_to_handler = {name: tool_name_to_handler[name] for name in allowed_tool_names}
239
+
240
+ return tool_name_to_tool_definition, tool_name_to_handler, allowed_tool_names