nighthawk-python 0.1.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.
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass, replace
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from opentelemetry.trace import get_tracer_provider
11
+
12
+ from ..configuration import StepExecutorConfiguration, StepExecutorConfigurationPatch
13
+ from ..errors import NighthawkError
14
+ from ..tools.registry import tool_scope
15
+
16
+ if TYPE_CHECKING:
17
+ from .step_executor import AgentStepExecutor, StepExecutor
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ExecutionContext:
22
+ """Immutable snapshot of the current execution context.
23
+
24
+ Attributes:
25
+ run_id: Unique identifier for the run.
26
+ scope_id: Unique identifier for the current scope.
27
+ """
28
+
29
+ run_id: str
30
+ scope_id: str
31
+
32
+
33
+ RUN_ID = "run.id"
34
+ SCOPE_ID = "scope.id"
35
+ STEP_ID = "step.id"
36
+ TOOL_CALL_ID = "tool_call.id"
37
+
38
+
39
+ _tracer = get_tracer_provider().get_tracer("nighthawk")
40
+
41
+
42
+ @contextmanager
43
+ def span(span_name: str, /, **attributes: Any) -> Iterator[None]:
44
+ with _tracer.start_as_current_span(span_name, attributes=attributes):
45
+ yield
46
+
47
+
48
+ def _generate_id() -> str:
49
+ return uuid.uuid4().hex
50
+
51
+
52
+ _step_executor_var: ContextVar[StepExecutor | None] = ContextVar(
53
+ "nighthawk_step_executor",
54
+ default=None,
55
+ )
56
+
57
+ _execution_context_var: ContextVar[ExecutionContext | None] = ContextVar(
58
+ "nighthawk_execution_context",
59
+ default=None,
60
+ )
61
+
62
+
63
+ _system_prompt_suffix_fragments_var: ContextVar[tuple[str, ...]] = ContextVar(
64
+ "nighthawk_system_prompt_suffix_fragments",
65
+ default=(),
66
+ )
67
+
68
+ _user_prompt_suffix_fragments_var: ContextVar[tuple[str, ...]] = ContextVar(
69
+ "nighthawk_user_prompt_suffix_fragments",
70
+ default=(),
71
+ )
72
+
73
+
74
+ def get_step_executor() -> StepExecutor:
75
+ """Return the active step executor.
76
+
77
+ Raises:
78
+ NighthawkError: If no step executor is set (i.e. called outside a run context).
79
+ """
80
+ step_executor = _step_executor_var.get()
81
+ if step_executor is None:
82
+ raise NighthawkError("StepExecutor is not set")
83
+ return step_executor
84
+
85
+
86
+ def get_execution_context() -> ExecutionContext:
87
+ """Return the active execution context.
88
+
89
+ Raises:
90
+ NighthawkError: If no execution context is set (i.e. called outside a run context).
91
+ """
92
+ execution_context = _execution_context_var.get()
93
+ if execution_context is None:
94
+ raise NighthawkError("ExecutionContext is not set")
95
+ return execution_context
96
+
97
+
98
+ def get_system_prompt_suffix_fragments() -> tuple[str, ...]:
99
+ return _system_prompt_suffix_fragments_var.get()
100
+
101
+
102
+ def get_user_prompt_suffix_fragments() -> tuple[str, ...]:
103
+ return _user_prompt_suffix_fragments_var.get()
104
+
105
+
106
+ def _resolve_agent_step_executor(step_executor: StepExecutor) -> AgentStepExecutor:
107
+ from .step_executor import AgentStepExecutor
108
+
109
+ if not isinstance(step_executor, AgentStepExecutor):
110
+ raise NighthawkError("StepExecutor configuration updates require current step_executor to be AgentStepExecutor")
111
+ return step_executor
112
+
113
+
114
+ def _replace_step_executor_with_configuration(
115
+ step_executor: StepExecutor,
116
+ *,
117
+ configuration: StepExecutorConfiguration,
118
+ ) -> StepExecutor:
119
+ from .step_executor import AgentStepExecutor
120
+
121
+ current_step_executor = _resolve_agent_step_executor(step_executor)
122
+
123
+ if current_step_executor.agent_is_managed:
124
+ return AgentStepExecutor.from_configuration(configuration=configuration)
125
+
126
+ if current_step_executor.agent is None:
127
+ raise NighthawkError("AgentStepExecutor.agent is not initialized")
128
+ return AgentStepExecutor.from_agent(
129
+ agent=current_step_executor.agent,
130
+ configuration=configuration,
131
+ )
132
+
133
+
134
+ @contextmanager
135
+ def run(
136
+ step_executor: StepExecutor,
137
+ *,
138
+ run_id: str | None = None,
139
+ ) -> Iterator[None]:
140
+ """Start an execution run with the given step executor.
141
+
142
+ Establishes a run-scoped context that makes the step executor
143
+ available to all Natural blocks executed within this scope.
144
+
145
+ Args:
146
+ step_executor: The step executor to use for Natural block execution.
147
+ run_id: Optional identifier for the run. If not provided, a UUID is
148
+ generated automatically.
149
+
150
+ Yields:
151
+ None
152
+
153
+ Example:
154
+ ```python
155
+ executor = AgentStepExecutor.from_configuration(
156
+ configuration=StepExecutorConfiguration(model="openai:gpt-4o"),
157
+ )
158
+ with nighthawk.run(executor):
159
+ result = my_natural_function()
160
+ ```
161
+ """
162
+ execution_context = ExecutionContext(
163
+ run_id=run_id or _generate_id(),
164
+ scope_id=_generate_id(),
165
+ )
166
+
167
+ with tool_scope():
168
+ step_executor_token = _step_executor_var.set(step_executor)
169
+ execution_context_token = _execution_context_var.set(execution_context)
170
+ system_fragments_token = _system_prompt_suffix_fragments_var.set(())
171
+ user_fragments_token = _user_prompt_suffix_fragments_var.set(())
172
+ try:
173
+ with span(
174
+ "nighthawk.run",
175
+ **{
176
+ RUN_ID: execution_context.run_id,
177
+ SCOPE_ID: execution_context.scope_id,
178
+ },
179
+ ):
180
+ yield
181
+ finally:
182
+ _user_prompt_suffix_fragments_var.reset(user_fragments_token)
183
+ _system_prompt_suffix_fragments_var.reset(system_fragments_token)
184
+ _execution_context_var.reset(execution_context_token)
185
+ _step_executor_var.reset(step_executor_token)
186
+
187
+
188
+ @contextmanager
189
+ def scope(
190
+ *,
191
+ step_executor_configuration: StepExecutorConfiguration | None = None,
192
+ step_executor_configuration_patch: StepExecutorConfigurationPatch | None = None,
193
+ step_executor: StepExecutor | None = None,
194
+ system_prompt_suffix_fragment: str | None = None,
195
+ user_prompt_suffix_fragment: str | None = None,
196
+ ) -> Iterator[StepExecutor]:
197
+ """Open a nested scope that can override the step executor or its configuration.
198
+
199
+ Must be called inside an active run context. Creates a new scope_id while
200
+ inheriting the run_id from the parent context.
201
+
202
+ Args:
203
+ step_executor_configuration: Full replacement configuration for the step
204
+ executor.
205
+ step_executor_configuration_patch: Partial override applied on top of the
206
+ current configuration.
207
+ step_executor: Replacement step executor for this scope.
208
+ system_prompt_suffix_fragment: Additional text appended to the system prompt.
209
+ user_prompt_suffix_fragment: Additional text appended to the user prompt.
210
+
211
+ Yields:
212
+ The step executor active within this scope.
213
+
214
+ Example:
215
+ ```python
216
+ with nighthawk.run(executor):
217
+ with nighthawk.scope(
218
+ step_executor_configuration_patch=StepExecutorConfigurationPatch(
219
+ model="openai:gpt-4o-mini",
220
+ ),
221
+ ) as scoped_executor:
222
+ result = my_natural_function()
223
+ ```
224
+ """
225
+ current_step_executor = get_step_executor()
226
+ current_execution_context = get_execution_context()
227
+
228
+ next_step_executor = current_step_executor
229
+
230
+ if step_executor is not None:
231
+ next_step_executor = step_executor
232
+
233
+ has_configuration_update = any(
234
+ value is not None
235
+ for value in (
236
+ step_executor_configuration,
237
+ step_executor_configuration_patch,
238
+ )
239
+ )
240
+
241
+ if has_configuration_update:
242
+ current_agent_step_executor = _resolve_agent_step_executor(next_step_executor)
243
+ next_configuration = current_agent_step_executor.configuration
244
+
245
+ if step_executor_configuration is not None:
246
+ next_configuration = step_executor_configuration
247
+
248
+ if step_executor_configuration_patch is not None:
249
+ next_configuration = step_executor_configuration_patch.apply_to(next_configuration)
250
+
251
+ next_step_executor = _replace_step_executor_with_configuration(
252
+ next_step_executor,
253
+ configuration=next_configuration,
254
+ )
255
+
256
+ next_execution_context = replace(
257
+ current_execution_context,
258
+ scope_id=_generate_id(),
259
+ )
260
+
261
+ next_system_fragments = _system_prompt_suffix_fragments_var.get()
262
+ next_user_fragments = _user_prompt_suffix_fragments_var.get()
263
+
264
+ if system_prompt_suffix_fragment is not None:
265
+ next_system_fragments = (*next_system_fragments, system_prompt_suffix_fragment)
266
+
267
+ if user_prompt_suffix_fragment is not None:
268
+ next_user_fragments = (*next_user_fragments, user_prompt_suffix_fragment)
269
+
270
+ with tool_scope():
271
+ step_executor_token = _step_executor_var.set(next_step_executor)
272
+ execution_context_token = _execution_context_var.set(next_execution_context)
273
+ system_fragments_token = _system_prompt_suffix_fragments_var.set(next_system_fragments)
274
+ user_fragments_token = _user_prompt_suffix_fragments_var.set(next_user_fragments)
275
+ try:
276
+ with span(
277
+ "nighthawk.scope",
278
+ **{
279
+ RUN_ID: next_execution_context.run_id,
280
+ SCOPE_ID: next_execution_context.scope_id,
281
+ },
282
+ ):
283
+ yield next_step_executor
284
+ finally:
285
+ _user_prompt_suffix_fragments_var.reset(user_fragments_token)
286
+ _system_prompt_suffix_fragments_var.reset(system_fragments_token)
287
+ _execution_context_var.reset(execution_context_token)
288
+ _step_executor_var.reset(step_executor_token)
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+ from dataclasses import dataclass, field, replace
7
+ from types import CellType
8
+
9
+ from ..errors import NighthawkError
10
+ from ..json_renderer import JsonRendererStyle
11
+
12
+ _MISSING: object = object()
13
+ """Sentinel indicating a name could not be resolved. Distinct from None."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ToolResultRenderingPolicy:
18
+ tokenizer_encoding_name: str
19
+ tool_result_max_tokens: int
20
+ json_renderer_style: JsonRendererStyle
21
+
22
+
23
+ DEFAULT_TOOL_RESULT_RENDERING_POLICY = ToolResultRenderingPolicy(
24
+ tokenizer_encoding_name="o200k_base",
25
+ tool_result_max_tokens=2_000,
26
+ json_renderer_style="strict",
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class StepContext:
32
+ """Mutable, per-step execution context passed to tools and executors.
33
+
34
+ ``step_globals`` and ``step_locals`` are mutable dicts. All mutations to
35
+ ``step_locals`` MUST go through :meth:`record_assignment` (for top-level
36
+ name bindings) or through the dotted-path assignment in
37
+ ``tools.assignment`` (which bumps ``step_locals_revision`` directly).
38
+ Direct dict writes bypass revision tracking and ``assigned_binding_names``
39
+ bookkeeping, which will cause incorrect commit behavior at Natural block
40
+ boundaries.
41
+ """
42
+
43
+ step_id: str
44
+
45
+ step_globals: dict[str, object]
46
+ step_locals: dict[str, object]
47
+
48
+ binding_commit_targets: set[str]
49
+ read_binding_names: frozenset[str]
50
+
51
+ # Ordinary user-provided binding (for example a global named "memory") may exist in step_locals.
52
+
53
+ binding_name_to_type: dict[str, object] = field(default_factory=dict)
54
+ assigned_binding_names: set[str] = field(default_factory=set)
55
+ step_locals_revision: int = 0
56
+ tool_result_rendering_policy: ToolResultRenderingPolicy | None = None
57
+
58
+ def record_assignment(self, name: str, value: object) -> None:
59
+ """Record an assignment to a step local variable.
60
+
61
+ Updates step_locals, marks the name as assigned, and bumps the revision.
62
+ """
63
+ self.step_locals[name] = value
64
+ self.assigned_binding_names.add(name)
65
+ self.step_locals_revision += 1
66
+
67
+
68
+ _step_context_stack_var: ContextVar[tuple[StepContext, ...]] = ContextVar(
69
+ "nighthawk_step_context_stack",
70
+ default=(),
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class PythonLookupState:
76
+ python_name_scope_stack: tuple[dict[str, object], ...] = ()
77
+ python_cell_scope_stack: tuple[dict[str, CellType], ...] = ()
78
+
79
+
80
+ _python_lookup_state_var: ContextVar[PythonLookupState] = ContextVar(
81
+ "nighthawk_python_lookup_state",
82
+ default=PythonLookupState(), # noqa: B039
83
+ )
84
+
85
+
86
+ @contextmanager
87
+ def step_context_scope(step_context: StepContext) -> Iterator[None]:
88
+ current_stack = _step_context_stack_var.get()
89
+ token = _step_context_stack_var.set((*current_stack, step_context))
90
+ try:
91
+ yield
92
+ finally:
93
+ _step_context_stack_var.reset(token)
94
+
95
+
96
+ @contextmanager
97
+ def python_name_scope(name_to_value: dict[str, object]) -> Iterator[None]:
98
+ current_lookup_state = _python_lookup_state_var.get()
99
+ next_lookup_state = replace(
100
+ current_lookup_state,
101
+ python_name_scope_stack=(*current_lookup_state.python_name_scope_stack, dict(name_to_value)),
102
+ )
103
+ token = _python_lookup_state_var.set(next_lookup_state)
104
+ try:
105
+ yield
106
+ finally:
107
+ _python_lookup_state_var.reset(token)
108
+
109
+
110
+ @contextmanager
111
+ def python_cell_scope(name_to_cell: dict[str, CellType]) -> Iterator[None]:
112
+ current_lookup_state = _python_lookup_state_var.get()
113
+ next_lookup_state = replace(
114
+ current_lookup_state,
115
+ python_cell_scope_stack=(*current_lookup_state.python_cell_scope_stack, dict(name_to_cell)),
116
+ )
117
+ token = _python_lookup_state_var.set(next_lookup_state)
118
+ try:
119
+ yield
120
+ finally:
121
+ _python_lookup_state_var.reset(token)
122
+
123
+
124
+ def get_step_context_stack() -> tuple[StepContext, ...]:
125
+ return _step_context_stack_var.get()
126
+
127
+
128
+ def get_python_name_scope_stack() -> tuple[dict[str, object], ...]:
129
+ return _python_lookup_state_var.get().python_name_scope_stack
130
+
131
+
132
+ def get_python_cell_scope_stack() -> tuple[dict[str, CellType], ...]:
133
+ return _python_lookup_state_var.get().python_cell_scope_stack
134
+
135
+
136
+ def get_current_step_context() -> StepContext:
137
+ """Return the innermost active step context.
138
+
139
+ Raises:
140
+ NighthawkError: If no step context is set (i.e. called outside step execution).
141
+ """
142
+ stack = _step_context_stack_var.get()
143
+ if not stack:
144
+ raise NighthawkError("StepContext is not set")
145
+ return stack[-1]
146
+
147
+
148
+ def resolve_name_in_step_context(step_context: StepContext, name: str) -> object:
149
+ """Resolve a name from step locals, step globals, or builtins.
150
+
151
+ Returns the resolved value, or ``_MISSING`` if the name cannot be found.
152
+ Callers must compare the result with ``value is _MISSING`` to detect
153
+ absent names (since the resolved value itself may be ``None``).
154
+ """
155
+ if name in step_context.step_locals:
156
+ return step_context.step_locals[name]
157
+
158
+ if name in step_context.step_globals:
159
+ return step_context.step_globals[name]
160
+
161
+ python_builtins = step_context.step_globals.get("__builtins__", __builtins__)
162
+
163
+ if isinstance(python_builtins, dict):
164
+ if name in python_builtins:
165
+ return python_builtins[name]
166
+ return _MISSING
167
+
168
+ if hasattr(python_builtins, name):
169
+ return getattr(python_builtins, name)
170
+
171
+ return _MISSING
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Literal, get_args
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ type StepKind = Literal["pass", "return", "break", "continue", "raise"]
8
+
9
+ STEP_KINDS: tuple[StepKind, ...] = get_args(StepKind.__value__)
10
+
11
+
12
+ _REFERENCE_PATH_PATTERN = r"^(?!__)[A-Za-z_][A-Za-z0-9_]*(?:\.(?!__)[A-Za-z_][A-Za-z0-9_]*)*$"
13
+
14
+
15
+ class PassStepOutcome(BaseModel):
16
+ model_config = ConfigDict(extra="forbid")
17
+
18
+ kind: Literal["pass"]
19
+
20
+
21
+ class ReturnStepOutcome(BaseModel):
22
+ model_config = ConfigDict(extra="forbid")
23
+
24
+ kind: Literal["return"]
25
+ return_reference_path: str
26
+
27
+
28
+ class BreakStepOutcome(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+
31
+ kind: Literal["break"]
32
+
33
+
34
+ class ContinueStepOutcome(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ kind: Literal["continue"]
38
+
39
+
40
+ class RaiseStepOutcome(BaseModel):
41
+ model_config = ConfigDict(extra="forbid")
42
+
43
+ kind: Literal["raise"]
44
+ raise_message: str
45
+ raise_error_type: str | None = None
46
+
47
+
48
+ # This union is used for host-side parsing after unwrapping the StepFinalResult envelope.
49
+
50
+ type StepOutcome = Annotated[
51
+ PassStepOutcome | ReturnStepOutcome | BreakStepOutcome | ContinueStepOutcome | RaiseStepOutcome,
52
+ Field(discriminator="kind"),
53
+ ]
54
+
55
+
56
+ # NOTE: StepFinalResult is an envelope that wraps StepOutcome for LLM structured output.
57
+ # OpenAI / Codex structured outputs require the root JSON schema to be a single object (no anyOf at root level).
58
+ # StepOutcome is a discriminated union whose JSON schema produces anyOf at root, which is rejected by these providers.
59
+ # By placing the union inside a ``result`` field, the anyOf moves to a nested level where it is accepted.
60
+ # Additionally, each variant uses ``additionalProperties: false`` so that kind-specific properties are enforced (e.g. ``kind: "return"`` cannot include ``raise_message``).
61
+
62
+
63
+ class StepFinalResult(BaseModel):
64
+ """Envelope that wraps StepOutcome for LLM structured output."""
65
+
66
+ model_config = ConfigDict(extra="forbid")
67
+
68
+ result: Annotated[
69
+ PassStepOutcome | ReturnStepOutcome | BreakStepOutcome | ContinueStepOutcome | RaiseStepOutcome,
70
+ Field(discriminator="kind"),
71
+ ]
72
+
73
+
74
+ def _build_pass_variant_schema() -> dict[str, object]:
75
+ return {
76
+ "type": "object",
77
+ "properties": {
78
+ "kind": {"type": "string", "const": "pass"},
79
+ },
80
+ "required": ["kind"],
81
+ "additionalProperties": False,
82
+ }
83
+
84
+
85
+ def _build_return_variant_schema() -> dict[str, object]:
86
+ return {
87
+ "type": "object",
88
+ "properties": {
89
+ "kind": {"type": "string", "const": "return"},
90
+ "return_reference_path": {"type": "string", "pattern": _REFERENCE_PATH_PATTERN},
91
+ },
92
+ "required": ["kind", "return_reference_path"],
93
+ "additionalProperties": False,
94
+ }
95
+
96
+
97
+ def _build_break_variant_schema() -> dict[str, object]:
98
+ return {
99
+ "type": "object",
100
+ "properties": {
101
+ "kind": {"type": "string", "const": "break"},
102
+ },
103
+ "required": ["kind"],
104
+ "additionalProperties": False,
105
+ }
106
+
107
+
108
+ def _build_continue_variant_schema() -> dict[str, object]:
109
+ return {
110
+ "type": "object",
111
+ "properties": {
112
+ "kind": {"type": "string", "const": "continue"},
113
+ },
114
+ "required": ["kind"],
115
+ "additionalProperties": False,
116
+ }
117
+
118
+
119
+ def _build_raise_variant_schema(
120
+ *,
121
+ raise_error_type_binding_names: tuple[str, ...],
122
+ ) -> dict[str, object]:
123
+ properties: dict[str, object] = {
124
+ "kind": {"type": "string", "const": "raise"},
125
+ "raise_message": {"type": "string"},
126
+ }
127
+ required: list[str] = ["kind", "raise_message"]
128
+
129
+ if raise_error_type_binding_names:
130
+ properties["raise_error_type"] = {
131
+ "type": "string",
132
+ "enum": list(raise_error_type_binding_names),
133
+ }
134
+
135
+ return {
136
+ "type": "object",
137
+ "properties": properties,
138
+ "required": required,
139
+ "additionalProperties": False,
140
+ }
141
+
142
+
143
+ def _build_variant_schema(
144
+ kind: StepKind,
145
+ *,
146
+ raise_error_type_binding_names: tuple[str, ...],
147
+ ) -> dict[str, object]:
148
+ match kind:
149
+ case "pass":
150
+ return _build_pass_variant_schema()
151
+ case "return":
152
+ return _build_return_variant_schema()
153
+ case "break":
154
+ return _build_break_variant_schema()
155
+ case "continue":
156
+ return _build_continue_variant_schema()
157
+ case "raise":
158
+ return _build_raise_variant_schema(raise_error_type_binding_names=raise_error_type_binding_names)
159
+
160
+
161
+ def build_step_json_schema(
162
+ *,
163
+ allowed_kinds: tuple[StepKind, ...],
164
+ raise_error_type_binding_names: tuple[str, ...],
165
+ ) -> dict[str, object]:
166
+ if not allowed_kinds:
167
+ raise ValueError("allowed_kinds must not be empty")
168
+
169
+ variants: list[dict[str, object]] = [
170
+ _build_variant_schema(kind, raise_error_type_binding_names=raise_error_type_binding_names) for kind in allowed_kinds
171
+ ]
172
+
173
+ if len(variants) == 1:
174
+ result_schema: dict[str, object] = variants[0]
175
+ else:
176
+ result_schema = {"anyOf": variants}
177
+
178
+ schema: dict[str, object] = {
179
+ "type": "object",
180
+ "title": "StepFinalResult",
181
+ "properties": {"result": result_schema},
182
+ "required": ["result"],
183
+ "additionalProperties": False,
184
+ }
185
+
186
+ return schema
187
+
188
+
189
+ def build_step_system_prompt_suffix_fragment(
190
+ *,
191
+ allowed_kinds: tuple[StepKind, ...],
192
+ raise_error_type_binding_names: tuple[str, ...],
193
+ ) -> str:
194
+ if not allowed_kinds:
195
+ raise ValueError("allowed_kinds must not be empty")
196
+
197
+ allowed_kinds_text = ", ".join(f"`{outcome_kind}`" for outcome_kind in allowed_kinds)
198
+
199
+ sections: list[str] = []
200
+ sections.append(
201
+ f"StepFinalResult — output exactly one JSON object with a `result` field. Inside `result`, `kind` must be one of: {allowed_kinds_text}.\n"
202
+ )
203
+
204
+ if "pass" in allowed_kinds:
205
+ sections.append('\nDefault: {"result": {"kind": "pass"}}\nChoose pass after completing the work. Most blocks end with pass.\n')
206
+
207
+ alternatives: list[str] = []
208
+
209
+ if "return" in allowed_kinds:
210
+ alternatives.append(
211
+ '- return: immediately return from the Python function.\n return_reference_path (required): name in step locals holding the value.\n Example: after nh_assign("x", "42"), output {"result": {"kind": "return", "return_reference_path": "x"}}.\n'
212
+ )
213
+
214
+ if "break" in allowed_kinds:
215
+ alternatives.append("- break: break from the surrounding Python loop.\n")
216
+
217
+ if "continue" in allowed_kinds:
218
+ alternatives.append("- continue: continue to the next loop iteration.\n")
219
+
220
+ if "raise" in allowed_kinds:
221
+ raise_text = "- raise: raise a Python exception.\n raise_message: required.\n"
222
+ if raise_error_type_binding_names:
223
+ error_type_names_text = ", ".join(f"`{name}`" for name in raise_error_type_binding_names)
224
+ raise_text += f" raise_error_type: optional, must be one of: {error_type_names_text}.\n"
225
+ alternatives.append(raise_text)
226
+
227
+ if alternatives:
228
+ sections.append("\nAlternatives (use only when the program text explicitly requires it):\n")
229
+ sections.extend(alternatives)
230
+
231
+ return "".join(sections)