nighthawk-python 0.13.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,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from . import oversight, resilience
3
+ from . import lifecycle, oversight, resilience
4
4
  from .composition import UNSET, Extend, Merge, UnsetType
5
5
  from .configuration import (
6
6
  StepContextLimits,
@@ -20,11 +20,12 @@ from .errors import (
20
20
  from .json_renderer import JsonableValue, to_jsonable_value
21
21
  from .natural.decorator import get_transformed_function, natural_function
22
22
  from .runtime.scoping import (
23
- ExecutionRef,
23
+ ExecutionReference,
24
24
  UsageMeter,
25
25
  get_capabilities,
26
- get_execution_ref,
26
+ get_execution_reference,
27
27
  get_implicit_references,
28
+ get_lifecycle,
28
29
  get_oversight,
29
30
  get_step_executor,
30
31
  get_system_prompt_suffix_fragments,
@@ -46,7 +47,7 @@ __all__ = [
46
47
  "ToolNameConflictError",
47
48
  "AgentStepExecutor",
48
49
  "ExecutionError",
49
- "ExecutionRef",
50
+ "ExecutionReference",
50
51
  "JsonableValue",
51
52
  "NaturalParseError",
52
53
  "NighthawkError",
@@ -62,7 +63,7 @@ __all__ = [
62
63
  "get_capabilities",
63
64
  "get_step_context",
64
65
  "get_usage_meter",
65
- "get_execution_ref",
66
+ "get_execution_reference",
66
67
  "get_implicit_references",
67
68
  "get_oversight",
68
69
  "get_step_executor",
@@ -70,6 +71,8 @@ __all__ = [
70
71
  "get_system_prompt_suffix_fragments",
71
72
  "get_user_prompt_suffix_fragments",
72
73
  "oversight",
74
+ "lifecycle",
75
+ "get_lifecycle",
73
76
  "natural_function",
74
77
  "get_transformed_function",
75
78
  "resilience",
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):
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,10 +6,12 @@ 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
17
  from .blocks import extract_program, find_natural_blocks
@@ -20,48 +22,10 @@ type NaturalFunctionCallable = Callable[..., Any]
20
22
 
21
23
  class _RunnerProxy:
22
24
  @staticmethod
23
- def run_step(
24
- natural_program: str,
25
- input_binding_names: list[str],
26
- output_binding_names: list[str],
27
- binding_name_to_type: dict[str, object],
28
- return_annotation: object,
29
- is_in_loop: bool,
30
- ) -> StepEnvelope:
25
+ def execution() -> AbstractContextManager[Runner]:
31
26
  caller_frame = sys._getframe(1)
32
- current_step_executor = get_step_executor()
33
- runner = Runner(current_step_executor)
34
- return runner.run_step(
35
- natural_program,
36
- input_binding_names,
37
- output_binding_names,
38
- binding_name_to_type,
39
- return_annotation,
40
- is_in_loop,
41
- caller_frame=caller_frame,
42
- )
43
-
44
- @staticmethod
45
- async def run_step_async(
46
- natural_program: str,
47
- input_binding_names: list[str],
48
- output_binding_names: list[str],
49
- binding_name_to_type: dict[str, object],
50
- return_annotation: object,
51
- is_in_loop: bool,
52
- ) -> StepEnvelope:
53
- caller_frame = sys._getframe(1)
54
- current_step_executor = get_step_executor()
55
- runner = Runner(current_step_executor)
56
- return await runner.run_step_async(
57
- natural_program,
58
- input_binding_names,
59
- output_binding_names,
60
- binding_name_to_type,
61
- return_annotation,
62
- is_in_loop,
63
- caller_frame=caller_frame,
64
- )
27
+ runner = Runner(get_step_executor())
28
+ return runner.execution(caller_frame=caller_frame)
65
29
 
66
30
 
67
31
  def _extract_inline_fstring_name_set(function_source: str, *, function_name: str) -> set[str]:
@@ -222,6 +186,7 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
222
186
  original_module = ast.Module(body=[], type_ignores=[])
223
187
 
224
188
  capture_name_set = _build_capture_name_set(source, func.__name__)
189
+ capture_name_set.update(func.__code__.co_freevars)
225
190
 
226
191
  definition_frame = inspect.currentframe()
227
192
  name_to_value: dict[str, object] = {}
@@ -232,6 +197,13 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
232
197
  if name in caller_frame.f_locals:
233
198
  name_to_value[name] = caller_frame.f_locals[name]
234
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
+
235
207
  captured_name_tuple = tuple(sorted(capture_name_set))
236
208
 
237
209
  transformed_module = transform_module_ast(original_module, captured_name_tuple=captured_name_tuple)
@@ -267,6 +239,25 @@ def natural_function(func: NaturalFunctionCallable | None = None) -> NaturalFunc
267
239
  if not callable(transformed):
268
240
  raise RuntimeError("Transformed function not found after factory execution")
269
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
+
270
261
  transformed_freevar_name_set = set(transformed.__code__.co_freevars)
271
262
  captured_name_set = set(factory_name_to_value.keys())
272
263
 
@@ -12,6 +12,12 @@ from .blocks import (
12
12
  )
13
13
 
14
14
 
15
+ def _copy_generated_location(statement: ast.stmt, source: ast.AST) -> ast.stmt:
16
+ for child in ast.walk(statement):
17
+ ast.copy_location(child, source)
18
+ return statement
19
+
20
+
15
21
  class NaturalTransformer(ast.NodeTransformer):
16
22
  def __init__(self, *, captured_name_tuple: tuple[str, ...]) -> None:
17
23
  super().__init__()
@@ -58,7 +64,7 @@ class NaturalTransformer(ast.NodeTransformer):
58
64
  sentinel_location.end_lineno = sentinel_location.lineno
59
65
  sentinel_location.end_col_offset = sentinel_location.col_offset
60
66
 
61
- injected_with_location = [ast.copy_location(statement, sentinel_location) for statement in injected]
67
+ injected_with_location = [_copy_generated_location(statement, sentinel_location) for statement in injected]
62
68
 
63
69
  body_without_docstring = node.body[1:]
64
70
  node.body = injected_with_location + body_without_docstring
@@ -201,7 +207,7 @@ class NaturalTransformer(ast.NodeTransformer):
201
207
  sentinel_location.end_lineno = sentinel_location.lineno
202
208
  sentinel_location.end_col_offset = sentinel_location.col_offset
203
209
 
204
- return [ast.copy_location(statement, sentinel_location) for statement in statements] # type: ignore[return-value]
210
+ return [_copy_generated_location(statement, sentinel_location) for statement in statements] # type: ignore[return-value]
205
211
 
206
212
  if isinstance(value, ast.JoinedStr) and _joined_string_is_natural_sentinel(value):
207
213
  _validate_joined_string_bindings_do_not_span_formatted_values(value)
@@ -233,7 +239,7 @@ class NaturalTransformer(ast.NodeTransformer):
233
239
  sentinel_location.end_lineno = sentinel_location.lineno
234
240
  sentinel_location.end_col_offset = sentinel_location.col_offset
235
241
 
236
- return [ast.copy_location(statement, sentinel_location) for statement in statements] # type: ignore[return-value]
242
+ return [_copy_generated_location(statement, sentinel_location) for statement in statements] # type: ignore[return-value]
237
243
 
238
244
  return node
239
245
 
@@ -300,7 +306,7 @@ def build_runtime_call_and_assignments(
300
306
  method_name = "run_step_async" if is_async_function else "run_step"
301
307
  call_expression = ast.Call(
302
308
  func=ast.Attribute(
303
- value=ast.Name(id="__nighthawk_runner__", ctx=ast.Load()),
309
+ value=ast.Name(id="__nh_execution__", ctx=ast.Load()),
304
310
  attr=method_name,
305
311
  ctx=ast.Load(),
306
312
  ),
@@ -323,7 +329,16 @@ def build_runtime_call_and_assignments(
323
329
 
324
330
  # Add binding commit assignments (dynamic per output binding).
325
331
  for name in output_binding_names:
326
- statements.extend(ast.parse(f'if "{name}" in __nh_bindings__:\n {name} = __nh_bindings__["{name}"]\n').body)
332
+ statements.extend(
333
+ ast.parse(
334
+ f'if "{name}" in __nh_bindings__:\n {name} = __nh_bindings__["{name}"]\n __nh_execution__.record_assignment("{name}", {name})\n'
335
+ ).body
336
+ )
337
+
338
+ guarded = ast.parse("with __nighthawk_runner__.execution() as __nh_execution__:\n pass\n").body[0]
339
+ assert isinstance(guarded, ast.With)
340
+ guarded.body = statements
341
+ statements = [guarded]
327
342
 
328
343
  # Add outcome extraction and dispatch.
329
344
  statements.extend(ast.parse('__nh_step_outcome__ = __nh_envelope__["outcome"]').body)
nighthawk/oversight.py CHANGED
@@ -8,7 +8,7 @@ from opentelemetry.trace import get_current_span
8
8
 
9
9
  from .composition import UNSET, UnsetType
10
10
  from .errors import NighthawkError
11
- from .runtime.scoping import ExecutionRef
11
+ from .runtime.execution_reference import ExecutionReference
12
12
  from .runtime.step_contract import StepKind
13
13
  from .runtime.step_result import Break, Continue, Pass, Raise, Return, StepResult
14
14
 
@@ -18,12 +18,17 @@ def _reference_mapping(name_to_value: Mapping[str, object]) -> Mapping[str, obje
18
18
 
19
19
 
20
20
  class OversightRejectedError(NighthawkError):
21
- """Raised when oversight rejects a tool call or step commit."""
21
+ """Raised when oversight explicitly rejects an execution boundary."""
22
+
23
+ def __init__(self, reason: str, *, subject: str = "tool_call") -> None:
24
+ self.reason = reason
25
+ self.subject = subject
26
+ super().__init__(reason)
22
27
 
23
28
 
24
29
  @dataclass(frozen=True)
25
30
  class ToolCall:
26
- execution_ref: ExecutionRef
31
+ execution_reference: ExecutionReference
27
32
  tool_name: str
28
33
  argument_name_to_value: Mapping[str, object]
29
34
  processed_natural_program: str
@@ -46,7 +51,7 @@ class StepCommit:
46
51
  Rewrite for changes and copy or serialize explicitly for durable history.
47
52
  """
48
53
 
49
- execution_ref: ExecutionRef
54
+ execution_reference: ExecutionReference
50
55
  processed_natural_program: str
51
56
  input_binding_name_to_value: Mapping[str, object]
52
57
  outcome: StepResult
@@ -78,6 +83,25 @@ class StepCommit:
78
83
  )
79
84
 
80
85
 
86
+ @dataclass(frozen=True)
87
+ class ReturnExpression:
88
+ """Trusted expression approval before core evaluation, await, and validation.
89
+
90
+ The mapping contains writes supplied and validated for this candidate, not
91
+ every declared write binding. A bare name can still refer to an awaitable;
92
+ return validation and later rewrites can change the relationship to writes.
93
+ """
94
+
95
+ execution_reference: ExecutionReference
96
+ expression: str
97
+ expected_type: object
98
+ processed_natural_program: str
99
+ validated_binding_name_to_value: Mapping[str, object]
100
+
101
+ def __post_init__(self) -> None:
102
+ object.__setattr__(self, "validated_binding_name_to_value", _reference_mapping(self.validated_binding_name_to_value))
103
+
104
+
81
105
  @dataclass(frozen=True)
82
106
  class Accept:
83
107
  reason: str | None = None
@@ -121,6 +145,7 @@ type StepCommitDecision = Accept | Reject | Rewrite
121
145
 
122
146
  @dataclass(frozen=True)
123
147
  class Oversight:
148
+ inspect_return_expression: Callable[[ReturnExpression], Accept | Reject] | None = None
124
149
  inspect_tool_call: Callable[[ToolCall], ToolCallDecision] | None = None
125
150
  inspect_step_commit: Callable[[StepCommit], StepCommitDecision] | None = None
126
151
 
@@ -129,20 +154,21 @@ def record_oversight_decision(
129
154
  *,
130
155
  subject: str,
131
156
  verdict: str,
132
- execution_ref: ExecutionRef,
157
+ execution_reference: ExecutionReference,
133
158
  tool_name: str | None = None,
134
159
  reason: str | None = None,
135
160
  ) -> None:
136
161
  current_span = get_current_span()
137
162
  if not current_span.is_recording():
138
163
  return
139
- if execution_ref.step_id is None:
140
- raise NighthawkError("Oversight decision events require ExecutionRef.step_id")
164
+ if execution_reference.step_execution_id is None:
165
+ raise NighthawkError("Oversight decision events require ExecutionReference.step_execution_id")
141
166
 
142
167
  attributes: dict[str, str] = {
143
- "run.id": execution_ref.run_id,
144
- "scope.id": execution_ref.scope_id,
145
- "step.id": execution_ref.step_id,
168
+ "run.id": execution_reference.run_id,
169
+ "scope.id": execution_reference.scope_id,
170
+ "step.execution.id": execution_reference.step_execution_id,
171
+ "step.source_location": execution_reference.source_location or "",
146
172
  "nighthawk.oversight.subject": subject,
147
173
  "nighthawk.oversight.verdict": verdict,
148
174
  }
@@ -167,6 +193,7 @@ __all__ = [
167
193
  "Reject",
168
194
  "Rewrite",
169
195
  "StepCommit",
196
+ "ReturnExpression",
170
197
  "StepCommitDecision",
171
198
  "ToolCall",
172
199
  "ToolCallDecision",
@@ -0,0 +1,13 @@
1
+ """Immutable invocation identity, independent of runtime configuration."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class ExecutionReference:
8
+ """Run and scope identity, plus invocation and source identity during a step."""
9
+
10
+ run_id: str
11
+ scope_id: str
12
+ step_execution_id: str | None = None
13
+ source_location: str | None = None
@@ -23,9 +23,9 @@ from ._user_content import (
23
23
  from .scoping import (
24
24
  RUN_ID,
25
25
  SCOPE_ID,
26
- STEP_ID,
26
+ STEP_EXECUTION_ID,
27
27
  _current_user_prompt_suffix_fragments,
28
- get_execution_ref,
28
+ get_execution_reference,
29
29
  )
30
30
  from .step_context import _MISSING, StepContext, resolve_name_in_step_context
31
31
 
@@ -538,7 +538,7 @@ def _render_reference_and_value_list_section(
538
538
  if token_limit_reached:
539
539
  dropped_multimodal = any(try_project_user_prompt_value(value) is not None for _, value in reference_and_value_list[shown_items:])
540
540
  log_attributes: dict[str, Any] = {
541
- STEP_ID: step_context.step_id,
541
+ STEP_EXECUTION_ID: step_context.execution_reference.step_execution_id or "",
542
542
  "nighthawk.prompt_context.section": section_name,
543
543
  "nighthawk.prompt_context.reason": "token_limit",
544
544
  "nighthawk.prompt_context.rendered_items": shown_items,
@@ -549,9 +549,9 @@ def _render_reference_and_value_list_section(
549
549
  # Spec §8.2.2: token truncation can drop explicit dotted multimodal
550
550
  # leaves because section ordering and budgets remain authoritative.
551
551
  try:
552
- execution_ref = get_execution_ref()
553
- log_attributes[RUN_ID] = execution_ref.run_id
554
- log_attributes[SCOPE_ID] = execution_ref.scope_id
552
+ execution_reference = get_execution_reference()
553
+ log_attributes[RUN_ID] = execution_reference.run_id
554
+ log_attributes[SCOPE_ID] = execution_reference.scope_id
555
555
  except Exception:
556
556
  pass
557
557
  logging.getLogger("nighthawk").info("prompt_context_truncated %s", log_attributes)