|
2 | 2 | import json |
3 | 3 | from dataclasses import dataclass, field |
4 | 4 | from pathlib import Path |
| 5 | +from typing import Annotated, Literal |
5 | 6 |
|
6 | 7 | import pytest |
| 8 | +from pydantic import BaseModel, Field, model_validator |
7 | 9 |
|
8 | 10 | import nighthawk as nh |
9 | 11 | from nighthawk.errors import ExecutionError, NighthawkError |
10 | 12 | from nighthawk.runtime.prompt import build_system_prompt |
11 | 13 | 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 |
13 | 15 | from tests.execution.stub_executor import StubExecutor |
14 | 16 |
|
15 | 17 | GLOBAL_NUMBER = 7 |
16 | 18 | SHADOWED_NUMBER = 1 |
17 | 19 |
|
18 | 20 |
|
| 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 | + |
19 | 58 | def global_import_file(file_path: Path | str) -> str: |
20 | 59 | _ = file_path |
21 | 60 | return '{"step_outcome": {"kind": "pass"}, "bindings": {"result": 20}}' |
@@ -113,6 +152,54 @@ async def f(x: int) -> int: |
113 | 152 | assert asyncio.run(f(10)) == 11 |
114 | 153 |
|
115 | 154 |
|
| 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 | + |
116 | 203 | def test_async_step_execution_sets_execution_ref_step_id() -> None: |
117 | 204 | observed_step_ids: list[str | None] = [] |
118 | 205 |
|
@@ -648,6 +735,36 @@ def test_step_system_prompt_injects_tool_result_max_tokens() -> None: |
648 | 735 | assert "$tool_result_max_tokens" not in resolved_system_prompt_text |
649 | 736 |
|
650 | 737 |
|
| 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 | + |
651 | 768 | def test_natural_function_can_override_step_executor_configuration_model_within_scope() -> None: |
652 | 769 | class FakeRunResult: |
653 | 770 | def __init__(self, output: object) -> None: |
|
0 commit comments