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,360 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, cast, runtime_checkable
4
+
5
+ from pydantic import TypeAdapter
6
+ from pydantic_ai import Agent, StructuredDict
7
+ from pydantic_ai.toolsets.function import FunctionToolset
8
+
9
+ from ..configuration import StepExecutorConfiguration
10
+ from ..errors import ExecutionError
11
+ from ..tools.execution import ToolResultWrapperToolset
12
+ from ..tools.registry import get_visible_tools
13
+ from .async_bridge import run_coroutine_synchronously
14
+ from .prompt import build_user_prompt, extract_references_and_program
15
+ from .scoping import (
16
+ RUN_ID,
17
+ SCOPE_ID,
18
+ STEP_ID,
19
+ get_execution_context,
20
+ get_system_prompt_suffix_fragments,
21
+ scope,
22
+ span,
23
+ )
24
+ from .step_context import (
25
+ _MISSING,
26
+ StepContext,
27
+ ToolResultRenderingPolicy,
28
+ resolve_name_in_step_context,
29
+ step_context_scope,
30
+ )
31
+ from .step_contract import (
32
+ STEP_KINDS,
33
+ StepFinalResult,
34
+ StepKind,
35
+ StepOutcome,
36
+ build_step_json_schema,
37
+ build_step_system_prompt_suffix_fragment,
38
+ )
39
+
40
+
41
+ @runtime_checkable
42
+ class AsyncExecutionAgent(Protocol):
43
+ """Protocol for agents that provide async step execution via ``run``."""
44
+
45
+ async def run(self, *args: Any, **kwargs: Any) -> Any: ...
46
+
47
+
48
+ @runtime_checkable
49
+ class SyncExecutionAgent(Protocol):
50
+ """Protocol for agents that provide sync step execution via ``run_sync``."""
51
+
52
+ def run_sync(self, *args: Any, **kwargs: Any) -> Any: ...
53
+
54
+
55
+ type StepExecutionAgent = AsyncExecutionAgent | SyncExecutionAgent
56
+
57
+
58
+ @runtime_checkable
59
+ class SyncStepExecutor(Protocol):
60
+ """Step executor that provides synchronous execution."""
61
+
62
+ def run_step(
63
+ self,
64
+ *,
65
+ processed_natural_program: str,
66
+ step_context: StepContext,
67
+ binding_names: list[str],
68
+ allowed_step_kinds: tuple[str, ...],
69
+ ) -> tuple[StepOutcome, dict[str, object]]: ...
70
+
71
+
72
+ @runtime_checkable
73
+ class AsyncStepExecutor(Protocol):
74
+ """Step executor that provides asynchronous execution."""
75
+
76
+ async def run_step_async(
77
+ self,
78
+ *,
79
+ processed_natural_program: str,
80
+ step_context: StepContext,
81
+ binding_names: list[str],
82
+ allowed_step_kinds: tuple[str, ...],
83
+ ) -> tuple[StepOutcome, dict[str, object]]: ...
84
+
85
+
86
+ type StepExecutor = SyncStepExecutor | AsyncStepExecutor
87
+
88
+
89
+ def _new_agent_step_executor(
90
+ configuration: StepExecutorConfiguration,
91
+ ) -> StepExecutionAgent:
92
+ model_identifier = configuration.model
93
+ provider, provider_model_name = model_identifier.split(":", 1)
94
+
95
+ match provider:
96
+ case "claude-code-sdk":
97
+ from ..backends.claude_code_sdk import ClaudeCodeSdkModel
98
+
99
+ model: object = ClaudeCodeSdkModel(model_name=(provider_model_name if provider_model_name != "default" else None))
100
+ case "claude-code-cli":
101
+ from ..backends.claude_code_cli import ClaudeCodeCliModel
102
+
103
+ model = ClaudeCodeCliModel(model_name=(provider_model_name if provider_model_name != "default" else None))
104
+ case "codex":
105
+ from ..backends.codex import CodexModel
106
+
107
+ model = CodexModel(model_name=(provider_model_name if provider_model_name != "default" else None))
108
+ case _:
109
+ model = model_identifier
110
+
111
+ constructor_arguments: dict[str, Any] = {}
112
+ if configuration.model_settings is not None:
113
+ constructor_arguments["model_settings"] = configuration.model_settings
114
+
115
+ agent = Agent(
116
+ model=model,
117
+ output_type=StepFinalResult,
118
+ deps_type=StepContext,
119
+ system_prompt=configuration.prompts.step_system_prompt_template,
120
+ **constructor_arguments,
121
+ )
122
+
123
+ @agent.system_prompt(dynamic=True)
124
+ def _system_prompt_suffixes() -> str | None: # pyright: ignore[reportUnusedFunction]
125
+ suffix_fragments = (
126
+ *configuration.system_prompt_suffix_fragments,
127
+ *get_system_prompt_suffix_fragments(),
128
+ )
129
+ if not suffix_fragments:
130
+ return None
131
+ return "\n\n".join(suffix_fragments)
132
+
133
+ return agent
134
+
135
+
136
+ class AgentStepExecutor:
137
+ """Step executor that delegates Natural block execution to a Pydantic AI agent.
138
+
139
+ Attributes:
140
+ configuration: The step executor configuration.
141
+ agent: The underlying agent instance. If not provided, one is created
142
+ from the configuration.
143
+ token_encoding: The tiktoken encoding resolved from the configuration.
144
+ tool_result_rendering_policy: Policy for rendering tool results.
145
+ agent_is_managed: Whether the agent was created internally from
146
+ the configuration (True) or provided externally (False).
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ configuration: StepExecutorConfiguration | None = None,
152
+ agent: StepExecutionAgent | None = None,
153
+ ) -> None:
154
+ self.configuration = configuration or StepExecutorConfiguration()
155
+ self.agent_is_managed = agent is None
156
+ self.agent = agent if agent is not None else _new_agent_step_executor(self.configuration)
157
+ self.token_encoding = self.configuration.resolve_token_encoding()
158
+ self.tool_result_rendering_policy = ToolResultRenderingPolicy(
159
+ tokenizer_encoding_name=self.token_encoding.name,
160
+ tool_result_max_tokens=(self.configuration.context_limits.tool_result_max_tokens),
161
+ json_renderer_style=self.configuration.json_renderer_style,
162
+ )
163
+
164
+ @classmethod
165
+ def from_agent(
166
+ cls,
167
+ *,
168
+ agent: StepExecutionAgent,
169
+ configuration: StepExecutorConfiguration | None = None,
170
+ ) -> AgentStepExecutor:
171
+ """Create an executor wrapping an existing agent.
172
+
173
+ Args:
174
+ agent: A pre-configured agent to use for step execution.
175
+ configuration: Optional configuration. Defaults to
176
+ StepExecutorConfiguration().
177
+ """
178
+ return cls(configuration=configuration, agent=agent)
179
+
180
+ @classmethod
181
+ def from_configuration(
182
+ cls,
183
+ *,
184
+ configuration: StepExecutorConfiguration,
185
+ ) -> AgentStepExecutor:
186
+ """Create an executor from a configuration, building a managed agent internally."""
187
+ return cls(configuration=configuration)
188
+
189
+ async def _run_agent(
190
+ self,
191
+ *,
192
+ user_prompt: str,
193
+ step_context: StepContext,
194
+ toolset: ToolResultWrapperToolset,
195
+ structured_output_type: object,
196
+ ) -> Any:
197
+ if self.agent is None:
198
+ raise ExecutionError("AgentStepExecutor.agent is not initialized")
199
+
200
+ if isinstance(self.agent, AsyncExecutionAgent):
201
+ return await self.agent.run(
202
+ user_prompt,
203
+ deps=step_context,
204
+ toolsets=[toolset],
205
+ output_type=structured_output_type,
206
+ )
207
+
208
+ if isinstance(self.agent, SyncExecutionAgent):
209
+ return self.agent.run_sync(
210
+ user_prompt,
211
+ deps=step_context,
212
+ toolsets=[toolset],
213
+ output_type=structured_output_type,
214
+ )
215
+
216
+ raise ExecutionError("AgentStepExecutor requires an agent with run(...) or run_sync(...)")
217
+
218
+ def _build_structured_output_and_prompt_fragment(
219
+ self,
220
+ *,
221
+ processed_natural_program: str,
222
+ step_context: StepContext,
223
+ allowed_step_kinds: tuple[str, ...],
224
+ ) -> tuple[object, str]:
225
+ """Build the structured output type and system prompt fragment for a step."""
226
+ unknown_kinds = set(allowed_step_kinds).difference(STEP_KINDS)
227
+ if unknown_kinds:
228
+ raise ExecutionError(f"Internal error: allowed_step_kinds contains unknown kinds: {tuple(sorted(unknown_kinds))}")
229
+
230
+ allowed_kinds_deduplicated = tuple(dict.fromkeys(allowed_step_kinds))
231
+ allowed_kinds_typed = cast(tuple[StepKind, ...], allowed_kinds_deduplicated)
232
+
233
+ referenced_names, _ = extract_references_and_program(processed_natural_program)
234
+
235
+ error_type_candidate_names: set[str] = set(referenced_names)
236
+ for name, value in step_context.step_locals.items():
237
+ if isinstance(value, type) and issubclass(value, BaseException) and value.__name__ == name:
238
+ error_type_candidate_names.add(name)
239
+
240
+ error_type_binding_name_list: list[str] = []
241
+ for name in sorted(error_type_candidate_names):
242
+ value = resolve_name_in_step_context(step_context, name)
243
+ if value is _MISSING:
244
+ continue
245
+ if not isinstance(value, type) or not issubclass(value, BaseException) or value.__name__ != name:
246
+ continue
247
+ error_type_binding_name_list.append(name)
248
+
249
+ raise_error_type_binding_names = tuple(error_type_binding_name_list)
250
+
251
+ step_system_prompt_fragment = build_step_system_prompt_suffix_fragment(
252
+ allowed_kinds=allowed_kinds_typed,
253
+ raise_error_type_binding_names=raise_error_type_binding_names,
254
+ )
255
+
256
+ outcome_json_schema = build_step_json_schema(
257
+ allowed_kinds=allowed_kinds_typed,
258
+ raise_error_type_binding_names=raise_error_type_binding_names,
259
+ )
260
+ structured_output_type = StructuredDict(outcome_json_schema, name="StepFinalResult")
261
+
262
+ return structured_output_type, step_system_prompt_fragment
263
+
264
+ def _parse_agent_result(
265
+ self,
266
+ result: Any,
267
+ ) -> StepOutcome:
268
+ """Parse the agent result into a StepOutcome."""
269
+ try:
270
+ raw_output = result.output
271
+ if isinstance(raw_output, StepFinalResult):
272
+ return raw_output.result
273
+ if isinstance(raw_output, dict) and "result" in raw_output:
274
+ return TypeAdapter(StepOutcome).validate_python(raw_output["result"])
275
+ return TypeAdapter(StepOutcome).validate_python(raw_output)
276
+ except Exception as e:
277
+ raise ExecutionError(f"Step produced invalid step outcome: {e}") from e
278
+
279
+ def _extract_bindings(
280
+ self,
281
+ *,
282
+ binding_names: list[str],
283
+ step_context: StepContext,
284
+ ) -> dict[str, object]:
285
+ """Extract committed bindings from the step context."""
286
+ bindings: dict[str, object] = {}
287
+ for name in binding_names:
288
+ if name in step_context.assigned_binding_names:
289
+ bindings[name] = step_context.step_locals[name]
290
+ return bindings
291
+
292
+ async def run_step_async(
293
+ self,
294
+ *,
295
+ processed_natural_program: str,
296
+ step_context: StepContext,
297
+ binding_names: list[str],
298
+ allowed_step_kinds: tuple[str, ...],
299
+ ) -> tuple[StepOutcome, dict[str, object]]:
300
+ if step_context.tool_result_rendering_policy is None:
301
+ step_context.tool_result_rendering_policy = self.tool_result_rendering_policy
302
+
303
+ user_prompt = build_user_prompt(
304
+ processed_natural_program=processed_natural_program,
305
+ step_context=step_context,
306
+ configuration=self.configuration,
307
+ )
308
+
309
+ visible_tool_list = get_visible_tools()
310
+ toolset = ToolResultWrapperToolset(FunctionToolset(visible_tool_list))
311
+
312
+ structured_output_type, step_system_prompt_fragment = self._build_structured_output_and_prompt_fragment(
313
+ processed_natural_program=processed_natural_program,
314
+ step_context=step_context,
315
+ allowed_step_kinds=allowed_step_kinds,
316
+ )
317
+
318
+ with scope(system_prompt_suffix_fragment=step_system_prompt_fragment):
319
+ execution_context = get_execution_context()
320
+ with (
321
+ span(
322
+ "nighthawk.step_executor",
323
+ **{
324
+ RUN_ID: execution_context.run_id,
325
+ SCOPE_ID: execution_context.scope_id,
326
+ STEP_ID: step_context.step_id,
327
+ },
328
+ ),
329
+ step_context_scope(step_context),
330
+ ):
331
+ result = await self._run_agent(
332
+ user_prompt=user_prompt,
333
+ step_context=step_context,
334
+ toolset=toolset,
335
+ structured_output_type=structured_output_type,
336
+ )
337
+
338
+ step_outcome = self._parse_agent_result(result)
339
+ bindings = self._extract_bindings(binding_names=binding_names, step_context=step_context)
340
+ return step_outcome, bindings
341
+
342
+ def run_step(
343
+ self,
344
+ *,
345
+ processed_natural_program: str,
346
+ step_context: StepContext,
347
+ binding_names: list[str],
348
+ allowed_step_kinds: tuple[str, ...],
349
+ ) -> tuple[StepOutcome, dict[str, object]]:
350
+ return cast(
351
+ tuple[StepOutcome, dict[str, object]],
352
+ run_coroutine_synchronously(
353
+ lambda: self.run_step_async(
354
+ processed_natural_program=processed_natural_program,
355
+ step_context=step_context,
356
+ binding_names=binding_names,
357
+ allowed_step_kinds=allowed_step_kinds,
358
+ )
359
+ ),
360
+ )
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from collections.abc import Awaitable, Callable, Iterator
6
+ from contextlib import contextmanager
7
+ from typing import Any
8
+
9
+ from pydantic_ai import RunContext
10
+ from pydantic_ai._instrumentation import InstrumentationNames
11
+
12
+
13
+ def generate_tool_call_id() -> str:
14
+ return str(uuid.uuid4())
15
+
16
+
17
+ def _resolve_instrumentation_names(*, run_context: RunContext[Any]) -> InstrumentationNames:
18
+ return InstrumentationNames.for_version(run_context.instrumentation_version)
19
+
20
+
21
+ def _resolve_trace_include_content(*, run_context: RunContext[Any]) -> bool:
22
+ return run_context.trace_include_content
23
+
24
+
25
+ def _build_tool_span_attributes(
26
+ *,
27
+ tool_name: str,
28
+ tool_call_id: str | None,
29
+ arguments: dict[str, Any],
30
+ instrumentation_names: InstrumentationNames,
31
+ include_content: bool,
32
+ ) -> dict[str, Any]:
33
+ attributes: dict[str, Any] = {
34
+ "gen_ai.tool.name": tool_name,
35
+ "logfire.msg": f"running tool: {tool_name}",
36
+ }
37
+
38
+ if tool_call_id is not None:
39
+ attributes["gen_ai.tool.call.id"] = tool_call_id
40
+
41
+ if include_content:
42
+ attributes[instrumentation_names.tool_arguments_attr] = json.dumps(arguments, default=str)
43
+ attributes["logfire.json_schema"] = json.dumps(
44
+ {
45
+ "type": "object",
46
+ "properties": {
47
+ instrumentation_names.tool_arguments_attr: {"type": "object"},
48
+ instrumentation_names.tool_result_attr: {"type": "object"},
49
+ "gen_ai.tool.name": {},
50
+ "gen_ai.tool.call.id": {},
51
+ },
52
+ }
53
+ )
54
+
55
+ return attributes
56
+
57
+
58
+ @contextmanager
59
+ def _start_tool_span(
60
+ *,
61
+ tool_name: str,
62
+ attributes: dict[str, Any],
63
+ instrumentation_names: InstrumentationNames,
64
+ run_context: RunContext[Any],
65
+ ) -> Iterator[Any]:
66
+ span_name = instrumentation_names.get_tool_span_name(tool_name)
67
+ with run_context.tracer.start_as_current_span(span_name, attributes=attributes) as span:
68
+ yield span
69
+
70
+
71
+ async def run_tool_instrumented(
72
+ *,
73
+ tool_name: str,
74
+ arguments: dict[str, Any],
75
+ call: Callable[[], Awaitable[str]],
76
+ run_context: RunContext[Any],
77
+ tool_call_id: str | None,
78
+ ) -> str:
79
+ instrumentation_names = _resolve_instrumentation_names(run_context=run_context)
80
+ include_content = _resolve_trace_include_content(run_context=run_context)
81
+
82
+ span_attributes = _build_tool_span_attributes(
83
+ tool_name=tool_name,
84
+ tool_call_id=tool_call_id,
85
+ arguments=arguments,
86
+ instrumentation_names=instrumentation_names,
87
+ include_content=include_content,
88
+ )
89
+
90
+ with _start_tool_span(
91
+ tool_name=tool_name,
92
+ attributes=span_attributes,
93
+ instrumentation_names=instrumentation_names,
94
+ run_context=run_context,
95
+ ) as span:
96
+ result_text = await call()
97
+ if include_content and span.is_recording():
98
+ span.set_attribute(instrumentation_names.tool_result_attr, result_text)
99
+ return result_text
File without changes
@@ -0,0 +1,246 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import inspect
5
+ import types
6
+ from typing import Any, NoReturn
7
+
8
+ from pydantic import BaseModel, TypeAdapter
9
+
10
+ from ..errors import ToolEvaluationError
11
+ from ..identifier_path import parse_identifier_path
12
+ from ..json_renderer import to_jsonable_value
13
+ from ..runtime.async_bridge import run_awaitable_value_synchronously
14
+ from ..runtime.step_context import StepContext
15
+ from .contracts import ToolBoundaryError
16
+
17
+
18
+ def _compile_expression(expression: str) -> types.CodeType:
19
+ """Compile a Python expression with top-level await support."""
20
+ return compile(
21
+ expression,
22
+ "<nighthawk-eval>",
23
+ "eval",
24
+ flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT,
25
+ )
26
+
27
+
28
+ async def eval_expression_async(step_context: StepContext, expression: str) -> object:
29
+ """Evaluate a Python expression inside the step execution environment (async canonical).
30
+
31
+ Security note: This uses ``eval()`` with ``ast.PyCF_ALLOW_TOP_LEVEL_AWAIT``
32
+ to execute LLM-generated expressions against ``step_globals`` and ``step_locals``.
33
+ Natural DSL sources are trusted, repository-managed assets.
34
+ Do not wire untrusted user input into expressions evaluated here.
35
+ """
36
+ try:
37
+ compiled_expression = _compile_expression(expression)
38
+ value = eval(compiled_expression, step_context.step_globals, step_context.step_locals)
39
+ if inspect.isawaitable(value):
40
+ return await value
41
+ return value
42
+ except Exception as e:
43
+ raise ToolEvaluationError(str(e)) from e
44
+
45
+
46
+ def eval_expression(step_context: StepContext, expression: str) -> object:
47
+ """Evaluate a Python expression inside the step execution environment (sync wrapper)."""
48
+ try:
49
+ compiled_expression = _compile_expression(expression)
50
+ value = eval(compiled_expression, step_context.step_globals, step_context.step_locals)
51
+ return run_awaitable_value_synchronously(value)
52
+ except Exception as e:
53
+ raise ToolEvaluationError(str(e)) from e
54
+
55
+
56
+ def _raise_invalid_input(*, message: str, guidance: str) -> NoReturn:
57
+ raise ToolBoundaryError(kind="invalid_input", message=message, guidance=guidance)
58
+
59
+
60
+ def _raise_resolution(*, message: str, guidance: str) -> NoReturn:
61
+ raise ToolBoundaryError(kind="resolution", message=message, guidance=guidance)
62
+
63
+
64
+ def _raise_execution(*, message: str, guidance: str) -> NoReturn:
65
+ raise ToolBoundaryError(kind="execution", message=message, guidance=guidance)
66
+
67
+
68
+ def _get_pydantic_field_type(model: BaseModel, field_name: str) -> object | None:
69
+ model_fields = getattr(type(model), "model_fields", None)
70
+ if model_fields is None:
71
+ return None
72
+ field = model_fields.get(field_name)
73
+ if field is None:
74
+ return None
75
+ return field.annotation
76
+
77
+
78
+ def _assign_value_to_target_path(
79
+ *,
80
+ step_context: StepContext,
81
+ target_path: str,
82
+ parsed_target_path: tuple[str, ...],
83
+ value: object,
84
+ ) -> dict[str, Any]:
85
+ if len(parsed_target_path) == 1:
86
+ name = parsed_target_path[0]
87
+
88
+ if name in step_context.read_binding_names:
89
+ _raise_invalid_input(
90
+ message=f"Cannot rebind read binding '{name}' with nh_assign.",
91
+ guidance=f"'{name}' is a read binding (<{name}>). To mutate it in-place, use nh_exec (e.g. {name}.update(...)). To rebind, declare it as a write binding (<:{name}>).",
92
+ )
93
+
94
+ expected_type = step_context.binding_name_to_type.get(name)
95
+
96
+ if expected_type is not None:
97
+ try:
98
+ adapted = TypeAdapter(expected_type)
99
+ value = adapted.validate_python(value)
100
+ except Exception as e:
101
+ _raise_invalid_input(
102
+ message=str(e),
103
+ guidance="Fix the value to match the expected type and retry.",
104
+ )
105
+
106
+ step_context.record_assignment(name, value)
107
+
108
+ return {
109
+ "target_path": target_path,
110
+ "step_locals_revision": step_context.step_locals_revision,
111
+ "updates": [{"path": target_path, "value": to_jsonable_value(value)}],
112
+ }
113
+
114
+ root_name = parsed_target_path[0]
115
+ attribute_path = parsed_target_path[1:]
116
+
117
+ if root_name not in step_context.step_locals:
118
+ _raise_resolution(
119
+ message=f"Unknown root name: {root_name}",
120
+ guidance="Fix the target path so the referenced root name exists, then retry.",
121
+ )
122
+ root_object = step_context.step_locals[root_name]
123
+
124
+ current_object = root_object
125
+ for attribute in attribute_path[:-1]:
126
+ try:
127
+ current_object = getattr(current_object, attribute)
128
+ except Exception as e:
129
+ _raise_resolution(
130
+ message=f"Failed to resolve attribute {attribute!r}: {e}",
131
+ guidance="Fix the target path so the referenced attributes exist, then retry.",
132
+ )
133
+
134
+ final_attribute = attribute_path[-1]
135
+
136
+ expected_type: object | None = None
137
+ if isinstance(current_object, BaseModel):
138
+ expected_type = _get_pydantic_field_type(current_object, final_attribute)
139
+ if expected_type is None:
140
+ _raise_invalid_input(
141
+ message=f"Unknown field on {type(current_object).__name__}: {final_attribute}",
142
+ guidance="Fix the target path so the referenced field exists, then retry.",
143
+ )
144
+
145
+ if expected_type is not None:
146
+ try:
147
+ adapted = TypeAdapter(expected_type)
148
+ value = adapted.validate_python(value)
149
+ except Exception as e:
150
+ _raise_invalid_input(
151
+ message=str(e),
152
+ guidance="Fix the value to match the expected type and retry.",
153
+ )
154
+
155
+ try:
156
+ setattr(current_object, final_attribute, value)
157
+ except Exception as e:
158
+ _raise_resolution(
159
+ message=f"Failed to set attribute {final_attribute!r}: {e}",
160
+ guidance="Fix the target path so the referenced attributes are assignable, then retry.",
161
+ )
162
+
163
+ # Dotted mutation bypasses record_assignment because commit selection is
164
+ # controlled only by <:name> bindings (top-level names). See design.md
165
+ # Section 8.3 "Commit and mutation notes" for the distinction.
166
+ step_context.step_locals_revision += 1
167
+
168
+ return {
169
+ "target_path": target_path,
170
+ "step_locals_revision": step_context.step_locals_revision,
171
+ "updates": [{"path": target_path, "value": to_jsonable_value(value)}],
172
+ }
173
+
174
+
175
+ def _resolve_value_for_assignment(step_context: StepContext, expression: str) -> object:
176
+ try:
177
+ return eval_expression(step_context, expression)
178
+ except Exception as e:
179
+ _raise_execution(
180
+ message=str(e),
181
+ guidance="Fix the expression and retry.",
182
+ )
183
+
184
+
185
+ async def _resolve_value_for_assignment_async(step_context: StepContext, expression: str) -> object:
186
+ try:
187
+ return await eval_expression_async(step_context, expression)
188
+ except Exception as e:
189
+ _raise_execution(
190
+ message=str(e),
191
+ guidance="Fix the expression and retry.",
192
+ )
193
+
194
+
195
+ def _validated_target_path(target_path: str) -> tuple[str, ...]:
196
+ parsed = parse_identifier_path(target_path)
197
+ if parsed is None:
198
+ _raise_invalid_input(
199
+ message="Invalid target_path; expected name(.field)* with ASCII identifiers",
200
+ guidance="Fix target_path to match name(.field)* with ASCII identifiers and retry.",
201
+ )
202
+ return parsed
203
+
204
+
205
+ def assign_tool(
206
+ step_context: StepContext,
207
+ target_path: str,
208
+ expression: str,
209
+ ) -> dict[str, Any]:
210
+ """Assign a computed value to a dotted target_path.
211
+
212
+ Target grammar:
213
+ - target_path := name ("." field)*
214
+
215
+ Notes:
216
+ - Any segment starting with "__" is forbidden.
217
+ - On success, returns a JSON-serializable payload with keys `target_path`, `step_locals_revision`, and `updates`.
218
+ - On failure, raises ToolBoundaryError.
219
+ - The operation is atomic: on any failure, no updates are performed.
220
+ """
221
+ parsed_target_path = _validated_target_path(target_path)
222
+ value = _resolve_value_for_assignment(step_context, expression)
223
+
224
+ return _assign_value_to_target_path(
225
+ step_context=step_context,
226
+ target_path=target_path,
227
+ parsed_target_path=parsed_target_path,
228
+ value=value,
229
+ )
230
+
231
+
232
+ async def assign_tool_async(
233
+ step_context: StepContext,
234
+ target_path: str,
235
+ expression: str,
236
+ ) -> dict[str, Any]:
237
+ """Async version of assign_tool."""
238
+ parsed_target_path = _validated_target_path(target_path)
239
+ value = await _resolve_value_for_assignment_async(step_context, expression)
240
+
241
+ return _assign_value_to_target_path(
242
+ step_context=step_context,
243
+ target_path=target_path,
244
+ parsed_target_path=parsed_target_path,
245
+ value=value,
246
+ )