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.
- nighthawk/__init__.py +48 -0
- nighthawk/backends/__init__.py +0 -0
- nighthawk/backends/base.py +95 -0
- nighthawk/backends/claude_code_cli.py +342 -0
- nighthawk/backends/claude_code_sdk.py +325 -0
- nighthawk/backends/codex.py +352 -0
- nighthawk/backends/mcp_boundary.py +129 -0
- nighthawk/backends/mcp_server.py +226 -0
- nighthawk/backends/tool_bridge.py +240 -0
- nighthawk/configuration.py +193 -0
- nighthawk/errors.py +25 -0
- nighthawk/identifier_path.py +35 -0
- nighthawk/json_renderer.py +216 -0
- nighthawk/natural/__init__.py +0 -0
- nighthawk/natural/blocks.py +279 -0
- nighthawk/natural/decorator.py +302 -0
- nighthawk/natural/transform.py +346 -0
- nighthawk/runtime/__init__.py +0 -0
- nighthawk/runtime/async_bridge.py +50 -0
- nighthawk/runtime/prompt.py +344 -0
- nighthawk/runtime/runner.py +462 -0
- nighthawk/runtime/scoping.py +288 -0
- nighthawk/runtime/step_context.py +171 -0
- nighthawk/runtime/step_contract.py +231 -0
- nighthawk/runtime/step_executor.py +360 -0
- nighthawk/runtime/tool_calls.py +99 -0
- nighthawk/tools/__init__.py +0 -0
- nighthawk/tools/assignment.py +246 -0
- nighthawk/tools/contracts.py +72 -0
- nighthawk/tools/execution.py +83 -0
- nighthawk/tools/provided.py +80 -0
- nighthawk/tools/registry.py +212 -0
- nighthawk_python-0.1.0.dist-info/METADATA +111 -0
- nighthawk_python-0.1.0.dist-info/RECORD +36 -0
- nighthawk_python-0.1.0.dist-info/WHEEL +4 -0
- nighthawk_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|