nighthawk-python 0.14.0__py3-none-any.whl → 0.16.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 (75) hide show
  1. nighthawk/__init__.py +15 -8
  2. nighthawk/backends/_text_fallback.py +7 -49
  3. nighthawk/backends/base.py +19 -14
  4. nighthawk/backends/claude_code_cli.py +4 -1
  5. nighthawk/backends/claude_code_sdk.py +1 -1
  6. nighthawk/backends/codex.py +8 -1
  7. nighthawk/backends/mcp_boundary.py +12 -41
  8. nighthawk/backends/mcp_server.py +0 -9
  9. nighthawk/backends/process_management.py +24 -0
  10. nighthawk/backends/text_projection.py +4 -25
  11. nighthawk/backends/tool_bridge.py +52 -55
  12. nighthawk/backends/tool_transport.py +88 -0
  13. nighthawk/composition.py +6 -2
  14. nighthawk/configuration.py +43 -4
  15. nighthawk/errors.py +0 -8
  16. nighthawk/function_call.py +189 -0
  17. nighthawk/identifier_path.py +1 -13
  18. nighthawk/json_renderer.py +76 -19
  19. nighthawk/lifecycle.py +30 -11
  20. nighthawk/natural/_names.py +23 -0
  21. nighthawk/natural/blocks.py +5 -36
  22. nighthawk/natural/decorator.py +105 -107
  23. nighthawk/natural/definition.py +45 -0
  24. nighthawk/natural/transform.py +135 -106
  25. nighthawk/oversight.py +15 -53
  26. nighthawk/resilience/_budget.py +11 -21
  27. nighthawk/resilience/_circuit_breaker.py +4 -25
  28. nighthawk/resilience/_fallback.py +0 -7
  29. nighthawk/resilience/_retry.py +0 -22
  30. nighthawk/resilience/_timeout.py +6 -30
  31. nighthawk/resilience/_vote.py +1 -7
  32. nighthawk/runtime/__init__.py +98 -0
  33. nighthawk/runtime/_user_content.py +23 -37
  34. nighthawk/runtime/annotations.py +73 -0
  35. nighthawk/runtime/assignment.py +145 -0
  36. nighthawk/runtime/async_bridge.py +168 -20
  37. nighthawk/runtime/bindings.py +111 -0
  38. nighthawk/runtime/context.py +101 -0
  39. nighthawk/runtime/description.py +482 -0
  40. nighthawk/runtime/discovery.py +122 -0
  41. nighthawk/runtime/embedded.py +107 -0
  42. nighthawk/runtime/environment.py +295 -0
  43. nighthawk/runtime/execution_reference.py +1 -0
  44. nighthawk/runtime/expression.py +93 -0
  45. nighthawk/runtime/inspection.py +52 -0
  46. nighthawk/runtime/interruption.py +18 -0
  47. nighthawk/runtime/model_candidate.py +57 -0
  48. nighthawk/runtime/notifications.py +41 -0
  49. nighthawk/runtime/operation_contract.py +65 -0
  50. nighthawk/runtime/operations.py +142 -0
  51. nighthawk/runtime/preparation.py +230 -0
  52. nighthawk/runtime/prompt.py +115 -199
  53. nighthawk/runtime/runner.py +262 -559
  54. nighthawk/runtime/scoping.py +90 -96
  55. nighthawk/runtime/step_context.py +36 -53
  56. nighthawk/runtime/step_contract.py +0 -3
  57. nighthawk/runtime/step_definition.py +41 -0
  58. nighthawk/runtime/step_executor.py +95 -77
  59. nighthawk/runtime/step_request.py +99 -0
  60. nighthawk/runtime/step_result.py +31 -23
  61. nighthawk/testing.py +82 -102
  62. nighthawk/tools/_admission.py +85 -0
  63. nighthawk/tools/_ordering.py +88 -0
  64. nighthawk/tools/_tool_return_content.py +19 -35
  65. nighthawk/tools/assignment.py +16 -250
  66. nighthawk/tools/contracts.py +8 -28
  67. nighthawk/tools/declarations.py +0 -1
  68. nighthawk/tools/execution.py +39 -113
  69. nighthawk/tools/provided.py +4 -10
  70. nighthawk/ulid.py +0 -1
  71. {nighthawk_python-0.14.0.dist-info → nighthawk_python-0.16.0.dist-info}/METADATA +26 -28
  72. nighthawk_python-0.16.0.dist-info/RECORD +80 -0
  73. {nighthawk_python-0.14.0.dist-info → nighthawk_python-0.16.0.dist-info}/WHEEL +1 -1
  74. nighthawk_python-0.14.0.dist-info/RECORD +0 -55
  75. {nighthawk_python-0.14.0.dist-info → nighthawk_python-0.16.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
- """Backend-private text-fallback formatting for multimodal content.
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
- """Return ``content.media_type`` for a FileUrl, or ``None`` when unresolvable.
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
- """Build the fallback metadata text for content the transport cannot resolve.
87
-
88
- Used only on the tool-return path. Spec §8.3 requires the rest of a
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
- """Format ``UploadedFile`` fallback lines including placeholder.
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
- """Format unresolvable ``BinaryContent`` fallback lines including placeholder.
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)]
@@ -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
- """Enforce the coding-agent-backend user-prompt admission policy.
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 request(
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()
@@ -123,7 +123,7 @@ class ClaudeCodeSdkModel(BackendModelBase):
123
123
  def system(self) -> str:
124
124
  return "anthropic"
125
125
 
126
- async def request(
126
+ async def _request(
127
127
  self,
128
128
  messages: list[ModelMessage],
129
129
  model_settings: ModelSettings | None,
@@ -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 request(
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
- # Non-image / non-audio blobs project to text per spec §8.3:
101
- # MCP's rich transport only has first-class slots for image and
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 _get_safe_tool_handler_result(
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
- context_token = otel_context.attach(parent_otel_context)
233
- try:
234
- try:
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 _get_safe_tool_handler_result(
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 _get_safe_tool_handler_result(
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,
@@ -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
- """Text projection for backends that must talk to a CLI text channel.
1
+ """Project request parts into CLI prompt text and staged files.
2
2
 
3
- Converts a ``RequestPromptPartList`` (user-prompt tuples plus tool-return parts)
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}:"