nighthawk-python 0.12.0__py3-none-any.whl → 0.14.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 +31 -14
- nighthawk/backends/claude_code_cli.py +1 -1
- nighthawk/backends/claude_code_sdk.py +1 -1
- nighthawk/backends/codex.py +1 -1
- nighthawk/backends/mcp_boundary.py +2 -2
- nighthawk/composition.py +59 -0
- nighthawk/configuration.py +17 -6
- nighthawk/errors.py +25 -3
- nighthawk/lifecycle.py +129 -0
- nighthawk/natural/decorator.py +83 -66
- nighthawk/natural/transform.py +22 -9
- nighthawk/oversight.py +86 -66
- nighthawk/resilience/_budget.py +2 -2
- nighthawk/runtime/execution_reference.py +13 -0
- nighthawk/runtime/prompt.py +6 -6
- nighthawk/runtime/runner.py +377 -238
- nighthawk/runtime/scoping.py +218 -167
- nighthawk/runtime/step_context.py +3 -2
- nighthawk/runtime/step_executor.py +25 -17
- nighthawk/runtime/step_result.py +49 -0
- nighthawk/testing.py +4 -0
- nighthawk/tools/declarations.py +110 -0
- nighthawk/tools/execution.py +6 -11
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.14.0.dist-info}/METADATA +2 -2
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.14.0.dist-info}/RECORD +27 -23
- nighthawk/tools/registry.py +0 -217
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.14.0.dist-info}/WHEEL +0 -0
- {nighthawk_python-0.12.0.dist-info → nighthawk_python-0.14.0.dist-info}/licenses/LICENSE +0 -0
nighthawk/__init__.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from . import oversight, resilience
|
|
3
|
+
from . import lifecycle, oversight, resilience
|
|
4
|
+
from .composition import UNSET, Extend, Merge, UnsetType
|
|
4
5
|
from .configuration import (
|
|
5
6
|
StepContextLimits,
|
|
6
7
|
StepExecutorConfiguration,
|
|
@@ -8,34 +9,45 @@ 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
|
+
ExecutionReference,
|
|
21
24
|
UsageMeter,
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
get_capabilities,
|
|
26
|
+
get_execution_reference,
|
|
24
27
|
get_implicit_references,
|
|
28
|
+
get_lifecycle,
|
|
29
|
+
get_oversight,
|
|
25
30
|
get_step_executor,
|
|
26
31
|
get_system_prompt_suffix_fragments,
|
|
32
|
+
get_tools,
|
|
33
|
+
get_usage_meter,
|
|
27
34
|
get_user_prompt_suffix_fragments,
|
|
28
35
|
run,
|
|
29
36
|
scope,
|
|
30
37
|
)
|
|
31
|
-
from .runtime.step_context import StepContext,
|
|
38
|
+
from .runtime.step_context import StepContext, get_step_context
|
|
32
39
|
from .runtime.step_executor import AgentStepExecutor, StepExecutor
|
|
33
|
-
from .tools.registry import tool
|
|
34
40
|
|
|
35
41
|
__all__ = [
|
|
42
|
+
"UNSET",
|
|
43
|
+
"Extend",
|
|
44
|
+
"Merge",
|
|
45
|
+
"UnsetType",
|
|
46
|
+
"NameConflictError",
|
|
47
|
+
"ToolNameConflictError",
|
|
36
48
|
"AgentStepExecutor",
|
|
37
49
|
"ExecutionError",
|
|
38
|
-
"
|
|
50
|
+
"ExecutionReference",
|
|
39
51
|
"JsonableValue",
|
|
40
52
|
"NaturalParseError",
|
|
41
53
|
"NighthawkError",
|
|
@@ -45,21 +57,26 @@ __all__ = [
|
|
|
45
57
|
"StepExecutorConfiguration",
|
|
46
58
|
"StepPromptTemplates",
|
|
47
59
|
"ToolEvaluationError",
|
|
48
|
-
"
|
|
60
|
+
"ToolDeclarationError",
|
|
49
61
|
"ToolValidationError",
|
|
50
62
|
"UsageMeter",
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
63
|
+
"get_capabilities",
|
|
64
|
+
"get_step_context",
|
|
65
|
+
"get_usage_meter",
|
|
66
|
+
"get_execution_reference",
|
|
54
67
|
"get_implicit_references",
|
|
68
|
+
"get_oversight",
|
|
55
69
|
"get_step_executor",
|
|
70
|
+
"get_tools",
|
|
56
71
|
"get_system_prompt_suffix_fragments",
|
|
57
72
|
"get_user_prompt_suffix_fragments",
|
|
58
73
|
"oversight",
|
|
74
|
+
"lifecycle",
|
|
75
|
+
"get_lifecycle",
|
|
59
76
|
"natural_function",
|
|
77
|
+
"get_transformed_function",
|
|
60
78
|
"resilience",
|
|
61
79
|
"run",
|
|
62
80
|
"scope",
|
|
63
81
|
"to_jsonable_value",
|
|
64
|
-
"tool",
|
|
65
82
|
]
|
|
@@ -15,7 +15,7 @@ from pydantic_ai.profiles import ModelProfile
|
|
|
15
15
|
from pydantic_ai.settings import ModelSettings
|
|
16
16
|
from pydantic_ai.usage import RequestUsage
|
|
17
17
|
|
|
18
|
-
from ..tools.
|
|
18
|
+
from ..tools.declarations import get_visible_tools
|
|
19
19
|
from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
|
|
20
20
|
from .claude_code_settings import ClaudeCodeModelSettings
|
|
21
21
|
from .mcp_server import mcp_server_if_needed
|
|
@@ -15,7 +15,7 @@ from pydantic_ai.settings import ModelSettings
|
|
|
15
15
|
from pydantic_ai.usage import RequestUsage
|
|
16
16
|
|
|
17
17
|
from ..json_renderer import to_jsonable_value
|
|
18
|
-
from ..tools.
|
|
18
|
+
from ..tools.declarations import get_visible_tools
|
|
19
19
|
from .base import BackendModelBase, append_text_projected_tool_result_preview_prompt
|
|
20
20
|
from .claude_code_settings import ClaudeCodeModelSettings
|
|
21
21
|
from .mcp_boundary import call_tool_for_claude_code_sdk
|
nighthawk/backends/codex.py
CHANGED
|
@@ -16,7 +16,7 @@ from pydantic_ai.profiles.openai import OpenAIJsonSchemaTransformer
|
|
|
16
16
|
from pydantic_ai.settings import ModelSettings
|
|
17
17
|
from pydantic_ai.usage import RequestUsage
|
|
18
18
|
|
|
19
|
-
from ..tools.
|
|
19
|
+
from ..tools.declarations import get_visible_tools
|
|
20
20
|
from .base import BackendModelBase, BackendModelSettings, append_text_projected_tool_result_preview_prompt
|
|
21
21
|
from .mcp_server import mcp_server_if_needed
|
|
22
22
|
from .text_projection import TextProjectedRequest, resolve_text_projection_staging_root_directory
|
|
@@ -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)
|
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
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .lifecycle import StepFailed
|
|
7
|
+
|
|
3
8
|
|
|
4
9
|
class NighthawkError(Exception):
|
|
5
10
|
"""Base exception for all Nighthawk errors."""
|
|
@@ -10,7 +15,16 @@ class NaturalParseError(NighthawkError):
|
|
|
10
15
|
|
|
11
16
|
|
|
12
17
|
class ExecutionError(NighthawkError):
|
|
13
|
-
"""
|
|
18
|
+
"""Runtime failure exposing its terminal record and chaining the original error.
|
|
19
|
+
|
|
20
|
+
Internal helpers may supply a message; the runtime boundary attaches a
|
|
21
|
+
StepFailed record to every outward internal execution failure.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, failure: StepFailed | str, *, description: str | None = None) -> None:
|
|
25
|
+
self.step_failed = None if isinstance(failure, str) else failure
|
|
26
|
+
message = failure if isinstance(failure, str) else str(failure.original_exception)
|
|
27
|
+
super().__init__(f"{description}: {message}" if description else message)
|
|
14
28
|
|
|
15
29
|
|
|
16
30
|
class ToolEvaluationError(NighthawkError):
|
|
@@ -21,5 +35,13 @@ class ToolValidationError(NighthawkError):
|
|
|
21
35
|
"""Raised when tool input validation fails."""
|
|
22
36
|
|
|
23
37
|
|
|
24
|
-
class
|
|
25
|
-
"""Raised when tool
|
|
38
|
+
class ToolDeclarationError(NighthawkError):
|
|
39
|
+
"""Raised when a tool declaration is invalid."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NameConflictError(NighthawkError):
|
|
43
|
+
"""Raised when distinct declarations claim the same name."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ToolNameConflictError(ToolDeclarationError, NameConflictError):
|
|
47
|
+
"""Raised when tool declarations claim the same or a reserved name."""
|
nighthawk/lifecycle.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Terminal delivery for host-owned execution ledgers.
|
|
2
|
+
|
|
3
|
+
Records snapshot collection structure, retaining the exact live Python values.
|
|
4
|
+
Hosts own serialization, persistence, and deduplication by step_execution_id.
|
|
5
|
+
Delivery is synchronous and attempted once, after caller binding assignments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from types import MappingProxyType
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from .errors import NighthawkError
|
|
14
|
+
from .runtime.execution_reference import ExecutionReference
|
|
15
|
+
from .runtime.step_contract import StepKind, StepOutcome
|
|
16
|
+
from .runtime.step_result import Break, Continue, Pass, Raise, Return, StepResult
|
|
17
|
+
|
|
18
|
+
type FailureStage = Literal[
|
|
19
|
+
"preparation",
|
|
20
|
+
"executor",
|
|
21
|
+
"outcome_validation",
|
|
22
|
+
"binding_validation",
|
|
23
|
+
"return_inspection",
|
|
24
|
+
"return_evaluation",
|
|
25
|
+
"return_await",
|
|
26
|
+
"return_validation",
|
|
27
|
+
"commit_inspection",
|
|
28
|
+
"rewrite_validation",
|
|
29
|
+
"oversight_rejection",
|
|
30
|
+
"raise_resolution",
|
|
31
|
+
"raise_construction",
|
|
32
|
+
"binding_assignment",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, kw_only=True)
|
|
37
|
+
class _StepFinished:
|
|
38
|
+
execution_reference: ExecutionReference
|
|
39
|
+
processed_natural_program: str | None = None
|
|
40
|
+
input_binding_name_to_value: Mapping[str, object] | None = None
|
|
41
|
+
allowed_step_kinds: tuple[StepKind, ...] | None = None
|
|
42
|
+
assigned_binding_name_to_value: Mapping[str, object] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if self.execution_reference.step_execution_id is None or self.execution_reference.source_location is None:
|
|
46
|
+
raise ValueError("Terminal records require invocation and source identity")
|
|
47
|
+
for name in ("input_binding_name_to_value", "assigned_binding_name_to_value", "validated_binding_name_to_value"):
|
|
48
|
+
value = getattr(self, name, None)
|
|
49
|
+
if value is not None:
|
|
50
|
+
object.__setattr__(self, name, MappingProxyType(dict(value)))
|
|
51
|
+
if self.allowed_step_kinds is not None:
|
|
52
|
+
object.__setattr__(self, "allowed_step_kinds", tuple(self.allowed_step_kinds))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, kw_only=True)
|
|
56
|
+
class StepCompleted(_StepFinished):
|
|
57
|
+
"""A resolved control result whose generated assignments completed."""
|
|
58
|
+
|
|
59
|
+
outcome: Pass | Return | Break | Continue
|
|
60
|
+
kind: Literal["completed"] = field(default="completed", init=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, kw_only=True)
|
|
64
|
+
class StepRaised(_StepFinished):
|
|
65
|
+
"""An approved DSL Raise, with its successfully constructed exception."""
|
|
66
|
+
|
|
67
|
+
outcome: Raise
|
|
68
|
+
exception: BaseException
|
|
69
|
+
kind: Literal["raised"] = field(default="raised", init=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, kw_only=True)
|
|
73
|
+
class StepFailed(_StepFinished):
|
|
74
|
+
"""An ordinary execution failure, distinct from a DSL-selected Raise."""
|
|
75
|
+
|
|
76
|
+
failure_stage: FailureStage
|
|
77
|
+
original_exception: Exception
|
|
78
|
+
attempted_outcome: StepOutcome | StepResult | None = None
|
|
79
|
+
validated_binding_name_to_value: Mapping[str, object] | None = None
|
|
80
|
+
inspection_subject: Literal["return_expression", "step_commit", "tool_call"] | None = None
|
|
81
|
+
rejection_reason: str | None = None
|
|
82
|
+
kind: Literal["failed"] = field(default="failed", init=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, kw_only=True)
|
|
86
|
+
class StepInterrupted(_StepFinished):
|
|
87
|
+
"""Cancellation or process control; propagation takes priority over delivery."""
|
|
88
|
+
|
|
89
|
+
failure_stage: FailureStage
|
|
90
|
+
original_exception: BaseException
|
|
91
|
+
kind: Literal["interrupted"] = field(default="interrupted", init=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
type StepFinished = StepCompleted | StepRaised | StepFailed | StepInterrupted
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class StepLifecycle:
|
|
99
|
+
"""Scoped synchronous notification, inherited with UNSET and cleared by None.
|
|
100
|
+
|
|
101
|
+
A callback may persist a record and raise a host exception referencing it.
|
|
102
|
+
Callback errors never trigger another execution notification or rollback.
|
|
103
|
+
Rethrowing the exact original exception preserves its existing cause.
|
|
104
|
+
Host deduplication must match both execution identity and the stored event.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
on_step_finished: Callable[[StepFinished], None] | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class StepDeliveryError(NighthawkError):
|
|
111
|
+
"""Host adapter delivery failure; does not assert that persistence succeeded."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, execution_reference: ExecutionReference, cause: BaseException) -> None:
|
|
114
|
+
self.execution_reference = execution_reference
|
|
115
|
+
self.delivery_cause = cause
|
|
116
|
+
super().__init__(f"Step terminal delivery failed: {cause}")
|
|
117
|
+
self.__cause__ = cause
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"FailureStage",
|
|
122
|
+
"StepCompleted",
|
|
123
|
+
"StepDeliveryError",
|
|
124
|
+
"StepFailed",
|
|
125
|
+
"StepFinished",
|
|
126
|
+
"StepInterrupted",
|
|
127
|
+
"StepLifecycle",
|
|
128
|
+
"StepRaised",
|
|
129
|
+
]
|
nighthawk/natural/decorator.py
CHANGED
|
@@ -6,14 +6,15 @@ import logging
|
|
|
6
6
|
import sys
|
|
7
7
|
import textwrap
|
|
8
8
|
from collections.abc import Awaitable, Callable
|
|
9
|
+
from contextlib import AbstractContextManager
|
|
9
10
|
from functools import wraps
|
|
11
|
+
from types import FunctionType
|
|
10
12
|
from typing import Any, cast
|
|
11
13
|
|
|
12
|
-
from ..runtime.runner import Runner
|
|
14
|
+
from ..runtime.runner import Runner
|
|
13
15
|
from ..runtime.scoping import get_step_executor
|
|
14
16
|
from ..runtime.step_context import python_cell_scope, python_name_scope
|
|
15
|
-
from
|
|
16
|
-
from .blocks import find_natural_blocks
|
|
17
|
+
from .blocks import extract_program, find_natural_blocks
|
|
17
18
|
from .transform import transform_module_ast
|
|
18
19
|
|
|
19
20
|
type NaturalFunctionCallable = Callable[..., Any]
|
|
@@ -21,48 +22,10 @@ type NaturalFunctionCallable = Callable[..., Any]
|
|
|
21
22
|
|
|
22
23
|
class _RunnerProxy:
|
|
23
24
|
@staticmethod
|
|
24
|
-
def
|
|
25
|
-
natural_program: str,
|
|
26
|
-
input_binding_names: list[str],
|
|
27
|
-
output_binding_names: list[str],
|
|
28
|
-
binding_name_to_type: dict[str, object],
|
|
29
|
-
return_annotation: object,
|
|
30
|
-
is_in_loop: bool,
|
|
31
|
-
) -> StepEnvelope:
|
|
25
|
+
def execution() -> AbstractContextManager[Runner]:
|
|
32
26
|
caller_frame = sys._getframe(1)
|
|
33
|
-
|
|
34
|
-
runner
|
|
35
|
-
return runner.run_step(
|
|
36
|
-
natural_program,
|
|
37
|
-
input_binding_names,
|
|
38
|
-
output_binding_names,
|
|
39
|
-
binding_name_to_type,
|
|
40
|
-
return_annotation,
|
|
41
|
-
is_in_loop,
|
|
42
|
-
caller_frame=caller_frame,
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
@staticmethod
|
|
46
|
-
async def run_step_async(
|
|
47
|
-
natural_program: str,
|
|
48
|
-
input_binding_names: list[str],
|
|
49
|
-
output_binding_names: list[str],
|
|
50
|
-
binding_name_to_type: dict[str, object],
|
|
51
|
-
return_annotation: object,
|
|
52
|
-
is_in_loop: bool,
|
|
53
|
-
) -> StepEnvelope:
|
|
54
|
-
caller_frame = sys._getframe(1)
|
|
55
|
-
current_step_executor = get_step_executor()
|
|
56
|
-
runner = Runner(current_step_executor)
|
|
57
|
-
return await runner.run_step_async(
|
|
58
|
-
natural_program,
|
|
59
|
-
input_binding_names,
|
|
60
|
-
output_binding_names,
|
|
61
|
-
binding_name_to_type,
|
|
62
|
-
return_annotation,
|
|
63
|
-
is_in_loop,
|
|
64
|
-
caller_frame=caller_frame,
|
|
65
|
-
)
|
|
27
|
+
runner = Runner(get_step_executor())
|
|
28
|
+
return runner.execution(caller_frame=caller_frame)
|
|
66
29
|
|
|
67
30
|
|
|
68
31
|
def _extract_inline_fstring_name_set(function_source: str, *, function_name: str) -> set[str]:
|
|
@@ -223,6 +186,7 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
223
186
|
original_module = ast.Module(body=[], type_ignores=[])
|
|
224
187
|
|
|
225
188
|
capture_name_set = _build_capture_name_set(source, func.__name__)
|
|
189
|
+
capture_name_set.update(func.__code__.co_freevars)
|
|
226
190
|
|
|
227
191
|
definition_frame = inspect.currentframe()
|
|
228
192
|
name_to_value: dict[str, object] = {}
|
|
@@ -233,39 +197,69 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
233
197
|
if name in caller_frame.f_locals:
|
|
234
198
|
name_to_value[name] = caller_frame.f_locals[name]
|
|
235
199
|
|
|
200
|
+
original_name_to_cell = dict(zip(func.__code__.co_freevars, func.__closure__ or (), strict=True))
|
|
201
|
+
for name, cell in original_name_to_cell.items():
|
|
202
|
+
try:
|
|
203
|
+
name_to_value[name] = cell.cell_contents
|
|
204
|
+
except ValueError:
|
|
205
|
+
name_to_value[name] = None
|
|
206
|
+
|
|
236
207
|
captured_name_tuple = tuple(sorted(capture_name_set))
|
|
237
208
|
|
|
238
209
|
transformed_module = transform_module_ast(original_module, captured_name_tuple=captured_name_tuple)
|
|
239
210
|
|
|
240
211
|
filename = inspect.getsourcefile(func) or "<nighthawk>"
|
|
241
212
|
|
|
213
|
+
# Runtime helpers referenced by the transformed body travel through the factory closure, not through
|
|
214
|
+
# module globals. The transformed function therefore shares ``func.__globals__`` with the original
|
|
215
|
+
# function: names defined in the module after decoration stay visible, and the module namespace is
|
|
216
|
+
# never polluted with helper names.
|
|
217
|
+
helper_name_to_value: dict[str, object] = {
|
|
218
|
+
"__nighthawk_runner__": _RunnerProxy(),
|
|
219
|
+
"__nh_extract_program__": extract_program,
|
|
220
|
+
"__nh_python_cell_scope__": python_cell_scope,
|
|
221
|
+
}
|
|
222
|
+
factory_name_to_value: dict[str, object] = {**name_to_value, **helper_name_to_value}
|
|
223
|
+
|
|
242
224
|
factory_module = _build_transformed_factory_module(
|
|
243
225
|
transformed_module=transformed_module,
|
|
244
226
|
function_name=func.__name__,
|
|
245
|
-
name_to_value=
|
|
227
|
+
name_to_value=factory_name_to_value,
|
|
246
228
|
)
|
|
247
229
|
code = compile(factory_module, filename, "exec")
|
|
248
230
|
|
|
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
231
|
module_namespace: dict[str, object] = {}
|
|
257
|
-
exec(code,
|
|
232
|
+
exec(code, func.__globals__, module_namespace)
|
|
258
233
|
|
|
259
234
|
factory = module_namespace.get("__nh_factory__")
|
|
260
235
|
if not callable(factory):
|
|
261
236
|
raise RuntimeError("Transformed factory not found after compilation")
|
|
262
237
|
|
|
263
|
-
transformed = factory(
|
|
238
|
+
transformed = factory(factory_name_to_value)
|
|
264
239
|
if not callable(transformed):
|
|
265
240
|
raise RuntimeError("Transformed function not found after factory execution")
|
|
266
241
|
|
|
242
|
+
if transformed.__closure__ is not None:
|
|
243
|
+
# Preserve original Python cells, including nonlocal writes and late rebinding.
|
|
244
|
+
transformed = FunctionType(
|
|
245
|
+
transformed.__code__,
|
|
246
|
+
transformed.__globals__,
|
|
247
|
+
transformed.__name__,
|
|
248
|
+
transformed.__defaults__,
|
|
249
|
+
tuple(
|
|
250
|
+
original_name_to_cell.get(name, cell)
|
|
251
|
+
for name, cell in zip(
|
|
252
|
+
transformed.__code__.co_freevars,
|
|
253
|
+
transformed.__closure__,
|
|
254
|
+
strict=True,
|
|
255
|
+
)
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
transformed.__kwdefaults__ = func.__kwdefaults__
|
|
259
|
+
transformed.__annotations__ = func.__annotations__
|
|
260
|
+
|
|
267
261
|
transformed_freevar_name_set = set(transformed.__code__.co_freevars)
|
|
268
|
-
captured_name_set = set(
|
|
262
|
+
captured_name_set = set(factory_name_to_value.keys())
|
|
269
263
|
|
|
270
264
|
unexpected_freevar_name_set = transformed_freevar_name_set - captured_name_set
|
|
271
265
|
allowed_unexpected_freevar_name_set = {func.__name__}
|
|
@@ -274,7 +268,7 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
274
268
|
f"Transformed function freevars do not match captured names. freevars={transformed.__code__.co_freevars!r} captured={tuple(sorted(name_to_value.keys()))!r}"
|
|
275
269
|
)
|
|
276
270
|
|
|
277
|
-
if transformed.__closure__ is None and
|
|
271
|
+
if transformed.__closure__ is None and transformed_freevar_name_set:
|
|
278
272
|
raise RuntimeError("Transformed function closure is missing for captured names")
|
|
279
273
|
|
|
280
274
|
if inspect.iscoroutinefunction(func):
|
|
@@ -282,20 +276,43 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
|
|
|
282
276
|
|
|
283
277
|
@wraps(func)
|
|
284
278
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
return await transformed_async(*args, **kwargs)
|
|
279
|
+
if name_to_value:
|
|
280
|
+
with python_name_scope(name_to_value):
|
|
281
|
+
return await transformed_async(*args, **kwargs)
|
|
282
|
+
return await transformed_async(*args, **kwargs)
|
|
290
283
|
|
|
284
|
+
async_wrapper.__nighthawk_transformed_function__ = transformed # type: ignore[attr-defined]
|
|
291
285
|
return cast(NaturalFunctionCallable, async_wrapper) # type: ignore[return-value]
|
|
292
286
|
|
|
293
287
|
@wraps(func)
|
|
294
288
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return transformed(*args, **kwargs)
|
|
289
|
+
if name_to_value:
|
|
290
|
+
with python_name_scope(name_to_value):
|
|
291
|
+
return transformed(*args, **kwargs)
|
|
292
|
+
return transformed(*args, **kwargs)
|
|
300
293
|
|
|
294
|
+
wrapper.__nighthawk_transformed_function__ = transformed # type: ignore[attr-defined]
|
|
301
295
|
return cast(NaturalFunctionCallable, wrapper) # type: ignore[return-value]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def get_transformed_function(function: Callable[..., object]) -> Callable[..., object]:
|
|
299
|
+
"""Find a Natural function's compiled body through conventional wrapper chains.
|
|
300
|
+
|
|
301
|
+
Standard inspect.unwrap follows the original source. This accessor exposes the
|
|
302
|
+
transformed body for inspection; normal execution should use the decorated callable
|
|
303
|
+
to establish its runtime context. Invalid inputs and wrapper cycles raise TypeError.
|
|
304
|
+
"""
|
|
305
|
+
current: object = function
|
|
306
|
+
seen_identity_set: set[int] = set()
|
|
307
|
+
while id(current) not in seen_identity_set:
|
|
308
|
+
seen_identity_set.add(id(current))
|
|
309
|
+
if isinstance(current, (staticmethod, classmethod)) or inspect.ismethod(current):
|
|
310
|
+
current = current.__func__
|
|
311
|
+
continue
|
|
312
|
+
transformed = getattr(current, "__nighthawk_transformed_function__", None)
|
|
313
|
+
if callable(transformed):
|
|
314
|
+
return transformed
|
|
315
|
+
current = getattr(current, "__wrapped__", None)
|
|
316
|
+
if current is None:
|
|
317
|
+
break
|
|
318
|
+
raise TypeError("Expected a Natural function or a conventional wrapper around one")
|