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.
- nighthawk/__init__.py +48 -0
- nighthawk/backends/__init__.py +0 -0
- nighthawk/backends/base.py +95 -0
- nighthawk/backends/claude_code_cli.py +342 -0
- nighthawk/backends/claude_code_sdk.py +325 -0
- nighthawk/backends/codex.py +352 -0
- nighthawk/backends/mcp_boundary.py +129 -0
- nighthawk/backends/mcp_server.py +226 -0
- nighthawk/backends/tool_bridge.py +240 -0
- nighthawk/configuration.py +193 -0
- nighthawk/errors.py +25 -0
- nighthawk/identifier_path.py +35 -0
- nighthawk/json_renderer.py +216 -0
- nighthawk/natural/__init__.py +0 -0
- nighthawk/natural/blocks.py +279 -0
- nighthawk/natural/decorator.py +302 -0
- nighthawk/natural/transform.py +346 -0
- nighthawk/runtime/__init__.py +0 -0
- nighthawk/runtime/async_bridge.py +50 -0
- nighthawk/runtime/prompt.py +344 -0
- nighthawk/runtime/runner.py +462 -0
- nighthawk/runtime/scoping.py +288 -0
- nighthawk/runtime/step_context.py +171 -0
- nighthawk/runtime/step_contract.py +231 -0
- nighthawk/runtime/step_executor.py +360 -0
- nighthawk/runtime/tool_calls.py +99 -0
- nighthawk/tools/__init__.py +0 -0
- nighthawk/tools/assignment.py +246 -0
- nighthawk/tools/contracts.py +72 -0
- nighthawk/tools/execution.py +83 -0
- nighthawk/tools/provided.py +80 -0
- nighthawk/tools/registry.py +212 -0
- nighthawk_python-0.1.0.dist-info/METADATA +111 -0
- nighthawk_python-0.1.0.dist-info/RECORD +36 -0
- nighthawk_python-0.1.0.dist-info/WHEEL +4 -0
- nighthawk_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|