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 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
- 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
23
  ExecutionRef,
21
24
  UsageMeter,
22
- get_current_usage_meter,
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, get_current_step_context
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
- "ToolRegistrationError",
59
+ "ToolDeclarationError",
49
60
  "ToolValidationError",
50
61
  "UsageMeter",
51
- "get_current_step_context",
52
- "get_current_usage_meter",
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
  ]
@@ -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.builtin_tools:
156
- raise UserError(f"{self.backend_label} does not support builtin tools")
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 = self._get_instructions(messages, model_request_parameters)
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.registry import get_visible_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
- supported_builtin_tools=frozenset([AbstractBuiltinTool]),
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.registry import get_visible_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
- supported_builtin_tools=frozenset([AbstractBuiltinTool]),
113
+ supported_native_tools=frozenset(),
115
114
  ),
116
115
  )
117
116
  self._model_name = model_name
@@ -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.registry import get_visible_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
- supported_builtin_tools=frozenset([AbstractBuiltinTool]),
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
- 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)
@@ -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, mimeType=content_item.media_type))
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, mimeType=content_item.media_type))
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
@@ -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.server.fastmcp.server import StreamableHTTPASGIApp
66
- from mcp.server.lowlevel.server import Server as McpLowLevelServer
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
- mcp_server = McpLowLevelServer("nighthawk")
74
-
75
- @mcp_server.list_tools()
76
- async def list_tools() -> list[Any]:
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
- inputSchema=tool_definition.parameters_json_schema,
89
+ input_schema=tool_definition.parameters_json_schema,
90
90
  )
91
91
  )
92
- return tools
93
-
94
- @mcp_server.call_tool(validate_input=False)
95
- async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
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
- return tool_handler_result_to_low_level_mcp_content(
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
- return await call_tool_for_low_level_mcp_server(
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)
@@ -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
@@ -21,5 +21,13 @@ class ToolValidationError(NighthawkError):
21
21
  """Raised when tool input validation fails."""
22
22
 
23
23
 
24
- class ToolRegistrationError(NighthawkError):
25
- """Raised when tool registration fails."""
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."""
@@ -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 ..tools.registry import call_scope
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=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, globals_namespace, module_namespace)
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(name_to_value)
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(name_to_value.keys())
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 name_to_value:
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
- 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)
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
- 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)
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")
@@ -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__["step_outcome"]').body)
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'