nighthawk-python 0.14.0__py3-none-any.whl → 0.15.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 +15 -8
- nighthawk/backends/_text_fallback.py +7 -49
- nighthawk/backends/base.py +19 -14
- nighthawk/backends/claude_code_cli.py +4 -1
- nighthawk/backends/claude_code_sdk.py +1 -1
- nighthawk/backends/codex.py +8 -1
- nighthawk/backends/mcp_boundary.py +12 -41
- nighthawk/backends/mcp_server.py +0 -9
- nighthawk/backends/process_management.py +24 -0
- nighthawk/backends/text_projection.py +4 -25
- nighthawk/backends/tool_bridge.py +52 -55
- nighthawk/backends/tool_transport.py +88 -0
- nighthawk/configuration.py +38 -0
- nighthawk/errors.py +0 -8
- nighthawk/function_call.py +177 -0
- nighthawk/identifier_path.py +1 -13
- nighthawk/json_renderer.py +76 -19
- nighthawk/lifecycle.py +30 -11
- nighthawk/natural/_names.py +23 -0
- nighthawk/natural/blocks.py +5 -36
- nighthawk/natural/decorator.py +105 -107
- nighthawk/natural/definition.py +45 -0
- nighthawk/natural/transform.py +135 -106
- nighthawk/oversight.py +10 -51
- nighthawk/resilience/_budget.py +11 -21
- nighthawk/resilience/_circuit_breaker.py +4 -25
- nighthawk/resilience/_fallback.py +0 -7
- nighthawk/resilience/_retry.py +0 -22
- nighthawk/resilience/_timeout.py +6 -30
- nighthawk/resilience/_vote.py +1 -7
- nighthawk/runtime/__init__.py +98 -0
- nighthawk/runtime/_user_content.py +23 -37
- nighthawk/runtime/annotations.py +73 -0
- nighthawk/runtime/assignment.py +145 -0
- nighthawk/runtime/async_bridge.py +168 -20
- nighthawk/runtime/bindings.py +111 -0
- nighthawk/runtime/context.py +101 -0
- nighthawk/runtime/description.py +482 -0
- nighthawk/runtime/discovery.py +122 -0
- nighthawk/runtime/embedded.py +107 -0
- nighthawk/runtime/environment.py +274 -0
- nighthawk/runtime/execution_reference.py +1 -0
- nighthawk/runtime/expression.py +93 -0
- nighthawk/runtime/inspection.py +52 -0
- nighthawk/runtime/interruption.py +18 -0
- nighthawk/runtime/model_candidate.py +49 -0
- nighthawk/runtime/notifications.py +41 -0
- nighthawk/runtime/operation_contract.py +65 -0
- nighthawk/runtime/operations.py +142 -0
- nighthawk/runtime/preparation.py +233 -0
- nighthawk/runtime/prompt.py +129 -199
- nighthawk/runtime/runner.py +265 -557
- nighthawk/runtime/scoping.py +79 -91
- nighthawk/runtime/step_context.py +35 -49
- nighthawk/runtime/step_contract.py +0 -3
- nighthawk/runtime/step_definition.py +41 -0
- nighthawk/runtime/step_executor.py +79 -67
- nighthawk/runtime/step_request.py +61 -0
- nighthawk/runtime/step_result.py +31 -23
- nighthawk/testing.py +69 -91
- nighthawk/tools/_admission.py +85 -0
- nighthawk/tools/_ordering.py +88 -0
- nighthawk/tools/_tool_return_content.py +19 -35
- nighthawk/tools/assignment.py +16 -250
- nighthawk/tools/contracts.py +8 -28
- nighthawk/tools/declarations.py +0 -1
- nighthawk/tools/execution.py +39 -113
- nighthawk/tools/provided.py +4 -10
- nighthawk/ulid.py +0 -1
- {nighthawk_python-0.14.0.dist-info → nighthawk_python-0.15.0.dist-info}/METADATA +26 -28
- nighthawk_python-0.15.0.dist-info/RECORD +80 -0
- {nighthawk_python-0.14.0.dist-info → nighthawk_python-0.15.0.dist-info}/WHEEL +1 -1
- nighthawk_python-0.14.0.dist-info/RECORD +0 -55
- {nighthawk_python-0.14.0.dist-info → nighthawk_python-0.15.0.dist-info}/licenses/LICENSE +0 -0
nighthawk/__init__.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from . import lifecycle, oversight, resilience
|
|
3
|
+
from . import lifecycle, oversight, resilience, runtime
|
|
4
4
|
from .composition import UNSET, Extend, Merge, UnsetType
|
|
5
5
|
from .configuration import (
|
|
6
|
+
ModelSelection,
|
|
6
7
|
StepContextLimits,
|
|
7
8
|
StepExecutorConfiguration,
|
|
8
9
|
StepPromptTemplates,
|
|
@@ -13,18 +14,17 @@ from .errors import (
|
|
|
13
14
|
NaturalParseError,
|
|
14
15
|
NighthawkError,
|
|
15
16
|
ToolDeclarationError,
|
|
16
|
-
ToolEvaluationError,
|
|
17
17
|
ToolNameConflictError,
|
|
18
|
-
ToolValidationError,
|
|
19
18
|
)
|
|
19
|
+
from .function_call import FunctionCall, FunctionCallError, FunctionCallHandler, function_call_scope, get_function_call
|
|
20
20
|
from .json_renderer import JsonableValue, to_jsonable_value
|
|
21
21
|
from .natural.decorator import get_transformed_function, natural_function
|
|
22
|
+
from .natural.definition import NaturalFunctionDefinition, get_natural_function_definition
|
|
22
23
|
from .runtime.scoping import (
|
|
23
24
|
ExecutionReference,
|
|
24
25
|
UsageMeter,
|
|
25
26
|
get_capabilities,
|
|
26
27
|
get_execution_reference,
|
|
27
|
-
get_implicit_references,
|
|
28
28
|
get_lifecycle,
|
|
29
29
|
get_oversight,
|
|
30
30
|
get_step_executor,
|
|
@@ -36,9 +36,19 @@ from .runtime.scoping import (
|
|
|
36
36
|
scope,
|
|
37
37
|
)
|
|
38
38
|
from .runtime.step_context import StepContext, get_step_context
|
|
39
|
-
from .runtime.step_executor import AgentStepExecutor, StepExecutor
|
|
39
|
+
from .runtime.step_executor import AgentStepExecutor, ConfigurableStepExecutor, StepExecutor
|
|
40
40
|
|
|
41
41
|
__all__ = [
|
|
42
|
+
"FunctionCall",
|
|
43
|
+
"FunctionCallError",
|
|
44
|
+
"FunctionCallHandler",
|
|
45
|
+
"function_call_scope",
|
|
46
|
+
"get_function_call",
|
|
47
|
+
"NaturalFunctionDefinition",
|
|
48
|
+
"get_natural_function_definition",
|
|
49
|
+
"ModelSelection",
|
|
50
|
+
"ConfigurableStepExecutor",
|
|
51
|
+
"runtime",
|
|
42
52
|
"UNSET",
|
|
43
53
|
"Extend",
|
|
44
54
|
"Merge",
|
|
@@ -56,15 +66,12 @@ __all__ = [
|
|
|
56
66
|
"StepExecutor",
|
|
57
67
|
"StepExecutorConfiguration",
|
|
58
68
|
"StepPromptTemplates",
|
|
59
|
-
"ToolEvaluationError",
|
|
60
69
|
"ToolDeclarationError",
|
|
61
|
-
"ToolValidationError",
|
|
62
70
|
"UsageMeter",
|
|
63
71
|
"get_capabilities",
|
|
64
72
|
"get_step_context",
|
|
65
73
|
"get_usage_meter",
|
|
66
74
|
"get_execution_reference",
|
|
67
|
-
"get_implicit_references",
|
|
68
75
|
"get_oversight",
|
|
69
76
|
"get_step_executor",
|
|
70
77
|
"get_tools",
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
Used by both the text-projected coding-agent backends (``text_projection``)
|
|
4
|
-
and the MCP tool-return transport (``mcp_boundary``) to emit a consistent
|
|
5
|
-
``<image>``/``<file>`` placeholder line followed by descriptive metadata when
|
|
6
|
-
multimodal content cannot be carried natively by the transport.
|
|
7
|
-
"""
|
|
1
|
+
"""Text fallback lines shared by text-projected backends and the MCP tool-return transport."""
|
|
8
2
|
|
|
9
3
|
from __future__ import annotations
|
|
10
4
|
|
|
@@ -20,7 +14,6 @@ from pydantic_ai.messages import (
|
|
|
20
14
|
|
|
21
15
|
|
|
22
16
|
def is_image_content(*, content: object) -> bool:
|
|
23
|
-
"""Return True when *content* represents image media."""
|
|
24
17
|
if isinstance(content, ImageUrl):
|
|
25
18
|
return True
|
|
26
19
|
if isinstance(content, BinaryContent):
|
|
@@ -32,13 +25,7 @@ def is_image_content(*, content: object) -> bool:
|
|
|
32
25
|
|
|
33
26
|
|
|
34
27
|
def _safe_file_url_media_type(*, content: FileUrl) -> str | None:
|
|
35
|
-
"""
|
|
36
|
-
|
|
37
|
-
``FileUrl.media_type`` is a pydantic-ai property that may raise
|
|
38
|
-
``ValueError`` when the MIME type cannot be inferred from the URL and no
|
|
39
|
-
explicit ``media_type`` was supplied (for example ``VideoUrl`` pointing at
|
|
40
|
-
a YouTube-like URL with no extension).
|
|
41
|
-
"""
|
|
28
|
+
"""``FileUrl.media_type`` raises ``ValueError`` when neither the URL nor the caller supplies a media type."""
|
|
42
29
|
try:
|
|
43
30
|
return content.media_type
|
|
44
31
|
except ValueError:
|
|
@@ -46,14 +33,6 @@ def _safe_file_url_media_type(*, content: FileUrl) -> str | None:
|
|
|
46
33
|
|
|
47
34
|
|
|
48
35
|
def format_file_url_as_text_lines(*, content: FileUrl) -> list[str]:
|
|
49
|
-
"""Format ``FileUrl`` metadata lines for transports that need a text fallback.
|
|
50
|
-
|
|
51
|
-
Returns only the descriptive metadata lines (the ``"... URL: <url>"`` line
|
|
52
|
-
plus an optional ``"Media type: ..."`` line). The ``<image>`` / ``<file>``
|
|
53
|
-
placeholder is emitted by :func:`format_file_url_with_placeholder`; this
|
|
54
|
-
helper is exported separately for tests and for callers that already know
|
|
55
|
-
which placeholder to use.
|
|
56
|
-
"""
|
|
57
36
|
if isinstance(content, ImageUrl):
|
|
58
37
|
label = "Image"
|
|
59
38
|
include_media_type = False
|
|
@@ -83,18 +62,9 @@ def format_unresolvable_content_text(
|
|
|
83
62
|
content: UploadedFile | BinaryContent,
|
|
84
63
|
transport_label: str,
|
|
85
64
|
) -> str:
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
successful tool-return payload to be preserved and only the unresolvable
|
|
90
|
-
item replaced with explanatory text. Coding-agent user-prompt admission
|
|
91
|
-
rejects ``UploadedFile`` outright at the backend boundary instead (see
|
|
92
|
-
``backends/base.py``), so this helper is not reused there.
|
|
93
|
-
|
|
94
|
-
``BinaryContent`` reaches this helper only on transports that cannot carry
|
|
95
|
-
embedded bytes natively (today: MCP, for non-image/non-audio blobs).
|
|
96
|
-
Text-projected backends stage the bytes to a local file rather than fall
|
|
97
|
-
back to this metadata text.
|
|
65
|
+
"""Tool-return path only.
|
|
66
|
+
|
|
67
|
+
Coding-agent user-prompt admission rejects ``UploadedFile`` earlier, and text-projected backends stage ``BinaryContent`` as files.
|
|
98
68
|
"""
|
|
99
69
|
if isinstance(content, UploadedFile):
|
|
100
70
|
return f"UploadedFile: provider={content.provider_name}, file_id={content.file_id} (not resolvable by {transport_label})"
|
|
@@ -102,7 +72,6 @@ def format_unresolvable_content_text(
|
|
|
102
72
|
|
|
103
73
|
|
|
104
74
|
def format_file_url_with_placeholder(*, content: FileUrl) -> list[str]:
|
|
105
|
-
"""Format ``FileUrl`` text-fallback lines including the ``<image>``/``<file>`` placeholder."""
|
|
106
75
|
placeholder = "<image>" if isinstance(content, ImageUrl) else "<file>"
|
|
107
76
|
return [placeholder, *format_file_url_as_text_lines(content=content)]
|
|
108
77
|
|
|
@@ -112,13 +81,7 @@ def format_uploaded_file_with_placeholder(
|
|
|
112
81
|
content: UploadedFile,
|
|
113
82
|
transport_label: str,
|
|
114
83
|
) -> list[str]:
|
|
115
|
-
"""
|
|
116
|
-
|
|
117
|
-
Used by text-projected backends (coding-agent backends) that cannot resolve
|
|
118
|
-
provider-owned file references but still want to surface the failure as
|
|
119
|
-
inline content. The MCP transport intentionally bypasses this helper and
|
|
120
|
-
emits only the bare fallback text without a placeholder.
|
|
121
|
-
"""
|
|
84
|
+
"""For text-projected backends; the MCP transport intentionally emits the fallback text without a placeholder."""
|
|
122
85
|
placeholder = "<image>" if is_image_content(content=content) else "<file>"
|
|
123
86
|
metadata_line_list = [format_unresolvable_content_text(content=content, transport_label=transport_label)]
|
|
124
87
|
if content.media_type is not None:
|
|
@@ -131,11 +94,6 @@ def format_unresolvable_binary_content_with_placeholder(
|
|
|
131
94
|
content: BinaryContent,
|
|
132
95
|
transport_label: str,
|
|
133
96
|
) -> list[str]:
|
|
134
|
-
"""
|
|
135
|
-
|
|
136
|
-
Used on transports that cannot carry embedded bytes natively (today: MCP,
|
|
137
|
-
for non-image/non-audio blobs). Text-projected backends stage the bytes to
|
|
138
|
-
a local file instead and do not use this helper.
|
|
139
|
-
"""
|
|
97
|
+
"""For transports that cannot carry embedded bytes; text-projected backends stage them as files instead."""
|
|
140
98
|
placeholder = "<image>" if is_image_content(content=content) else "<file>"
|
|
141
99
|
return [placeholder, format_unresolvable_content_text(content=content, transport_label=transport_label)]
|
nighthawk/backends/base.py
CHANGED
|
@@ -12,6 +12,7 @@ from pydantic_ai.messages import (
|
|
|
12
12
|
InstructionPart,
|
|
13
13
|
ModelMessage,
|
|
14
14
|
ModelRequest,
|
|
15
|
+
ModelResponse,
|
|
15
16
|
RetryPromptPart,
|
|
16
17
|
SystemPromptPart,
|
|
17
18
|
TextContent,
|
|
@@ -27,6 +28,7 @@ from pydantic_ai.settings import ModelSettings
|
|
|
27
28
|
from ..configuration import TEXT_PROJECTED_TOOL_RESULT_PREVIEW_SYSTEM_PROMPT_FRAGMENT
|
|
28
29
|
from ..runtime.prompt import resolve_step_system_prompt_template_text
|
|
29
30
|
from .tool_bridge import ToolHandler, prepare_allowed_tools, resolve_current_tool_result_rendering_policy
|
|
31
|
+
from .tool_transport import ToolTransport
|
|
30
32
|
|
|
31
33
|
if TYPE_CHECKING:
|
|
32
34
|
from .text_projection import TextProjectedRequest
|
|
@@ -36,20 +38,7 @@ type RequestPromptPartList = list[RequestPromptPart]
|
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
def _validate_coding_agent_user_prompt_content_item(*, item: UserContent, backend_label: str) -> None:
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
This layers two checks:
|
|
42
|
-
|
|
43
|
-
1. Structural check (policy-free): reject anything outside the Pydantic AI
|
|
44
|
-
``UserContent`` union (``str | TextContent | CachePoint | <multimodal>``).
|
|
45
|
-
2. Admission policy (coding-agent-specific): reject ``UploadedFile`` even
|
|
46
|
-
though it is a legal ``UserContent`` member, because coding-agent
|
|
47
|
-
backends cannot resolve provider-owned file references.
|
|
48
|
-
|
|
49
|
-
Only backends subclassing :class:`BackendModelBase` (the coding-agent
|
|
50
|
-
backends) route through this helper; provider-backed executors send
|
|
51
|
-
``UserContent`` natively and bypass this path.
|
|
52
|
-
"""
|
|
41
|
+
"""Reject non-``UserContent`` items and ``UploadedFile``, whose provider-owned references coding-agent backends cannot resolve."""
|
|
53
42
|
if isinstance(item, UploadedFile):
|
|
54
43
|
raise UserError(
|
|
55
44
|
f"{backend_label} does not support UploadedFile user prompt content; provider-owned uploaded files cannot be resolved by this backend"
|
|
@@ -172,6 +161,22 @@ class BackendModelBase(Model):
|
|
|
172
161
|
super().__init__(profile=profile)
|
|
173
162
|
self.backend_label = backend_label
|
|
174
163
|
|
|
164
|
+
async def request(
|
|
165
|
+
self,
|
|
166
|
+
messages: list[ModelMessage],
|
|
167
|
+
model_settings: ModelSettings | None,
|
|
168
|
+
model_request_parameters: ModelRequestParameters,
|
|
169
|
+
) -> ModelResponse:
|
|
170
|
+
return await ToolTransport().run(lambda: self._request(messages, model_settings, model_request_parameters))
|
|
171
|
+
|
|
172
|
+
async def _request(
|
|
173
|
+
self,
|
|
174
|
+
messages: list[ModelMessage],
|
|
175
|
+
model_settings: ModelSettings | None,
|
|
176
|
+
model_request_parameters: ModelRequestParameters,
|
|
177
|
+
) -> ModelResponse:
|
|
178
|
+
raise NotImplementedError
|
|
179
|
+
|
|
175
180
|
def _prepare_common_request_parts(
|
|
176
181
|
self,
|
|
177
182
|
*,
|
|
@@ -19,6 +19,7 @@ from ..tools.declarations import get_visible_tools
|
|
|
19
19
|
from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
|
|
20
20
|
from .claude_code_settings import ClaudeCodeModelSettings
|
|
21
21
|
from .mcp_server import mcp_server_if_needed
|
|
22
|
+
from .process_management import stop_process
|
|
22
23
|
from .text_projection import TextProjectedRequest, resolve_text_projection_staging_root_directory
|
|
23
24
|
|
|
24
25
|
|
|
@@ -149,7 +150,7 @@ class ClaudeCodeCliModel(BackendModelBase):
|
|
|
149
150
|
def system(self) -> str:
|
|
150
151
|
return "anthropic"
|
|
151
152
|
|
|
152
|
-
async def
|
|
153
|
+
async def _request(
|
|
153
154
|
self,
|
|
154
155
|
messages: list[ModelMessage],
|
|
155
156
|
model_settings: ModelSettings | None,
|
|
@@ -159,6 +160,7 @@ class ClaudeCodeCliModel(BackendModelBase):
|
|
|
159
160
|
mcp_configuration_file: IO[str] | None = None
|
|
160
161
|
projected_request: TextProjectedRequest | None = None
|
|
161
162
|
|
|
163
|
+
process: asyncio.subprocess.Process | None = None
|
|
162
164
|
try:
|
|
163
165
|
model_settings, model_request_parameters = self.prepare_request(model_settings, model_request_parameters)
|
|
164
166
|
claude_code_cli_model_settings = ClaudeCodeCliModelSettings.from_model_settings(model_settings)
|
|
@@ -304,6 +306,7 @@ class ClaudeCodeCliModel(BackendModelBase):
|
|
|
304
306
|
except Exception as exception:
|
|
305
307
|
raise UnexpectedModelBehavior("Claude Code CLI backend failed") from exception
|
|
306
308
|
finally:
|
|
309
|
+
await stop_process(process)
|
|
307
310
|
if system_prompt_file is not None:
|
|
308
311
|
with contextlib.suppress(Exception):
|
|
309
312
|
system_prompt_file.close()
|
nighthawk/backends/codex.py
CHANGED
|
@@ -19,6 +19,7 @@ from pydantic_ai.usage import RequestUsage
|
|
|
19
19
|
from ..tools.declarations import get_visible_tools
|
|
20
20
|
from .base import BackendModelBase, BackendModelSettings, append_text_projected_tool_result_preview_prompt
|
|
21
21
|
from .mcp_server import mcp_server_if_needed
|
|
22
|
+
from .process_management import stop_process
|
|
22
23
|
from .text_projection import TextProjectedRequest, resolve_text_projection_staging_root_directory
|
|
23
24
|
|
|
24
25
|
type SandboxMode = Literal["read-only", "workspace-write", "danger-full-access"]
|
|
@@ -178,7 +179,7 @@ class CodexModel(BackendModelBase):
|
|
|
178
179
|
def system(self) -> str:
|
|
179
180
|
return "openai"
|
|
180
181
|
|
|
181
|
-
async def
|
|
182
|
+
async def _request(
|
|
182
183
|
self,
|
|
183
184
|
messages: list[ModelMessage],
|
|
184
185
|
model_settings: ModelSettings | None,
|
|
@@ -193,6 +194,8 @@ class CodexModel(BackendModelBase):
|
|
|
193
194
|
|
|
194
195
|
output_schema_file: IO[str] | None = None
|
|
195
196
|
projected_request: TextProjectedRequest | None = None
|
|
197
|
+
process: asyncio.subprocess.Process | None = None
|
|
198
|
+
stderr_task: asyncio.Task[bytes] | None = None
|
|
196
199
|
|
|
197
200
|
try:
|
|
198
201
|
codex_model_settings = CodexModelSettings.from_model_settings(model_settings)
|
|
@@ -334,6 +337,10 @@ class CodexModel(BackendModelBase):
|
|
|
334
337
|
except Exception as exception:
|
|
335
338
|
raise UnexpectedModelBehavior("Codex backend failed") from exception
|
|
336
339
|
finally:
|
|
340
|
+
if stderr_task is not None and not stderr_task.done():
|
|
341
|
+
stderr_task.cancel()
|
|
342
|
+
await asyncio.gather(stderr_task, return_exceptions=True)
|
|
343
|
+
await stop_process(process)
|
|
337
344
|
if output_schema_file is not None:
|
|
338
345
|
with contextlib.suppress(Exception):
|
|
339
346
|
output_schema_file.close()
|
|
@@ -18,12 +18,8 @@ from ..runtime.step_context import (
|
|
|
18
18
|
)
|
|
19
19
|
from ..tools._tool_return_content import resolve_tool_return_segments
|
|
20
20
|
from ..tools.contracts import (
|
|
21
|
-
BoundaryFailureObservation,
|
|
22
|
-
ToolError,
|
|
23
21
|
ToolHandlerResult,
|
|
24
22
|
ToolOutcome,
|
|
25
|
-
ToolResultObservation,
|
|
26
|
-
build_tool_result_observation,
|
|
27
23
|
render_tool_handler_result_preview_text,
|
|
28
24
|
)
|
|
29
25
|
from ._text_fallback import (
|
|
@@ -53,18 +49,11 @@ def _resolve_effective_tool_result_rendering_policy(
|
|
|
53
49
|
return _resolve_tool_result_rendering_policy()
|
|
54
50
|
|
|
55
51
|
|
|
56
|
-
def _tool_boundary_failure_result(*, message: str, guidance: str) -> ToolResultObservation:
|
|
57
|
-
tool_outcome: ToolOutcome = {
|
|
58
|
-
"payload": None,
|
|
59
|
-
"error": {"kind": "internal", "message": message, "guidance": guidance},
|
|
60
|
-
}
|
|
61
|
-
return build_tool_result_observation(tool_outcome=tool_outcome)
|
|
62
|
-
|
|
63
|
-
|
|
64
52
|
def _tool_outcome_to_mcp_content_list(
|
|
65
53
|
*,
|
|
66
54
|
tool_name: str,
|
|
67
55
|
tool_outcome: ToolOutcome,
|
|
56
|
+
rendering_policy: ToolResultRenderingPolicy,
|
|
68
57
|
) -> list[object]:
|
|
69
58
|
from mcp import types as mcp_types
|
|
70
59
|
|
|
@@ -73,6 +62,7 @@ def _tool_outcome_to_mcp_content_list(
|
|
|
73
62
|
segments = resolve_tool_return_segments(
|
|
74
63
|
tool_name=tool_name,
|
|
75
64
|
payload=tool_outcome["payload"],
|
|
65
|
+
rendering_policy=rendering_policy,
|
|
76
66
|
)
|
|
77
67
|
if segments.is_empty_success():
|
|
78
68
|
return []
|
|
@@ -97,10 +87,8 @@ def _tool_outcome_to_mcp_content_list(
|
|
|
97
87
|
if content_item.is_audio:
|
|
98
88
|
content_block_list.append(mcp_types.AudioContent(type="audio", data=content_item.base64, mime_type=content_item.media_type))
|
|
99
89
|
continue
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
# audio media, and Nighthawk intentionally does NOT wrap other
|
|
103
|
-
# blobs in an ``EmbeddedResource`` with a synthetic URI scheme.
|
|
90
|
+
# MCP has first-class slots only for image and audio media.
|
|
91
|
+
# Other blobs project to text rather than an ``EmbeddedResource`` with a synthetic URI scheme.
|
|
104
92
|
content_block_list.append(
|
|
105
93
|
mcp_types.TextContent(
|
|
106
94
|
type="text",
|
|
@@ -192,6 +180,7 @@ def tool_handler_result_to_low_level_mcp_content(
|
|
|
192
180
|
return _tool_outcome_to_mcp_content_list(
|
|
193
181
|
tool_name=tool_name,
|
|
194
182
|
tool_outcome=tool_outcome,
|
|
183
|
+
rendering_policy=effective_rendering_policy,
|
|
195
184
|
)
|
|
196
185
|
except Exception as exception:
|
|
197
186
|
_record_mcp_projection_fallback(tool_name=tool_name, exception=exception)
|
|
@@ -221,36 +210,18 @@ def _tool_handler_result_to_claude_code_sdk_content(
|
|
|
221
210
|
return {"content": dumped_content_block_list}
|
|
222
211
|
|
|
223
212
|
|
|
224
|
-
async def
|
|
213
|
+
async def _get_tool_handler_result(
|
|
225
214
|
*,
|
|
226
215
|
tool_name: str,
|
|
227
216
|
arguments: dict[str, object],
|
|
228
217
|
tool_handler: ToolHandler,
|
|
229
218
|
parent_otel_context: OtelContext,
|
|
230
219
|
) -> ToolHandlerResult:
|
|
220
|
+
context_token = otel_context.attach(parent_otel_context)
|
|
231
221
|
try:
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
return await tool_handler(arguments)
|
|
236
|
-
except Exception as exception:
|
|
237
|
-
error_message = str(exception) or "Tool boundary wrapper failed"
|
|
238
|
-
return _tool_boundary_failure_result(
|
|
239
|
-
message=error_message,
|
|
240
|
-
guidance="The tool boundary wrapper failed. Retry or report this error.",
|
|
241
|
-
)
|
|
242
|
-
finally:
|
|
243
|
-
otel_context.detach(context_token)
|
|
244
|
-
except Exception as exception:
|
|
245
|
-
tool_error: ToolError = {
|
|
246
|
-
"kind": "internal",
|
|
247
|
-
"message": str(exception) or "Tool boundary wrapper failed",
|
|
248
|
-
"guidance": "The tool boundary wrapper failed. Retry or report this error.",
|
|
249
|
-
}
|
|
250
|
-
return BoundaryFailureObservation(
|
|
251
|
-
kind="boundary_failure",
|
|
252
|
-
tool_error=tool_error,
|
|
253
|
-
)
|
|
222
|
+
return await tool_handler(arguments)
|
|
223
|
+
finally:
|
|
224
|
+
otel_context.detach(context_token)
|
|
254
225
|
|
|
255
226
|
|
|
256
227
|
async def call_tool_for_claude_code_sdk(
|
|
@@ -261,7 +232,7 @@ async def call_tool_for_claude_code_sdk(
|
|
|
261
232
|
parent_otel_context: OtelContext,
|
|
262
233
|
rendering_policy: ToolResultRenderingPolicy | None = None,
|
|
263
234
|
) -> dict[str, object]:
|
|
264
|
-
tool_handler_result = await
|
|
235
|
+
tool_handler_result = await _get_tool_handler_result(
|
|
265
236
|
tool_name=tool_name,
|
|
266
237
|
arguments=arguments,
|
|
267
238
|
tool_handler=tool_handler,
|
|
@@ -282,7 +253,7 @@ async def call_tool_for_low_level_mcp_server(
|
|
|
282
253
|
parent_otel_context: OtelContext,
|
|
283
254
|
rendering_policy: ToolResultRenderingPolicy | None = None,
|
|
284
255
|
) -> list[object]:
|
|
285
|
-
tool_handler_result = await
|
|
256
|
+
tool_handler_result = await _get_tool_handler_result(
|
|
286
257
|
tool_name=tool_name,
|
|
287
258
|
arguments=arguments,
|
|
288
259
|
tool_handler=tool_handler,
|
nighthawk/backends/mcp_server.py
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
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
1
|
from __future__ import annotations
|
|
9
2
|
|
|
10
3
|
import asyncio
|
|
@@ -31,8 +24,6 @@ from .tool_bridge import get_current_step_run_context_required
|
|
|
31
24
|
|
|
32
25
|
|
|
33
26
|
class McpServer:
|
|
34
|
-
"""In-process MCP tool server backed by Nighthawk tool handlers."""
|
|
35
|
-
|
|
36
27
|
def __init__(
|
|
37
28
|
self,
|
|
38
29
|
*,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Bound cleanup of a coding-agent process after request failure or cancellation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def stop_process(process: asyncio.subprocess.Process | None) -> None:
|
|
10
|
+
if process is None or process.returncode is not None:
|
|
11
|
+
return
|
|
12
|
+
with suppress(ProcessLookupError):
|
|
13
|
+
process.terminate()
|
|
14
|
+
try:
|
|
15
|
+
await asyncio.wait_for(process.communicate(), timeout=1.0)
|
|
16
|
+
except TimeoutError:
|
|
17
|
+
with suppress(ProcessLookupError):
|
|
18
|
+
process.kill()
|
|
19
|
+
with suppress(TimeoutError):
|
|
20
|
+
await asyncio.wait_for(process.communicate(), timeout=1.0)
|
|
21
|
+
except BaseException:
|
|
22
|
+
with suppress(ProcessLookupError):
|
|
23
|
+
process.kill()
|
|
24
|
+
raise
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Project request parts into CLI prompt text and staged files.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
into a single prompt string and, when needed, a staged temporary directory that
|
|
5
|
-
holds ``BinaryContent`` bytes as local files.
|
|
6
|
-
|
|
7
|
-
Backend-specific concerns (transport selection, CLI option assembly) live in
|
|
8
|
-
the individual backend modules. This module must not grow into a general
|
|
9
|
-
projection layer.
|
|
3
|
+
Backend-specific concerns stay in the backend modules; this module must not grow into a general projection layer.
|
|
10
4
|
"""
|
|
11
5
|
|
|
12
6
|
from __future__ import annotations
|
|
@@ -38,6 +32,7 @@ from ._text_fallback import (
|
|
|
38
32
|
is_image_content,
|
|
39
33
|
)
|
|
40
34
|
from .base import RequestPromptPartList
|
|
35
|
+
from .tool_bridge import resolve_current_tool_result_rendering_policy
|
|
41
36
|
|
|
42
37
|
|
|
43
38
|
@dataclass
|
|
@@ -46,7 +41,6 @@ class TextProjectedRequest:
|
|
|
46
41
|
temporary_directory: tempfile.TemporaryDirectory[str] | None
|
|
47
42
|
|
|
48
43
|
def cleanup(self) -> None:
|
|
49
|
-
"""Clean up the temporary directory, if any, suppressing all exceptions."""
|
|
50
44
|
if self.temporary_directory is not None:
|
|
51
45
|
with contextlib.suppress(Exception):
|
|
52
46
|
self.temporary_directory.cleanup()
|
|
@@ -106,22 +100,6 @@ def _sanitize_projected_file_stem(*, identifier: str) -> str:
|
|
|
106
100
|
|
|
107
101
|
|
|
108
102
|
class _TextProjectionStager:
|
|
109
|
-
"""Owns the staging temporary directory for a single projection pass.
|
|
110
|
-
|
|
111
|
-
Per-pass orchestration responsibilities bundled here:
|
|
112
|
-
|
|
113
|
-
- Lazy lifetime of the staging temporary directory: created on the first
|
|
114
|
-
call to :meth:`stage_binary` and kept alive across the rest of the
|
|
115
|
-
projection so that adjacent ``BinaryContent`` items share one directory.
|
|
116
|
-
- Per-pass file-name collision avoidance via
|
|
117
|
-
:meth:`_build_unique_projected_file_path`, so two ``BinaryContent`` items
|
|
118
|
-
that share an identifier within one step do not clobber each other.
|
|
119
|
-
- Coordination of stem sanitization
|
|
120
|
-
(:func:`_sanitize_projected_file_stem`) and extension selection
|
|
121
|
-
(:func:`_guess_file_extension` plus the deterministic
|
|
122
|
-
``_KNOWN_MEDIA_TYPE_TO_EXTENSION`` map) at staging time.
|
|
123
|
-
"""
|
|
124
|
-
|
|
125
103
|
def __init__(self, *, staging_root_directory: Path | None) -> None:
|
|
126
104
|
self._staging_root_directory = staging_root_directory
|
|
127
105
|
self._temporary_directory: tempfile.TemporaryDirectory[str] | None = None
|
|
@@ -230,6 +208,7 @@ def _project_tool_return_part_to_text(
|
|
|
230
208
|
segments = resolve_tool_return_segments(
|
|
231
209
|
tool_name=tool_return_part.tool_name,
|
|
232
210
|
payload=tool_return_part.content,
|
|
211
|
+
rendering_policy=resolve_current_tool_result_rendering_policy(),
|
|
233
212
|
)
|
|
234
213
|
if segments.is_empty_success():
|
|
235
214
|
return f"Tool return from {tool_return_part.tool_name}:"
|