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 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()