Skip to content

Commit 009b728

Browse files
committed
Add finalize validation for committed bindings
1 parent eb25443 commit 009b728

11 files changed

Lines changed: 238 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Tool-boundary regression test ensuring recoverable `ToolBoundaryError` is wrapped into JSON payload.
1515

1616
### Changed
17+
- Finalized committed binding validation at step boundaries, added write-root dirty tracking for dotted `nh_assign`, and aligned prompt/docs with the new validation contract.
1718
- Redesigned `nh.scope()` around `mode` semantics (`"inherit"` default, `"replace"` for explicit replacement).
1819
- Scope prompt suffix arguments now use list-based forms: `system_prompt_suffix_fragments` and `user_prompt_suffix_fragments`.
1920
- Introduced `ExecutionRef` (`run_id`, `scope_id`, optional `step_id`) and renamed runtime accessor to `get_execution_ref`.

docs/natural-blocks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def detect_language(text: str):
129129
return language
130130
```
131131

132-
Type annotations on write bindings enable validation and coercion at commit time.
132+
Type annotations on write bindings enable validation and coercion at step finalization for values that are actually committed.
133133

134134
### Pydantic model write bindings
135135

docs/philosophy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ calculate_average([1, "2", "three", "cuatro", "五"]) # 3.0
2323

2424
Binding functions like `<python_average>` appear in the prompt as a compact signature line. The LLM's pre-trained Python knowledge lets it reason about types, return values, and composition from the signature alone, without JSON Schema or protocol overhead. See [Tool exposure efficiency](#tool-exposure-efficiency) for the quantitative comparison with MCP and CLI tool exposure.
2525

26-
With provider-backed executors, each Natural block is a single LLM call. A sentiment classifier whose write binding is typed as `Literal["positive", "negative", "neutral"]` rejects any output outside the declared set -- Pydantic validates the type annotation at runtime, not as a hint. The same mechanism applies to numeric extraction (`int`, `float`), structured parsing (Pydantic models), and any task where the judgment space is bounded. Because the host program owns the loop, a misclassified result can be retried, logged, or routed to a fallback -- all in ordinary Python.
26+
With provider-backed executors, each Natural block is a single LLM call. A sentiment classifier whose write binding is typed as `Literal["positive", "negative", "neutral"]` rejects any output outside the declared set -- Pydantic validates committed write bindings at step finalization, not as a hint. The same mechanism applies to numeric extraction (`int`, `float`), structured parsing (Pydantic models), and any task where the judgment space is bounded. Because the host program owns the loop, a misclassified result can be retried, logged, or routed to a fallback -- all in ordinary Python.
2727

2828
With [coding agent backends](coding-agent-backends.md), the same boundary contract applies, but each Natural block becomes an autonomous agent execution. The agent can read files, run commands, and invoke skills -- while typed bindings enforce what crosses the boundary back to Python. The same `scope()` and `run()` context managers that structure human-written workflows are equally legible to a coding agent constructing workflows programmatically. When a coding agent operates inside a Natural block, binding functions appear as Python signatures in the prompt:
2929

docs/specification.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ Eval tool:
393393

394394
- `nh_eval(expression: str) -> object`
395395
- Evaluate a Python expression and return the result. Use to inspect values, call functions, and mutate objects in-place.
396+
- In-place mutations performed via `nh_eval` are not runtime-validated.
396397
- If the evaluated expression is awaitable, it is awaited before returning.
397398

398399
Binding tool:
@@ -422,13 +423,16 @@ Semantics of `nh_assign`:
422423
- Traverse attributes for each intermediate segment.
423424
- Assign using attribute assignment on the final segment.
424425
- Validation:
425-
- Validate only when runtime type metadata is available; otherwise assign without validation.
426+
- Validate only the final assigned field when runtime type metadata is available; otherwise assign without validation.
426427

427428
Commit and mutation notes:
428429

429430
- Commit selection is controlled only by `<:name>` bindings.
430431
- `<:name>` selects which top-level names are committed from `step_locals` into Python locals at Natural block boundaries.
431-
- Dotted mutation is independent of `<:name>`.
432+
- Dotted `nh_assign` on a write binding root marks that root as dirty for commit selection.
433+
- Dotted `nh_assign` on a read binding root does not participate in commit selection.
434+
- Final validation is applied only to committed write bindings at step finalization.
435+
- Dotted mutation on read bindings and in-place mutation via `nh_eval` are outside the final validation guarantee.
432436

433437
Write tool return value:
434438

src/nighthawk/runtime/runner.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,12 +402,42 @@ def _apply_bindings_and_validate_kind(
402402
step_outcome: StepOutcome,
403403
bindings: dict[str, object],
404404
allowed_step_kinds: tuple[StepKind, ...],
405-
) -> str:
405+
) -> tuple[str, dict[str, object]]:
406406
step_outcome_kind = step_outcome.kind
407407
if step_outcome_kind not in allowed_step_kinds:
408408
raise ExecutionError(f"Step '{step_outcome_kind}' is not allowed for this step. Allowed kinds: {allowed_step_kinds}")
409-
step_context.step_locals.update(bindings)
410-
return step_outcome_kind
409+
if step_outcome_kind == "raise":
410+
return step_outcome_kind, dict(bindings)
411+
validated_bindings = self._validate_and_coerce_output_bindings(
412+
step_context=step_context,
413+
bindings=bindings,
414+
)
415+
step_context.step_locals.update(validated_bindings)
416+
return step_outcome_kind, validated_bindings
417+
418+
def _validate_and_coerce_output_bindings(
419+
self,
420+
*,
421+
step_context: StepContext,
422+
bindings: dict[str, object],
423+
) -> dict[str, object]:
424+
validated_binding_name_to_value = dict(bindings)
425+
for binding_name in step_context.binding_commit_targets:
426+
if binding_name not in validated_binding_name_to_value:
427+
continue
428+
429+
expected_type = step_context.binding_name_to_type.get(binding_name)
430+
if expected_type is None:
431+
continue
432+
433+
try:
434+
validated_binding_name_to_value[binding_name] = TypeAdapter(expected_type).validate_python(
435+
validated_binding_name_to_value[binding_name]
436+
)
437+
except Exception as exception:
438+
raise ExecutionError(f"Output binding '{binding_name}' failed validation: {exception}") from exception
439+
440+
return validated_binding_name_to_value
411441

412442
def _apply_step_oversight_if_needed(
413443
self,
@@ -477,7 +507,7 @@ async def _finalize_step(
477507
allow_awaitable_return: bool,
478508
) -> StepEnvelope:
479509
try:
480-
step_outcome_kind = self._apply_bindings_and_validate_kind(
510+
step_outcome_kind, validated_bindings = self._apply_bindings_and_validate_kind(
481511
step_context=preparation.step_context,
482512
step_outcome=step_outcome,
483513
bindings=bindings,
@@ -515,7 +545,7 @@ async def _finalize_step(
515545
return StepEnvelope(
516546
step_outcome=step_outcome,
517547
input_bindings=dict(preparation.input_binding_name_to_value),
518-
bindings=bindings,
548+
bindings=validated_bindings,
519549
return_value=return_value,
520550
)
521551

src/nighthawk/runtime/step_context.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class StepContext:
3232
"""Mutable, per-step execution context passed to tools and executors.
3333
3434
``step_globals`` and ``step_locals`` are mutable dicts. All mutations to ``step_locals`` MUST go through :meth:`record_assignment` (for top-level name bindings) or through the dotted-path assignment in ``tools.assignment`` (which bumps ``step_locals_revision`` directly).
35-
Direct dict writes bypass revision tracking and ``assigned_binding_names`` bookkeeping, which will cause incorrect commit behavior at Natural block boundaries.
35+
Direct dict writes bypass revision tracking, ``assigned_binding_names``, and ``dirty_output_binding_names`` bookkeeping, which will cause incorrect commit behavior at Natural block boundaries.
3636
"""
3737

3838
step_id: str
@@ -49,6 +49,7 @@ class StepContext:
4949
processed_natural_program: str = ""
5050
binding_name_to_type: dict[str, object] = field(default_factory=dict)
5151
assigned_binding_names: set[str] = field(default_factory=set)
52+
dirty_output_binding_names: set[str] = field(default_factory=set)
5253
step_locals_revision: int = 0
5354
tool_result_rendering_policy: ToolResultRenderingPolicy | None = None
5455

@@ -61,6 +62,11 @@ def record_assignment(self, name: str, value: object) -> None:
6162
self.assigned_binding_names.add(name)
6263
self.step_locals_revision += 1
6364

65+
def record_output_binding_mutation(self, name: str) -> None:
66+
"""Record an in-place mutation affecting a committed output binding root."""
67+
self.dirty_output_binding_names.add(name)
68+
self.step_locals_revision += 1
69+
6470

6571
_step_context_stack_var: ContextVar[tuple[StepContext, ...]] = ContextVar(
6672
"nighthawk_step_context_stack",

src/nighthawk/runtime/step_executor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ def _extract_bindings(
283283
"""Extract committed bindings from the step context."""
284284
bindings: dict[str, object] = {}
285285
for name in binding_names:
286-
if name in step_context.assigned_binding_names:
286+
if name in step_context.assigned_binding_names or name in step_context.dirty_output_binding_names:
287287
bindings[name] = step_context.step_locals[name]
288288
return bindings
289289

src/nighthawk/tools/assignment.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,13 @@ def _assign_value_to_target_path(
176176
guidance="Fix the target path so the referenced attributes are assignable, then retry.",
177177
)
178178

179-
# Dotted mutation bypasses record_assignment because commit selection is
180-
# controlled only by <:name> bindings (top-level names). See design.md
181-
# Section 8.3 "Commit and mutation notes" for the distinction.
182-
step_context.step_locals_revision += 1
179+
# Dotted mutation bypasses record_assignment. Top-level rebinding still
180+
# drives ordinary assignment tracking, while write-binding roots touched by
181+
# dotted nh_assign are tracked separately for commit selection.
182+
if root_name in step_context.binding_commit_targets:
183+
step_context.record_output_binding_mutation(root_name)
184+
else:
185+
step_context.step_locals_revision += 1
183186

184187
return {
185188
"target_path": target_path,

tests/execution/test_runtime.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,59 @@
22
import json
33
from dataclasses import dataclass, field
44
from pathlib import Path
5+
from typing import Annotated, Literal
56

67
import pytest
8+
from pydantic import BaseModel, Field, model_validator
79

810
import nighthawk as nh
911
from nighthawk.errors import ExecutionError, NighthawkError
1012
from nighthawk.runtime.prompt import build_system_prompt
1113
from nighthawk.runtime.step_context import StepContext
12-
from nighthawk.runtime.step_contract import PassStepOutcome, ReturnStepOutcome, StepKind
14+
from nighthawk.runtime.step_contract import PassStepOutcome, ReturnStepOutcome, StepFinalResult, StepKind
1315
from tests.execution.stub_executor import StubExecutor
1416

1517
GLOBAL_NUMBER = 7
1618
SHADOWED_NUMBER = 1
1719

1820

21+
class RuntimeChildModel(BaseModel):
22+
value: int
23+
24+
25+
class RuntimeResultModel(BaseModel):
26+
child: RuntimeChildModel | None = None
27+
value: int = 0
28+
29+
30+
class RuntimeApproveProposal(BaseModel):
31+
kind: Literal["approve"]
32+
score: int
33+
34+
35+
class RuntimeRejectProposal(BaseModel):
36+
kind: Literal["reject"]
37+
reason: str
38+
39+
40+
RuntimeProposal = Annotated[RuntimeApproveProposal | RuntimeRejectProposal, Field(discriminator="kind")]
41+
42+
43+
class RuntimeProposalEnvelope(BaseModel):
44+
proposals: list[RuntimeProposal]
45+
46+
47+
class RuntimePlanUpdateDecision(BaseModel):
48+
should_update: bool
49+
next_plan: str | None = None
50+
51+
@model_validator(mode="after")
52+
def validate_next_plan(self) -> "RuntimePlanUpdateDecision":
53+
if self.should_update and self.next_plan is None:
54+
raise ValueError("next_plan is required when should_update is true")
55+
return self
56+
57+
1958
def global_import_file(file_path: Path | str) -> str:
2059
_ = file_path
2160
return '{"step_outcome": {"kind": "pass"}, "bindings": {"result": 20}}'
@@ -113,6 +152,54 @@ async def f(x: int) -> int:
113152
assert asyncio.run(f(10)) == 11
114153

115154

155+
def test_pass_step_finalize_coerces_committed_model_binding() -> None:
156+
with nh.run(StubExecutor()):
157+
158+
@nh.natural_function
159+
def f() -> str:
160+
"""natural
161+
<:result>
162+
{"step_outcome": {"kind": "pass"}, "bindings": {"result": {"child": {"value": "7"}}}}
163+
"""
164+
result: RuntimeResultModel
165+
assert result.child is not None # noqa: F821 # pyright: ignore[reportUndefinedVariable, reportUnboundVariable, reportAttributeAccessIssue]
166+
return f"{type(result).__name__}:{type(result.child).__name__}:{result.child.value}" # noqa: F821 # pyright: ignore[reportUndefinedVariable, reportUnboundVariable, reportAttributeAccessIssue]
167+
168+
assert f() == "RuntimeResultModel:RuntimeChildModel:7"
169+
170+
171+
def test_pass_step_finalize_coerces_discriminated_union_list_items() -> None:
172+
with nh.run(StubExecutor()):
173+
174+
@nh.natural_function
175+
def f() -> str:
176+
"""natural
177+
<:result>
178+
{"step_outcome": {"kind": "pass"}, "bindings": {"result": {"proposals": [{"kind": "approve", "score": "5"}]}}}
179+
"""
180+
result: RuntimeProposalEnvelope
181+
first_proposal = result.proposals[0] # noqa: F821 # pyright: ignore[reportUndefinedVariable, reportUnboundVariable, reportAttributeAccessIssue]
182+
return f"{type(first_proposal).__name__}:{first_proposal.score}"
183+
184+
assert f() == "RuntimeApproveProposal:5"
185+
186+
187+
def test_pass_step_finalize_rejects_cross_field_model_violation() -> None:
188+
with nh.run(StubExecutor()):
189+
190+
@nh.natural_function
191+
def f() -> int:
192+
"""natural
193+
<:decision>
194+
{"step_outcome": {"kind": "pass"}, "bindings": {"decision": {"should_update": true, "next_plan": null}}}
195+
"""
196+
decision: RuntimePlanUpdateDecision
197+
return 1 if decision.should_update else 0 # noqa: F821 # pyright: ignore[reportUndefinedVariable, reportUnboundVariable, reportAttributeAccessIssue]
198+
199+
with pytest.raises(ExecutionError, match="Output binding 'decision' failed validation"):
200+
f()
201+
202+
116203
def test_async_step_execution_sets_execution_ref_step_id() -> None:
117204
observed_step_ids: list[str | None] = []
118205

@@ -648,6 +735,36 @@ def test_step_system_prompt_injects_tool_result_max_tokens() -> None:
648735
assert "$tool_result_max_tokens" not in resolved_system_prompt_text
649736

650737

738+
def test_agent_executor_commits_write_binding_after_dotted_assignment() -> None:
739+
class FakeRunResult:
740+
def __init__(self, output: object) -> None:
741+
self.output = output
742+
743+
class FakeAgent:
744+
def run_sync(self, user_prompt: str, *, deps=None, **kwargs): # type: ignore[no-untyped-def]
745+
from nighthawk.tools.assignment import assign_tool
746+
747+
assert deps is not None
748+
_ = user_prompt
749+
_ = kwargs
750+
751+
assign_tool(deps, "result.value", "9")
752+
return FakeRunResult(StepFinalResult(result=PassStepOutcome(kind="pass")))
753+
754+
with nh.run(nh.AgentStepExecutor.from_agent(agent=FakeAgent())):
755+
756+
@nh.natural_function
757+
def f() -> int:
758+
result: RuntimeResultModel = RuntimeResultModel(value=0)
759+
"""natural
760+
<:result>
761+
Update the result value.
762+
"""
763+
return result.value
764+
765+
assert f() == 9
766+
767+
651768
def test_natural_function_can_override_step_executor_configuration_model_within_scope() -> None:
652769
class FakeRunResult:
653770
def __init__(self, output: object) -> None:

tests/governance/test_governance.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
1111
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
1212
from opentelemetry.trace import StatusCode
13+
from pydantic import BaseModel
1314

1415
import nighthawk as nh
1516
from nighthawk.errors import ExecutionError, NighthawkError
@@ -19,6 +20,10 @@
1920
from tests.execution.stub_executor import StubExecutor
2021

2122

23+
class GovernanceResultModel(BaseModel):
24+
value: int
25+
26+
2227
@pytest.fixture
2328
def step_span_exporter() -> Generator[InMemorySpanExporter, None, None]:
2429
span_exporter = InMemorySpanExporter()
@@ -169,6 +174,25 @@ def natural_value_function() -> int:
169174
natural_value_function()
170175

171176

177+
def test_step_commit_rewrite_is_coerced_by_finalize_validation() -> None:
178+
def rewrite_step(review: nh.oversight.StepCommitProposal) -> nh.oversight.Rewrite:
179+
assert review.proposed_binding_name_to_value["result"] == {"value": 1}
180+
return nh.oversight.Rewrite(rewritten_binding_name_to_value={"result": {"value": "29"}})
181+
182+
with nh.run(StubExecutor()), nh.scope(oversight=nh.oversight.Oversight(inspect_step_commit=rewrite_step)):
183+
184+
@nh.natural_function
185+
def natural_value_function() -> int:
186+
"""natural
187+
<:result>
188+
{"step_outcome": {"kind": "pass"}, "bindings": {"result": {"value": 1}}}
189+
"""
190+
result: GovernanceResultModel
191+
return result.value # noqa: F821 # pyright: ignore[reportUndefinedVariable, reportUnboundVariable, reportAttributeAccessIssue]
192+
193+
assert natural_value_function() == 29
194+
195+
172196
def test_empty_rewrite_is_rejected() -> None:
173197
with pytest.raises(ValueError, match="Rewrite must change"):
174198
nh.oversight.Rewrite()

0 commit comments

Comments
 (0)