nighthawk-python 0.11.0__py3-none-any.whl → 0.13.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 +23 -9
- nighthawk/backends/base.py +29 -3
- nighthawk/backends/claude_code_cli.py +2 -3
- nighthawk/backends/claude_code_sdk.py +2 -3
- nighthawk/backends/codex.py +2 -3
- nighthawk/backends/mcp_boundary.py +4 -4
- nighthawk/backends/mcp_server.py +28 -16
- nighthawk/composition.py +59 -0
- nighthawk/configuration.py +17 -6
- nighthawk/errors.py +10 -2
- nighthawk/natural/decorator.py +50 -24
- nighthawk/natural/transform.py +2 -4
- nighthawk/oversight.py +49 -56
- nighthawk/resilience/_budget.py +2 -2
- nighthawk/runtime/runner.py +135 -94
- nighthawk/runtime/scoping.py +181 -144
- nighthawk/runtime/step_context.py +1 -1
- nighthawk/runtime/step_executor.py +26 -18
- nighthawk/runtime/step_result.py +49 -0
- nighthawk/tools/declarations.py +110 -0
- {nighthawk_python-0.11.0.dist-info → nighthawk_python-0.13.0.dist-info}/METADATA +9 -9
- {nighthawk_python-0.11.0.dist-info → nighthawk_python-0.13.0.dist-info}/RECORD +24 -22
- {nighthawk_python-0.11.0.dist-info → nighthawk_python-0.13.0.dist-info}/WHEEL +1 -1
- nighthawk/tools/registry.py +0 -217
- {nighthawk_python-0.11.0.dist-info → nighthawk_python-0.13.0.dist-info}/licenses/LICENSE +0 -0
nighthawk/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from . import oversight, resilience
|
|
4
|
+
from .composition import UNSET, Extend, Merge, UnsetType
|
|
4
5
|
from .configuration import (
|
|
5
6
|
StepContextLimits,
|
|
6
7
|
StepExecutorConfiguration,
|
|
@@ -8,31 +9,41 @@ from .configuration import (
|
|
|
8
9
|
)
|
|
9
10
|
from .errors import (
|
|
10
11
|
ExecutionError,
|
|
12
|
+
NameConflictError,
|
|
11
13
|
NaturalParseError,
|
|
12
14
|
NighthawkError,
|
|
15
|
+
ToolDeclarationError,
|
|
13
16
|
ToolEvaluationError,
|
|
14
|
-
|
|
17
|
+
ToolNameConflictError,
|
|
15
18
|
ToolValidationError,
|
|
16
19
|
)
|
|
17
20
|
from .json_renderer import JsonableValue, to_jsonable_value
|
|
18
|
-
from .natural.decorator import natural_function
|
|
21
|
+
from .natural.decorator import get_transformed_function, natural_function
|
|
19
22
|
from .runtime.scoping import (
|
|
20
23
|
ExecutionRef,
|
|
21
24
|
UsageMeter,
|
|
22
|
-
|
|
25
|
+
get_capabilities,
|
|
23
26
|
get_execution_ref,
|
|
24
27
|
get_implicit_references,
|
|
28
|
+
get_oversight,
|
|
25
29
|
get_step_executor,
|
|
26
30
|
get_system_prompt_suffix_fragments,
|
|
31
|
+
get_tools,
|
|
32
|
+
get_usage_meter,
|
|
27
33
|
get_user_prompt_suffix_fragments,
|
|
28
34
|
run,
|
|
29
35
|
scope,
|
|
30
36
|
)
|
|
31
|
-
from .runtime.step_context import StepContext,
|
|
37
|
+
from .runtime.step_context import StepContext, get_step_context
|
|
32
38
|
from .runtime.step_executor import AgentStepExecutor, StepExecutor
|
|
33
|
-
from .tools.registry import tool
|
|
34
39
|
|
|
35
40
|
__all__ = [
|
|
41
|
+
"UNSET",
|
|
42
|
+
"Extend",
|
|
43
|
+
"Merge",
|
|
44
|
+
"UnsetType",
|
|
45
|
+
"NameConflictError",
|
|
46
|
+
"ToolNameConflictError",
|
|
36
47
|
"AgentStepExecutor",
|
|
37
48
|
"ExecutionError",
|
|
38
49
|
"ExecutionRef",
|
|
@@ -45,21 +56,24 @@ __all__ = [
|
|
|
45
56
|
"StepExecutorConfiguration",
|
|
46
57
|
"StepPromptTemplates",
|
|
47
58
|
"ToolEvaluationError",
|
|
48
|
-
"
|
|
59
|
+
"ToolDeclarationError",
|
|
49
60
|
"ToolValidationError",
|
|
50
61
|
"UsageMeter",
|
|
51
|
-
"
|
|
52
|
-
"
|
|
62
|
+
"get_capabilities",
|
|
63
|
+
"get_step_context",
|
|
64
|
+
"get_usage_meter",
|
|
53
65
|
"get_execution_ref",
|
|
54
66
|
"get_implicit_references",
|
|
67
|
+
"get_oversight",
|
|
55
68
|
"get_step_executor",
|
|
69
|
+
"get_tools",
|
|
56
70
|
"get_system_prompt_suffix_fragments",
|
|
57
71
|
"get_user_prompt_suffix_fragments",
|
|
58
72
|
"oversight",
|
|
59
73
|
"natural_function",
|
|
74
|
+
"get_transformed_function",
|
|
60
75
|
"resilience",
|
|
61
76
|
"run",
|
|
62
77
|
"scope",
|
|
63
78
|
"to_jsonable_value",
|
|
64
|
-
"tool",
|
|
65
79
|
]
|
nighthawk/backends/base.py
CHANGED
|
@@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, field_validator
|
|
|
9
9
|
from pydantic_ai.exceptions import UnexpectedModelBehavior, UserError
|
|
10
10
|
from pydantic_ai.messages import (
|
|
11
11
|
CachePoint,
|
|
12
|
+
InstructionPart,
|
|
12
13
|
ModelMessage,
|
|
13
14
|
ModelRequest,
|
|
14
15
|
RetryPromptPart,
|
|
@@ -73,6 +74,31 @@ def _collect_system_prompt_text(model_request: ModelRequest) -> str:
|
|
|
73
74
|
return "\n\n".join(parts)
|
|
74
75
|
|
|
75
76
|
|
|
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
|
+
|
|
76
102
|
def _resolve_current_tool_result_max_tokens() -> int:
|
|
77
103
|
return resolve_current_tool_result_rendering_policy().tool_result_max_tokens
|
|
78
104
|
|
|
@@ -152,8 +178,8 @@ class BackendModelBase(Model):
|
|
|
152
178
|
messages: list[ModelMessage],
|
|
153
179
|
model_request_parameters: ModelRequestParameters,
|
|
154
180
|
) -> PreparedRequestParts:
|
|
155
|
-
if model_request_parameters.
|
|
156
|
-
raise UserError(f"{self.backend_label} does not support
|
|
181
|
+
if model_request_parameters.native_tools:
|
|
182
|
+
raise UserError(f"{self.backend_label} does not support native tools")
|
|
157
183
|
|
|
158
184
|
if model_request_parameters.allow_image_output:
|
|
159
185
|
raise UserError(f"{self.backend_label} does not support image output")
|
|
@@ -162,7 +188,7 @@ class BackendModelBase(Model):
|
|
|
162
188
|
|
|
163
189
|
system_prompt_text = _collect_system_prompt_text(model_request)
|
|
164
190
|
|
|
165
|
-
instructions =
|
|
191
|
+
instructions = _collect_instruction_text(messages, model_request_parameters)
|
|
166
192
|
if instructions:
|
|
167
193
|
system_prompt_text = "\n\n".join([system_prompt_text, instructions]) if system_prompt_text else instructions
|
|
168
194
|
|
|
@@ -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
|
|
@@ -16,7 +15,7 @@ from pydantic_ai.profiles import ModelProfile
|
|
|
16
15
|
from pydantic_ai.settings import ModelSettings
|
|
17
16
|
from pydantic_ai.usage import RequestUsage
|
|
18
17
|
|
|
19
|
-
from ..tools.
|
|
18
|
+
from ..tools.declarations import get_visible_tools
|
|
20
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
|
|
@@ -137,7 +136,7 @@ class ClaudeCodeCliModel(BackendModelBase):
|
|
|
137
136
|
supports_json_object_output=False,
|
|
138
137
|
supports_image_output=False,
|
|
139
138
|
default_structured_output_mode="native",
|
|
140
|
-
|
|
139
|
+
supported_native_tools=frozenset(),
|
|
141
140
|
),
|
|
142
141
|
)
|
|
143
142
|
self._model_name = model_name
|
|
@@ -7,7 +7,6 @@ from datetime import datetime
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from opentelemetry import context as otel_context
|
|
10
|
-
from pydantic_ai.builtin_tools import AbstractBuiltinTool
|
|
11
10
|
from pydantic_ai.exceptions import UnexpectedModelBehavior
|
|
12
11
|
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
|
|
13
12
|
from pydantic_ai.models import ModelRequestParameters
|
|
@@ -16,7 +15,7 @@ from pydantic_ai.settings import ModelSettings
|
|
|
16
15
|
from pydantic_ai.usage import RequestUsage
|
|
17
16
|
|
|
18
17
|
from ..json_renderer import to_jsonable_value
|
|
19
|
-
from ..tools.
|
|
18
|
+
from ..tools.declarations import get_visible_tools
|
|
20
19
|
from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
|
|
21
20
|
from .claude_code_settings import ClaudeCodeModelSettings
|
|
22
21
|
from .mcp_boundary import call_tool_for_claude_code_sdk
|
|
@@ -111,7 +110,7 @@ class ClaudeCodeSdkModel(BackendModelBase):
|
|
|
111
110
|
supports_json_object_output=False,
|
|
112
111
|
supports_image_output=False,
|
|
113
112
|
default_structured_output_mode="native",
|
|
114
|
-
|
|
113
|
+
supported_native_tools=frozenset(),
|
|
115
114
|
),
|
|
116
115
|
)
|
|
117
116
|
self._model_name = model_name
|
nighthawk/backends/codex.py
CHANGED
|
@@ -8,7 +8,6 @@ from dataclasses import replace
|
|
|
8
8
|
from typing import IO, Any, Literal, 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,7 +16,7 @@ from pydantic_ai.profiles.openai import OpenAIJsonSchemaTransformer
|
|
|
17
16
|
from pydantic_ai.settings import ModelSettings
|
|
18
17
|
from pydantic_ai.usage import RequestUsage
|
|
19
18
|
|
|
20
|
-
from ..tools.
|
|
19
|
+
from ..tools.declarations import get_visible_tools
|
|
21
20
|
from .base import BackendModelBase, BackendModelSettings, append_text_projected_tool_result_preview_prompt
|
|
22
21
|
from .mcp_server import mcp_server_if_needed
|
|
23
22
|
from .text_projection import TextProjectedRequest, resolve_text_projection_staging_root_directory
|
|
@@ -165,7 +164,7 @@ class CodexModel(BackendModelBase):
|
|
|
165
164
|
supports_json_object_output=False,
|
|
166
165
|
supports_image_output=False,
|
|
167
166
|
default_structured_output_mode="native",
|
|
168
|
-
|
|
167
|
+
supported_native_tools=frozenset(),
|
|
169
168
|
json_schema_transformer=_CodexJsonSchemaTransformer,
|
|
170
169
|
),
|
|
171
170
|
)
|
|
@@ -13,7 +13,7 @@ from pydantic_ai.messages import BinaryContent, FileUrl, TextContent, UploadedFi
|
|
|
13
13
|
from ..runtime.step_context import (
|
|
14
14
|
DEFAULT_TOOL_RESULT_RENDERING_POLICY,
|
|
15
15
|
ToolResultRenderingPolicy,
|
|
16
|
-
|
|
16
|
+
get_step_context,
|
|
17
17
|
resolve_tool_result_rendering_policy,
|
|
18
18
|
)
|
|
19
19
|
from ..tools._tool_return_content import resolve_tool_return_segments
|
|
@@ -38,7 +38,7 @@ _logger = logging.getLogger("nighthawk")
|
|
|
38
38
|
|
|
39
39
|
def _resolve_tool_result_rendering_policy() -> ToolResultRenderingPolicy:
|
|
40
40
|
try:
|
|
41
|
-
step_context =
|
|
41
|
+
step_context = get_step_context()
|
|
42
42
|
except Exception:
|
|
43
43
|
return DEFAULT_TOOL_RESULT_RENDERING_POLICY
|
|
44
44
|
return resolve_tool_result_rendering_policy(step_context.tool_result_rendering_policy)
|
|
@@ -92,10 +92,10 @@ def _tool_outcome_to_mcp_content_list(
|
|
|
92
92
|
|
|
93
93
|
if isinstance(content_item, BinaryContent):
|
|
94
94
|
if content_item.is_image:
|
|
95
|
-
content_block_list.append(mcp_types.ImageContent(type="image", data=content_item.base64,
|
|
95
|
+
content_block_list.append(mcp_types.ImageContent(type="image", data=content_item.base64, mime_type=content_item.media_type))
|
|
96
96
|
continue
|
|
97
97
|
if content_item.is_audio:
|
|
98
|
-
content_block_list.append(mcp_types.AudioContent(type="audio", data=content_item.base64,
|
|
98
|
+
content_block_list.append(mcp_types.AudioContent(type="audio", data=content_item.base64, mime_type=content_item.media_type))
|
|
99
99
|
continue
|
|
100
100
|
# Non-image / non-audio blobs project to text per spec §8.3:
|
|
101
101
|
# MCP's rich transport only has first-class slots for image and
|
nighthawk/backends/mcp_server.py
CHANGED
|
@@ -15,7 +15,7 @@ import time
|
|
|
15
15
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
16
16
|
from contextlib import asynccontextmanager
|
|
17
17
|
from contextvars import copy_context
|
|
18
|
-
from typing import Any
|
|
18
|
+
from typing import Any, cast
|
|
19
19
|
|
|
20
20
|
from opentelemetry import context as otel_context
|
|
21
21
|
from pydantic_ai.exceptions import UnexpectedModelBehavior
|
|
@@ -62,20 +62,20 @@ class McpServer:
|
|
|
62
62
|
raise RuntimeError("MCP tool server is already started")
|
|
63
63
|
|
|
64
64
|
import uvicorn
|
|
65
|
-
from mcp
|
|
66
|
-
from mcp.server
|
|
65
|
+
from mcp import types as mcp_types
|
|
66
|
+
from mcp.server import Server as McpLowLevelServer
|
|
67
|
+
from mcp.server import ServerRequestContext
|
|
67
68
|
from mcp.server.streamable_http_manager import (
|
|
69
|
+
StreamableHTTPASGIApp,
|
|
68
70
|
StreamableHTTPSessionManager,
|
|
69
71
|
)
|
|
70
72
|
from starlette.applications import Starlette
|
|
71
73
|
from starlette.routing import Route
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
from mcp import types as mcp_types
|
|
78
|
-
|
|
75
|
+
async def list_tools(
|
|
76
|
+
_context: ServerRequestContext[Any, Any],
|
|
77
|
+
_parameters: mcp_types.PaginatedRequestParams | None,
|
|
78
|
+
) -> mcp_types.ListToolsResult:
|
|
79
79
|
tools: list[mcp_types.Tool] = []
|
|
80
80
|
for tool_name in sorted(self._tool_name_to_handler.keys()):
|
|
81
81
|
tool_definition = self._tool_name_to_tool_definition.get(tool_name)
|
|
@@ -86,13 +86,17 @@ class McpServer:
|
|
|
86
86
|
mcp_types.Tool(
|
|
87
87
|
name=tool_name,
|
|
88
88
|
description=tool_definition.description or "",
|
|
89
|
-
|
|
89
|
+
input_schema=tool_definition.parameters_json_schema,
|
|
90
90
|
)
|
|
91
91
|
)
|
|
92
|
-
return tools
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
return mcp_types.ListToolsResult(tools=tools)
|
|
93
|
+
|
|
94
|
+
async def call_tool(
|
|
95
|
+
_context: ServerRequestContext[Any, Any],
|
|
96
|
+
parameters: mcp_types.CallToolRequestParams,
|
|
97
|
+
) -> mcp_types.CallToolResult:
|
|
98
|
+
name = parameters.name
|
|
99
|
+
arguments = parameters.arguments or {}
|
|
96
100
|
handler = self._tool_name_to_handler.get(name)
|
|
97
101
|
if handler is None:
|
|
98
102
|
tool_outcome: ToolOutcome = {
|
|
@@ -100,19 +104,27 @@ class McpServer:
|
|
|
100
104
|
"error": {"kind": "resolution", "message": f"Unknown tool: {name}", "guidance": "Choose a visible tool name and retry."},
|
|
101
105
|
}
|
|
102
106
|
tool_handler_result: ToolHandlerResult = build_tool_result_observation(tool_outcome=tool_outcome)
|
|
103
|
-
|
|
107
|
+
content_block_list = tool_handler_result_to_low_level_mcp_content(
|
|
104
108
|
tool_name=name,
|
|
105
109
|
tool_handler_result=tool_handler_result,
|
|
106
110
|
rendering_policy=self._tool_result_rendering_policy,
|
|
107
111
|
)
|
|
112
|
+
return mcp_types.CallToolResult(content=cast(list[mcp_types.ContentBlock], content_block_list))
|
|
108
113
|
|
|
109
|
-
|
|
114
|
+
content_block_list = await call_tool_for_low_level_mcp_server(
|
|
110
115
|
tool_name=name,
|
|
111
116
|
arguments=arguments,
|
|
112
117
|
tool_handler=handler,
|
|
113
118
|
parent_otel_context=self._parent_otel_context,
|
|
114
119
|
rendering_policy=self._tool_result_rendering_policy,
|
|
115
120
|
)
|
|
121
|
+
return mcp_types.CallToolResult(content=cast(list[mcp_types.ContentBlock], content_block_list))
|
|
122
|
+
|
|
123
|
+
mcp_server = McpLowLevelServer(
|
|
124
|
+
"nighthawk",
|
|
125
|
+
on_list_tools=list_tools,
|
|
126
|
+
on_call_tool=call_tool,
|
|
127
|
+
)
|
|
116
128
|
|
|
117
129
|
session_manager = StreamableHTTPSessionManager(app=mcp_server)
|
|
118
130
|
streamable_http_asgi = StreamableHTTPASGIApp(session_manager)
|
nighthawk/composition.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Explicit inheritance and composition operations for execution scopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnsetType:
|
|
12
|
+
"""The singleton type of :data:`UNSET`, representing an omitted argument."""
|
|
13
|
+
|
|
14
|
+
__slots__ = ()
|
|
15
|
+
_instance: ClassVar[UnsetType | None] = None
|
|
16
|
+
|
|
17
|
+
def __new__(cls) -> UnsetType:
|
|
18
|
+
if cls._instance is None:
|
|
19
|
+
cls._instance = super().__new__(cls)
|
|
20
|
+
return cls._instance
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return "UNSET"
|
|
24
|
+
|
|
25
|
+
def __copy__(self) -> UnsetType:
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __deepcopy__(self, identity_to_copy: dict[int, object]) -> UnsetType:
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
UNSET = UnsetType()
|
|
33
|
+
"""Inherit the enclosing scope value; also marks omitted rewrite fields."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, init=False)
|
|
37
|
+
class Extend[T]:
|
|
38
|
+
"""Append a sequence to an inherited ordered collection, preserving entry identity."""
|
|
39
|
+
|
|
40
|
+
values: tuple[T, ...]
|
|
41
|
+
|
|
42
|
+
def __init__(self, values: Sequence[T]) -> None:
|
|
43
|
+
if not isinstance(values, Sequence) or isinstance(values, (str, bytes, bytearray)):
|
|
44
|
+
raise TypeError("Extend requires a sequence of entries")
|
|
45
|
+
object.__setattr__(self, "values", tuple(values))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, init=False)
|
|
49
|
+
class Merge[T]:
|
|
50
|
+
"""Merge named references with inherited references, checking conflicts by identity."""
|
|
51
|
+
|
|
52
|
+
name_to_value: Mapping[str, T]
|
|
53
|
+
|
|
54
|
+
def __init__(self, name_to_value: Mapping[str, T]) -> None:
|
|
55
|
+
if not isinstance(name_to_value, Mapping):
|
|
56
|
+
raise TypeError("Merge requires a name-to-value mapping")
|
|
57
|
+
if any(not isinstance(name, str) for name in name_to_value):
|
|
58
|
+
raise TypeError("Merge names must be strings")
|
|
59
|
+
object.__setattr__(self, "name_to_value", MappingProxyType(dict(name_to_value)))
|
nighthawk/configuration.py
CHANGED
|
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
5
|
import tiktoken
|
|
6
|
-
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, InstanceOf, field_serializer, field_validator
|
|
7
|
+
from pydantic_ai.models import Model
|
|
8
|
+
from pydantic_core import PydanticSerializationError
|
|
7
9
|
|
|
8
10
|
from .json_renderer import JsonRendererStyle
|
|
9
11
|
|
|
@@ -113,7 +115,10 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
113
115
|
"""Configuration for a step executor.
|
|
114
116
|
|
|
115
117
|
Attributes:
|
|
116
|
-
model:
|
|
118
|
+
model: Provider-qualified identifier or borrowed Pydantic AI Model instance.
|
|
119
|
+
Instances retain identity; the host owns client lifetime. Serialization
|
|
120
|
+
must explicitly exclude a live model field, including in nested records.
|
|
121
|
+
Configuration representations omit the model to avoid traversing credentials.
|
|
117
122
|
model_settings: Provider-specific model settings. Accepts a dict or a
|
|
118
123
|
backend-specific BaseModel instance (auto-converted to dict).
|
|
119
124
|
prompts: Prompt templates for step execution.
|
|
@@ -128,7 +133,7 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
128
133
|
|
|
129
134
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
130
135
|
|
|
131
|
-
model: str = "openai-responses:gpt-5.
|
|
136
|
+
model: str | InstanceOf[Model] = Field(default="openai-responses:gpt-5.6-luna", repr=False)
|
|
132
137
|
model_settings: dict[str, Any] | BaseModel | None = None
|
|
133
138
|
|
|
134
139
|
@field_validator("model_settings", mode="before")
|
|
@@ -147,8 +152,14 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
147
152
|
|
|
148
153
|
@field_validator("model")
|
|
149
154
|
@classmethod
|
|
150
|
-
def _validate_model(cls, value: str) -> str:
|
|
151
|
-
return _validate_model_identifier(value)
|
|
155
|
+
def _validate_model(cls, value: str | Model) -> str | Model:
|
|
156
|
+
return _validate_model_identifier(value) if isinstance(value, str) else value
|
|
157
|
+
|
|
158
|
+
@field_serializer("model")
|
|
159
|
+
def _serialize_model(self, value: str | Model) -> str:
|
|
160
|
+
if not isinstance(value, str):
|
|
161
|
+
raise PydanticSerializationError("A runtime Model cannot be serialized; exclude the model field explicitly")
|
|
162
|
+
return value
|
|
152
163
|
|
|
153
164
|
def resolve_token_encoding(self) -> tiktoken.Encoding:
|
|
154
165
|
"""Return the tiktoken encoding for this configuration.
|
|
@@ -160,7 +171,7 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
160
171
|
if self.tokenizer_encoding is not None:
|
|
161
172
|
return tiktoken.get_encoding(self.tokenizer_encoding)
|
|
162
173
|
|
|
163
|
-
|
|
174
|
+
model_name = self.model.split(":", 1)[1] if isinstance(self.model, str) else self.model.model_name
|
|
164
175
|
|
|
165
176
|
try:
|
|
166
177
|
return tiktoken.encoding_for_model(model_name)
|
nighthawk/errors.py
CHANGED
|
@@ -21,5 +21,13 @@ class ToolValidationError(NighthawkError):
|
|
|
21
21
|
"""Raised when tool input validation fails."""
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
class
|
|
25
|
-
"""Raised when tool
|
|
24
|
+
class ToolDeclarationError(NighthawkError):
|
|
25
|
+
"""Raised when a tool declaration is invalid."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NameConflictError(NighthawkError):
|
|
29
|
+
"""Raised when distinct declarations claim the same name."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolNameConflictError(ToolDeclarationError, NameConflictError):
|
|
33
|
+
"""Raised when tool declarations claim the same or a reserved name."""
|
nighthawk/natural/decorator.py
CHANGED
|
@@ -12,8 +12,7 @@ from typing import Any, cast
|
|
|
12
12
|
from ..runtime.runner import Runner, StepEnvelope
|
|
13
13
|
from ..runtime.scoping import get_step_executor
|
|
14
14
|
from ..runtime.step_context import python_cell_scope, python_name_scope
|
|
15
|
-
from
|
|
16
|
-
from .blocks import find_natural_blocks
|
|
15
|
+
from .blocks import extract_program, find_natural_blocks
|
|
17
16
|
from .transform import transform_module_ast
|
|
18
17
|
|
|
19
18
|
type NaturalFunctionCallable = Callable[..., Any]
|
|
@@ -239,33 +238,37 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
239
238
|
|
|
240
239
|
filename = inspect.getsourcefile(func) or "<nighthawk>"
|
|
241
240
|
|
|
241
|
+
# Runtime helpers referenced by the transformed body travel through the factory closure, not through
|
|
242
|
+
# module globals. The transformed function therefore shares ``func.__globals__`` with the original
|
|
243
|
+
# function: names defined in the module after decoration stay visible, and the module namespace is
|
|
244
|
+
# never polluted with helper names.
|
|
245
|
+
helper_name_to_value: dict[str, object] = {
|
|
246
|
+
"__nighthawk_runner__": _RunnerProxy(),
|
|
247
|
+
"__nh_extract_program__": extract_program,
|
|
248
|
+
"__nh_python_cell_scope__": python_cell_scope,
|
|
249
|
+
}
|
|
250
|
+
factory_name_to_value: dict[str, object] = {**name_to_value, **helper_name_to_value}
|
|
251
|
+
|
|
242
252
|
factory_module = _build_transformed_factory_module(
|
|
243
253
|
transformed_module=transformed_module,
|
|
244
254
|
function_name=func.__name__,
|
|
245
|
-
name_to_value=
|
|
255
|
+
name_to_value=factory_name_to_value,
|
|
246
256
|
)
|
|
247
257
|
code = compile(factory_module, filename, "exec")
|
|
248
258
|
|
|
249
|
-
globals_namespace: dict[str, object] = dict(func.__globals__)
|
|
250
|
-
globals_namespace["__nighthawk_runner__"] = _RunnerProxy()
|
|
251
|
-
from .blocks import extract_program as _nh_extract_program
|
|
252
|
-
|
|
253
|
-
globals_namespace["__nh_extract_program__"] = _nh_extract_program
|
|
254
|
-
globals_namespace["__nh_python_cell_scope__"] = python_cell_scope
|
|
255
|
-
|
|
256
259
|
module_namespace: dict[str, object] = {}
|
|
257
|
-
exec(code,
|
|
260
|
+
exec(code, func.__globals__, module_namespace)
|
|
258
261
|
|
|
259
262
|
factory = module_namespace.get("__nh_factory__")
|
|
260
263
|
if not callable(factory):
|
|
261
264
|
raise RuntimeError("Transformed factory not found after compilation")
|
|
262
265
|
|
|
263
|
-
transformed = factory(
|
|
266
|
+
transformed = factory(factory_name_to_value)
|
|
264
267
|
if not callable(transformed):
|
|
265
268
|
raise RuntimeError("Transformed function not found after factory execution")
|
|
266
269
|
|
|
267
270
|
transformed_freevar_name_set = set(transformed.__code__.co_freevars)
|
|
268
|
-
captured_name_set = set(
|
|
271
|
+
captured_name_set = set(factory_name_to_value.keys())
|
|
269
272
|
|
|
270
273
|
unexpected_freevar_name_set = transformed_freevar_name_set - captured_name_set
|
|
271
274
|
allowed_unexpected_freevar_name_set = {func.__name__}
|
|
@@ -274,7 +277,7 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
274
277
|
f"Transformed function freevars do not match captured names. freevars={transformed.__code__.co_freevars!r} captured={tuple(sorted(name_to_value.keys()))!r}"
|
|
275
278
|
)
|
|
276
279
|
|
|
277
|
-
if transformed.__closure__ is None and
|
|
280
|
+
if transformed.__closure__ is None and transformed_freevar_name_set:
|
|
278
281
|
raise RuntimeError("Transformed function closure is missing for captured names")
|
|
279
282
|
|
|
280
283
|
if inspect.iscoroutinefunction(func):
|
|
@@ -282,20 +285,43 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
282
285
|
|
|
283
286
|
@wraps(func)
|
|
284
287
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return await transformed_async(*args, **kwargs)
|
|
288
|
+
if name_to_value:
|
|
289
|
+
with python_name_scope(name_to_value):
|
|
290
|
+
return await transformed_async(*args, **kwargs)
|
|
291
|
+
return await transformed_async(*args, **kwargs)
|
|
290
292
|
|
|
293
|
+
async_wrapper.__nighthawk_transformed_function__ = transformed # type: ignore[attr-defined]
|
|
291
294
|
return cast(NaturalFunctionCallable, async_wrapper) # type: ignore[return-value]
|
|
292
295
|
|
|
293
296
|
@wraps(func)
|
|
294
297
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return transformed(*args, **kwargs)
|
|
298
|
+
if name_to_value:
|
|
299
|
+
with python_name_scope(name_to_value):
|
|
300
|
+
return transformed(*args, **kwargs)
|
|
301
|
+
return transformed(*args, **kwargs)
|
|
300
302
|
|
|
303
|
+
wrapper.__nighthawk_transformed_function__ = transformed # type: ignore[attr-defined]
|
|
301
304
|
return cast(NaturalFunctionCallable, wrapper) # type: ignore[return-value]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_transformed_function(function: Callable[..., object]) -> Callable[..., object]:
|
|
308
|
+
"""Find a Natural function's compiled body through conventional wrapper chains.
|
|
309
|
+
|
|
310
|
+
Standard inspect.unwrap follows the original source. This accessor exposes the
|
|
311
|
+
transformed body for inspection; normal execution should use the decorated callable
|
|
312
|
+
to establish its runtime context. Invalid inputs and wrapper cycles raise TypeError.
|
|
313
|
+
"""
|
|
314
|
+
current: object = function
|
|
315
|
+
seen_identity_set: set[int] = set()
|
|
316
|
+
while id(current) not in seen_identity_set:
|
|
317
|
+
seen_identity_set.add(id(current))
|
|
318
|
+
if isinstance(current, (staticmethod, classmethod)) or inspect.ismethod(current):
|
|
319
|
+
current = current.__func__
|
|
320
|
+
continue
|
|
321
|
+
transformed = getattr(current, "__nighthawk_transformed_function__", None)
|
|
322
|
+
if callable(transformed):
|
|
323
|
+
return transformed
|
|
324
|
+
current = getattr(current, "__wrapped__", None)
|
|
325
|
+
if current is None:
|
|
326
|
+
break
|
|
327
|
+
raise TypeError("Expected a Natural function or a conventional wrapper around one")
|
nighthawk/natural/transform.py
CHANGED
|
@@ -326,11 +326,9 @@ def build_runtime_call_and_assignments(
|
|
|
326
326
|
statements.extend(ast.parse(f'if "{name}" in __nh_bindings__:\n {name} = __nh_bindings__["{name}"]\n').body)
|
|
327
327
|
|
|
328
328
|
# Add outcome extraction and dispatch.
|
|
329
|
-
statements.extend(ast.parse('__nh_step_outcome__ = __nh_envelope__["
|
|
329
|
+
statements.extend(ast.parse('__nh_step_outcome__ = __nh_envelope__["outcome"]').body)
|
|
330
330
|
|
|
331
|
-
outcome_source =
|
|
332
|
-
'if __nh_step_outcome__ is not None:\n if __nh_step_outcome__.kind == "return":\n return __nh_envelope__["return_value"]\n'
|
|
333
|
-
)
|
|
331
|
+
outcome_source = 'if __nh_step_outcome__ is not None:\n if __nh_step_outcome__.kind == "return":\n return __nh_step_outcome__.value\n'
|
|
334
332
|
if is_in_loop:
|
|
335
333
|
outcome_source += (
|
|
336
334
|
' if __nh_step_outcome__.kind == "break":\n break\n if __nh_step_outcome__.kind == "continue":\n continue\n'
|