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 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
- ToolRegistrationError,
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
- ExecutionRef,
23
+ ExecutionReference,
21
24
  UsageMeter,
22
- get_current_usage_meter,
23
- get_execution_ref,
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, get_current_step_context
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
- "ExecutionRef",
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
- "ToolRegistrationError",
60
+ "ToolDeclarationError",
49
61
  "ToolValidationError",
50
62
  "UsageMeter",
51
- "get_current_step_context",
52
- "get_current_usage_meter",
53
- "get_execution_ref",
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.registry import get_visible_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.registry import get_visible_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
@@ -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.registry import get_visible_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
- get_current_step_context,
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 = get_current_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)
@@ -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)))
@@ -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: Model identifier in "provider:model" format (e.g. "openai:gpt-4o").
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.4-nano"
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
- _, model_name = self.model.split(":", 1)
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
- """Raised when a Natural block execution fails."""
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 ToolRegistrationError(NighthawkError):
25
- """Raised when tool registration fails."""
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
+ ]
@@ -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, StepEnvelope
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 ..tools.registry import call_scope
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 run_step(
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
- current_step_executor = get_step_executor()
34
- runner = Runner(current_step_executor)
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=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, globals_namespace, module_namespace)
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(name_to_value)
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(name_to_value.keys())
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 name_to_value:
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
- with call_scope():
286
- if name_to_value:
287
- with python_name_scope(name_to_value):
288
- return await transformed_async(*args, **kwargs)
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
- with call_scope():
296
- if name_to_value:
297
- with python_name_scope(name_to_value):
298
- return transformed(*args, **kwargs)
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")