nighthawk-python 0.10.0__py3-none-any.whl → 0.12.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 CHANGED
@@ -16,7 +16,18 @@ from .errors import (
16
16
  )
17
17
  from .json_renderer import JsonableValue, to_jsonable_value
18
18
  from .natural.decorator import natural_function
19
- from .runtime.scoping import ExecutionRef, UsageMeter, get_current_usage_meter, get_execution_ref, get_step_executor, run, scope
19
+ from .runtime.scoping import (
20
+ ExecutionRef,
21
+ UsageMeter,
22
+ get_current_usage_meter,
23
+ get_execution_ref,
24
+ get_implicit_references,
25
+ get_step_executor,
26
+ get_system_prompt_suffix_fragments,
27
+ get_user_prompt_suffix_fragments,
28
+ run,
29
+ scope,
30
+ )
20
31
  from .runtime.step_context import StepContext, get_current_step_context
21
32
  from .runtime.step_executor import AgentStepExecutor, StepExecutor
22
33
  from .tools.registry import tool
@@ -40,7 +51,10 @@ __all__ = [
40
51
  "get_current_step_context",
41
52
  "get_current_usage_meter",
42
53
  "get_execution_ref",
54
+ "get_implicit_references",
43
55
  "get_step_executor",
56
+ "get_system_prompt_suffix_fragments",
57
+ "get_user_prompt_suffix_fragments",
44
58
  "oversight",
45
59
  "natural_function",
46
60
  "resilience",
@@ -0,0 +1,141 @@
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
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pydantic_ai.messages import (
12
+ AudioUrl,
13
+ BinaryContent,
14
+ DocumentUrl,
15
+ FileUrl,
16
+ ImageUrl,
17
+ UploadedFile,
18
+ VideoUrl,
19
+ )
20
+
21
+
22
+ def is_image_content(*, content: object) -> bool:
23
+ """Return True when *content* represents image media."""
24
+ if isinstance(content, ImageUrl):
25
+ return True
26
+ if isinstance(content, BinaryContent):
27
+ return content.is_image
28
+ if isinstance(content, UploadedFile):
29
+ media_type = content.media_type
30
+ return isinstance(media_type, str) and media_type.startswith("image/")
31
+ return False
32
+
33
+
34
+ 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
+ """
42
+ try:
43
+ return content.media_type
44
+ except ValueError:
45
+ return None
46
+
47
+
48
+ 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
+ if isinstance(content, ImageUrl):
58
+ label = "Image"
59
+ include_media_type = False
60
+ elif isinstance(content, AudioUrl):
61
+ label = "Audio"
62
+ include_media_type = True
63
+ elif isinstance(content, DocumentUrl):
64
+ label = "Document"
65
+ include_media_type = True
66
+ elif isinstance(content, VideoUrl):
67
+ label = "Video"
68
+ include_media_type = True
69
+ else:
70
+ label = "File"
71
+ include_media_type = True
72
+
73
+ rendered_line_list: list[str] = [f"{label} URL: {content.url}"]
74
+ if include_media_type:
75
+ media_type = _safe_file_url_media_type(content=content)
76
+ if media_type is not None:
77
+ rendered_line_list.append(f"Media type: {media_type}")
78
+ return rendered_line_list
79
+
80
+
81
+ def format_unresolvable_content_text(
82
+ *,
83
+ content: UploadedFile | BinaryContent,
84
+ transport_label: str,
85
+ ) -> 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.
98
+ """
99
+ if isinstance(content, UploadedFile):
100
+ return f"UploadedFile: provider={content.provider_name}, file_id={content.file_id} (not resolvable by {transport_label})"
101
+ return f"BinaryContent: identifier={content.identifier}, media_type={content.media_type} (embedded by this tool; not resolvable by {transport_label})"
102
+
103
+
104
+ def format_file_url_with_placeholder(*, content: FileUrl) -> list[str]:
105
+ """Format ``FileUrl`` text-fallback lines including the ``<image>``/``<file>`` placeholder."""
106
+ placeholder = "<image>" if isinstance(content, ImageUrl) else "<file>"
107
+ return [placeholder, *format_file_url_as_text_lines(content=content)]
108
+
109
+
110
+ def format_uploaded_file_with_placeholder(
111
+ *,
112
+ content: UploadedFile,
113
+ transport_label: str,
114
+ ) -> 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
+ """
122
+ placeholder = "<image>" if is_image_content(content=content) else "<file>"
123
+ metadata_line_list = [format_unresolvable_content_text(content=content, transport_label=transport_label)]
124
+ if content.media_type is not None:
125
+ metadata_line_list.append(f"Media type: {content.media_type}")
126
+ return [placeholder, *metadata_line_list]
127
+
128
+
129
+ def format_unresolvable_binary_content_with_placeholder(
130
+ *,
131
+ content: BinaryContent,
132
+ transport_label: str,
133
+ ) -> 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
+ """
140
+ placeholder = "<image>" if is_image_content(content=content) else "<file>"
141
+ return [placeholder, format_unresolvable_content_text(content=content, transport_label=transport_label)]
@@ -1,15 +1,62 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable, Sequence
4
+ from dataclasses import dataclass
3
5
  from pathlib import Path
4
- from typing import Any, Self
6
+ from typing import TYPE_CHECKING, Any, Self
5
7
 
6
8
  from pydantic import BaseModel, ConfigDict, field_validator
7
9
  from pydantic_ai.exceptions import UnexpectedModelBehavior, UserError
8
- from pydantic_ai.messages import ModelMessage, ModelRequest, RetryPromptPart, SystemPromptPart, ToolReturnPart, UserPromptPart
10
+ from pydantic_ai.messages import (
11
+ CachePoint,
12
+ InstructionPart,
13
+ ModelMessage,
14
+ ModelRequest,
15
+ RetryPromptPart,
16
+ SystemPromptPart,
17
+ TextContent,
18
+ ToolReturnPart,
19
+ UploadedFile,
20
+ UserContent,
21
+ UserPromptPart,
22
+ is_multi_modal_content,
23
+ )
9
24
  from pydantic_ai.models import Model, ModelRequestParameters
10
25
  from pydantic_ai.settings import ModelSettings
11
26
 
12
- from .tool_bridge import ToolHandler, prepare_allowed_tools
27
+ from ..configuration import TEXT_PROJECTED_TOOL_RESULT_PREVIEW_SYSTEM_PROMPT_FRAGMENT
28
+ from ..runtime.prompt import resolve_step_system_prompt_template_text
29
+ from .tool_bridge import ToolHandler, prepare_allowed_tools, resolve_current_tool_result_rendering_policy
30
+
31
+ if TYPE_CHECKING:
32
+ from .text_projection import TextProjectedRequest
33
+
34
+ type RequestPromptPart = tuple[UserContent, ...] | ToolReturnPart
35
+ type RequestPromptPartList = list[RequestPromptPart]
36
+
37
+
38
+ 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
+ """
53
+ if isinstance(item, UploadedFile):
54
+ raise UserError(
55
+ f"{backend_label} does not support UploadedFile user prompt content; provider-owned uploaded files cannot be resolved by this backend"
56
+ )
57
+ if isinstance(item, str | TextContent | CachePoint) or is_multi_modal_content(item):
58
+ return
59
+ raise UserError(f"{backend_label} does not support user prompt content type {type(item).__name__}")
13
60
 
14
61
 
15
62
  def _find_most_recent_model_request(messages: list[ModelMessage]) -> ModelRequest:
@@ -27,20 +74,87 @@ def _collect_system_prompt_text(model_request: ModelRequest) -> str:
27
74
  return "\n\n".join(parts)
28
75
 
29
76
 
30
- def _collect_user_prompt_text(model_request: ModelRequest, *, backend_label: str) -> str:
31
- parts: list[str] = []
77
+ def _collect_instruction_text(
78
+ messages: list[ModelMessage],
79
+ model_request_parameters: ModelRequestParameters,
80
+ ) -> str | None:
81
+ instruction_parts = model_request_parameters.instruction_parts
82
+ if instruction_parts is not None:
83
+ return InstructionPart.join(instruction_parts)
84
+
85
+ model_request_list = [message for message in reversed(messages) if isinstance(message, ModelRequest)]
86
+ if not model_request_list:
87
+ return None
88
+
89
+ most_recent_model_request = model_request_list[0]
90
+ if most_recent_model_request.instructions is not None:
91
+ return most_recent_model_request.instructions
92
+
93
+ if len(model_request_list) < 2:
94
+ return None
95
+
96
+ if all(isinstance(part, ToolReturnPart | RetryPromptPart) for part in most_recent_model_request.parts):
97
+ return model_request_list[1].instructions
98
+
99
+ return None
100
+
101
+
102
+ def _resolve_current_tool_result_max_tokens() -> int:
103
+ return resolve_current_tool_result_rendering_policy().tool_result_max_tokens
104
+
105
+
106
+ def append_text_projected_tool_result_preview_prompt(*, system_prompt_text: str) -> str:
107
+ """Append the text-projected tool-result preview warning to a system prompt.
108
+
109
+ Backends should call this only after confirming that at least one Nighthawk
110
+ tool will actually be exposed to the model. If no tool is exposed, the
111
+ preview-loss caveat is irrelevant and adds prompt noise.
112
+ """
113
+ fragment = resolve_step_system_prompt_template_text(
114
+ template_text=TEXT_PROJECTED_TOOL_RESULT_PREVIEW_SYSTEM_PROMPT_FRAGMENT,
115
+ tool_result_max_tokens=_resolve_current_tool_result_max_tokens(),
116
+ )
117
+ if not system_prompt_text:
118
+ return fragment
119
+ return "\n".join([system_prompt_text, fragment])
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class PreparedRequestParts:
124
+ system_prompt_text: str
125
+ request_prompt_part_list: RequestPromptPartList
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class PreparedTextProjectedRequest:
130
+ system_prompt_text: str
131
+ user_prompt_text: str
132
+ projected_request: TextProjectedRequest
133
+
134
+
135
+ def _normalize_user_prompt_content(content: str | Sequence[UserContent], *, backend_label: str) -> tuple[UserContent, ...]:
136
+ if isinstance(content, str):
137
+ return (content,)
138
+
139
+ normalized_content_list: list[UserContent] = []
140
+ for item in content:
141
+ _validate_coding_agent_user_prompt_content_item(item=item, backend_label=backend_label)
142
+ normalized_content_list.append(item)
143
+
144
+ return tuple(normalized_content_list)
145
+
146
+
147
+ def _collect_request_prompt_part_list(model_request: ModelRequest, *, backend_label: str) -> RequestPromptPartList:
148
+ request_prompt_part_list: RequestPromptPartList = []
32
149
  for part in model_request.parts:
33
150
  if isinstance(part, UserPromptPart):
34
- if isinstance(part.content, str):
35
- parts.append(part.content)
36
- else:
37
- raise UserError(f"{backend_label} does not support non-text user prompts")
151
+ request_prompt_part_list.append(_normalize_user_prompt_content(part.content, backend_label=backend_label))
38
152
  elif isinstance(part, RetryPromptPart):
39
- parts.append(part.model_response())
153
+ request_prompt_part_list.append((part.model_response(),))
40
154
  elif isinstance(part, ToolReturnPart):
41
- raise UserError(f"{backend_label} does not support tool-return parts")
155
+ request_prompt_part_list.append(part)
42
156
 
43
- return "\n\n".join(p for p in parts if p)
157
+ return request_prompt_part_list
44
158
 
45
159
 
46
160
  class BackendModelBase(Model):
@@ -63,9 +177,9 @@ class BackendModelBase(Model):
63
177
  *,
64
178
  messages: list[ModelMessage],
65
179
  model_request_parameters: ModelRequestParameters,
66
- ) -> tuple[ModelRequest, str, str]:
67
- if model_request_parameters.builtin_tools:
68
- raise UserError(f"{self.backend_label} does not support builtin tools")
180
+ ) -> PreparedRequestParts:
181
+ if model_request_parameters.native_tools:
182
+ raise UserError(f"{self.backend_label} does not support native tools")
69
183
 
70
184
  if model_request_parameters.allow_image_output:
71
185
  raise UserError(f"{self.backend_label} does not support image output")
@@ -74,15 +188,46 @@ class BackendModelBase(Model):
74
188
 
75
189
  system_prompt_text = _collect_system_prompt_text(model_request)
76
190
 
77
- instructions = self._get_instructions(messages, model_request_parameters)
191
+ instructions = _collect_instruction_text(messages, model_request_parameters)
78
192
  if instructions:
79
193
  system_prompt_text = "\n\n".join([system_prompt_text, instructions]) if system_prompt_text else instructions
80
194
 
81
- user_prompt_text = _collect_user_prompt_text(model_request, backend_label=self.backend_label)
195
+ request_prompt_part_list = _collect_request_prompt_part_list(model_request, backend_label=self.backend_label)
196
+ return PreparedRequestParts(
197
+ system_prompt_text=system_prompt_text,
198
+ request_prompt_part_list=request_prompt_part_list,
199
+ )
200
+
201
+ def _prepare_text_projected_request(
202
+ self,
203
+ *,
204
+ messages: list[ModelMessage],
205
+ model_request_parameters: ModelRequestParameters,
206
+ staging_root_directory: Path,
207
+ empty_prompt_exception_factory: Callable[[str], Exception],
208
+ ) -> PreparedTextProjectedRequest:
209
+ from .text_projection import project_request_prompt_part_list_to_text
210
+
211
+ prepared_request_parts = self._prepare_common_request_parts(
212
+ messages=messages,
213
+ model_request_parameters=model_request_parameters,
214
+ )
215
+ system_prompt_text = prepared_request_parts.system_prompt_text
216
+
217
+ projected_request = project_request_prompt_part_list_to_text(
218
+ prepared_request_parts.request_prompt_part_list,
219
+ staging_root_directory=staging_root_directory,
220
+ )
221
+ user_prompt_text = projected_request.prompt_text
82
222
  if user_prompt_text.strip() == "":
83
- raise UserError(f"{self.backend_label} requires a non-empty user prompt")
223
+ projected_request.cleanup()
224
+ raise empty_prompt_exception_factory(f"{self.backend_label} requires a non-empty user prompt")
84
225
 
85
- return model_request, system_prompt_text, user_prompt_text
226
+ return PreparedTextProjectedRequest(
227
+ system_prompt_text=system_prompt_text,
228
+ user_prompt_text=user_prompt_text,
229
+ projected_request=projected_request,
230
+ )
86
231
 
87
232
  async def _prepare_allowed_tools(
88
233
  self,
@@ -8,7 +8,6 @@ import tempfile
8
8
  from typing import IO, TypedDict
9
9
 
10
10
  from pydantic import field_validator
11
- from pydantic_ai.builtin_tools import AbstractBuiltinTool
12
11
  from pydantic_ai.exceptions import UnexpectedModelBehavior, UserError
13
12
  from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
14
13
  from pydantic_ai.models import ModelRequestParameters
@@ -17,9 +16,10 @@ from pydantic_ai.settings import ModelSettings
17
16
  from pydantic_ai.usage import RequestUsage
18
17
 
19
18
  from ..tools.registry import get_visible_tools
20
- from .base import BackendModelBase
19
+ from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
21
20
  from .claude_code_settings import ClaudeCodeModelSettings
22
21
  from .mcp_server import mcp_server_if_needed
22
+ from .text_projection import TextProjectedRequest, resolve_text_projection_staging_root_directory
23
23
 
24
24
 
25
25
  class ClaudeCodeCliModelSettings(ClaudeCodeModelSettings):
@@ -136,7 +136,7 @@ class ClaudeCodeCliModel(BackendModelBase):
136
136
  supports_json_object_output=False,
137
137
  supports_image_output=False,
138
138
  default_structured_output_mode="native",
139
- supported_builtin_tools=frozenset([AbstractBuiltinTool]),
139
+ supported_native_tools=frozenset(),
140
140
  ),
141
141
  )
142
142
  self._model_name = model_name
@@ -157,16 +157,24 @@ class ClaudeCodeCliModel(BackendModelBase):
157
157
  ) -> ModelResponse:
158
158
  system_prompt_file: IO[str] | None = None
159
159
  mcp_configuration_file: IO[str] | None = None
160
+ projected_request: TextProjectedRequest | None = None
160
161
 
161
162
  try:
162
163
  model_settings, model_request_parameters = self.prepare_request(model_settings, model_request_parameters)
164
+ claude_code_cli_model_settings = ClaudeCodeCliModelSettings.from_model_settings(model_settings)
165
+ staging_root_directory = resolve_text_projection_staging_root_directory(
166
+ working_directory=claude_code_cli_model_settings.working_directory,
167
+ )
163
168
 
164
- _, system_prompt_text, user_prompt_text = self._prepare_common_request_parts(
169
+ prepared_projected_request = self._prepare_text_projected_request(
165
170
  messages=messages,
166
171
  model_request_parameters=model_request_parameters,
172
+ staging_root_directory=staging_root_directory,
173
+ empty_prompt_exception_factory=UserError,
167
174
  )
168
-
169
- claude_code_cli_model_settings = ClaudeCodeCliModelSettings.from_model_settings(model_settings)
175
+ projected_request = prepared_projected_request.projected_request
176
+ system_prompt_text = prepared_projected_request.system_prompt_text
177
+ user_prompt_text = prepared_projected_request.user_prompt_text
170
178
 
171
179
  tool_name_to_tool_definition, tool_name_to_handler, allowed_tool_names = await self._prepare_allowed_tools(
172
180
  model_request_parameters=model_request_parameters,
@@ -175,6 +183,7 @@ class ClaudeCodeCliModel(BackendModelBase):
175
183
  )
176
184
 
177
185
  if allowed_tool_names:
186
+ system_prompt_text = append_text_projected_tool_result_preview_prompt(system_prompt_text=system_prompt_text)
178
187
  system_prompt_text = "\n".join(
179
188
  [
180
189
  system_prompt_text,
@@ -301,3 +310,5 @@ class ClaudeCodeCliModel(BackendModelBase):
301
310
  if mcp_configuration_file is not None:
302
311
  with contextlib.suppress(Exception):
303
312
  mcp_configuration_file.close()
313
+ if projected_request is not None:
314
+ projected_request.cleanup()