nighthawk-python 0.9.0__py3-none-any.whl → 0.11.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
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from . import oversight, resilience
3
4
  from .configuration import (
4
5
  StepContextLimits,
5
6
  StepExecutorConfiguration,
@@ -15,15 +16,26 @@ from .errors import (
15
16
  )
16
17
  from .json_renderer import JsonableValue, to_jsonable_value
17
18
  from .natural.decorator import natural_function
18
- from .runtime.scoping import ExecutionContext, UsageMeter, get_current_usage_meter, get_execution_context, 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
+ )
19
31
  from .runtime.step_context import StepContext, get_current_step_context
20
32
  from .runtime.step_executor import AgentStepExecutor, StepExecutor
21
33
  from .tools.registry import tool
22
34
 
23
35
  __all__ = [
24
36
  "AgentStepExecutor",
25
- "ExecutionContext",
26
37
  "ExecutionError",
38
+ "ExecutionRef",
27
39
  "JsonableValue",
28
40
  "NaturalParseError",
29
41
  "NighthawkError",
@@ -38,9 +50,14 @@ __all__ = [
38
50
  "UsageMeter",
39
51
  "get_current_step_context",
40
52
  "get_current_usage_meter",
41
- "get_execution_context",
53
+ "get_execution_ref",
54
+ "get_implicit_references",
42
55
  "get_step_executor",
56
+ "get_system_prompt_suffix_fragments",
57
+ "get_user_prompt_suffix_fragments",
58
+ "oversight",
43
59
  "natural_function",
60
+ "resilience",
44
61
  "run",
45
62
  "scope",
46
63
  "to_jsonable_value",
@@ -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,61 @@
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
+ ModelMessage,
13
+ ModelRequest,
14
+ RetryPromptPart,
15
+ SystemPromptPart,
16
+ TextContent,
17
+ ToolReturnPart,
18
+ UploadedFile,
19
+ UserContent,
20
+ UserPromptPart,
21
+ is_multi_modal_content,
22
+ )
9
23
  from pydantic_ai.models import Model, ModelRequestParameters
10
24
  from pydantic_ai.settings import ModelSettings
11
25
 
12
- from .tool_bridge import ToolHandler, prepare_allowed_tools
26
+ from ..configuration import TEXT_PROJECTED_TOOL_RESULT_PREVIEW_SYSTEM_PROMPT_FRAGMENT
27
+ from ..runtime.prompt import resolve_step_system_prompt_template_text
28
+ from .tool_bridge import ToolHandler, prepare_allowed_tools, resolve_current_tool_result_rendering_policy
29
+
30
+ if TYPE_CHECKING:
31
+ from .text_projection import TextProjectedRequest
32
+
33
+ type RequestPromptPart = tuple[UserContent, ...] | ToolReturnPart
34
+ type RequestPromptPartList = list[RequestPromptPart]
35
+
36
+
37
+ def _validate_coding_agent_user_prompt_content_item(*, item: UserContent, backend_label: str) -> None:
38
+ """Enforce the coding-agent-backend user-prompt admission policy.
39
+
40
+ This layers two checks:
41
+
42
+ 1. Structural check (policy-free): reject anything outside the Pydantic AI
43
+ ``UserContent`` union (``str | TextContent | CachePoint | <multimodal>``).
44
+ 2. Admission policy (coding-agent-specific): reject ``UploadedFile`` even
45
+ though it is a legal ``UserContent`` member, because coding-agent
46
+ backends cannot resolve provider-owned file references.
47
+
48
+ Only backends subclassing :class:`BackendModelBase` (the coding-agent
49
+ backends) route through this helper; provider-backed executors send
50
+ ``UserContent`` natively and bypass this path.
51
+ """
52
+ if isinstance(item, UploadedFile):
53
+ raise UserError(
54
+ f"{backend_label} does not support UploadedFile user prompt content; provider-owned uploaded files cannot be resolved by this backend"
55
+ )
56
+ if isinstance(item, str | TextContent | CachePoint) or is_multi_modal_content(item):
57
+ return
58
+ raise UserError(f"{backend_label} does not support user prompt content type {type(item).__name__}")
13
59
 
14
60
 
15
61
  def _find_most_recent_model_request(messages: list[ModelMessage]) -> ModelRequest:
@@ -27,20 +73,62 @@ def _collect_system_prompt_text(model_request: ModelRequest) -> str:
27
73
  return "\n\n".join(parts)
28
74
 
29
75
 
30
- def _collect_user_prompt_text(model_request: ModelRequest, *, backend_label: str) -> str:
31
- parts: list[str] = []
76
+ def _resolve_current_tool_result_max_tokens() -> int:
77
+ return resolve_current_tool_result_rendering_policy().tool_result_max_tokens
78
+
79
+
80
+ def append_text_projected_tool_result_preview_prompt(*, system_prompt_text: str) -> str:
81
+ """Append the text-projected tool-result preview warning to a system prompt.
82
+
83
+ Backends should call this only after confirming that at least one Nighthawk
84
+ tool will actually be exposed to the model. If no tool is exposed, the
85
+ preview-loss caveat is irrelevant and adds prompt noise.
86
+ """
87
+ fragment = resolve_step_system_prompt_template_text(
88
+ template_text=TEXT_PROJECTED_TOOL_RESULT_PREVIEW_SYSTEM_PROMPT_FRAGMENT,
89
+ tool_result_max_tokens=_resolve_current_tool_result_max_tokens(),
90
+ )
91
+ if not system_prompt_text:
92
+ return fragment
93
+ return "\n".join([system_prompt_text, fragment])
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class PreparedRequestParts:
98
+ system_prompt_text: str
99
+ request_prompt_part_list: RequestPromptPartList
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class PreparedTextProjectedRequest:
104
+ system_prompt_text: str
105
+ user_prompt_text: str
106
+ projected_request: TextProjectedRequest
107
+
108
+
109
+ def _normalize_user_prompt_content(content: str | Sequence[UserContent], *, backend_label: str) -> tuple[UserContent, ...]:
110
+ if isinstance(content, str):
111
+ return (content,)
112
+
113
+ normalized_content_list: list[UserContent] = []
114
+ for item in content:
115
+ _validate_coding_agent_user_prompt_content_item(item=item, backend_label=backend_label)
116
+ normalized_content_list.append(item)
117
+
118
+ return tuple(normalized_content_list)
119
+
120
+
121
+ def _collect_request_prompt_part_list(model_request: ModelRequest, *, backend_label: str) -> RequestPromptPartList:
122
+ request_prompt_part_list: RequestPromptPartList = []
32
123
  for part in model_request.parts:
33
124
  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")
125
+ request_prompt_part_list.append(_normalize_user_prompt_content(part.content, backend_label=backend_label))
38
126
  elif isinstance(part, RetryPromptPart):
39
- parts.append(part.model_response())
127
+ request_prompt_part_list.append((part.model_response(),))
40
128
  elif isinstance(part, ToolReturnPart):
41
- raise UserError(f"{backend_label} does not support tool-return parts")
129
+ request_prompt_part_list.append(part)
42
130
 
43
- return "\n\n".join(p for p in parts if p)
131
+ return request_prompt_part_list
44
132
 
45
133
 
46
134
  class BackendModelBase(Model):
@@ -63,7 +151,7 @@ class BackendModelBase(Model):
63
151
  *,
64
152
  messages: list[ModelMessage],
65
153
  model_request_parameters: ModelRequestParameters,
66
- ) -> tuple[ModelRequest, str, str]:
154
+ ) -> PreparedRequestParts:
67
155
  if model_request_parameters.builtin_tools:
68
156
  raise UserError(f"{self.backend_label} does not support builtin tools")
69
157
 
@@ -78,11 +166,42 @@ class BackendModelBase(Model):
78
166
  if instructions:
79
167
  system_prompt_text = "\n\n".join([system_prompt_text, instructions]) if system_prompt_text else instructions
80
168
 
81
- user_prompt_text = _collect_user_prompt_text(model_request, backend_label=self.backend_label)
169
+ request_prompt_part_list = _collect_request_prompt_part_list(model_request, backend_label=self.backend_label)
170
+ return PreparedRequestParts(
171
+ system_prompt_text=system_prompt_text,
172
+ request_prompt_part_list=request_prompt_part_list,
173
+ )
174
+
175
+ def _prepare_text_projected_request(
176
+ self,
177
+ *,
178
+ messages: list[ModelMessage],
179
+ model_request_parameters: ModelRequestParameters,
180
+ staging_root_directory: Path,
181
+ empty_prompt_exception_factory: Callable[[str], Exception],
182
+ ) -> PreparedTextProjectedRequest:
183
+ from .text_projection import project_request_prompt_part_list_to_text
184
+
185
+ prepared_request_parts = self._prepare_common_request_parts(
186
+ messages=messages,
187
+ model_request_parameters=model_request_parameters,
188
+ )
189
+ system_prompt_text = prepared_request_parts.system_prompt_text
190
+
191
+ projected_request = project_request_prompt_part_list_to_text(
192
+ prepared_request_parts.request_prompt_part_list,
193
+ staging_root_directory=staging_root_directory,
194
+ )
195
+ user_prompt_text = projected_request.prompt_text
82
196
  if user_prompt_text.strip() == "":
83
- raise UserError(f"{self.backend_label} requires a non-empty user prompt")
197
+ projected_request.cleanup()
198
+ raise empty_prompt_exception_factory(f"{self.backend_label} requires a non-empty user prompt")
84
199
 
85
- return model_request, system_prompt_text, user_prompt_text
200
+ return PreparedTextProjectedRequest(
201
+ system_prompt_text=system_prompt_text,
202
+ user_prompt_text=user_prompt_text,
203
+ projected_request=projected_request,
204
+ )
86
205
 
87
206
  async def _prepare_allowed_tools(
88
207
  self,
@@ -17,9 +17,10 @@ from pydantic_ai.settings import ModelSettings
17
17
  from pydantic_ai.usage import RequestUsage
18
18
 
19
19
  from ..tools.registry import get_visible_tools
20
- from .base import BackendModelBase
20
+ from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
21
21
  from .claude_code_settings import ClaudeCodeModelSettings
22
22
  from .mcp_server import mcp_server_if_needed
23
+ from .text_projection import TextProjectedRequest, resolve_text_projection_staging_root_directory
23
24
 
24
25
 
25
26
  class ClaudeCodeCliModelSettings(ClaudeCodeModelSettings):
@@ -157,16 +158,24 @@ class ClaudeCodeCliModel(BackendModelBase):
157
158
  ) -> ModelResponse:
158
159
  system_prompt_file: IO[str] | None = None
159
160
  mcp_configuration_file: IO[str] | None = None
161
+ projected_request: TextProjectedRequest | None = None
160
162
 
161
163
  try:
162
164
  model_settings, model_request_parameters = self.prepare_request(model_settings, model_request_parameters)
165
+ claude_code_cli_model_settings = ClaudeCodeCliModelSettings.from_model_settings(model_settings)
166
+ staging_root_directory = resolve_text_projection_staging_root_directory(
167
+ working_directory=claude_code_cli_model_settings.working_directory,
168
+ )
163
169
 
164
- _, system_prompt_text, user_prompt_text = self._prepare_common_request_parts(
170
+ prepared_projected_request = self._prepare_text_projected_request(
165
171
  messages=messages,
166
172
  model_request_parameters=model_request_parameters,
173
+ staging_root_directory=staging_root_directory,
174
+ empty_prompt_exception_factory=UserError,
167
175
  )
168
-
169
- claude_code_cli_model_settings = ClaudeCodeCliModelSettings.from_model_settings(model_settings)
176
+ projected_request = prepared_projected_request.projected_request
177
+ system_prompt_text = prepared_projected_request.system_prompt_text
178
+ user_prompt_text = prepared_projected_request.user_prompt_text
170
179
 
171
180
  tool_name_to_tool_definition, tool_name_to_handler, allowed_tool_names = await self._prepare_allowed_tools(
172
181
  model_request_parameters=model_request_parameters,
@@ -175,6 +184,7 @@ class ClaudeCodeCliModel(BackendModelBase):
175
184
  )
176
185
 
177
186
  if allowed_tool_names:
187
+ system_prompt_text = append_text_projected_tool_result_preview_prompt(system_prompt_text=system_prompt_text)
178
188
  system_prompt_text = "\n".join(
179
189
  [
180
190
  system_prompt_text,
@@ -301,3 +311,5 @@ class ClaudeCodeCliModel(BackendModelBase):
301
311
  if mcp_configuration_file is not None:
302
312
  with contextlib.suppress(Exception):
303
313
  mcp_configuration_file.close()
314
+ if projected_request is not None:
315
+ projected_request.cleanup()