nighthawk-python 0.15.0__py3-none-any.whl → 0.17.0__py3-none-any.whl

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