nighthawk-python 0.1.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 +48 -0
- nighthawk/backends/__init__.py +0 -0
- nighthawk/backends/base.py +95 -0
- nighthawk/backends/claude_code_cli.py +342 -0
- nighthawk/backends/claude_code_sdk.py +325 -0
- nighthawk/backends/codex.py +352 -0
- nighthawk/backends/mcp_boundary.py +129 -0
- nighthawk/backends/mcp_server.py +226 -0
- nighthawk/backends/tool_bridge.py +240 -0
- nighthawk/configuration.py +193 -0
- nighthawk/errors.py +25 -0
- nighthawk/identifier_path.py +35 -0
- nighthawk/json_renderer.py +216 -0
- nighthawk/natural/__init__.py +0 -0
- nighthawk/natural/blocks.py +279 -0
- nighthawk/natural/decorator.py +302 -0
- nighthawk/natural/transform.py +346 -0
- nighthawk/runtime/__init__.py +0 -0
- nighthawk/runtime/async_bridge.py +50 -0
- nighthawk/runtime/prompt.py +344 -0
- nighthawk/runtime/runner.py +462 -0
- nighthawk/runtime/scoping.py +288 -0
- nighthawk/runtime/step_context.py +171 -0
- nighthawk/runtime/step_contract.py +231 -0
- nighthawk/runtime/step_executor.py +360 -0
- nighthawk/runtime/tool_calls.py +99 -0
- nighthawk/tools/__init__.py +0 -0
- nighthawk/tools/assignment.py +246 -0
- nighthawk/tools/contracts.py +72 -0
- nighthawk/tools/execution.py +83 -0
- nighthawk/tools/provided.py +80 -0
- nighthawk/tools/registry.py +212 -0
- nighthawk_python-0.1.0.dist-info/METADATA +111 -0
- nighthawk_python-0.1.0.dist-info/RECORD +36 -0
- nighthawk_python-0.1.0.dist-info/WHEEL +4 -0
- nighthawk_python-0.1.0.dist-info/licenses/LICENSE +21 -0
nighthawk/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .configuration import (
|
|
4
|
+
StepContextLimits,
|
|
5
|
+
StepExecutorConfiguration,
|
|
6
|
+
StepExecutorConfigurationPatch,
|
|
7
|
+
StepPromptTemplates,
|
|
8
|
+
)
|
|
9
|
+
from .errors import (
|
|
10
|
+
ExecutionError,
|
|
11
|
+
NaturalParseError,
|
|
12
|
+
NighthawkError,
|
|
13
|
+
ToolEvaluationError,
|
|
14
|
+
ToolRegistrationError,
|
|
15
|
+
ToolValidationError,
|
|
16
|
+
)
|
|
17
|
+
from .json_renderer import JsonableValue, to_jsonable_value
|
|
18
|
+
from .natural.decorator import natural_function
|
|
19
|
+
from .runtime.scoping import ExecutionContext, get_execution_context, get_step_executor, run, scope
|
|
20
|
+
from .runtime.step_context import StepContext, get_current_step_context
|
|
21
|
+
from .runtime.step_executor import AgentStepExecutor, StepExecutor
|
|
22
|
+
from .tools.registry import tool
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AgentStepExecutor",
|
|
26
|
+
"ExecutionError",
|
|
27
|
+
"ExecutionContext",
|
|
28
|
+
"JsonableValue",
|
|
29
|
+
"NaturalParseError",
|
|
30
|
+
"NighthawkError",
|
|
31
|
+
"StepContext",
|
|
32
|
+
"StepContextLimits",
|
|
33
|
+
"StepExecutor",
|
|
34
|
+
"StepExecutorConfiguration",
|
|
35
|
+
"StepExecutorConfigurationPatch",
|
|
36
|
+
"StepPromptTemplates",
|
|
37
|
+
"ToolEvaluationError",
|
|
38
|
+
"ToolRegistrationError",
|
|
39
|
+
"ToolValidationError",
|
|
40
|
+
"get_current_step_context",
|
|
41
|
+
"get_execution_context",
|
|
42
|
+
"get_step_executor",
|
|
43
|
+
"natural_function",
|
|
44
|
+
"run",
|
|
45
|
+
"scope",
|
|
46
|
+
"to_jsonable_value",
|
|
47
|
+
"tool",
|
|
48
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic_ai.exceptions import UnexpectedModelBehavior, UserError
|
|
6
|
+
from pydantic_ai.messages import ModelMessage, ModelRequest, RetryPromptPart, SystemPromptPart, ToolReturnPart, UserPromptPart
|
|
7
|
+
from pydantic_ai.models import Model, ModelRequestParameters
|
|
8
|
+
|
|
9
|
+
from .tool_bridge import ToolHandler, prepare_allowed_tools
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _find_most_recent_model_request(messages: list[ModelMessage]) -> ModelRequest:
|
|
13
|
+
for message in reversed(messages):
|
|
14
|
+
if isinstance(message, ModelRequest):
|
|
15
|
+
return message
|
|
16
|
+
raise UnexpectedModelBehavior("No ModelRequest found in message history")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _collect_system_prompt_text(model_request: ModelRequest) -> str:
|
|
20
|
+
parts: list[str] = []
|
|
21
|
+
for part in model_request.parts:
|
|
22
|
+
if isinstance(part, SystemPromptPart) and part.content:
|
|
23
|
+
parts.append(part.content)
|
|
24
|
+
return "\n\n".join(parts)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _collect_user_prompt_text(model_request: ModelRequest, *, backend_label: str) -> str:
|
|
28
|
+
parts: list[str] = []
|
|
29
|
+
for part in model_request.parts:
|
|
30
|
+
if isinstance(part, UserPromptPart):
|
|
31
|
+
if isinstance(part.content, str):
|
|
32
|
+
parts.append(part.content)
|
|
33
|
+
else:
|
|
34
|
+
raise UserError(f"{backend_label} does not support non-text user prompts")
|
|
35
|
+
elif isinstance(part, RetryPromptPart):
|
|
36
|
+
parts.append(part.model_response())
|
|
37
|
+
elif isinstance(part, ToolReturnPart):
|
|
38
|
+
raise UserError(f"{backend_label} does not support tool-return parts")
|
|
39
|
+
|
|
40
|
+
return "\n\n".join(p for p in parts if p)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BackendModelBase(Model):
|
|
44
|
+
"""Shared request prelude for backends that expose Nighthawk tools via Pydantic AI FunctionToolset.
|
|
45
|
+
|
|
46
|
+
Provider-specific backends should:
|
|
47
|
+
- call `prepare_request(...)` and then `_prepare_common_request_parts(...)`
|
|
48
|
+
- call `_prepare_allowed_tools(...)` to get filtered tool definitions/handlers
|
|
49
|
+
- handle provider-specific transport/execution and convert to `ModelResponse`
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
backend_label: str
|
|
53
|
+
|
|
54
|
+
def __init__(self, *, backend_label: str, profile: Any) -> None:
|
|
55
|
+
super().__init__(profile=profile)
|
|
56
|
+
self.backend_label = backend_label
|
|
57
|
+
|
|
58
|
+
def _prepare_common_request_parts(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
messages: list[ModelMessage],
|
|
62
|
+
model_request_parameters: ModelRequestParameters,
|
|
63
|
+
) -> tuple[ModelRequest, str, str]:
|
|
64
|
+
if model_request_parameters.builtin_tools:
|
|
65
|
+
raise UserError(f"{self.backend_label} does not support builtin tools")
|
|
66
|
+
|
|
67
|
+
if model_request_parameters.allow_image_output:
|
|
68
|
+
raise UserError(f"{self.backend_label} does not support image output")
|
|
69
|
+
|
|
70
|
+
model_request = _find_most_recent_model_request(messages)
|
|
71
|
+
|
|
72
|
+
system_prompt_text = _collect_system_prompt_text(model_request)
|
|
73
|
+
|
|
74
|
+
instructions = self._get_instructions(messages, model_request_parameters)
|
|
75
|
+
if instructions:
|
|
76
|
+
system_prompt_text = "\n\n".join([system_prompt_text, instructions]) if system_prompt_text else instructions
|
|
77
|
+
|
|
78
|
+
user_prompt_text = _collect_user_prompt_text(model_request, backend_label=self.backend_label)
|
|
79
|
+
if user_prompt_text.strip() == "":
|
|
80
|
+
raise UserError(f"{self.backend_label} requires a non-empty user prompt")
|
|
81
|
+
|
|
82
|
+
return model_request, system_prompt_text, user_prompt_text
|
|
83
|
+
|
|
84
|
+
async def _prepare_allowed_tools(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
model_request_parameters: ModelRequestParameters,
|
|
88
|
+
configured_allowed_tool_names: tuple[str, ...] | None,
|
|
89
|
+
visible_tools: list[Any],
|
|
90
|
+
) -> tuple[dict[str, Any], dict[str, ToolHandler], tuple[str, ...]]:
|
|
91
|
+
return await prepare_allowed_tools(
|
|
92
|
+
model_request_parameters=model_request_parameters,
|
|
93
|
+
configured_allowed_tool_names=configured_allowed_tool_names,
|
|
94
|
+
visible_tools=visible_tools,
|
|
95
|
+
)
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Literal, TypedDict
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
12
|
+
from pydantic_ai.builtin_tools import AbstractBuiltinTool
|
|
13
|
+
from pydantic_ai.exceptions import UnexpectedModelBehavior, UserError
|
|
14
|
+
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
|
|
15
|
+
from pydantic_ai.models import ModelRequestParameters
|
|
16
|
+
from pydantic_ai.profiles import ModelProfile
|
|
17
|
+
from pydantic_ai.settings import ModelSettings
|
|
18
|
+
from pydantic_ai.usage import RequestUsage
|
|
19
|
+
|
|
20
|
+
from ..tools.registry import get_visible_tools
|
|
21
|
+
from .base import BackendModelBase
|
|
22
|
+
from .mcp_server import mcp_server_if_needed
|
|
23
|
+
|
|
24
|
+
type PermissionMode = Literal["default", "acceptEdits", "plan", "bypassPermissions"]
|
|
25
|
+
|
|
26
|
+
type SettingSource = Literal["user", "project", "local"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClaudeCodeCliModelSettings(BaseModel):
|
|
30
|
+
"""Settings for the Claude Code CLI backend.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
allowed_tool_names: Nighthawk tool names exposed to the model.
|
|
34
|
+
claude_executable: Path or name of the Claude Code CLI executable.
|
|
35
|
+
claude_max_turns: Maximum conversation turns.
|
|
36
|
+
max_budget_usd: Maximum dollar amount to spend on API calls.
|
|
37
|
+
permission_mode: Claude Code permission mode.
|
|
38
|
+
setting_sources: Configuration sources to load.
|
|
39
|
+
working_directory: Absolute path to the working directory for Claude Code CLI.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
allowed_tool_names: tuple[str, ...] | None = None
|
|
45
|
+
claude_executable: str = "claude"
|
|
46
|
+
claude_max_turns: int | None = None
|
|
47
|
+
max_budget_usd: float | None = None
|
|
48
|
+
permission_mode: PermissionMode | None = None
|
|
49
|
+
setting_sources: list[SettingSource] | None = None
|
|
50
|
+
working_directory: str = ""
|
|
51
|
+
|
|
52
|
+
@field_validator("claude_executable")
|
|
53
|
+
@classmethod
|
|
54
|
+
def _validate_claude_executable(cls, value: str) -> str:
|
|
55
|
+
if value.strip() == "":
|
|
56
|
+
raise ValueError("claude_executable must be a non-empty string")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
@field_validator("claude_max_turns")
|
|
60
|
+
@classmethod
|
|
61
|
+
def _validate_claude_max_turns(cls, value: int | None) -> int | None:
|
|
62
|
+
if value is not None and value <= 0:
|
|
63
|
+
raise ValueError("claude_max_turns must be greater than 0")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
@field_validator("max_budget_usd")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _validate_max_budget_usd(cls, value: float | None) -> float | None:
|
|
69
|
+
if value is not None and value <= 0:
|
|
70
|
+
raise ValueError("max_budget_usd must be greater than 0")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
@field_validator("working_directory")
|
|
74
|
+
@classmethod
|
|
75
|
+
def _validate_working_directory(cls, value: str) -> str:
|
|
76
|
+
if value and not Path(value).is_absolute():
|
|
77
|
+
raise ValueError("working_directory must be an absolute path")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_claude_code_cli_model_settings(model_settings: ModelSettings | None) -> ClaudeCodeCliModelSettings:
|
|
82
|
+
if model_settings is None:
|
|
83
|
+
return ClaudeCodeCliModelSettings()
|
|
84
|
+
try:
|
|
85
|
+
return ClaudeCodeCliModelSettings.model_validate(model_settings)
|
|
86
|
+
except Exception as exception:
|
|
87
|
+
raise UserError(str(exception)) from exception
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_mcp_configuration_file(mcp_server_url: str) -> IO[str]:
|
|
91
|
+
configuration = {
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"nighthawk": {
|
|
94
|
+
"type": "http",
|
|
95
|
+
"url": mcp_server_url,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
temporary_file = tempfile.NamedTemporaryFile(mode="wt", encoding="utf-8", prefix="nighthawk-claude-mcp-", suffix=".json") # noqa: SIM115
|
|
100
|
+
temporary_file.write(json.dumps(configuration))
|
|
101
|
+
temporary_file.flush()
|
|
102
|
+
return temporary_file
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _ClaudeCodeCliTurnOutcome(TypedDict):
|
|
106
|
+
output_text: str
|
|
107
|
+
model_name: str | None
|
|
108
|
+
usage: RequestUsage
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_claude_code_json_output(stdout_text: str) -> _ClaudeCodeCliTurnOutcome:
|
|
112
|
+
try:
|
|
113
|
+
output = json.loads(stdout_text)
|
|
114
|
+
except Exception as exception:
|
|
115
|
+
raise UnexpectedModelBehavior("Claude Code CLI produced invalid JSON output") from exception
|
|
116
|
+
|
|
117
|
+
if not isinstance(output, dict):
|
|
118
|
+
raise UnexpectedModelBehavior("Claude Code CLI produced non-object JSON output")
|
|
119
|
+
|
|
120
|
+
is_error = output.get("is_error")
|
|
121
|
+
if is_error:
|
|
122
|
+
error_result = output.get("result", "Claude Code CLI reported an error")
|
|
123
|
+
raise UnexpectedModelBehavior(f"Claude Code CLI error: {error_result}")
|
|
124
|
+
|
|
125
|
+
structured_output = output.get("structured_output")
|
|
126
|
+
if isinstance(structured_output, dict):
|
|
127
|
+
result_text = json.dumps(structured_output, ensure_ascii=False)
|
|
128
|
+
else:
|
|
129
|
+
result_text = output.get("result")
|
|
130
|
+
if not isinstance(result_text, str):
|
|
131
|
+
raise UnexpectedModelBehavior("Claude Code CLI did not produce a result string")
|
|
132
|
+
|
|
133
|
+
usage = RequestUsage()
|
|
134
|
+
usage_value = output.get("usage")
|
|
135
|
+
if isinstance(usage_value, dict):
|
|
136
|
+
input_tokens = usage_value.get("input_tokens")
|
|
137
|
+
if isinstance(input_tokens, int):
|
|
138
|
+
usage.input_tokens = input_tokens
|
|
139
|
+
|
|
140
|
+
output_tokens = usage_value.get("output_tokens")
|
|
141
|
+
if isinstance(output_tokens, int):
|
|
142
|
+
usage.output_tokens = output_tokens
|
|
143
|
+
|
|
144
|
+
cache_read_input_tokens = usage_value.get("cache_read_input_tokens")
|
|
145
|
+
if isinstance(cache_read_input_tokens, int):
|
|
146
|
+
usage.cache_read_tokens = cache_read_input_tokens
|
|
147
|
+
|
|
148
|
+
cache_creation_input_tokens = usage_value.get("cache_creation_input_tokens")
|
|
149
|
+
if isinstance(cache_creation_input_tokens, int):
|
|
150
|
+
usage.cache_write_tokens = cache_creation_input_tokens
|
|
151
|
+
|
|
152
|
+
model_name: str | None = None
|
|
153
|
+
model_usage = output.get("modelUsage")
|
|
154
|
+
if isinstance(model_usage, dict):
|
|
155
|
+
model_names = list(model_usage.keys())
|
|
156
|
+
if model_names:
|
|
157
|
+
model_name = model_names[0]
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
"output_text": result_text,
|
|
161
|
+
"model_name": model_name,
|
|
162
|
+
"usage": usage,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ClaudeCodeCliModel(BackendModelBase):
|
|
167
|
+
"""Pydantic AI model that delegates to Claude Code via the CLI."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, *, model_name: str | None = None) -> None:
|
|
170
|
+
super().__init__(
|
|
171
|
+
backend_label="Claude Code CLI backend",
|
|
172
|
+
profile=ModelProfile(
|
|
173
|
+
supports_tools=True,
|
|
174
|
+
supports_json_schema_output=True,
|
|
175
|
+
supports_json_object_output=False,
|
|
176
|
+
supports_image_output=False,
|
|
177
|
+
default_structured_output_mode="native",
|
|
178
|
+
supported_builtin_tools=frozenset([AbstractBuiltinTool]),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
self._model_name = model_name
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def model_name(self) -> str:
|
|
185
|
+
return f"claude-code-cli:{self._model_name or 'default'}"
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def system(self) -> str:
|
|
189
|
+
return "anthropic"
|
|
190
|
+
|
|
191
|
+
async def request(
|
|
192
|
+
self,
|
|
193
|
+
messages: list[ModelMessage],
|
|
194
|
+
model_settings: ModelSettings | None,
|
|
195
|
+
model_request_parameters: ModelRequestParameters,
|
|
196
|
+
) -> ModelResponse:
|
|
197
|
+
system_prompt_file: IO[str] | None = None
|
|
198
|
+
mcp_configuration_file: IO[str] | None = None
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
model_settings, model_request_parameters = self.prepare_request(model_settings, model_request_parameters)
|
|
202
|
+
|
|
203
|
+
_, system_prompt_text, user_prompt_text = self._prepare_common_request_parts(
|
|
204
|
+
messages=messages,
|
|
205
|
+
model_request_parameters=model_request_parameters,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
claude_code_cli_model_settings = _get_claude_code_cli_model_settings(model_settings)
|
|
209
|
+
|
|
210
|
+
tool_name_to_tool_definition, tool_name_to_handler, allowed_tool_names = await self._prepare_allowed_tools(
|
|
211
|
+
model_request_parameters=model_request_parameters,
|
|
212
|
+
configured_allowed_tool_names=claude_code_cli_model_settings.allowed_tool_names,
|
|
213
|
+
visible_tools=get_visible_tools(),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if allowed_tool_names:
|
|
217
|
+
system_prompt_text = "\n".join(
|
|
218
|
+
[
|
|
219
|
+
system_prompt_text,
|
|
220
|
+
"",
|
|
221
|
+
"Tool access:",
|
|
222
|
+
"- Nighthawk tools are exposed via MCP; tool names are prefixed with: mcp__nighthawk__",
|
|
223
|
+
"- Example: to call nh_exec(...), use: mcp__nighthawk__nh_exec",
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
output_object = model_request_parameters.output_object
|
|
228
|
+
|
|
229
|
+
async with mcp_server_if_needed(
|
|
230
|
+
tool_name_to_tool_definition=tool_name_to_tool_definition,
|
|
231
|
+
tool_name_to_handler=tool_name_to_handler,
|
|
232
|
+
) as mcp_server_url:
|
|
233
|
+
# Write system prompt to a temporary file to avoid CLI argument length limits.
|
|
234
|
+
system_prompt_file = tempfile.NamedTemporaryFile(mode="wt", encoding="utf-8", prefix="nighthawk-claude-system-", suffix=".txt") # noqa: SIM115
|
|
235
|
+
system_prompt_file.write(system_prompt_text)
|
|
236
|
+
system_prompt_file.flush()
|
|
237
|
+
|
|
238
|
+
claude_arguments: list[str] = [
|
|
239
|
+
claude_code_cli_model_settings.claude_executable,
|
|
240
|
+
"-p",
|
|
241
|
+
"--output-format",
|
|
242
|
+
"json",
|
|
243
|
+
"--no-session-persistence",
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
if self._model_name is not None:
|
|
247
|
+
claude_arguments.extend(["--model", self._model_name])
|
|
248
|
+
|
|
249
|
+
claude_arguments.extend(["--append-system-prompt-file", system_prompt_file.name])
|
|
250
|
+
|
|
251
|
+
permission_mode = claude_code_cli_model_settings.permission_mode
|
|
252
|
+
if permission_mode == "bypassPermissions":
|
|
253
|
+
claude_arguments.append("--dangerously-skip-permissions")
|
|
254
|
+
elif permission_mode is not None:
|
|
255
|
+
claude_arguments.extend(["--permission-mode", permission_mode])
|
|
256
|
+
|
|
257
|
+
setting_sources = claude_code_cli_model_settings.setting_sources
|
|
258
|
+
if setting_sources is not None:
|
|
259
|
+
claude_arguments.extend(["--setting-sources", ",".join(setting_sources)])
|
|
260
|
+
|
|
261
|
+
claude_max_turns = claude_code_cli_model_settings.claude_max_turns
|
|
262
|
+
if claude_max_turns is not None:
|
|
263
|
+
claude_arguments.extend(["--max-turns", str(claude_max_turns)])
|
|
264
|
+
|
|
265
|
+
max_budget_usd = claude_code_cli_model_settings.max_budget_usd
|
|
266
|
+
if max_budget_usd is not None:
|
|
267
|
+
claude_arguments.extend(["--max-budget-usd", str(max_budget_usd)])
|
|
268
|
+
|
|
269
|
+
if mcp_server_url is not None:
|
|
270
|
+
mcp_configuration_file = _build_mcp_configuration_file(mcp_server_url)
|
|
271
|
+
claude_arguments.extend(["--mcp-config", mcp_configuration_file.name])
|
|
272
|
+
|
|
273
|
+
allowed_tool_patterns = [f"mcp__nighthawk__{tool_name}" for tool_name in allowed_tool_names]
|
|
274
|
+
for pattern in allowed_tool_patterns:
|
|
275
|
+
claude_arguments.extend(["--allowedTools", pattern])
|
|
276
|
+
|
|
277
|
+
if output_object is not None:
|
|
278
|
+
schema = dict(output_object.json_schema)
|
|
279
|
+
if output_object.name:
|
|
280
|
+
schema["title"] = output_object.name
|
|
281
|
+
if output_object.description:
|
|
282
|
+
schema["description"] = output_object.description
|
|
283
|
+
claude_arguments.extend(["--json-schema", json.dumps(schema)])
|
|
284
|
+
|
|
285
|
+
working_directory = claude_code_cli_model_settings.working_directory
|
|
286
|
+
cwd: str | None = working_directory if working_directory else None
|
|
287
|
+
|
|
288
|
+
# Build subprocess environment: inherit current environment but remove CLAUDECODE
|
|
289
|
+
# to avoid nested-session detection. Unlike the SDK backend, this does not modify
|
|
290
|
+
# the process-global environment.
|
|
291
|
+
subprocess_environment = {key: value for key, value in os.environ.items() if key != "CLAUDECODE"}
|
|
292
|
+
|
|
293
|
+
process = await asyncio.create_subprocess_exec(
|
|
294
|
+
*claude_arguments,
|
|
295
|
+
stdin=asyncio.subprocess.PIPE,
|
|
296
|
+
stdout=asyncio.subprocess.PIPE,
|
|
297
|
+
stderr=asyncio.subprocess.PIPE,
|
|
298
|
+
cwd=cwd,
|
|
299
|
+
env=subprocess_environment,
|
|
300
|
+
)
|
|
301
|
+
if process.stdin is None or process.stdout is None or process.stderr is None:
|
|
302
|
+
raise UnexpectedModelBehavior("Claude Code CLI subprocess streams are unexpectedly None")
|
|
303
|
+
|
|
304
|
+
stdout_bytes, stderr_bytes = await process.communicate(input=user_prompt_text.encode("utf-8"))
|
|
305
|
+
|
|
306
|
+
return_code = process.returncode
|
|
307
|
+
|
|
308
|
+
if return_code != 0:
|
|
309
|
+
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
|
310
|
+
stdout_tail = stdout_bytes.decode("utf-8", errors="replace").strip()
|
|
311
|
+
|
|
312
|
+
detail_parts: list[str] = []
|
|
313
|
+
if stderr_text:
|
|
314
|
+
detail_parts.append(f"stderr={stderr_text[:2000]}")
|
|
315
|
+
if stdout_tail:
|
|
316
|
+
detail_parts.append(f"stdout_tail={stdout_tail[:4000]}")
|
|
317
|
+
if not detail_parts:
|
|
318
|
+
detail_parts.append("no stderr or stdout was captured")
|
|
319
|
+
|
|
320
|
+
detail = " | ".join(detail_parts)
|
|
321
|
+
raise UnexpectedModelBehavior(f"Claude Code CLI exited with non-zero status. {detail}")
|
|
322
|
+
|
|
323
|
+
stdout_text = stdout_bytes.decode("utf-8")
|
|
324
|
+
turn_outcome = _parse_claude_code_json_output(stdout_text)
|
|
325
|
+
|
|
326
|
+
return ModelResponse(
|
|
327
|
+
parts=[TextPart(content=turn_outcome["output_text"])],
|
|
328
|
+
usage=turn_outcome["usage"],
|
|
329
|
+
model_name=turn_outcome["model_name"],
|
|
330
|
+
provider_name="claude-code-cli",
|
|
331
|
+
)
|
|
332
|
+
except (UserError, UnexpectedModelBehavior, ValueError):
|
|
333
|
+
raise
|
|
334
|
+
except Exception as exception:
|
|
335
|
+
raise UnexpectedModelBehavior("Claude Code CLI backend failed") from exception
|
|
336
|
+
finally:
|
|
337
|
+
if system_prompt_file is not None:
|
|
338
|
+
with contextlib.suppress(Exception):
|
|
339
|
+
system_prompt_file.close()
|
|
340
|
+
if mcp_configuration_file is not None:
|
|
341
|
+
with contextlib.suppress(Exception):
|
|
342
|
+
mcp_configuration_file.close()
|