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 +15 -1
- nighthawk/backends/_text_fallback.py +141 -0
- nighthawk/backends/base.py +164 -19
- nighthawk/backends/claude_code_cli.py +17 -6
- nighthawk/backends/claude_code_sdk.py +157 -140
- nighthawk/backends/codex.py +22 -9
- nighthawk/backends/mcp_boundary.py +231 -65
- nighthawk/backends/mcp_server.py +44 -43
- nighthawk/backends/text_projection.py +352 -0
- nighthawk/backends/tool_bridge.py +100 -68
- nighthawk/configuration.py +9 -3
- nighthawk/json_renderer.py +17 -0
- nighthawk/runtime/_user_content.py +101 -0
- nighthawk/runtime/prompt.py +328 -116
- nighthawk/runtime/runner.py +2 -2
- nighthawk/runtime/scoping.py +47 -3
- nighthawk/runtime/step_context.py +7 -0
- nighthawk/runtime/step_contract.py +13 -4
- nighthawk/runtime/step_executor.py +19 -8
- nighthawk/runtime/tool_calls.py +9 -6
- nighthawk/tools/_tool_return_content.py +66 -0
- nighthawk/tools/contracts.py +126 -2
- nighthawk/tools/execution.py +93 -14
- {nighthawk_python-0.10.0.dist-info → nighthawk_python-0.12.0.dist-info}/METADATA +13 -13
- nighthawk_python-0.12.0.dist-info/RECORD +51 -0
- {nighthawk_python-0.10.0.dist-info → nighthawk_python-0.12.0.dist-info}/WHEEL +1 -1
- nighthawk_python-0.10.0.dist-info/RECORD +0 -47
- {nighthawk_python-0.10.0.dist-info → nighthawk_python-0.12.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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)]
|
nighthawk/backends/base.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
request_prompt_part_list.append((part.model_response(),))
|
|
40
154
|
elif isinstance(part, ToolReturnPart):
|
|
41
|
-
|
|
155
|
+
request_prompt_part_list.append(part)
|
|
42
156
|
|
|
43
|
-
return
|
|
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
|
-
) ->
|
|
67
|
-
if model_request_parameters.
|
|
68
|
-
raise UserError(f"{self.backend_label} does not support
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
projected_request.cleanup()
|
|
224
|
+
raise empty_prompt_exception_factory(f"{self.backend_label} requires a non-empty user prompt")
|
|
84
225
|
|
|
85
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|