nighthawk-python 0.15.0__py3-none-any.whl → 0.17.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/backends/mcp_boundary.py +33 -6
- nighthawk/backends/mcp_server.py +4 -6
- nighthawk/backends/tool_bridge.py +16 -3
- nighthawk/composition.py +6 -2
- nighthawk/configuration.py +5 -4
- nighthawk/function_call.py +16 -4
- nighthawk/lifecycle.py +2 -2
- nighthawk/oversight.py +36 -10
- nighthawk/runtime/__init__.py +14 -1
- nighthawk/runtime/environment.py +50 -29
- nighthawk/runtime/expression.py +1 -1
- nighthawk/runtime/model_candidate.py +12 -4
- nighthawk/runtime/model_response.py +43 -0
- nighthawk/runtime/operation_contract.py +33 -2
- nighthawk/runtime/operations.py +49 -19
- nighthawk/runtime/preparation.py +1 -4
- nighthawk/runtime/prompt.py +3 -17
- nighthawk/runtime/runner.py +31 -26
- nighthawk/runtime/scoping.py +13 -7
- nighthawk/runtime/step_context.py +4 -5
- nighthawk/runtime/step_executor.py +68 -33
- nighthawk/runtime/step_request.py +45 -7
- nighthawk/testing.py +25 -23
- nighthawk/tools/_ordering.py +171 -7
- nighthawk/tools/contracts.py +6 -0
- nighthawk/tools/execution.py +31 -3
- {nighthawk_python-0.15.0.dist-info → nighthawk_python-0.17.0.dist-info}/METADATA +1 -1
- {nighthawk_python-0.15.0.dist-info → nighthawk_python-0.17.0.dist-info}/RECORD +30 -29
- {nighthawk_python-0.15.0.dist-info → nighthawk_python-0.17.0.dist-info}/WHEEL +0 -0
- {nighthawk_python-0.15.0.dist-info → nighthawk_python-0.17.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from typing import cast
|
|
4
|
+
from typing import TYPE_CHECKING, cast
|
|
5
5
|
|
|
6
6
|
import tiktoken
|
|
7
7
|
from opentelemetry import context as otel_context
|
|
@@ -29,6 +29,9 @@ from ._text_fallback import (
|
|
|
29
29
|
)
|
|
30
30
|
from .tool_bridge import ToolHandler
|
|
31
31
|
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from mcp.types import CallToolResult
|
|
34
|
+
|
|
32
35
|
_logger = logging.getLogger("nighthawk")
|
|
33
36
|
|
|
34
37
|
|
|
@@ -187,27 +190,51 @@ def tool_handler_result_to_low_level_mcp_content(
|
|
|
187
190
|
return [mcp_types.TextContent(type="text", text=render_preview())]
|
|
188
191
|
|
|
189
192
|
|
|
193
|
+
def tool_handler_result_to_low_level_mcp_result(
|
|
194
|
+
*,
|
|
195
|
+
tool_name: str,
|
|
196
|
+
tool_handler_result: ToolHandlerResult,
|
|
197
|
+
rendering_policy: ToolResultRenderingPolicy | None = None,
|
|
198
|
+
) -> CallToolResult:
|
|
199
|
+
from mcp import types as mcp_types
|
|
200
|
+
|
|
201
|
+
content = tool_handler_result_to_low_level_mcp_content(
|
|
202
|
+
tool_name=tool_name, tool_handler_result=tool_handler_result, rendering_policy=rendering_policy
|
|
203
|
+
)
|
|
204
|
+
error = tool_handler_result["tool_outcome"]["error"] if tool_handler_result["kind"] == "tool_result" else None
|
|
205
|
+
return mcp_types.CallToolResult(
|
|
206
|
+
content=cast(list[mcp_types.ContentBlock], content),
|
|
207
|
+
is_error=tool_handler_result["kind"] != "tool_result" or error is not None,
|
|
208
|
+
structured_content={"error": dict(error)} if error is not None else None,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
190
212
|
def _tool_handler_result_to_claude_code_sdk_content(
|
|
191
213
|
*,
|
|
192
214
|
tool_name: str,
|
|
193
215
|
tool_handler_result: ToolHandlerResult,
|
|
194
216
|
rendering_policy: ToolResultRenderingPolicy | None = None,
|
|
195
217
|
) -> dict[str, object]:
|
|
196
|
-
|
|
218
|
+
result = tool_handler_result_to_low_level_mcp_result(
|
|
197
219
|
tool_name=tool_name,
|
|
198
220
|
tool_handler_result=tool_handler_result,
|
|
199
221
|
rendering_policy=rendering_policy,
|
|
200
222
|
)
|
|
201
223
|
|
|
202
224
|
dumped_content_block_list: list[dict[str, object]] = []
|
|
203
|
-
for content_block in
|
|
225
|
+
for content_block in result.content:
|
|
204
226
|
model_dump = getattr(content_block, "model_dump", None)
|
|
205
227
|
if callable(model_dump):
|
|
206
228
|
dumped_content_block_list.append(cast(dict[str, object], model_dump(mode="json", by_alias=True, exclude_none=True)))
|
|
207
229
|
continue
|
|
208
230
|
raise TypeError(f"Unsupported MCP content block: {type(content_block).__name__}")
|
|
209
231
|
|
|
210
|
-
|
|
232
|
+
response: dict[str, object] = {"content": dumped_content_block_list}
|
|
233
|
+
if result.is_error:
|
|
234
|
+
response["isError"] = True
|
|
235
|
+
if result.structured_content is not None:
|
|
236
|
+
response["structuredContent"] = result.structured_content
|
|
237
|
+
return response
|
|
211
238
|
|
|
212
239
|
|
|
213
240
|
async def _get_tool_handler_result(
|
|
@@ -252,14 +279,14 @@ async def call_tool_for_low_level_mcp_server(
|
|
|
252
279
|
tool_handler: ToolHandler,
|
|
253
280
|
parent_otel_context: OtelContext,
|
|
254
281
|
rendering_policy: ToolResultRenderingPolicy | None = None,
|
|
255
|
-
) ->
|
|
282
|
+
) -> CallToolResult:
|
|
256
283
|
tool_handler_result = await _get_tool_handler_result(
|
|
257
284
|
tool_name=tool_name,
|
|
258
285
|
arguments=arguments,
|
|
259
286
|
tool_handler=tool_handler,
|
|
260
287
|
parent_otel_context=parent_otel_context,
|
|
261
288
|
)
|
|
262
|
-
return
|
|
289
|
+
return tool_handler_result_to_low_level_mcp_result(
|
|
263
290
|
tool_name=tool_name,
|
|
264
291
|
tool_handler_result=tool_handler_result,
|
|
265
292
|
rendering_policy=rendering_policy,
|
nighthawk/backends/mcp_server.py
CHANGED
|
@@ -8,7 +8,7 @@ import time
|
|
|
8
8
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
9
9
|
from contextlib import asynccontextmanager
|
|
10
10
|
from contextvars import copy_context
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
from opentelemetry import context as otel_context
|
|
14
14
|
from pydantic_ai.exceptions import UnexpectedModelBehavior
|
|
@@ -19,7 +19,7 @@ from ..runtime.step_context import (
|
|
|
19
19
|
resolve_tool_result_rendering_policy,
|
|
20
20
|
)
|
|
21
21
|
from ..tools.contracts import ToolHandlerResult, ToolOutcome, build_tool_result_observation
|
|
22
|
-
from .mcp_boundary import call_tool_for_low_level_mcp_server,
|
|
22
|
+
from .mcp_boundary import call_tool_for_low_level_mcp_server, tool_handler_result_to_low_level_mcp_result
|
|
23
23
|
from .tool_bridge import get_current_step_run_context_required
|
|
24
24
|
|
|
25
25
|
|
|
@@ -95,21 +95,19 @@ class McpServer:
|
|
|
95
95
|
"error": {"kind": "resolution", "message": f"Unknown tool: {name}", "guidance": "Choose a visible tool name and retry."},
|
|
96
96
|
}
|
|
97
97
|
tool_handler_result: ToolHandlerResult = build_tool_result_observation(tool_outcome=tool_outcome)
|
|
98
|
-
|
|
98
|
+
return tool_handler_result_to_low_level_mcp_result(
|
|
99
99
|
tool_name=name,
|
|
100
100
|
tool_handler_result=tool_handler_result,
|
|
101
101
|
rendering_policy=self._tool_result_rendering_policy,
|
|
102
102
|
)
|
|
103
|
-
return mcp_types.CallToolResult(content=cast(list[mcp_types.ContentBlock], content_block_list))
|
|
104
103
|
|
|
105
|
-
|
|
104
|
+
return await call_tool_for_low_level_mcp_server(
|
|
106
105
|
tool_name=name,
|
|
107
106
|
arguments=arguments,
|
|
108
107
|
tool_handler=handler,
|
|
109
108
|
parent_otel_context=self._parent_otel_context,
|
|
110
109
|
rendering_policy=self._tool_result_rendering_policy,
|
|
111
110
|
)
|
|
112
|
-
return mcp_types.CallToolResult(content=cast(list[mcp_types.ContentBlock], content_block_list))
|
|
113
111
|
|
|
114
112
|
mcp_server = McpLowLevelServer(
|
|
115
113
|
"nighthawk",
|
|
@@ -154,8 +154,10 @@ async def execute_tool_call(
|
|
|
154
154
|
try:
|
|
155
155
|
value = await manager.handle_call(ToolCallPart(tool_name, arguments, tool_call_id), wrap_validation_errors=False)
|
|
156
156
|
except ValidationError as exception:
|
|
157
|
-
if run_context.deps.operation_failure is
|
|
158
|
-
raise
|
|
157
|
+
if run_context.deps.operation_failure is not None:
|
|
158
|
+
raise run_context.deps.operation_failure from run_context.deps.operation_failure.__cause__
|
|
159
|
+
if observed_toolset.outcome is not None and (observed_toolset.outcome["error"] or {}).get("status") == "rejected":
|
|
160
|
+
return build_tool_result_observation(tool_outcome=observed_toolset.outcome)
|
|
159
161
|
retry_text = RetryPromptPart(
|
|
160
162
|
tool_name=tool_name,
|
|
161
163
|
content=exception.errors(include_url=False, include_context=False),
|
|
@@ -163,9 +165,20 @@ async def execute_tool_call(
|
|
|
163
165
|
).model_response()
|
|
164
166
|
return _build_retry_prompt_observation(retry_text=retry_text)
|
|
165
167
|
except ModelRetry as exception:
|
|
168
|
+
if run_context.deps.operation_failure is not None:
|
|
169
|
+
raise run_context.deps.operation_failure from run_context.deps.operation_failure.__cause__
|
|
170
|
+
if observed_toolset.outcome is not None and (observed_toolset.outcome["error"] or {}).get("status") == "rejected":
|
|
171
|
+
return build_tool_result_observation(tool_outcome=observed_toolset.outcome)
|
|
166
172
|
return _build_retry_prompt_observation(retry_text=exception.message)
|
|
167
173
|
|
|
168
|
-
if
|
|
174
|
+
if run_context.deps.operation_failure is not None:
|
|
175
|
+
raise run_context.deps.operation_failure
|
|
176
|
+
recorded_error = observed_toolset.outcome["error"] if observed_toolset.outcome is not None else None
|
|
177
|
+
if recorded_error is not None and recorded_error.get("status") == "rejected":
|
|
178
|
+
# A capability may replace a return value, but cannot turn a refused body into a success.
|
|
179
|
+
assert observed_toolset.outcome is not None
|
|
180
|
+
tool_outcome = observed_toolset.outcome
|
|
181
|
+
elif isinstance(value, ToolDenied):
|
|
169
182
|
tool_outcome: ToolOutcome = {"payload": None, "error": {"kind": "oversight", "message": value.message, "guidance": None}}
|
|
170
183
|
elif observed_toolset.outcome is not None and value is observed_toolset.return_value:
|
|
171
184
|
tool_outcome = observed_toolset.outcome
|
nighthawk/composition.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Omitted arguments and explicit collection composition operations."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -30,7 +30,11 @@ class UnsetType:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
UNSET = UnsetType()
|
|
33
|
-
"""
|
|
33
|
+
"""An omitted argument: use the receiving API's documented default behavior.
|
|
34
|
+
|
|
35
|
+
Scopes inherit, runs create fresh resources, and rewrites retain the current field.
|
|
36
|
+
Ordinary values, including empty collections, remain explicit values.
|
|
37
|
+
"""
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
@dataclass(frozen=True, init=False)
|
nighthawk/configuration.py
CHANGED
|
@@ -121,8 +121,9 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
121
121
|
Instances retain identity; the host owns client lifetime. Serialization
|
|
122
122
|
must explicitly exclude a live model field, including in nested records.
|
|
123
123
|
Configuration representations omit the model to avoid traversing credentials.
|
|
124
|
-
model_settings: Provider-specific
|
|
125
|
-
backend-specific BaseModel
|
|
124
|
+
model_settings: Provider-specific overrides. Defaults to an empty dict;
|
|
125
|
+
accepts a dict or a backend-specific BaseModel (converted to dict).
|
|
126
|
+
None and UNSET are invalid. An empty dict preserves model-owned defaults.
|
|
126
127
|
prompts: Prompt templates for step execution.
|
|
127
128
|
context_limits: Token and item limits for context rendering.
|
|
128
129
|
json_renderer_style: Headson rendering style for JSON summarization.
|
|
@@ -136,11 +137,11 @@ class StepExecutorConfiguration(BaseModel):
|
|
|
136
137
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
137
138
|
|
|
138
139
|
model: str | InstanceOf[Model] = Field(default="openai-responses:gpt-5.6-luna", repr=False)
|
|
139
|
-
model_settings: dict[str, Any] | BaseModel
|
|
140
|
+
model_settings: dict[str, Any] | BaseModel = Field(default_factory=dict)
|
|
140
141
|
|
|
141
142
|
@field_validator("model_settings", mode="before")
|
|
142
143
|
@classmethod
|
|
143
|
-
def _normalize_model_settings(cls, value: Any) -> dict[str, Any]
|
|
144
|
+
def _normalize_model_settings(cls, value: Any) -> dict[str, Any]:
|
|
144
145
|
if isinstance(value, BaseModel):
|
|
145
146
|
return value.model_dump()
|
|
146
147
|
return value
|
nighthawk/function_call.py
CHANGED
|
@@ -10,8 +10,9 @@ from contextlib import contextmanager, nullcontext
|
|
|
10
10
|
from contextvars import ContextVar
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
from types import MappingProxyType
|
|
13
|
-
from typing import Any, Protocol
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
14
|
|
|
15
|
+
from .composition import UNSET, UnsetType
|
|
15
16
|
from .configuration import ModelSelection
|
|
16
17
|
from .errors import NighthawkError
|
|
17
18
|
from .natural.definition import NaturalFunctionDefinition
|
|
@@ -31,6 +32,7 @@ class FunctionCall:
|
|
|
31
32
|
model_selection: ModelSelection | None
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
@runtime_checkable
|
|
34
36
|
class FunctionCallHandler(Protocol):
|
|
35
37
|
"""Application-owned admission, grouping, and observation of function calls."""
|
|
36
38
|
|
|
@@ -48,9 +50,19 @@ _call: ContextVar[FunctionCall | None] = ContextVar("nighthawk_function_call", d
|
|
|
48
50
|
|
|
49
51
|
|
|
50
52
|
@contextmanager
|
|
51
|
-
def function_call_scope(handler: FunctionCallHandler | None) -> Iterator[None]:
|
|
52
|
-
"""
|
|
53
|
-
|
|
53
|
+
def function_call_scope(handler: FunctionCallHandler | None | UnsetType = UNSET) -> Iterator[None]:
|
|
54
|
+
"""Inherit, replace, or clear interception independently of executor runs.
|
|
55
|
+
|
|
56
|
+
Omission or UNSET inherits; None clears; a handler replaces the current one.
|
|
57
|
+
Invalid handlers fail before context installation.
|
|
58
|
+
"""
|
|
59
|
+
if (
|
|
60
|
+
not isinstance(handler, UnsetType)
|
|
61
|
+
and handler is not None
|
|
62
|
+
and (not isinstance(handler, FunctionCallHandler) or not callable(handler.invoke) or not callable(handler.invoke_async))
|
|
63
|
+
):
|
|
64
|
+
raise TypeError("handler must implement FunctionCallHandler, be None, or be UNSET")
|
|
65
|
+
token = _handler.set(_handler.get() if isinstance(handler, UnsetType) else handler)
|
|
54
66
|
try:
|
|
55
67
|
yield
|
|
56
68
|
finally:
|
nighthawk/lifecycle.py
CHANGED
|
@@ -31,7 +31,7 @@ type FailureStage = Literal[
|
|
|
31
31
|
"return_validation",
|
|
32
32
|
"commit_inspection",
|
|
33
33
|
"rewrite_validation",
|
|
34
|
-
"
|
|
34
|
+
"oversight_abort",
|
|
35
35
|
"raise_construction",
|
|
36
36
|
"binding_assignment",
|
|
37
37
|
]
|
|
@@ -82,7 +82,7 @@ class StepFailed(_StepFinished):
|
|
|
82
82
|
attempted_outcome: StepResult | None = None
|
|
83
83
|
validated_binding_name_to_value: Mapping[str, object] | None = None
|
|
84
84
|
inspection_subject: Literal["step_commit", "operation"] | None = None
|
|
85
|
-
|
|
85
|
+
abort_reason: str | None = None
|
|
86
86
|
kind: Literal["failed"] = field(default="failed", init=False)
|
|
87
87
|
|
|
88
88
|
|
nighthawk/oversight.py
CHANGED
|
@@ -18,8 +18,8 @@ def _reference_mapping(name_to_value: Mapping[str, object]) -> Mapping[str, obje
|
|
|
18
18
|
return MappingProxyType(dict(name_to_value))
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
class
|
|
22
|
-
"""
|
|
21
|
+
class StepAbortedError(NighthawkError):
|
|
22
|
+
"""An explicit host decision ending the inspected step, without rollback."""
|
|
23
23
|
|
|
24
24
|
def __init__(self, reason: str, *, subject: str = "operation") -> None:
|
|
25
25
|
self.reason = reason
|
|
@@ -75,17 +75,38 @@ class Accept:
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
@dataclass(frozen=True)
|
|
78
|
-
class
|
|
78
|
+
class RejectOperation:
|
|
79
|
+
"""Do not execute this operation; expose reason and guidance to its caller or model."""
|
|
80
|
+
|
|
81
|
+
reason: str
|
|
82
|
+
guidance: str | None = None
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
if not isinstance(self.reason, str) or not self.reason.strip():
|
|
86
|
+
raise ValueError("RejectOperation requires a nonempty string reason")
|
|
87
|
+
if self.guidance is not None and not isinstance(self.guidance, str):
|
|
88
|
+
raise TypeError("RejectOperation guidance must be a string or None")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class AbortStep:
|
|
93
|
+
"""End the current step; catching its exception cannot authorize completion."""
|
|
94
|
+
|
|
79
95
|
reason: str
|
|
80
96
|
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
if not isinstance(self.reason, str) or not self.reason.strip():
|
|
99
|
+
raise ValueError("AbortStep requires a nonempty string reason")
|
|
100
|
+
|
|
81
101
|
|
|
82
102
|
@dataclass(frozen=True)
|
|
83
103
|
class Rewrite:
|
|
84
|
-
"""Replace supplied commit fields; UNSET
|
|
104
|
+
"""Replace supplied commit fields; UNSET retains and Return(None) returns None.
|
|
85
105
|
|
|
86
106
|
A mapping replaces all writes. Use outcome=Return(value=...) to supply
|
|
87
107
|
a new return value or change another allowed outcome into a return.
|
|
88
108
|
Replacements are validated before commit without replaying return expressions.
|
|
109
|
+
A resolved Raise cannot be paired with an explicit nonempty output mapping.
|
|
89
110
|
"""
|
|
90
111
|
|
|
91
112
|
outcome: StepResult | UnsetType = UNSET
|
|
@@ -96,19 +117,22 @@ class Rewrite:
|
|
|
96
117
|
if not isinstance(self.outcome, (UnsetType, Pass, Return, Break, Continue, Raise)):
|
|
97
118
|
raise TypeError("Rewrite outcome must be a resolved StepResult or UNSET")
|
|
98
119
|
if not isinstance(self.binding_name_to_value, UnsetType):
|
|
99
|
-
if not isinstance(self.binding_name_to_value, Mapping):
|
|
100
|
-
raise TypeError("Rewrite binding_name_to_value must be a mapping or UNSET")
|
|
120
|
+
if not isinstance(self.binding_name_to_value, Mapping) or any(not isinstance(name, str) for name in self.binding_name_to_value):
|
|
121
|
+
raise TypeError("Rewrite binding_name_to_value must be a string-keyed mapping or UNSET")
|
|
101
122
|
object.__setattr__(self, "binding_name_to_value", _reference_mapping(self.binding_name_to_value))
|
|
102
123
|
if self.outcome is UNSET and self.binding_name_to_value is UNSET:
|
|
103
124
|
raise ValueError("Rewrite must change outcome or binding_name_to_value")
|
|
125
|
+
if isinstance(self.outcome, Raise) and not isinstance(self.binding_name_to_value, UnsetType) and len(self.binding_name_to_value):
|
|
126
|
+
raise ValueError("Rewrite Raise cannot include output bindings")
|
|
104
127
|
|
|
105
128
|
|
|
106
|
-
type
|
|
129
|
+
type OperationDecision = Accept | RejectOperation | AbortStep
|
|
130
|
+
type StepCommitDecision = Accept | Rewrite | AbortStep
|
|
107
131
|
|
|
108
132
|
|
|
109
133
|
@dataclass(frozen=True)
|
|
110
134
|
class Oversight:
|
|
111
|
-
inspect_operation: Callable[[OperationRequest],
|
|
135
|
+
inspect_operation: Callable[[OperationRequest], OperationDecision | Awaitable[OperationDecision]] | None = None
|
|
112
136
|
inspect_step_commit: Callable[[StepCommit], StepCommitDecision | Awaitable[StepCommitDecision]] | None = None
|
|
113
137
|
|
|
114
138
|
|
|
@@ -150,9 +174,11 @@ __all__ = [
|
|
|
150
174
|
"Raise",
|
|
151
175
|
"StepResult",
|
|
152
176
|
"Accept",
|
|
177
|
+
"AbortStep",
|
|
178
|
+
"OperationDecision",
|
|
153
179
|
"Oversight",
|
|
154
|
-
"
|
|
155
|
-
"
|
|
180
|
+
"RejectOperation",
|
|
181
|
+
"StepAbortedError",
|
|
156
182
|
"Rewrite",
|
|
157
183
|
"StepCommit",
|
|
158
184
|
"StepCommitDecision",
|
nighthawk/runtime/__init__.py
CHANGED
|
@@ -18,7 +18,14 @@ if TYPE_CHECKING:
|
|
|
18
18
|
from .environment import StepEnvironment
|
|
19
19
|
from .expression import eval_expression, eval_expression_async
|
|
20
20
|
from .inspection import inspect_value
|
|
21
|
-
from .operation_contract import
|
|
21
|
+
from .operation_contract import (
|
|
22
|
+
OperationDeliveryError,
|
|
23
|
+
OperationError,
|
|
24
|
+
OperationFinished,
|
|
25
|
+
OperationRejectedError,
|
|
26
|
+
OperationRejection,
|
|
27
|
+
OperationRequest,
|
|
28
|
+
)
|
|
22
29
|
from .step_definition import StepDefinition
|
|
23
30
|
from .step_request import StepCandidate, StepRequest
|
|
24
31
|
from .step_result import Break, Continue, Pass, Raise, Return, StepResult
|
|
@@ -48,6 +55,9 @@ _name_to_module = {
|
|
|
48
55
|
"StepEnvironment": "environment",
|
|
49
56
|
"inspect_value": "inspection",
|
|
50
57
|
"OperationError": "operation_contract",
|
|
58
|
+
"OperationDeliveryError": "operation_contract",
|
|
59
|
+
"OperationRejectedError": "operation_contract",
|
|
60
|
+
"OperationRejection": "operation_contract",
|
|
51
61
|
"OperationFinished": "operation_contract",
|
|
52
62
|
"OperationRequest": "operation_contract",
|
|
53
63
|
"Pass": "step_result",
|
|
@@ -82,6 +92,9 @@ __all__ = [
|
|
|
82
92
|
"StepEnvironment",
|
|
83
93
|
"inspect_value",
|
|
84
94
|
"OperationError",
|
|
95
|
+
"OperationDeliveryError",
|
|
96
|
+
"OperationRejectedError",
|
|
97
|
+
"OperationRejection",
|
|
85
98
|
"OperationFinished",
|
|
86
99
|
"OperationRequest",
|
|
87
100
|
"Pass",
|
nighthawk/runtime/environment.py
CHANGED
|
@@ -5,13 +5,21 @@ from __future__ import annotations
|
|
|
5
5
|
from collections.abc import Iterator, Mapping
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from types import CellType, MappingProxyType
|
|
8
|
-
from typing import Literal
|
|
8
|
+
from typing import Literal, Never
|
|
9
9
|
|
|
10
10
|
from .bindings import MISSING_ANNOTATION
|
|
11
11
|
from .description import annotation_text
|
|
12
12
|
from .discovery import DiscoveryResult, page_names
|
|
13
13
|
|
|
14
|
-
type EnvironmentSource = Literal["effective", "python", "
|
|
14
|
+
type EnvironmentSource = Literal["effective", "python", "task", "step"]
|
|
15
|
+
|
|
16
|
+
_EMPTY_NAME_TO_VALUE: Mapping[str, Never] = MappingProxyType({})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _copy_name_to_value[T](name_to_value: Mapping[str, T], parameter: str) -> dict[str, T]:
|
|
20
|
+
if not isinstance(name_to_value, Mapping) or any(not isinstance(name, str) for name in name_to_value):
|
|
21
|
+
raise TypeError(f"{parameter} must be a string-keyed mapping")
|
|
22
|
+
return dict(name_to_value)
|
|
15
23
|
|
|
16
24
|
|
|
17
25
|
class MissingEnvironmentNameError(NameError):
|
|
@@ -63,6 +71,16 @@ class _ExpressionNamespace(dict[str, object]):
|
|
|
63
71
|
return self._override_name_to_value[name]
|
|
64
72
|
return self._environment.resolve(name)
|
|
65
73
|
|
|
74
|
+
def __contains__(self, name: object) -> bool:
|
|
75
|
+
# Python 3.14 forward references check membership before looking up a name.
|
|
76
|
+
if not isinstance(name, str):
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
self[name]
|
|
80
|
+
except MissingEnvironmentNameError:
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
66
84
|
|
|
67
85
|
@dataclass(frozen=True)
|
|
68
86
|
class _RuntimeAccess:
|
|
@@ -78,7 +96,7 @@ class _RuntimeAccess:
|
|
|
78
96
|
class StepEnvironment:
|
|
79
97
|
"""Read-only environment views with a separate, tracked working namespace.
|
|
80
98
|
|
|
81
|
-
|
|
99
|
+
Python working associations and scoped task references are captured at entry.
|
|
82
100
|
Application values remain live; the views do not freeze their object graphs.
|
|
83
101
|
"""
|
|
84
102
|
|
|
@@ -88,28 +106,29 @@ class StepEnvironment:
|
|
|
88
106
|
python_name_to_value: Mapping[str, object],
|
|
89
107
|
python_globals: dict[str, object],
|
|
90
108
|
local_name_set: frozenset[str] = frozenset(),
|
|
91
|
-
python_name_to_cell: Mapping[str, CellType]
|
|
92
|
-
|
|
93
|
-
task_name_to_value: Mapping[str, object] | None = None,
|
|
109
|
+
python_name_to_cell: Mapping[str, CellType] = _EMPTY_NAME_TO_VALUE,
|
|
110
|
+
task_name_to_value: Mapping[str, object] = _EMPTY_NAME_TO_VALUE,
|
|
94
111
|
output_name_set: frozenset[str] = frozenset(),
|
|
95
|
-
binding_name_to_annotation: Mapping[str, object]
|
|
96
|
-
binding_name_to_type: Mapping[str, object]
|
|
112
|
+
binding_name_to_annotation: Mapping[str, object] = _EMPTY_NAME_TO_VALUE,
|
|
113
|
+
binding_name_to_type: Mapping[str, object] = _EMPTY_NAME_TO_VALUE,
|
|
97
114
|
runtime_name_set: frozenset[str] = frozenset(),
|
|
98
115
|
) -> None:
|
|
116
|
+
python_name_to_value = _copy_name_to_value(python_name_to_value, "python_name_to_value")
|
|
117
|
+
if not isinstance(python_globals, dict) or any(not isinstance(name, str) for name in python_globals):
|
|
118
|
+
raise TypeError("python_globals must be a string-keyed dict")
|
|
99
119
|
self._runtime_name_set = frozenset(runtime_name_set)
|
|
100
120
|
self._python_name_to_value = {name: value for name, value in python_name_to_value.items() if not self._is_runtime_name(name)}
|
|
101
121
|
self._python_global_name_to_value = python_globals
|
|
102
122
|
builtins_value = python_globals.get("__builtins__", __builtins__)
|
|
103
123
|
self._python_builtin_name_to_value: dict[str, object] = builtins_value if isinstance(builtins_value, dict) else vars(builtins_value)
|
|
104
124
|
self._local_name_set = local_name_set
|
|
105
|
-
self._python_name_to_cell =
|
|
125
|
+
self._python_name_to_cell = _copy_name_to_value(python_name_to_cell, "python_name_to_cell")
|
|
106
126
|
self._output_name_set = output_name_set
|
|
107
127
|
self._step_name_to_value: dict[str, object] = {}
|
|
108
|
-
self._binding_name_to_annotation =
|
|
109
|
-
self._binding_name_to_type =
|
|
128
|
+
self._binding_name_to_annotation = _copy_name_to_value(binding_name_to_annotation, "binding_name_to_annotation")
|
|
129
|
+
self._binding_name_to_type = _copy_name_to_value(binding_name_to_type, "binding_name_to_type")
|
|
110
130
|
self.python_name_to_value: Mapping[str, object] = _PythonEnvironmentView(self)
|
|
111
|
-
self.
|
|
112
|
-
self.task_name_to_value = MappingProxyType(dict(task_name_to_value or {}))
|
|
131
|
+
self.task_name_to_value = MappingProxyType(_copy_name_to_value(task_name_to_value, "task_name_to_value"))
|
|
113
132
|
self.step_name_to_value = MappingProxyType(self._step_name_to_value)
|
|
114
133
|
known_names = (
|
|
115
134
|
python_name_to_value.keys()
|
|
@@ -117,7 +136,6 @@ class StepEnvironment:
|
|
|
117
136
|
| self._python_builtin_name_to_value.keys()
|
|
118
137
|
| self._local_name_set
|
|
119
138
|
| self._python_name_to_cell.keys()
|
|
120
|
-
| self.parent_name_to_value.keys()
|
|
121
139
|
| self.task_name_to_value.keys()
|
|
122
140
|
| self._runtime_name_set
|
|
123
141
|
)
|
|
@@ -166,7 +184,6 @@ class StepEnvironment:
|
|
|
166
184
|
source_to_name_to_value = {
|
|
167
185
|
"step": self.step_name_to_value,
|
|
168
186
|
"python": self.python_name_to_value,
|
|
169
|
-
"parent": self.parent_name_to_value,
|
|
170
187
|
"task": self.task_name_to_value,
|
|
171
188
|
}
|
|
172
189
|
if source != "effective" and source not in source_to_name_to_value:
|
|
@@ -188,19 +205,25 @@ class StepEnvironment:
|
|
|
188
205
|
self._step_name_to_value[name] = value
|
|
189
206
|
|
|
190
207
|
def snapshot_working_state(self) -> dict[str, object]:
|
|
191
|
-
|
|
208
|
+
"""Copy this step's bound working names for explicit application-owned sharing.
|
|
209
|
+
|
|
210
|
+
Include Python locals, live cells, and tracked assignments.
|
|
211
|
+
Exclude module globals, builtins, scoped task references, and runtime names.
|
|
212
|
+
Referenced objects retain identity; the returned mapping is independent.
|
|
213
|
+
"""
|
|
214
|
+
name_to_value: dict[str, object] = {}
|
|
192
215
|
for name in self._python_name_to_value.keys() | self._local_name_set | self._python_name_to_cell.keys():
|
|
193
216
|
if self._is_runtime_name(name):
|
|
194
217
|
continue
|
|
195
218
|
try:
|
|
196
|
-
|
|
219
|
+
name_to_value[name] = self._resolve_python(name)
|
|
197
220
|
except (KeyError, UnboundLocalError):
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return
|
|
221
|
+
continue
|
|
222
|
+
name_to_value.update(self.step_name_to_value)
|
|
223
|
+
return name_to_value
|
|
201
224
|
|
|
202
|
-
def expression_namespace(self, overrides: Mapping[str, object]
|
|
203
|
-
return _ExpressionNamespace(self, overrides
|
|
225
|
+
def expression_namespace(self, overrides: Mapping[str, object] = _EMPTY_NAME_TO_VALUE) -> dict[str, object]:
|
|
226
|
+
return _ExpressionNamespace(self, _copy_name_to_value(overrides, "overrides"))
|
|
204
227
|
|
|
205
228
|
def reference(self, name: str, *, source: EnvironmentSource = "effective") -> str:
|
|
206
229
|
"""Return a Python expression resolving this binding when evaluated."""
|
|
@@ -208,18 +231,17 @@ class StepEnvironment:
|
|
|
208
231
|
|
|
209
232
|
def discover(self, *, source: EnvironmentSource = "effective", cursor: str | None = None, max_items: int = 8) -> dict[str, object]:
|
|
210
233
|
"""List names with provenance, usable references, and progressing pages."""
|
|
211
|
-
|
|
234
|
+
source_to_name_to_value = {
|
|
212
235
|
"python": self.python_name_to_value,
|
|
213
|
-
"parent": self.parent_name_to_value,
|
|
214
236
|
"task": self.task_name_to_value,
|
|
215
237
|
"step": self.step_name_to_value,
|
|
216
238
|
}
|
|
217
|
-
if source != "effective" and source not in
|
|
218
|
-
return {"error": "Unknown source", "recovery": "Choose effective, python,
|
|
239
|
+
if source != "effective" and source not in source_to_name_to_value:
|
|
240
|
+
return {"error": "Unknown source", "recovery": "Choose effective, python, task, or step."}
|
|
219
241
|
names = (
|
|
220
|
-
tuple(sorted(set().union(*[set(view) for view in
|
|
242
|
+
tuple(sorted(set().union(*[set(view) for view in source_to_name_to_value.values()])))
|
|
221
243
|
if source == "effective"
|
|
222
|
-
else tuple(sorted(
|
|
244
|
+
else tuple(sorted(source_to_name_to_value[source]))
|
|
223
245
|
)
|
|
224
246
|
page = page_names(names, source=source, cursor=cursor, max_items=max_items)
|
|
225
247
|
if page.error is not None:
|
|
@@ -244,7 +266,6 @@ class StepEnvironment:
|
|
|
244
266
|
for source, view in (
|
|
245
267
|
("step", self.step_name_to_value),
|
|
246
268
|
("python", self.python_name_to_value),
|
|
247
|
-
("parent", self.parent_name_to_value),
|
|
248
269
|
("task", self.task_name_to_value),
|
|
249
270
|
)
|
|
250
271
|
if name in view
|
nighthawk/runtime/expression.py
CHANGED
|
@@ -38,7 +38,7 @@ async def evaluate_compiled_expression(
|
|
|
38
38
|
allow_awaitable_result: bool = True,
|
|
39
39
|
override_name_to_value: Mapping[str, object] | None = None,
|
|
40
40
|
) -> object:
|
|
41
|
-
namespace = step_context.environment.expression_namespace(override_name_to_value)
|
|
41
|
+
namespace = step_context.environment.expression_namespace({} if override_name_to_value is None else override_name_to_value)
|
|
42
42
|
try:
|
|
43
43
|
value = eval(compiled, namespace, namespace)
|
|
44
44
|
if inspect.isawaitable(value):
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from collections.abc import Mapping
|
|
4
4
|
|
|
5
|
+
from ..composition import UNSET, UnsetType
|
|
5
6
|
from ..errors import ExecutionError
|
|
6
7
|
from .expression import evaluate_expression_operation
|
|
7
8
|
from .operation_contract import OperationError
|
|
@@ -13,14 +14,21 @@ from .step_result import Break, Continue, Pass, Raise, Return, StepResult
|
|
|
13
14
|
async def resolve_model_candidate(
|
|
14
15
|
request: StepRequest,
|
|
15
16
|
outcome: StepOutcome,
|
|
16
|
-
binding_name_to_value: Mapping[str, object],
|
|
17
|
+
binding_name_to_value: Mapping[str, object] | UnsetType = UNSET,
|
|
17
18
|
) -> StepCandidate:
|
|
18
19
|
"""Resolve a provider response once; leave commit validation to the runner."""
|
|
19
20
|
if outcome.kind not in request.allowed_step_kinds:
|
|
20
21
|
raise ExecutionError(f"Step '{outcome.kind}' is not allowed for this step. Allowed kinds: {request.allowed_step_kinds}")
|
|
21
|
-
|
|
22
|
+
if not isinstance(binding_name_to_value, UnsetType) and (
|
|
23
|
+
not isinstance(binding_name_to_value, Mapping) or any(not isinstance(name, str) for name in binding_name_to_value)
|
|
24
|
+
):
|
|
25
|
+
raise TypeError("binding_name_to_value must be a string-keyed mapping or UNSET")
|
|
26
|
+
supplied_name_to_value = {} if isinstance(binding_name_to_value, UnsetType) else dict(binding_name_to_value)
|
|
27
|
+
undeclared = supplied_name_to_value.keys() - set(request.output_binding_names)
|
|
22
28
|
if undeclared:
|
|
23
29
|
raise ExecutionError(f"Undeclared output binding names: {sorted(undeclared)}")
|
|
30
|
+
if isinstance(outcome, RaiseStepOutcome) and supplied_name_to_value:
|
|
31
|
+
raise ExecutionError("Raise cannot include output bindings")
|
|
24
32
|
resolved: StepResult
|
|
25
33
|
if isinstance(outcome, ReturnStepOutcome):
|
|
26
34
|
value = await evaluate_expression_operation(
|
|
@@ -29,7 +37,7 @@ async def resolve_model_candidate(
|
|
|
29
37
|
purpose="return",
|
|
30
38
|
allow_awaitable_result=request.allow_awaitable_return,
|
|
31
39
|
expected_type=request.return_annotation,
|
|
32
|
-
override_name_to_value=
|
|
40
|
+
override_name_to_value=supplied_name_to_value,
|
|
33
41
|
)
|
|
34
42
|
resolved = Return(value)
|
|
35
43
|
elif isinstance(outcome, RaiseStepOutcome):
|
|
@@ -46,4 +54,4 @@ async def resolve_model_candidate(
|
|
|
46
54
|
resolved = Raise(outcome.raise_message, error_type)
|
|
47
55
|
else:
|
|
48
56
|
resolved = {"pass": Pass, "break": Break, "continue": Continue}[outcome.kind]()
|
|
49
|
-
return StepCandidate(outcome=resolved, binding_name_to_value=binding_name_to_value)
|
|
57
|
+
return StepCandidate(outcome=resolved, binding_name_to_value=UNSET if isinstance(binding_name_to_value, UnsetType) else supplied_name_to_value)
|